mirror of https://github.com/chaitin/PandaWiki.git
commit
74e8b03975
|
|
@ -3,6 +3,7 @@ import Emoji from '@/components/Emoji';
|
|||
import { postApiV1CreationTabComplete, putApiV1NodeDetail } from '@/request';
|
||||
import { V1NodeDetailResp } from '@/request/types';
|
||||
import { useAppSelector } from '@/store';
|
||||
import { completeIncompleteLinks } from '@/utils';
|
||||
import {
|
||||
EditorMarkdown,
|
||||
MarkdownEditorRef,
|
||||
|
|
@ -201,7 +202,8 @@ const Wrap = ({ detail: defaultDetail }: WrapProps) => {
|
|||
value = nodeDetail?.content || '';
|
||||
}
|
||||
if (!value) return;
|
||||
const blob = new Blob([value], { type: `text/${type}` });
|
||||
const content = completeIncompleteLinks(value);
|
||||
const blob = new Blob([content], { type: `text/${type}` });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
|
|
|
|||
|
|
@ -152,3 +152,196 @@ export const validateUrl = (url: string): boolean => {
|
|||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 链接补全配置选项
|
||||
*/
|
||||
export interface CompleteLinksOptions {
|
||||
/**
|
||||
* 协议相对链接(//example.com)的处理策略
|
||||
* - 'preserve': 保持原样
|
||||
* - 'current': 使用当前页面的协议(http 或 https)
|
||||
* - 'https': 强制使用 https(默认)
|
||||
* - 'http': 强制使用 http
|
||||
*/
|
||||
schemaRelative?: 'preserve' | 'current' | 'https' | 'http';
|
||||
/**
|
||||
* FTP 链接的处理策略
|
||||
* - 'preserve': 保持原样(默认)
|
||||
* - 'https': 转换为 https(ftp://example.com -> https://example.com)
|
||||
* - 'remove': 移除 ftp:// 前缀,转为普通域名
|
||||
*/
|
||||
ftpProtocol?: 'preserve' | 'https' | 'remove';
|
||||
/**
|
||||
* HTTP 链接的处理策略
|
||||
* - 'preserve': 保持原样(默认)
|
||||
* - 'https': 转换为 https
|
||||
*/
|
||||
httpProtocol?: 'preserve' | 'https';
|
||||
/**
|
||||
* 裸域名补全时使用的协议
|
||||
* - 'https': 使用 https(默认)
|
||||
* - 'http': 使用 http
|
||||
* - 'current': 使用当前页面的协议
|
||||
*/
|
||||
bareDomainProtocol?: 'https' | 'http' | 'current';
|
||||
}
|
||||
|
||||
/**
|
||||
* 将文本中的所有链接补全为完整链接(含协议的绝对地址)
|
||||
* - 处理 Markdown 链接: [title](href)
|
||||
* - 处理 HTML 链接: <a href="...">...</a>
|
||||
* - 处理 HTML 标签的 src 属性: <img src="...">, <iframe src="...">, <script src="..."> 等
|
||||
* - 相对/根路径/上级路径 将基于 window.location.href 解析为绝对地址
|
||||
* - 裸域名/子域名(如 example.com / sub.example.com)自动补全协议前缀
|
||||
* - 已包含协议(http/https/ftp/mailto/tel/data等)或锚点(#)的根据配置处理
|
||||
*
|
||||
* @param text 要处理的文本
|
||||
* @param options 处理选项配置
|
||||
*/
|
||||
export function completeIncompleteLinks(
|
||||
text: string,
|
||||
options: CompleteLinksOptions = {},
|
||||
): string {
|
||||
if (!text) return text;
|
||||
|
||||
const {
|
||||
schemaRelative = 'https',
|
||||
ftpProtocol = 'preserve',
|
||||
httpProtocol = 'preserve',
|
||||
bareDomainProtocol = 'https',
|
||||
} = options;
|
||||
|
||||
const baseHref =
|
||||
typeof window !== 'undefined' && window.location
|
||||
? window.location.href
|
||||
: '';
|
||||
const currentProtocol =
|
||||
typeof window !== 'undefined' && window.location
|
||||
? window.location.protocol
|
||||
: 'https:';
|
||||
|
||||
const isProtocolLike = (href: string) =>
|
||||
/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(href);
|
||||
|
||||
const isHash = (href: string) => href.startsWith('#');
|
||||
|
||||
const isSchemaRelative = (href: string) => href.startsWith('//');
|
||||
|
||||
const isBareDomain = (href: string) => {
|
||||
if (/[\s"'<>]/.test(href)) return false;
|
||||
if (href.startsWith('/') || href.startsWith('.')) return false;
|
||||
return /^[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)+(?::\d+)?(\/.*)?$/.test(href);
|
||||
};
|
||||
|
||||
const getProtocolForBareDomain = (): string => {
|
||||
if (bareDomainProtocol === 'current') {
|
||||
return currentProtocol;
|
||||
}
|
||||
return bareDomainProtocol === 'http' ? 'http:' : 'https:';
|
||||
};
|
||||
|
||||
const resolveHref = (href: string): string => {
|
||||
const trimmed = href.trim();
|
||||
if (!trimmed) return href;
|
||||
|
||||
// 锚点链接保持原样
|
||||
if (isHash(trimmed)) return trimmed;
|
||||
|
||||
// 处理协议相对链接(//example.com)
|
||||
if (isSchemaRelative(trimmed)) {
|
||||
if (schemaRelative === 'preserve') return trimmed;
|
||||
if (schemaRelative === 'current') return currentProtocol + trimmed;
|
||||
if (schemaRelative === 'http') return 'http:' + trimmed;
|
||||
return 'https:' + trimmed; // 默认 https
|
||||
}
|
||||
|
||||
// 处理已有协议的链接
|
||||
if (isProtocolLike(trimmed)) {
|
||||
const protocolMatch = trimmed.match(/^([a-zA-Z][a-zA-Z\d+\-.]*):/);
|
||||
if (protocolMatch) {
|
||||
const protocol = protocolMatch[1].toLowerCase();
|
||||
|
||||
// 处理 FTP 协议
|
||||
if (protocol === 'ftp') {
|
||||
if (ftpProtocol === 'preserve') return trimmed;
|
||||
if (ftpProtocol === 'https') {
|
||||
return trimmed.replace(/^ftp:/i, 'https:');
|
||||
}
|
||||
if (ftpProtocol === 'remove') {
|
||||
return trimmed.replace(/^ftp:\/\//i, '');
|
||||
}
|
||||
}
|
||||
|
||||
// 处理 HTTP 协议
|
||||
if (protocol === 'http') {
|
||||
if (httpProtocol === 'preserve') return trimmed;
|
||||
if (httpProtocol === 'https') {
|
||||
return trimmed.replace(/^http:/i, 'https:');
|
||||
}
|
||||
}
|
||||
|
||||
// 其他协议(https, mailto, tel, data 等)保持原样
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
// 处理裸域名
|
||||
if (isBareDomain(trimmed)) {
|
||||
const protocol = getProtocolForBareDomain();
|
||||
return `${protocol}//${trimmed}`;
|
||||
}
|
||||
|
||||
// 处理相对路径、根路径、上级路径
|
||||
try {
|
||||
if (baseHref) {
|
||||
return new URL(trimmed, baseHref).toString();
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
// 处理 Markdown: [text](href)
|
||||
const mdRe = /\[([^\]]+)\]\(([^)]+)\)/g;
|
||||
text = text.replace(mdRe, (_m, label: string, href: string) => {
|
||||
const completed = resolveHref(href);
|
||||
return `[${label}](${completed})`;
|
||||
});
|
||||
|
||||
// 处理 HTML: <a href="..."> / <a href='...'>
|
||||
const htmlRe = /(<a\b[^>]*?\bhref=(["']))([^"']+)(\2)/gi;
|
||||
text = text.replace(
|
||||
htmlRe,
|
||||
(
|
||||
_m: string,
|
||||
pre: string,
|
||||
quote: string,
|
||||
href: string,
|
||||
postQuote: string,
|
||||
) => {
|
||||
const completed = resolveHref(href);
|
||||
return `${pre}${completed}${postQuote}`;
|
||||
},
|
||||
);
|
||||
|
||||
// 处理 HTML 标签中的 src 属性: <img src="...">, <iframe src="...">, <script src="..."> 等
|
||||
const srcRe = /(<[a-zA-Z][a-zA-Z0-9]*\b[^>]*?\bsrc=(["']))([^"']+)(\2)/gi;
|
||||
text = text.replace(
|
||||
srcRe,
|
||||
(
|
||||
_m: string,
|
||||
pre: string,
|
||||
quote: string,
|
||||
src: string,
|
||||
postQuote: string,
|
||||
) => {
|
||||
const completed = resolveHref(src);
|
||||
return `${pre}${completed}${postQuote}`;
|
||||
},
|
||||
);
|
||||
|
||||
return text;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@
|
|||
"license": "ISC",
|
||||
"packageManager": "pnpm@10.12.1",
|
||||
"dependencies": {
|
||||
"@ctzhian/tiptap": "^1.12.18",
|
||||
"@ctzhian/tiptap": "^1.12.20",
|
||||
"@ctzhian/ui": "^7.0.5",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@ importers:
|
|||
.:
|
||||
dependencies:
|
||||
'@ctzhian/tiptap':
|
||||
specifier: ^1.12.18
|
||||
version: 1.12.18(3f8aa6e4b731b59772b9acd58d22fc94)
|
||||
specifier: ^1.12.20
|
||||
version: 1.12.20(3f8aa6e4b731b59772b9acd58d22fc94)
|
||||
'@ctzhian/ui':
|
||||
specifier: ^7.0.5
|
||||
version: 7.0.5(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0))(@mui/icons-material@7.3.4(@mui/material@7.3.4(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(@types/react@19.2.2)(react@19.2.0))(@mui/material@7.3.4(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(@mui/utils@7.3.3(@types/react@19.2.2)(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
|
|
@ -514,8 +514,8 @@ packages:
|
|||
react: '>=16.9.0'
|
||||
react-dom: '>=16.9.0'
|
||||
|
||||
'@ctzhian/tiptap@1.12.18':
|
||||
resolution: {integrity: sha512-ofhfo6Gz5r/e7DD/IAbNsuuT3iV7f1M/SoYPUhtvmhFJICwqgAAXH452sxZ4BUtMBwj8YvmgD+cE2J8JImQWFQ==}
|
||||
'@ctzhian/tiptap@1.12.20':
|
||||
resolution: {integrity: sha512-FLGgzZcvNpf1ncgPdagFaHEfqnzkWjiRw3s9tT1loyhaX+KrxQRjY86MW3qPh7qB6WIhZsfb8T1sgWLwRNN0/Q==}
|
||||
peerDependencies:
|
||||
'@emotion/react': ^11
|
||||
'@emotion/styled': ^11
|
||||
|
|
@ -5995,7 +5995,7 @@ snapshots:
|
|||
- react-native
|
||||
- typescript
|
||||
|
||||
'@ctzhian/tiptap@1.12.18(3f8aa6e4b731b59772b9acd58d22fc94)':
|
||||
'@ctzhian/tiptap@1.12.20(3f8aa6e4b731b59772b9acd58d22fc94)':
|
||||
dependencies:
|
||||
'@emotion/react': 11.14.0(@types/react@19.2.2)(react@19.2.0)
|
||||
'@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0)
|
||||
|
|
|
|||
Loading…
Reference in New Issue