From 76a7dfd125d96b48f9b2258ccf374d1274454d76 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 15 Jun 2026 13:17:45 +0800 Subject: [PATCH 1/2] feat(link): support schema link renderers --- .../link/__test__/schema-renderer.test.ts | 23 +++++ src/plugins/link/index.ts | 5 ++ src/plugins/link/node/LinkNode.ts | 83 ++++++++++++++++++- src/plugins/link/plugin/index.ts | 30 ++++++- src/plugins/link/react/ReactLinkPlugin.tsx | 6 +- src/plugins/link/react/type.ts | 3 + src/plugins/link/service/i-link-service.ts | 53 ++++++++++++ 7 files changed, 196 insertions(+), 7 deletions(-) create mode 100644 src/plugins/link/__test__/schema-renderer.test.ts diff --git a/src/plugins/link/__test__/schema-renderer.test.ts b/src/plugins/link/__test__/schema-renderer.test.ts new file mode 100644 index 00000000..324ea1b4 --- /dev/null +++ b/src/plugins/link/__test__/schema-renderer.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { LinkService } from '../service/i-link-service'; + +describe('schema link renderer', () => { + it('matches schema renderer protocols with or without url separators', () => { + const render = vi.fn(); + const linkService = new LinkService(); + + linkService.setSchemaLinkRenderers([{ protocol: 'schema://', render }]); + + expect(linkService.getSchemaLinkRenderer('schema://card/123')).toBe(render); + expect(linkService.getSchemaLinkRenderer('http://example.com')).toBeNull(); + }); + + it('allows custom protocols to be configured for sanitization', () => { + const linkService = new LinkService(); + + linkService.setAllowedProtocols(['schema://']); + + expect(linkService.getAllowedProtocols().has('schema:')).toBe(true); + }); +}); diff --git a/src/plugins/link/index.ts b/src/plugins/link/index.ts index ff581cfc..d28e6f90 100644 --- a/src/plugins/link/index.ts +++ b/src/plugins/link/index.ts @@ -2,3 +2,8 @@ export { INSERT_LINK_COMMAND } from './command'; export * from './plugin'; export * from './react'; export { ILinkService } from './service/i-link-service'; +export type { + SchemaLinkRenderer, + SchemaLinkRendererConfig, + SchemaLinkRendererProps, +} from './service/i-link-service'; diff --git a/src/plugins/link/node/LinkNode.ts b/src/plugins/link/node/LinkNode.ts index 6183075f..fa0adb24 100644 --- a/src/plugins/link/node/LinkNode.ts +++ b/src/plugins/link/node/LinkNode.ts @@ -22,14 +22,22 @@ import { $isRangeSelection, $normalizeSelection__EXPERIMENTAL, $setSelection, + ElementDOMSlot, ElementNode, Spread, createCommand, } from 'lexical'; -import { assert } from '@/editor-kernel/utils'; +import { + assert, + getKernelFromEditor, + getKernelFromEditorConfig, + reconcileDecorator, +} from '@/editor-kernel/utils'; import { createDebugLogger } from '@/utils/debug'; +import { ILinkService, LinkService } from '../service/i-link-service'; + const logger = createDebugLogger('plugin', 'link'); export type LinkAttributes = { @@ -50,6 +58,8 @@ export type SerializedLinkNode = Spread< type LinkHTMLElementType = HTMLAnchorElement | HTMLSpanElement; const SUPPORTED_URL_PROTOCOLS = new Set(['http:', 'https:', 'mailto:', 'sms:', 'tel:']); +const LINK_RENDER_HOST_SELECTOR = '[data-link-render-host="true"]'; +const LINK_CONTENT_SLOT_SELECTOR = '[data-link-content-slot="true"]'; export const HOVER_LINK_COMMAND = createCommand<{ event: MouseEvent; @@ -94,6 +104,19 @@ export class LinkNode extends ElementNode { createDOM(config: EditorConfig, editor: LexicalEditor): LinkHTMLElementType { logger.debug('🔍 config', config); const element = document.createElement('a'); + const linkService = getLinkServiceFromEditor(editor); + if (linkService?.getSchemaLinkRenderer(this.__url)) { + element.dataset.schemaLink = 'true'; + element.contentEditable = 'false'; + const host = document.createElement('span'); + host.dataset.linkRenderHost = 'true'; + element.append(host); + const contentSlot = document.createElement('span'); + contentSlot.dataset.linkContentSlot = 'true'; + contentSlot.style.display = 'none'; + element.append(contentSlot); + reconcileSchemaLinkDecorator(editor, this, linkService); + } this.updateLinkDOM(null, element, config); addClassNamesToElement(element, config.theme.link); element.addEventListener('mouseenter', (event) => { @@ -123,8 +146,9 @@ export class LinkNode extends ElementNode { _config: EditorConfig, ) { if (isHTMLAnchorElement(anchor)) { + const linkService = getLinkServiceFromConfig(_config); if (!prevNode || prevNode.__url !== this.__url) { - anchor.href = this.sanitizeUrl(this.__url); + anchor.href = this.sanitizeUrl(this.__url, linkService?.getAllowedProtocols()); } for (const attr of ['target', 'rel', 'title'] as const) { const key = `__${attr}` as const; @@ -141,6 +165,15 @@ export class LinkNode extends ElementNode { } updateDOM(prevNode: this, anchor: LinkHTMLElementType, config: EditorConfig): boolean { + const linkService = getLinkServiceFromConfig(config); + const prevHasRenderer = Boolean(linkService?.getSchemaLinkRenderer(prevNode.__url)); + const nextHasRenderer = Boolean(linkService?.getSchemaLinkRenderer(this.__url)); + if (prevHasRenderer !== nextHasRenderer) { + return true; + } + if (nextHasRenderer) { + reconcileSchemaLinkDecorator(getKernelFromEditorConfig(config)?.getLexicalEditor(), this, linkService); + } this.updateLinkDOM(prevNode, anchor, config); return false; } @@ -167,13 +200,13 @@ export class LinkNode extends ElementNode { .setTitle(serializedNode.title || null); } - sanitizeUrl(url: string): string { + sanitizeUrl(url: string, allowedProtocols: Set = SUPPORTED_URL_PROTOCOLS): string { // eslint-disable-next-line no-param-reassign url = formatUrl(url); try { const parsedUrl = new URL(formatUrl(url)); // eslint-disable-next-line no-script-url - if (!SUPPORTED_URL_PROTOCOLS.has(parsedUrl.protocol)) { + if (!allowedProtocols.has(parsedUrl.protocol)) { return 'about:blank'; } } catch { @@ -280,6 +313,48 @@ export class LinkNode extends ElementNode { isWebSiteURI(): boolean { return this.__url.startsWith('https://') || this.__url.startsWith('http://'); } + + getDOMSlot(element: HTMLElement): ElementDOMSlot { + const contentSlot = element.querySelector(LINK_CONTENT_SLOT_SELECTOR); + if (contentSlot instanceof HTMLElement) { + return super.getDOMSlot(element).withElement(contentSlot); + } + return super.getDOMSlot(element); + } +} + +function getLinkServiceFromEditor(editor: LexicalEditor): LinkService | null { + return (getKernelFromEditor(editor)?.requireService(ILinkService) as LinkService | null) || null; +} + +function getLinkServiceFromConfig(config: EditorConfig): LinkService | null { + return ( + (getKernelFromEditorConfig(config)?.requireService(ILinkService) as LinkService | null) || null + ); +} + +function reconcileSchemaLinkDecorator( + editor: LexicalEditor | null | undefined, + node: LinkNode, + linkService: LinkService | null, +): void { + if (!editor) return; + const renderer = linkService?.getSchemaLinkRenderer(node.getURL()); + if (!renderer) return; + + reconcileDecorator(editor, node.getKey(), { + queryDOM: (element: HTMLElement) => + (element.querySelector(LINK_RENDER_HOST_SELECTOR) as HTMLElement | null) || element, + render: renderer({ + editor, + node, + rel: node.getRel(), + target: node.getTarget(), + text: node.getTextContent(), + title: node.getTitle(), + url: node.getURL(), + }), + }); } function $convertAnchorElement(domNode: Node): DOMConversionOutput { diff --git a/src/plugins/link/plugin/index.ts b/src/plugins/link/plugin/index.ts index 40441c8c..29a74b9f 100644 --- a/src/plugins/link/plugin/index.ts +++ b/src/plugins/link/plugin/index.ts @@ -1,4 +1,10 @@ -import { $createTextNode, COMMAND_PRIORITY_NORMAL, LexicalEditor, PASTE_COMMAND } from 'lexical'; +import { + $createTextNode, + COMMAND_PRIORITY_NORMAL, + LexicalEditor, + PASTE_COMMAND, + TextNode, +} from 'lexical'; import { INodeHelper } from '@/editor-kernel/inode/helper'; import { KernelPlugin } from '@/editor-kernel/plugin'; @@ -14,13 +20,15 @@ import { LinkAttributes, LinkNode, } from '../node/LinkNode'; -import { ILinkService, LinkService } from '../service/i-link-service'; +import { ILinkService, LinkService, SchemaLinkRendererConfig } from '../service/i-link-service'; import { registerLinkCommands } from './registry'; export interface LinkPluginOptions { + allowedProtocols?: string[]; attributes?: LinkAttributes; enableHotkey?: boolean; linkRegex?: RegExp; + schemaLinkRenderers?: SchemaLinkRendererConfig[]; theme?: { link?: string; }; @@ -42,6 +50,16 @@ export const LinkPlugin: IEditorPluginConstructor = class super(); // Register the link nodes kernel.registerNodes([LinkNode, AutoLinkNode]); + this.service.setAllowedProtocols([ + 'http:', + 'https:', + 'mailto:', + 'sms:', + 'tel:', + ...(config?.schemaLinkRenderers?.map(({ protocol }) => protocol) || []), + ...(config?.allowedProtocols || []), + ]); + this.service.setSchemaLinkRenderers(config?.schemaLinkRenderers); kernel.registerService(ILinkService, this.service); if (config?.theme) { kernel.registerThemes(config.theme); @@ -61,6 +79,14 @@ export const LinkPlugin: IEditorPluginConstructor = class validateUrl: this.config?.validateUrl, }), ); + this.register( + editor.registerNodeTransform(TextNode, (textNode) => { + const parent = textNode.getParent(); + if ($isLinkNode(parent) && this.service.getSchemaLinkRenderer(parent.getURL())) { + parent.getWritable(); + } + }), + ); this.register( editor.registerCommand( PASTE_COMMAND, diff --git a/src/plugins/link/react/ReactLinkPlugin.tsx b/src/plugins/link/react/ReactLinkPlugin.tsx index dbfb1d35..d5bc2a67 100644 --- a/src/plugins/link/react/ReactLinkPlugin.tsx +++ b/src/plugins/link/react/ReactLinkPlugin.tsx @@ -15,10 +15,12 @@ import { styles } from './style'; import { ReactLinkPluginProps } from './type'; export const ReactLinkPlugin: FC = ({ + allowedProtocols, theme, enableHotkey = true, validateUrl, attributes, + schemaLinkRenderers, }) => { const [enableToolbar, setEnableToolbar] = useState(false); const [editor] = useLexicalComposerContext(); @@ -26,12 +28,14 @@ export const ReactLinkPlugin: FC = ({ useLayoutEffect(() => { editor.registerPlugin(MarkdownPlugin); editor.registerPlugin(LinkPlugin, { + allowedProtocols, attributes, enableHotkey, + schemaLinkRenderers, theme: theme || styles, validateUrl, }); - }, [attributes, enableHotkey, styles, theme, validateUrl]); + }, [allowedProtocols, attributes, enableHotkey, schemaLinkRenderers, styles, theme, validateUrl]); useLexicalEditor(() => { const linkService = editor.requireService(ILinkService) as LinkService; diff --git a/src/plugins/link/react/type.ts b/src/plugins/link/react/type.ts index 7892f549..bc00cfe1 100644 --- a/src/plugins/link/react/type.ts +++ b/src/plugins/link/react/type.ts @@ -1,9 +1,12 @@ import { LinkAttributes } from '@/plugins/link/node/LinkNode'; +import type { SchemaLinkRendererConfig } from '@/plugins/link/service/i-link-service'; export interface ReactLinkPluginProps { + allowedProtocols?: string[]; attributes?: LinkAttributes; className?: string; enableHotkey?: boolean; + schemaLinkRenderers?: SchemaLinkRendererConfig[]; theme?: { link?: string; }; diff --git a/src/plugins/link/service/i-link-service.ts b/src/plugins/link/service/i-link-service.ts index c09c701e..44525ed5 100644 --- a/src/plugins/link/service/i-link-service.ts +++ b/src/plugins/link/service/i-link-service.ts @@ -1,11 +1,34 @@ /* eslint-disable no-redeclare */ /* eslint-disable @typescript-eslint/no-redeclare */ import EventEmitter from 'eventemitter3'; +import type { LexicalEditor } from 'lexical'; +import type { ReactNode } from 'react'; import { genServiceId } from '@/editor-kernel'; import { IServiceID } from '@/types'; +import type { LinkNode } from '../node/LinkNode'; + +export interface SchemaLinkRendererProps { + editor: LexicalEditor; + node: LinkNode; + rel: null | string; + target: null | string; + text: string; + title: null | string; + url: string; +} + +export type SchemaLinkRenderer = (props: SchemaLinkRendererProps) => ReactNode; + +export interface SchemaLinkRendererConfig { + protocol: string; + render: SchemaLinkRenderer; +} + export interface ILinkService { + getAllowedProtocols(): Set; + getSchemaLinkRenderer(url: string): SchemaLinkRenderer | null; setLinkToolbar(enable: boolean): void; } @@ -13,13 +36,43 @@ export const ILinkService: IServiceID = genServiceId export class LinkService extends EventEmitter<'linkToolbarChange'> implements ILinkService { private _enableLinkToolbar: boolean = true; + private _allowedProtocols = new Set(['http:', 'https:', 'mailto:', 'sms:', 'tel:']); + private _schemaLinkRenderers = new Map(); public get enableLinkToolbar(): boolean { return this._enableLinkToolbar; } + getAllowedProtocols(): Set { + return this._allowedProtocols; + } + + getSchemaLinkRenderer(url: string): SchemaLinkRenderer | null { + try { + const { protocol } = new URL(url); + return this._schemaLinkRenderers.get(protocol) || null; + } catch { + return null; + } + } + + setAllowedProtocols(protocols: string[]): void { + this._allowedProtocols = new Set(protocols.map(normalizeProtocol)); + } + + setSchemaLinkRenderers(renderers: SchemaLinkRendererConfig[] = []): void { + this._schemaLinkRenderers = new Map( + renderers.map(({ protocol, render }) => [normalizeProtocol(protocol), render]), + ); + } + setLinkToolbar(enable: boolean): void { this._enableLinkToolbar = enable; this.emit('linkToolbarChange', enable); } } + +function normalizeProtocol(protocol: string): string { + const protocolName = protocol.split(':')[0]; + return `${protocolName}:`; +} From 45ff73974411cb65efd1ce487ff717739e4bfee0 Mon Sep 17 00:00:00 2001 From: huge Date: Sun, 28 Jun 2026 11:20:52 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20link=20card=20ifr?= =?UTF-8?q?ame=20and=20schema=20rendering?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/locale/index.ts | 4 + src/plugins/link/__test__/litexml.test.ts | 71 +++- .../link/__test__/renderer-registry.test.ts | 48 +++ .../link/__test__/schema-renderer.test.ts | 214 +++++++++- src/plugins/link/__test__/toolbar.test.ts | 349 ++++++++++++++++ src/plugins/link/command/index.ts | 3 +- src/plugins/link/conversion/index.ts | 244 +++++++++++ src/plugins/link/demos/data.json | 133 +++++- src/plugins/link/demos/index.tsx | 257 +++++++++++- src/plugins/link/index.ts | 17 +- src/plugins/link/node/LinkCardNode.ts | 266 ++++++++++++ src/plugins/link/node/LinkIframeNode.ts | 196 +++++++++ src/plugins/link/node/LinkNode.ts | 71 +--- src/plugins/link/node/SchemaNode.ts | 222 ++++++++++ src/plugins/link/normalization/index.ts | 58 +++ src/plugins/link/plugin/index.ts | 183 +++++++-- src/plugins/link/react/ReactLinkPlugin.tsx | 136 +++++- .../link/react/components/LinkCard.tsx | 197 +++++++++ .../link/react/components/LinkEdit.tsx | 99 ++++- .../link/react/components/LinkIframe.tsx | 176 ++++++++ .../link/react/components/LinkToolbar.tsx | 386 +++++++++++++++--- .../link/react/components/SchemaLink.tsx | 71 ++++ src/plugins/link/react/renderer-registry.ts | 198 +++++++++ src/plugins/link/react/style.ts | 36 +- src/plugins/link/react/type.ts | 27 +- src/plugins/link/service/i-link-service.ts | 364 +++++++++++++++-- src/plugins/toolbar/react/index.tsx | 126 +++--- src/react/Editor/demos/index.tsx | 264 +++++++++++- src/react/EditorProvider/demos/index.tsx | 4 + src/utils/updatePosition.ts | 7 +- 30 files changed, 4145 insertions(+), 282 deletions(-) create mode 100644 src/plugins/link/__test__/renderer-registry.test.ts create mode 100644 src/plugins/link/__test__/toolbar.test.ts create mode 100644 src/plugins/link/conversion/index.ts create mode 100644 src/plugins/link/node/LinkCardNode.ts create mode 100644 src/plugins/link/node/LinkIframeNode.ts create mode 100644 src/plugins/link/node/SchemaNode.ts create mode 100644 src/plugins/link/normalization/index.ts create mode 100644 src/plugins/link/react/components/LinkCard.tsx create mode 100644 src/plugins/link/react/components/LinkIframe.tsx create mode 100644 src/plugins/link/react/components/SchemaLink.tsx create mode 100644 src/plugins/link/react/renderer-registry.ts diff --git a/src/locale/index.ts b/src/locale/index.ts index e3d989a4..cb75b893 100644 --- a/src/locale/index.ts +++ b/src/locale/index.ts @@ -24,6 +24,10 @@ export default { replace: 'Replace', }, link: { + convertToCard: 'Convert to Card', + convertToIframe: 'Convert to Iframe', + convertToLink: 'Convert to Link', + convertToSchema: 'Convert to Schema', edit: 'Edit Link', editLinkTitle: 'Link', editTextTitle: 'Text', diff --git a/src/plugins/link/__test__/litexml.test.ts b/src/plugins/link/__test__/litexml.test.ts index ee672c49..216fb034 100644 --- a/src/plugins/link/__test__/litexml.test.ts +++ b/src/plugins/link/__test__/litexml.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest'; +import { describe, expect, it } from 'vitest'; import Editor, { resetRandomKey } from '@/editor-kernel'; import { CommonPlugin } from '@/plugins/common'; @@ -34,4 +34,73 @@ describe('link litexml', () => { `

logo

`, ); }); + + it('new link display nodes should write markdown as normal links', () => { + editor.setDocument('json', { + root: { + children: [ + { + description: 'Home page', + icon: '', + openTarget: '_self', + title: 'Card title', + type: 'link-card', + url: 'https://lobehub.com', + version: 1, + }, + { + src: 'https://lobehub.com', + title: 'Iframe title', + type: 'link-iframe', + url: 'https://lobehub.com/embed', + version: 1, + }, + { + payload: { id: '123' }, + schemaType: 'card', + title: 'Schema title', + type: 'schema-link', + url: 'schema://card/123', + version: 1, + }, + ], + direction: 'ltr', + type: 'root', + version: 1, + }, + }); + + const markdown = editor.getDocument('markdown') as unknown as string; + expect(markdown).toBe( + '[Card title](https://lobehub.com)\n' + + '[Iframe title](https://lobehub.com/embed)\n' + + '[Schema title](schema://card/123)\n', + ); + }); + + it('new link display nodes should read and write litexml', () => { + editor.setDocument( + 'litexml', + '' + + '' + + '' + + '' + + '', + ); + + const markdown = editor.getDocument('markdown') as unknown as string; + expect(markdown).toBe( + '[Card title](https://lobehub.com)\n' + + '[Iframe title](https://lobehub.com/embed)\n' + + '[Schema title](schema://card/123)\n', + ); + + const xml = editor.getDocument('litexml') as unknown as string; + expect(xml).toContain(' { + it('splits react schema rules into aligned core rules and renderers by id', () => { + const render = vi.fn(() => 'schema-render'); + const { coreRules, schemaRenderers } = splitReactSchemaRules([ + { + id: 'schema-card', + match: (url) => url.startsWith('schema://'), + render, + }, + ]); + + expect(coreRules?.[0]).toEqual({ + id: 'schema-card', + match: expect.any(Function), + }); + expect(schemaRenderers.get(coreRules?.[0].id || '')).toBe(render); + }); + + it('renders schema nodes by the schemaType id from split rules', () => { + const render = vi.fn(() => 'schema-render'); + const { schemaRenderers } = splitReactSchemaRules([ + { + id: 'schema-card', + match: (url) => url.startsWith('schema://'), + render, + }, + ]); + const registry = new LinkReactRendererRegistry(); + registry.update({ schemaRenderers }); + + expect( + registry.renderSchemaNode({ + editor: {} as any, + node: {} as any, + payload: null, + schema: null, + schemaType: 'schema-card', + title: 'Schema card', + url: 'schema://card/123', + }), + ).toBe('schema-render'); + expect(render).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/plugins/link/__test__/schema-renderer.test.ts b/src/plugins/link/__test__/schema-renderer.test.ts index 324ea1b4..fc273688 100644 --- a/src/plugins/link/__test__/schema-renderer.test.ts +++ b/src/plugins/link/__test__/schema-renderer.test.ts @@ -1,23 +1,223 @@ +import { + $createParagraphNode, + $createTextNode, + $getRoot, + $isElementNode, + type LexicalEditor, + createEditor, +} from 'lexical'; import { describe, expect, it, vi } from 'vitest'; +import { LinkNode } from '../node/LinkNode'; +import { SchemaNode } from '../node/SchemaNode'; +import { normalizeSchemaLinkNode } from '../plugin'; import { LinkService } from '../service/i-link-service'; -describe('schema link renderer', () => { - it('matches schema renderer protocols with or without url separators', () => { - const render = vi.fn(); +const editor = {} as LexicalEditor; + +describe('link display rules', () => { + it('matches generic and amap embed rules by configuration', () => { + const linkService = new LinkService(); + linkService.setEmbedRules([ + { + allowCard: true, + allowIframe: true, + id: 'amap', + match: (url) => url.includes('uri.amap.com'), + }, + { + allowCard: true, + id: 'generic', + match: (url) => /^https?:\/\//.test(url), + }, + ]); + + const context = { editor, text: 'map', title: 'map' }; + + expect(linkService.getEmbedRule('https://uri.amap.com/marker', context)?.id).toBe('amap'); + expect(linkService.getEmbedRule('https://lobehub.com', context)?.id).toBe('generic'); + expect(linkService.getEmbedRule('schema://card/123', context)).toBeNull(); + }); + + it('matches schema rules for schema and custom protocols', () => { const linkService = new LinkService(); + linkService.setSchemaRules([ + { + id: 'schema', + match: (url) => url.startsWith('schema://'), + }, + { + id: 'alipay', + match: (url) => url.startsWith('alipay://'), + }, + ]); - linkService.setSchemaLinkRenderers([{ protocol: 'schema://', render }]); + const context = { editor, text: 'pay', title: 'pay' }; - expect(linkService.getSchemaLinkRenderer('schema://card/123')).toBe(render); - expect(linkService.getSchemaLinkRenderer('http://example.com')).toBeNull(); + expect(linkService.getSchemaRule('schema://card/123', context)?.id).toBe('schema'); + expect(linkService.getSchemaRule('alipay://pay/2088', context)?.id).toBe('alipay'); + expect(linkService.getSchemaRule('https://lobehub.com', context)).toBeNull(); + }); + + it('parses schema url details for renderers', () => { + const linkService = new LinkService(); + const schema = linkService.parseSchemaUrl('schema://card/123?source=demo#top'); + + expect(schema).toEqual({ + hash: '#top', + host: 'card', + params: { source: 'demo' }, + pathname: '/123', + protocol: 'schema:', + raw: 'schema://card/123?source=demo#top', + search: '?source=demo', + }); }); it('allows custom protocols to be configured for sanitization', () => { const linkService = new LinkService(); - linkService.setAllowedProtocols(['schema://']); + linkService.setAllowedProtocols(['schema://', 'alipay:']); expect(linkService.getAllowedProtocols().has('schema:')).toBe(true); + expect(linkService.getAllowedProtocols().has('alipay:')).toBe(true); + }); + + it('keeps toolbar base enabled separate from suppression tokens', () => { + const linkService = new LinkService(); + const handleChange = vi.fn(); + linkService.on('linkToolbarChange', handleChange); + + const token = linkService.suppressLinkToolbar('test-toolbar'); + expect(linkService.enableLinkToolbar).toBe(false); + expect(handleChange).toHaveBeenLastCalledWith(false); + + linkService.setLinkToolbar(true); + expect(linkService.enableLinkToolbar).toBe(false); + + linkService.restoreLinkToolbar(token); + expect(linkService.enableLinkToolbar).toBe(true); + expect(handleChange).toHaveBeenLastCalledWith(true); + + linkService.setLinkToolbar(false); + expect(linkService.enableLinkToolbar).toBe(false); + const secondToken = linkService.suppressLinkToolbar('second-toolbar'); + linkService.restoreLinkToolbar(secondToken); + expect(linkService.enableLinkToolbar).toBe(false); + + linkService.setLinkToolbar(true); + expect(linkService.enableLinkToolbar).toBe(true); + }); + + it('keeps legacy schema renderer protocol matching available', () => { + const linkService = new LinkService(); + + linkService.setSchemaLinkRenderers([{ protocol: 'schema://' }]); + + expect(linkService.hasSchemaLinkRenderer('schema://card/123')).toBe(true); + expect(linkService.hasSchemaLinkRenderer('https://lobehub.com')).toBe(false); + }); + + it('updates protocol, schema rules, and legacy renderers through service config', () => { + const linkService = new LinkService(); + + linkService.updateConfig({ + allowedProtocols: ['alipay://'], + schemaLinkRenderers: [{ protocol: 'schema://' }], + schemaRules: [ + { + id: 'alipay', + match: (url) => url.startsWith('alipay://'), + }, + ], + }); + + const context = { editor, text: 'pay', title: 'pay' }; + expect(linkService.getAllowedProtocols().has('alipay:')).toBe(true); + expect(linkService.getAllowedProtocols().has('schema:')).toBe(true); + expect(linkService.hasSchemaLinkRenderer('schema://card/123')).toBe(true); + expect(linkService.getSchemaRule('schema://card/123', context)?.id).toBe('schema'); + expect(linkService.getSchemaRule('alipay://pay/2088', context)?.id).toBe('alipay'); + }); + + it('converts legacy schema renderer links to schema nodes automatically', async () => { + const linkService = new LinkService(); + const lexicalEditor = createEditor({ + nodes: [LinkNode, SchemaNode], + }); + linkService.setSchemaLinkRenderers([{ protocol: 'schema://' }]); + linkService.setSchemaRules([ + { + id: 'schema', + match: (url) => url.startsWith('schema://'), + }, + ]); + + await lexicalEditor.update(() => { + const paragraph = $createParagraphNode(); + const linkNode = new LinkNode('schema://card/123', { + rel: 'noreferrer', + target: '_blank', + title: 'Schema title', + }); + linkNode.append($createTextNode('Schema card')); + paragraph.append(linkNode); + $getRoot().append(paragraph); + + expect(normalizeSchemaLinkNode(linkNode, lexicalEditor, linkService)).toBe(true); + }); + + let childType = ''; + let schemaTitle = ''; + await lexicalEditor.getEditorState().read(() => { + const paragraph = $getRoot().getFirstChildOrThrow(); + expect($isElementNode(paragraph)).toBe(true); + if (!$isElementNode(paragraph)) return; + const schemaNode = paragraph.getChildren()[0]; + childType = schemaNode.getType(); + schemaTitle = schemaNode.getTextContent(); + }); + + expect(childType).toBe('schema-link'); + expect(schemaTitle).toBe('Schema title'); + }); + + it('normalizes schema rule links without legacy renderer registration', async () => { + const linkService = new LinkService(); + const lexicalEditor = createEditor({ + nodes: [LinkNode, SchemaNode], + }); + linkService.setSchemaRules([ + { + id: 'alipay', + match: (url) => url.startsWith('alipay://'), + }, + ]); + + await lexicalEditor.update(() => { + const paragraph = $createParagraphNode(); + const linkNode = new LinkNode('alipay://pay/2088?amount=10', { + title: 'Pay now', + }); + linkNode.append($createTextNode('Pay')); + paragraph.append(linkNode); + $getRoot().append(paragraph); + + expect(normalizeSchemaLinkNode(linkNode, lexicalEditor, linkService)).toBe(true); + }); + + let childType = ''; + let schemaTitle = ''; + await lexicalEditor.getEditorState().read(() => { + const paragraph = $getRoot().getFirstChildOrThrow(); + expect($isElementNode(paragraph)).toBe(true); + if (!$isElementNode(paragraph)) return; + const schemaNode = paragraph.getChildren()[0]; + childType = schemaNode.getType(); + schemaTitle = schemaNode.getTextContent(); + }); + + expect(childType).toBe('schema-link'); + expect(schemaTitle).toBe('Pay now'); }); }); diff --git a/src/plugins/link/__test__/toolbar.test.ts b/src/plugins/link/__test__/toolbar.test.ts new file mode 100644 index 00000000..37d3e147 --- /dev/null +++ b/src/plugins/link/__test__/toolbar.test.ts @@ -0,0 +1,349 @@ +import { + $createParagraphNode, + $createTextNode, + $getRoot, + $isElementNode, + type LexicalEditor, + createEditor, +} from 'lexical'; +import { describe, expect, it } from 'vitest'; + +import { + getLinkToolbarCapabilities, + replaceWithBlockIframeNode, + replaceWithCardNode, + replaceWithIframeNode, + replaceWithInlineNode, +} from '../conversion'; +import { LinkCardNode } from '../node/LinkCardNode'; +import { LinkIframeNode } from '../node/LinkIframeNode'; +import { LinkNode } from '../node/LinkNode'; +import { SchemaNode } from '../node/SchemaNode'; +import { LinkService } from '../service/i-link-service'; + +async function readCapabilities( + callback: (editor: LexicalEditor) => ReturnType, +) { + const lexicalEditor = createEditor({ + nodes: [LinkNode, LinkCardNode, LinkIframeNode, SchemaNode], + }); + let capabilities: ReturnType | undefined; + + await lexicalEditor.update(() => { + capabilities = callback(lexicalEditor); + }); + + return capabilities; +} + +describe('link toolbar conversions', () => { + it('shows card and iframe conversion for matching regular links', async () => { + const linkService = new LinkService(); + linkService.setEmbedRules([ + { + allowCard: true, + allowIframe: true, + id: 'web', + match: (url) => /^https?:\/\//.test(url), + }, + ]); + + await expect( + readCapabilities((editor) => + getLinkToolbarCapabilities( + new LinkNode('https://lobehub.com', { title: 'LobeHub' }), + editor, + linkService, + ), + ), + ).resolves.toEqual({ + canConvertToCard: true, + canConvertToIframe: true, + canConvertToLink: false, + canConvertToSchema: false, + }); + }); + + it('shows schema conversion only for matching schema links', async () => { + const linkService = new LinkService(); + linkService.setSchemaRules([ + { + id: 'alipay', + match: (url) => url.startsWith('alipay://'), + }, + ]); + + await expect( + readCapabilities((editor) => + getLinkToolbarCapabilities( + new LinkNode('alipay://pay/2088', { title: 'Pay' }), + editor, + linkService, + ), + ), + ).resolves.toEqual({ + canConvertToCard: false, + canConvertToIframe: false, + canConvertToLink: false, + canConvertToSchema: true, + }); + }); + + it('allows card and iframe to convert to each other and back to link', async () => { + const linkService = new LinkService(); + + await expect( + readCapabilities((editor) => + getLinkToolbarCapabilities( + new LinkCardNode('https://lobehub.com', 'LobeHub'), + editor, + linkService, + ), + ), + ).resolves.toEqual({ + canConvertToCard: false, + canConvertToIframe: true, + canConvertToLink: true, + canConvertToSchema: false, + }); + + await expect( + readCapabilities((editor) => + getLinkToolbarCapabilities( + new LinkIframeNode('https://lobehub.com', undefined, 'LobeHub'), + editor, + linkService, + ), + ), + ).resolves.toEqual({ + canConvertToCard: true, + canConvertToIframe: false, + canConvertToLink: true, + canConvertToSchema: false, + }); + }); + + it('allows schema nodes to convert back only to link', async () => { + const linkService = new LinkService(); + + await expect( + readCapabilities((editor) => + getLinkToolbarCapabilities( + new SchemaNode('schema://card/123', 'card', { id: 123 }, 'Schema card'), + editor, + linkService, + ), + ), + ).resolves.toEqual({ + canConvertToCard: false, + canConvertToIframe: false, + canConvertToLink: true, + canConvertToSchema: false, + }); + }); + + it('replaces an empty paragraph wrapper when converting a card to iframe', async () => { + const lexicalEditor = createEditor({ + nodes: [LinkNode, LinkCardNode, LinkIframeNode, SchemaNode], + }); + + await lexicalEditor.update(() => { + const paragraph = $createParagraphNode(); + const cardNode = new LinkCardNode('https://lobehub.com', 'LobeHub'); + paragraph.append(cardNode); + $getRoot().append(paragraph); + + replaceWithBlockIframeNode( + cardNode, + new LinkIframeNode('https://lobehub.com', 'https://lobehub.com', 'LobeHub'), + ); + }); + + let rootChildrenTypes: string[] = []; + await lexicalEditor.getEditorState().read(() => { + rootChildrenTypes = $getRoot() + .getChildren() + .map((node) => node.getType()); + }); + + expect(rootChildrenTypes).toEqual(['link-iframe']); + }); + + it('converts a regular link node to a card node', async () => { + const lexicalEditor = createEditor({ + nodes: [LinkNode, LinkCardNode, LinkIframeNode, SchemaNode], + }); + const linkService = new LinkService(); + linkService.setEmbedRules([ + { + allowCard: true, + getCardPayload: (url) => ({ title: 'Card title', url }), + id: 'web', + match: (url) => /^https?:\/\//.test(url), + }, + ]); + + await lexicalEditor.update(() => { + const paragraph = $createParagraphNode(); + const linkNode = new LinkNode('https://lobehub.com', { title: 'LobeHub' }); + linkNode.append($createTextNode('LobeHub')); + paragraph.append(linkNode); + $getRoot().append(paragraph); + + replaceWithCardNode(linkNode, lexicalEditor, linkService); + }); + + let childType = ''; + let title = ''; + await lexicalEditor.getEditorState().read(() => { + const paragraph = $getRoot().getFirstChildOrThrow(); + expect($isElementNode(paragraph)).toBe(true); + if (!$isElementNode(paragraph)) return; + const child = paragraph.getChildren()[0]; + childType = child.getType(); + title = child.getTextContent(); + }); + + expect(childType).toBe('link-card'); + expect(title).toBe('Card title'); + }); + + it('converts a regular link node to a block iframe node', async () => { + const lexicalEditor = createEditor({ + nodes: [LinkNode, LinkCardNode, LinkIframeNode, SchemaNode], + }); + const linkService = new LinkService(); + linkService.setEmbedRules([ + { + allowIframe: true, + getIframePayload: (url) => ({ src: `${url}/embed`, title: 'Iframe title', url }), + id: 'web', + match: (url) => /^https?:\/\//.test(url), + }, + ]); + + await lexicalEditor.update(() => { + const paragraph = $createParagraphNode(); + const linkNode = new LinkNode('https://lobehub.com', { title: 'LobeHub' }); + linkNode.append($createTextNode('LobeHub')); + paragraph.append(linkNode); + $getRoot().append(paragraph); + + replaceWithIframeNode(linkNode, lexicalEditor, linkService); + }); + + let rootChildrenTypes: string[] = []; + let textContent = ''; + await lexicalEditor.getEditorState().read(() => { + const child = $getRoot().getFirstChildOrThrow(); + rootChildrenTypes = $getRoot() + .getChildren() + .map((node) => node.getType()); + textContent = child.getTextContent(); + }); + + expect(rootChildrenTypes).toEqual(['link-iframe']); + expect(textContent).toBe('Iframe title'); + }); + + it('splits a paragraph when converting an inline link in the middle to iframe', async () => { + const lexicalEditor = createEditor({ + nodes: [LinkNode, LinkCardNode, LinkIframeNode, SchemaNode], + }); + const linkService = new LinkService(); + linkService.setEmbedRules([ + { + allowIframe: true, + id: 'web', + match: (url) => /^https?:\/\//.test(url), + }, + ]); + + await lexicalEditor.update(() => { + const paragraph = $createParagraphNode(); + const linkNode = new LinkNode('https://lobehub.com', { title: 'LobeHub' }); + linkNode.append($createTextNode('LobeHub')); + paragraph.append($createTextNode('before '), linkNode, $createTextNode(' after')); + $getRoot().append(paragraph); + + replaceWithIframeNode(linkNode, lexicalEditor, linkService); + }); + + let rootChildrenTypes: string[] = []; + let rootChildrenText: string[] = []; + await lexicalEditor.getEditorState().read(() => { + const children = $getRoot().getChildren(); + rootChildrenTypes = children.map((node) => node.getType()); + rootChildrenText = children.map((node) => node.getTextContent()); + }); + + expect(rootChildrenTypes).toEqual(['paragraph', 'link-iframe', 'paragraph']); + expect(rootChildrenText).toEqual(['before ', 'LobeHub', ' after']); + }); + + it('wraps a block iframe in a paragraph when converting to card', async () => { + const lexicalEditor = createEditor({ + nodes: [LinkNode, LinkCardNode, LinkIframeNode, SchemaNode], + }); + const linkService = new LinkService(); + linkService.setEmbedRules([ + { + allowCard: true, + id: 'web', + match: (url) => /^https?:\/\//.test(url), + }, + ]); + + await lexicalEditor.update(() => { + const iframeNode = new LinkIframeNode('https://lobehub.com', undefined, 'LobeHub'); + $getRoot().append(iframeNode); + + replaceWithCardNode(iframeNode, lexicalEditor, linkService); + }); + + let rootChildrenTypes: string[] = []; + let paragraphChildrenTypes: string[] = []; + await lexicalEditor.getEditorState().read(() => { + const paragraph = $getRoot().getFirstChildOrThrow(); + rootChildrenTypes = $getRoot() + .getChildren() + .map((node) => node.getType()); + expect($isElementNode(paragraph)).toBe(true); + if (!$isElementNode(paragraph)) return; + paragraphChildrenTypes = paragraph.getChildren().map((node) => node.getType()); + }); + + expect(rootChildrenTypes).toEqual(['paragraph']); + expect(paragraphChildrenTypes).toEqual(['link-card']); + }); + + it('wraps a block iframe in a paragraph when converting to link', async () => { + const lexicalEditor = createEditor({ + nodes: [LinkNode, LinkCardNode, LinkIframeNode, SchemaNode], + }); + + await lexicalEditor.update(() => { + const iframeNode = new LinkIframeNode('https://lobehub.com', undefined, 'LobeHub'); + const linkNode = new LinkNode('https://lobehub.com', { title: 'LobeHub' }); + linkNode.append($createTextNode('LobeHub')); + $getRoot().append(iframeNode); + + replaceWithInlineNode(iframeNode, linkNode); + }); + + let rootChildrenTypes: string[] = []; + let paragraphChildrenTypes: string[] = []; + await lexicalEditor.getEditorState().read(() => { + const paragraph = $getRoot().getFirstChildOrThrow(); + rootChildrenTypes = $getRoot() + .getChildren() + .map((node) => node.getType()); + expect($isElementNode(paragraph)).toBe(true); + if (!$isElementNode(paragraph)) return; + paragraphChildrenTypes = paragraph.getChildren().map((node) => node.getType()); + }); + + expect(rootChildrenTypes).toEqual(['paragraph']); + expect(paragraphChildrenTypes).toEqual(['link']); + }); +}); diff --git a/src/plugins/link/command/index.ts b/src/plugins/link/command/index.ts index 0566b956..072babec 100644 --- a/src/plugins/link/command/index.ts +++ b/src/plugins/link/command/index.ts @@ -1,4 +1,3 @@ -import { LinkNode } from '@lexical/link'; import { mergeRegister } from '@lexical/utils'; import { $createTextNode, @@ -10,7 +9,7 @@ import { createCommand, } from 'lexical'; -import { $createLinkNode } from '../node/LinkNode'; +import { $createLinkNode, LinkNode } from '../node/LinkNode'; export const INSERT_LINK_COMMAND = createCommand<{ title?: string; url?: string }>( 'INSERT_LINK_COMMAND', diff --git a/src/plugins/link/conversion/index.ts b/src/plugins/link/conversion/index.ts new file mode 100644 index 00000000..e058c704 --- /dev/null +++ b/src/plugins/link/conversion/index.ts @@ -0,0 +1,244 @@ +/* eslint-disable @typescript-eslint/no-use-before-define */ +import { + $createParagraphNode, + $createTextNode, + $getNodeByKey, + $isParagraphNode, + $isRootNode, + LexicalEditor, + LexicalNode, +} from 'lexical'; + +import { $createLinkCardNode, $isLinkCardNode, LinkCardNode } from '../node/LinkCardNode'; +import { $createLinkIframeNode, $isLinkIframeNode, LinkIframeNode } from '../node/LinkIframeNode'; +import { $createLinkNode, $isLinkNode, LinkNode } from '../node/LinkNode'; +import { $createSchemaNode, $isSchemaNode, SchemaNode } from '../node/SchemaNode'; +import { + LinkRuleContext, + LinkService, + LinkToolbarNode, + getNodeTitle, + getNodeUrl, +} from '../service/i-link-service'; + +export interface LinkToolbarCapabilities { + canConvertToCard: boolean; + canConvertToIframe: boolean; + canConvertToLink: boolean; + canConvertToSchema: boolean; +} + +export function getLinkToolbarCapabilities( + node: LinkToolbarNode, + editor: LexicalEditor, + linkService: LinkService | null, +): LinkToolbarCapabilities { + const url = getNodeUrl(node); + const title = getNodeTitle(node); + const context = createRuleContext(editor, title, title); + const embedRule = linkService?.getEmbedRule(url, context); + const schemaRule = + $isLinkNode(node) && + linkService?.getSchemaRule(url, { + ...context, + schema: linkService.parseSchemaUrl(url), + }); + + return { + canConvertToCard: + ($isLinkNode(node) && Boolean(embedRule?.allowCard)) || $isLinkIframeNode(node), + canConvertToIframe: + ($isLinkNode(node) && Boolean(embedRule?.allowIframe)) || $isLinkCardNode(node), + canConvertToLink: !$isLinkNode(node), + canConvertToSchema: $isLinkNode(node) && Boolean(schemaRule), + }; +} + +export function convertLinkToolbarNodeToLink(node: LinkToolbarNode): LinkNode { + const url = getNodeUrl(node); + const title = getNodeTitle(node); + const linkNode = $createLinkNode(url, { + target: $isLinkCardNode(node) ? node.getOpenTarget() : null, + title, + }); + linkNode.append($createTextNode(title)); + replaceWithInlineNode(node, linkNode); + return linkNode; +} + +export function convertLinkToolbarNodeByKeyToLink(editor: LexicalEditor, key: string): void { + editor.update(() => { + const node = $getNodeByKey(key); + if (!$isLinkToolbarNode(node)) return; + convertLinkToolbarNodeToLink(node).selectEnd(); + }); +} + +export function convertLinkNodeToSchema( + node: LinkNode, + editor: LexicalEditor, + linkService: LinkService, +): SchemaNode | null { + const url = node.getURL(); + const title = node.getTitle() || node.getTextContent() || url; + const schema = linkService.parseSchemaUrl(url); + const rule = linkService.getSchemaRule(url, { + ...createRuleContext(editor, node.getTextContent(), title), + schema, + }); + if (!rule) return null; + const parsed = rule.parse?.(url, schema); + const payload = + parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? normalizeSchemaPayload(parsed as Record) + : { payload: parsed }; + const schemaNode = $createSchemaNode({ + payload: payload.payload, + schemaType: (payload.schemaType as string | undefined) || rule.id, + title: (payload.title as string | undefined) || title, + url: (payload.url as string | undefined) || url, + }); + node.replace(schemaNode); + return schemaNode; +} + +export function convertLinkNodeByKeyToSchema( + editor: LexicalEditor, + key: string, + linkService: LinkService, +): void { + editor.update(() => { + const node = $getNodeByKey(key); + if (!$isLinkNode(node)) return; + convertLinkNodeToSchema(node, editor, linkService); + }); +} + +export function replaceWithCardNode( + node: LinkNode | LinkIframeNode, + editor: LexicalEditor, + linkService: LinkService, +): LinkCardNode { + const url = getNodeUrl(node); + const title = getNodeTitle(node); + const context = createRuleContext(editor, title, title); + const rule = linkService.getEmbedRule(url, context); + const payload = rule?.getCardPayload?.(url, context); + const cardNode = $createLinkCardNode({ + description: payload?.description, + icon: payload?.icon, + openTarget: payload?.openTarget || ($isLinkNode(node) ? node.getTarget() : null) || '_blank', + title: payload?.title || title, + url: payload?.url || url, + }); + replaceWithInlineNode(node, cardNode); + return cardNode; +} + +export function replaceNodeByKeyWithCardNode( + editor: LexicalEditor, + key: string, + linkService: LinkService, +): void { + editor.update(() => { + const node = $getNodeByKey(key); + if (!$isLinkNode(node) && !$isLinkIframeNode(node)) return; + replaceWithCardNode(node, editor, linkService); + }); +} + +export function replaceWithIframeNode( + node: LinkNode | LinkCardNode, + editor: LexicalEditor, + linkService: LinkService, +): LinkIframeNode { + const url = getNodeUrl(node); + const title = getNodeTitle(node); + const context = createRuleContext(editor, title, title); + const rule = linkService.getEmbedRule(url, context); + const payload = rule?.getIframePayload?.(url, context); + const iframeNode = $createLinkIframeNode({ + src: payload?.src || url, + title: payload?.title || title, + url: payload?.url || url, + }); + replaceWithBlockIframeNode(node, iframeNode); + return iframeNode; +} + +export function replaceNodeByKeyWithIframeNode( + editor: LexicalEditor, + key: string, + linkService: LinkService, +): void { + editor.update(() => { + const node = $getNodeByKey(key); + if (!$isLinkNode(node) && !$isLinkCardNode(node)) return; + replaceWithIframeNode(node, editor, linkService); + }); +} + +export function replaceWithInlineNode(node: LexicalNode, inlineNode: LexicalNode): void { + if (node.isInline()) { + node.replace(inlineNode); + return; + } + + const paragraph = $createParagraphNode(); + paragraph.append(inlineNode); + node.replace(paragraph); +} + +export function replaceWithBlockIframeNode(node: LexicalNode, iframeNode: LinkIframeNode): void { + const parent = node.getParent(); + if (parent && !$isRootNode(parent) && !parent.isInline() && parent.getChildrenSize() === 1) { + parent.replace(iframeNode); + return; + } + if (parent && $isParagraphNode(parent)) { + const previousSiblings = node.getPreviousSiblings(); + const nextSiblings = node.getNextSiblings(); + + if (previousSiblings.length === 0) { + parent.insertBefore(iframeNode); + node.remove(); + if (parent.getChildrenSize() === 0) parent.remove(); + return; + } + + if (nextSiblings.length === 0) { + parent.insertAfter(iframeNode); + node.remove(); + return; + } + + const nextParagraph = $createParagraphNode(); + nextParagraph.setFormat(parent.getFormatType()); + nextParagraph.setIndent(parent.getIndent()); + nextParagraph.setDirection(parent.getDirection()); + nextParagraph.append(...nextSiblings); + + parent.insertAfter(iframeNode); + iframeNode.insertAfter(nextParagraph); + node.remove(); + return; + } + node.replace(iframeNode); +} + +export function $isLinkToolbarNode(node: LexicalNode | null | undefined): node is LinkToolbarNode { + return ( + $isLinkNode(node) || $isLinkCardNode(node) || $isLinkIframeNode(node) || $isSchemaNode(node) + ); +} + +function createRuleContext(editor: LexicalEditor, text: string, title: string): LinkRuleContext { + return { editor, text, title }; +} + +function normalizeSchemaPayload(payload: Record): Record { + if ('payload' in payload || 'schemaType' in payload || 'title' in payload || 'url' in payload) { + return payload; + } + return { payload }; +} diff --git a/src/plugins/link/demos/data.json b/src/plugins/link/demos/data.json index 42b07db8..f1a80659 100644 --- a/src/plugins/link/demos/data.json +++ b/src/plugins/link/demos/data.json @@ -1,6 +1,59 @@ { "root": { "children": [ + { + "children": [ + { + "detail": 0, + "format": 0, + "mode": "normal", + "style": "", + "text": "Hover these links and convert them: ", + "type": "text", + "version": 1 + } + ], + "direction": "ltr", + "format": "", + "indent": 0, + "textFormat": 0, + "textStyle": "", + "type": "paragraph", + "version": 1 + }, + { + "children": [ + { + "children": [ + { + "detail": 0, + "format": 0, + "mode": "normal", + "style": "", + "text": "LobeHub website", + "type": "text", + "version": 1 + } + ], + "direction": "ltr", + "format": "", + "indent": 0, + "rel": null, + "target": null, + "title": "LobeHub website", + "type": "link", + "url": "https://lobehub.com", + "version": 1 + } + ], + "direction": "ltr", + "format": "", + "indent": 0, + "textFormat": 0, + "textStyle": "", + "type": "paragraph", + "version": 1 + }, { "children": [ { @@ -10,7 +63,7 @@ "format": 0, "mode": "normal", "style": "", - "text": "lobehub", + "text": "Amap share link", "type": "text", "version": 1 } @@ -18,26 +71,90 @@ "direction": "ltr", "format": "", "indent": 0, + "rel": null, + "target": null, + "title": "Amap share link", "type": "link", - "version": 1, + "url": "https://uri.amap.com/marker?position=116.397428,39.90923&name=Beijing", + "version": 1 + } + ], + "direction": "ltr", + "format": "", + "indent": 0, + "textFormat": 0, + "textStyle": "", + "type": "paragraph", + "version": 1 + }, + { + "children": [ + { + "children": [ + { + "detail": 0, + "format": 0, + "mode": "normal", + "style": "", + "text": "Schema card", + "type": "text", + "version": 1 + } + ], + "direction": "ltr", + "format": "", + "indent": 0, "rel": null, "target": null, - "title": "http://lobehub.com", - "url": "http://lobehub.com" + "title": "Schema card", + "type": "link", + "url": "schema://card/123?source=demo", + "version": 1 } ], "direction": "ltr", "format": "", "indent": 0, + "textFormat": 0, + "textStyle": "", "type": "paragraph", - "version": 1, + "version": 1 + }, + { + "children": [ + { + "children": [ + { + "detail": 0, + "format": 0, + "mode": "normal", + "style": "", + "text": "Alipay action", + "type": "text", + "version": 1 + } + ], + "direction": "ltr", + "format": "", + "indent": 0, + "rel": null, + "target": null, + "title": "Alipay action", + "type": "link", + "url": "alipay://pay/2088?amount=10", + "version": 1 + } + ], + "direction": "ltr", + "format": "", + "indent": 0, "textFormat": 0, - "textStyle": "" + "textStyle": "", + "type": "paragraph", + "version": 1 } ], "direction": "ltr", - "format": "", - "indent": 0, "type": "root", "version": 1 } diff --git a/src/plugins/link/demos/index.tsx b/src/plugins/link/demos/index.tsx index 00eff766..8e2499ef 100644 --- a/src/plugins/link/demos/index.tsx +++ b/src/plugins/link/demos/index.tsx @@ -1,14 +1,267 @@ -import { ReactEditor, ReactEditorContent, ReactLinkPlugin, ReactPlainText } from '@lobehub/editor'; +import { + type LinkEmbedRule, + ReactEditor, + ReactEditorContent, + ReactLinkPlugin, + ReactPlainText, + type SchemaRule, +} from '@lobehub/editor'; +import { createStaticStyles } from 'antd-style'; import content from './data.json'; +const styles = createStaticStyles(({ css, cssVar }) => ({ + card: css` + display: inline-flex; + gap: 4px; + align-items: center; + + max-width: min(320px, 100%); + padding-block: 0; + padding-inline: 2px; + + line-height: 1; + color: ${cssVar.colorLink}; + text-decoration: none; + vertical-align: baseline; + + &[data-selected='true'] { + border-radius: 5px; + outline: 2px solid ${cssVar.colorPrimaryBorder}; + outline-offset: 1px; + } + + &:hover { + color: ${cssVar.colorLinkHover}; + text-decoration: none; + } + `, + icon: css` + position: relative; + inset-block-start: 0.06em; + + overflow: hidden; + display: grid; + flex: none; + place-items: center; + + width: 1.1em; + height: 1.1em; + border-radius: 5px; + + font-size: 11px; + line-height: 1; + color: ${cssVar.colorTextSecondary}; + + background: ${cssVar.colorFillQuaternary}; + + img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; + } + `, + iframe: css` + position: relative; + + overflow: hidden; + + width: 100%; + border: 1px solid ${cssVar.colorBorderSecondary}; + border-radius: 8px; + + &[data-selected='true'], + &:focus, + &:focus-within { + border-color: ${cssVar.colorPrimary}; + outline: none; + box-shadow: 0 0 0 2px ${cssVar.colorPrimaryBg}; + } + `, + iframeLoading: css` + display: flex; + gap: 8px; + align-items: center; + justify-content: center; + + height: 320px; + + font-size: 13px; + color: ${cssVar.colorTextSecondary}; + + background: ${cssVar.colorFillQuaternary}; + `, + iframeSpinner: css` + width: 14px; + height: 14px; + border: 2px solid ${cssVar.colorBorderSecondary}; + border-block-start-color: ${cssVar.colorPrimary}; + border-radius: 50%; + + animation: lobe-link-iframe-spin 1s linear infinite; + + @keyframes lobe-link-iframe-spin { + to { + transform: rotate(360deg); + } + } + `, + iframeTitle: css` + padding-block: 8px; + padding-inline: 10px; + border-block-end: 1px solid ${cssVar.colorBorderSecondary}; + + font-size: 12px; + color: ${cssVar.colorTextSecondary}; + `, + schema: css` + display: inline-grid; + gap: 4px; + + padding-block: 8px; + padding-inline: 10px; + border: 1px solid ${cssVar.colorBorderSecondary}; + border-radius: 8px; + + background: ${cssVar.colorFillQuaternary}; + `, + title: css` + overflow: hidden; + display: inline-block; + + min-width: 0; + + line-height: 1; + text-overflow: ellipsis; + white-space: nowrap; + `, +})); + +const amapIcon = + 'data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 48 48%22%3E%3Crect width=%2248%22 height=%2248%22 rx=%2210%22 fill=%22%23f6fbff%22/%3E%3Cpath d=%22M8 24 40 8 27 40l-5-13-14-3Z%22 fill=%22%231677ff%22/%3E%3Cpath d=%22m22 27 18-19-13 32-5-13Z%22 fill=%22%2300b96b%22 opacity=%22.82%22/%3E%3Cpath d=%22M8 24 40 8 19 29l3-2-14-3Z%22 fill=%22%2369c0ff%22/%3E%3C/svg%3E'; + +const amapRule: LinkEmbedRule = { + allowCard: true, + allowIframe: true, + getCardPayload: (url) => ({ + icon: amapIcon, + title: '高德地图', + url, + }), + getIframePayload: (url) => ({ + src: url, + title: 'Amap embed', + url, + }), + id: 'amap-share', + match: (url) => /(^https?:\/\/)?(uri\.amap\.com|amap\.com)\//.test(url), +}; + +const genericWebRule: LinkEmbedRule = { + allowCard: true, + allowIframe: true, + getCardPayload: (url, context) => ({ + description: url, + title: context.title || url, + url, + }), + id: 'generic-web', + match: (url) => /^https?:\/\//.test(url), +}; + +const schemaRules: SchemaRule[] = [ + { + id: 'schema-card', + match: (url) => url.startsWith('schema://'), + parse: (url, schema) => ({ + payload: schema, + schemaType: schema?.host || 'schema', + title: `Schema ${schema?.pathname || url}`, + url, + }), + }, + { + id: 'alipay', + match: (url) => url.startsWith('alipay://'), + parse: (url, schema) => ({ + payload: schema, + schemaType: 'alipay', + title: 'Alipay schema action', + url, + }), + }, +]; + export default () => { return ( - + ( + + + {icon ? : title.slice(0, 1).toUpperCase()} + + {title} + + )} + renderLinkIframe={({ isLoading, isSelected, onLoad, onMouseDownCapture, src, title }) => ( +
+
+ {title} +
+ {isLoading && ( +
+ + Loading embed... +
+ )} +