Merge pull request #1505 from KuaiYu95/fe/error

fix: 修复无法插入链接的 bug
This commit is contained in:
xiaomakuaiz 2025-11-11 18:22:04 +08:00 committed by GitHub
commit 74e8b03975
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 202 additions and 7 deletions

View File

@ -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;

View File

@ -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': httpsftp://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;
}

View File

@ -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",

View File

@ -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)