diff --git a/.changeset/link-authoring-ux.md b/.changeset/link-authoring-ux.md new file mode 100644 index 00000000..b94cd130 --- /dev/null +++ b/.changeset/link-authoring-ux.md @@ -0,0 +1,19 @@ +--- +"@inkeep/open-knowledge": minor +--- + +Link authoring in the visual editor now matches the conventions of the editors +you already know. Typing a full URL (`https://…`, `www.…`, or an email) and +pressing space or enter converts it to a link — including local-development +URLs like `http://localhost:5174` — while plain words and filenames like +`AGENTS.md` are never touched, and one undo restores plain text. Pasting or +dropping a lone URL links it; pasting a URL over selected text links that text +instead; `Cmd+Shift+V` still pastes plain. Typing markdown's `[text](url)` +shorthand converts on the closing parenthesis. `Cmd+K` is now dual-role: with +text selected it opens the link popover (focused, pre-filled from your +clipboard when it holds a URL), with the cursor inside a link it opens that +link for editing, and everywhere else it keeps opening the command palette. +Linkification never fires on content written by other collaborators or agents +— only on your own local typing and paste gestures. Also fixes two +long-standing popover bugs: the URL input now reliably takes focus on open, +and Escape correctly closes the popover after dismissing path suggestions. diff --git a/bun.lock b/bun.lock index 11c625db..2f7495f1 100644 --- a/bun.lock +++ b/bun.lock @@ -180,6 +180,7 @@ "@testing-library/jest-dom": "^6", "@testing-library/react": "^16", "@testing-library/user-event": "^14", + "@tiptap/starter-kit": "^3.22.3", "@types/jsdom": "^28", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", diff --git a/docs/content/features/editor.mdx b/docs/content/features/editor.mdx index d980feaf..606bbe18 100644 --- a/docs/content/features/editor.mdx +++ b/docs/content/features/editor.mdx @@ -1,7 +1,7 @@ --- title: Editor icon: LuPenLine -description: The OpenKnowledge editor. WYSIWYG markdown, source toggle, content blocks, inline frontmatter, and the file sidebar. +description: The OpenKnowledge editor. WYSIWYG markdown, source toggle, content blocks, link authoring, inline frontmatter, and the file sidebar. --- The editor is where you read, write, and collaborate on your docs, and where AI agents land when they edit. WYSIWYG by default with a source-mode toggle, a frontmatter properties pane, real-time CRDT collaboration, and a file sidebar that organizes the project. @@ -40,6 +40,15 @@ An `html preview` code fence renders its contents live in a sandboxed iframe ins `Cmd+F` opens find in the visual editor; `Cmd+Option+F` (`Ctrl+H` on Windows / Linux) adds the replace controls. `Cmd+G` and `Shift+Cmd+G` step through matches. +## Links + +Links work the way they do in the editors you already know: + +- **Type a URL, then press space or enter.** A full URL (`https://…`, `www.…`, or an email address) becomes a link when you press space or enter after it. Plain words and filenames like `AGENTS.md` are left alone, and one `Cmd+Z` turns a conversion back into plain text. +- **Paste or drag in a URL.** Pasted (or dropped) on its own, it becomes a link. Pasted over selected text, it links that text instead — the text stays, the URL becomes its destination. `Cmd+Shift+V` pastes plain, with no linking. +- **Type `[text](url)`.** Closing the parenthesis converts the markdown shorthand into a real link; `Cmd+Z` restores the literal text. +- **Press `Cmd+K` with text selected.** The link popover opens with the URL field focused, pre-filled from your clipboard when it holds a URL. With the cursor inside an existing link, `Cmd+K` opens that link for editing. With no selection, `Cmd+K` keeps opening the command palette, as everywhere else. + ## Right-click menu and spell check In the desktop app, right-clicking editable text opens a native context menu. Any editable field gets **Cut / Copy / Paste / Select All**; when there's a selection or a flagged word under the cursor, **Look Up** (the macOS dictionary panel) and **Search with Google** join it. @@ -185,6 +194,7 @@ Keyboard shortcuts (use `Ctrl` on Windows / Linux in place of `Cmd`, unless the | `Cmd+1` ... `Cmd+8` | Jump to one of the first eight editor tabs. | | `Cmd+9` | Jump to the last editor tab. | | `Cmd+Shift+T` | Reopen the most recently closed editor tab. | +| `Cmd+K` | Command palette — or, with text selected in the visual editor, add a link (cursor inside a link edits it). | | `Cmd+Option+S` | Show / Hide the file sidebar (left). Matches Apple's standard sidebar accelerator. | | `Cmd+Option+B` | Show / Hide the document panel (right). Matches VS Code's Secondary Side Bar accelerator. | | `Cmd+J` | Show / Hide the docked terminal (desktop app). | diff --git a/packages/app/package.json b/packages/app/package.json index 5b9b286a..69510af9 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -26,7 +26,7 @@ "measure:fuzz": "bash scripts/measure-fuzz.sh", "measure:stress": "bash scripts/measure-stress.sh", "perf:compare": "bash scripts/perf-compare.sh", - "test:e2e": "playwright test tests/stress/ux-interactions.e2e.ts tests/stress/file-tree-create.e2e.ts tests/stress/file-tree-drag-to-root.e2e.ts tests/stress/file-tree-deselect-to-root.e2e.ts tests/stress/file-tree-compact-folders.e2e.ts tests/stress/create-then-rename-editable.e2e.ts tests/stress/find-replace.e2e.ts tests/stress/editor-tabs.e2e.ts tests/stress/crdt-stress.e2e.ts tests/stress/slash-command.e2e.ts tests/stress/paste-fidelity.e2e.ts tests/stress/fr-7a-disconnect-source-mode.e2e.ts tests/stress/docs-open.e2e.ts tests/stress/frozen-table-headers.e2e.ts tests/stress/asset-embed.e2e.ts tests/stress/asset-embed-advanced.e2e.ts tests/stress/asset-click-dispatch.e2e.ts tests/stress/handoff.e2e.ts tests/stress/multi-agent-presence.e2e.ts tests/stress/editor-mode-persistence.e2e.ts tests/stress/sidebar-search-pill.e2e.ts tests/stress/command-palette-semantic.e2e.ts tests/stress/agent-activity-panel.e2e.ts tests/stress/drop-pipeline-auto-open.e2e.ts tests/stress/command-palette-flicker.e2e.ts tests/stress/cm6-list-hanging-indent.e2e.ts tests/stress/ng7-rapid-nav-coherence.e2e.ts tests/stress/reveal-on-activate.e2e.ts tests/stress/selection-indicator.e2e.ts tests/stress/new-file-cross-doc-bleed.e2e.ts tests/stress/editor-mode-flip-cross-doc-bleed.e2e.ts tests/stress/editor-area-viewport-resize.e2e.ts tests/stress/qa-sidebar-responsive.e2e.ts tests/stress/prd-6955-reassertion-repro.e2e.ts tests/stress/prd-6955-reassertion-wedge.e2e.ts tests/stress/prd-6914-repro.e2e.ts tests/stress/showall-lazy-tree.e2e.ts tests/stress/tabs-component-strip.e2e.ts tests/stress/source-find-scroll.e2e.ts tests/stress/harness-app-warmth.e2e.ts tests/stress/qa-canary-authoring-both-modes.e2e.ts tests/stress/qa-canary-live-typing.e2e.ts tests/stress/keystroke-cadence-danger-space.e2e.ts tests/stress/jsx-unregistered-backspace-delete.e2e.ts tests/stress/jsx-unregistered-ime-concurrent.e2e.ts tests/stress/jsx-backspace-delete.e2e.ts", + "test:e2e": "playwright test tests/stress/ux-interactions.e2e.ts tests/stress/file-tree-create.e2e.ts tests/stress/file-tree-drag-to-root.e2e.ts tests/stress/file-tree-deselect-to-root.e2e.ts tests/stress/file-tree-compact-folders.e2e.ts tests/stress/create-then-rename-editable.e2e.ts tests/stress/find-replace.e2e.ts tests/stress/editor-tabs.e2e.ts tests/stress/crdt-stress.e2e.ts tests/stress/slash-command.e2e.ts tests/stress/paste-fidelity.e2e.ts tests/stress/fr-7a-disconnect-source-mode.e2e.ts tests/stress/docs-open.e2e.ts tests/stress/frozen-table-headers.e2e.ts tests/stress/asset-embed.e2e.ts tests/stress/asset-embed-advanced.e2e.ts tests/stress/asset-click-dispatch.e2e.ts tests/stress/handoff.e2e.ts tests/stress/multi-agent-presence.e2e.ts tests/stress/editor-mode-persistence.e2e.ts tests/stress/sidebar-search-pill.e2e.ts tests/stress/command-palette-semantic.e2e.ts tests/stress/agent-activity-panel.e2e.ts tests/stress/drop-pipeline-auto-open.e2e.ts tests/stress/command-palette-flicker.e2e.ts tests/stress/cm6-list-hanging-indent.e2e.ts tests/stress/ng7-rapid-nav-coherence.e2e.ts tests/stress/reveal-on-activate.e2e.ts tests/stress/selection-indicator.e2e.ts tests/stress/new-file-cross-doc-bleed.e2e.ts tests/stress/editor-mode-flip-cross-doc-bleed.e2e.ts tests/stress/editor-area-viewport-resize.e2e.ts tests/stress/qa-sidebar-responsive.e2e.ts tests/stress/prd-6955-reassertion-repro.e2e.ts tests/stress/prd-6955-reassertion-wedge.e2e.ts tests/stress/prd-6914-repro.e2e.ts tests/stress/showall-lazy-tree.e2e.ts tests/stress/tabs-component-strip.e2e.ts tests/stress/source-find-scroll.e2e.ts tests/stress/harness-app-warmth.e2e.ts tests/stress/qa-canary-authoring-both-modes.e2e.ts tests/stress/qa-canary-live-typing.e2e.ts tests/stress/keystroke-cadence-danger-space.e2e.ts tests/stress/jsx-unregistered-backspace-delete.e2e.ts tests/stress/jsx-unregistered-ime-concurrent.e2e.ts tests/stress/jsx-backspace-delete.e2e.ts tests/stress/link-authoring-bytes.e2e.ts tests/stress/link-authoring-apex.e2e.ts", "test:e2e:install-browsers": "playwright install chromium webkit firefox", "test:visual": "playwright test --config playwright.visual.config.ts", "test:visual:update": "playwright test --config playwright.visual.config.ts --update-snapshots", @@ -180,6 +180,7 @@ "@testing-library/jest-dom": "^6", "@testing-library/react": "^16", "@testing-library/user-event": "^14", + "@tiptap/starter-kit": "^3.22.3", "@types/jsdom": "^28", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", diff --git a/packages/app/playwright.config.ts b/packages/app/playwright.config.ts index 0111e4b7..3386df49 100644 --- a/packages/app/playwright.config.ts +++ b/packages/app/playwright.config.ts @@ -14,16 +14,19 @@ import { defineConfig } from '@playwright/test'; */ /** - * Single-browser (Chromium) — all E2E tests use programmatic clipboard + * Single-browser (Chromium). Most E2E tests use programmatic clipboard * injection via `dispatchEvent(new ClipboardEvent(...))`, not real browser - * clipboard APIs. Cross-browser clipboard differences (Safari user-activation - * rules, Firefox async clipboard restrictions) are not exercised because the - * tests bypass the native clipboard permission model entirely. Running 3× + * clipboard APIs, so cross-browser clipboard differences (Safari + * user-activation rules, Firefox async clipboard restrictions) aren't + * exercised. The one exception is the FR8 clipboard-denial case in + * link-authoring-apex.e2e.ts, which runs a fresh context with clipboard-read + * withheld to assert the real permission gate degrades silently — it needs no + * cross-browser matrix either (the denial path is host-agnostic). Running 3× * browsers adds ~10 minutes of CI time with zero additional coverage. * - * If future tests exercise REAL browser clipboard (e.g., `page.keyboard.press - * ('Meta+V')` with system clipboard content), add per-file project scoping - * for those tests only — not a global 3× multiplier. + * If future tests exercise REAL browser clipboard reads/writes that DO vary by + * engine, add per-file project scoping for those tests only — not a global 3× + * multiplier. */ const isCI = !!process.env.CI; diff --git a/packages/app/src/editor/bubble-menu/BubbleMenuBar.tsx b/packages/app/src/editor/bubble-menu/BubbleMenuBar.tsx index a75ed58f..2bc96080 100644 --- a/packages/app/src/editor/bubble-menu/BubbleMenuBar.tsx +++ b/packages/app/src/editor/bubble-menu/BubbleMenuBar.tsx @@ -5,8 +5,8 @@ import { useEditorState } from '@tiptap/react'; import { BubbleMenu } from '@tiptap/react/menus'; import { useRef, useState } from 'react'; import { Separator } from '@/components/ui/separator'; -import { getFindReplaceState } from '../find-replace/tiptap-find-replace-extension'; import { BlockTypeSelector } from './BlockTypeSelector'; +import { shouldShowBubbleMenu } from './bubble-menu-state'; import { EditWithAiBubbleButton } from './EditWithAiBubbleButton'; import { FileBubbleButtons, isFileNodeSelected } from './FileBubbleButtons'; import { FootnoteBubbleButton } from './FootnoteBubbleButton'; @@ -14,22 +14,6 @@ import { ImageAlignButtons, isImageNodeSelected } from './ImageAlignButtons'; import { InlineFormatButtons } from './InlineFormatButtons'; import { LinkEditPopover } from './LinkEditPopover'; -function shouldShowBubbleMenu({ editor }: { editor: Editor }): boolean { - if (getFindReplaceState(editor.state).query) return false; - if (editor.isActive('codeBlock')) return false; - // Image / File NodeSelection — show the menu so the per-type buttons - // (`ImageAlignButtons` / `FileBubbleButtons`) are reachable even though - // `textBetween` is empty across a leaf atom. Bypasses the text-bearing- - // selection guards below. - if (isImageNodeSelected(editor)) return true; - if (isFileNodeSelected(editor)) return true; - if (editor.state.selection.empty) return false; - const { from, to } = editor.state.selection; - const text = editor.state.doc.textBetween(from, to, ' '); - if (!text.trim()) return false; - return true; -} - export function BubbleMenuBar({ editor, shortcutEnabled = true, @@ -124,7 +108,11 @@ export function BubbleMenuBar({ - + ({ usePageList: () => ({ @@ -27,12 +35,18 @@ function makeEditor({ onSetLink, onUnsetLink, selectionEmpty = true, + selectionFrom = 1, + selectionTo = 1, + viewFocused = true, }: { active?: boolean; href?: string; onSetLink?: (attrs: { href: string }) => void; onUnsetLink?: () => void; selectionEmpty?: boolean; + selectionFrom?: number; + selectionTo?: number; + viewFocused?: boolean; } = {}): Editor { const chain = { focus: () => chain, @@ -48,7 +62,14 @@ function makeEditor({ }; return { - state: { selection: { empty: selectionEmpty } }, + state: { + selection: { empty: selectionEmpty, from: selectionFrom, to: selectionTo }, + doc: { textBetween: () => (selectionEmpty ? '' : 'docs') }, + }, + view: { hasFocus: () => viewFocused }, + // The claim reads the non-throwing `editorView` field (never the `view` + // throwing proxy); mirror the real Editor by exposing both. + editorView: { hasFocus: () => viewFocused }, getAttributes: mock((name: string) => (name === 'link' ? { href } : {})), isActive: mock((name: string) => name === 'link' && active), on: mock(() => {}), @@ -57,14 +78,43 @@ function makeEditor({ } as unknown as Editor; } -function renderPopover(editor: Editor) { +function renderPopover(editor: Editor, opts: { shortcutEnabled?: boolean } = {}) { return render( - + , ); } +function cmdKEvent(init: KeyboardEventInit = {}): KeyboardEvent { + return new KeyboardEvent('keydown', { + key: 'k', + bubbles: true, + cancelable: true, + ...(isMacOS() ? { metaKey: true } : { ctrlKey: true }), + ...init, + }); +} + +function stubClipboardRead(impl: () => Promise) { + const readText = mock(impl); + const original = Object.getOwnPropertyDescriptor(navigator, 'clipboard'); + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { readText }, + }); + return { + readText, + restore: () => { + if (original) { + Object.defineProperty(navigator, 'clipboard', original); + } else { + Reflect.deleteProperty(navigator, 'clipboard'); + } + }, + }; +} + afterEach(() => { cleanup(); if (nativeRequestAnimationFrame) { @@ -144,3 +194,279 @@ describe('LinkEditPopover', () => { }); }); }); + +describe('LinkEditPopover ⌘K dual-role claim', () => { + test('claims ⌘K and opens the URL input when the focused editor has a text selection', async () => { + renderPopover(makeEditor({ selectionEmpty: false, selectionFrom: 1, selectionTo: 5 }), { + shortcutEnabled: true, + }); + + const event = cmdKEvent(); + fireEvent(window, event); + + expect(event.defaultPrevented).toBe(true); + await waitFor(() => { + expect(screen.getByRole('combobox', { name: 'Link URL' })).toBeTruthy(); + }); + }); + + test('claims in the capture phase, starving window-bubble consumers like the palette', () => { + // The command palette's opener is a window-bubble keydown listener. A + // claimed ⌘K must never reach one — even one registered before this + // component mounted. Dispatch below window so the phases separate. + let bubbleFired = false; + const bubbleListener = () => { + bubbleFired = true; + }; + window.addEventListener('keydown', bubbleListener); + try { + renderPopover(makeEditor({ selectionEmpty: false, selectionFrom: 1, selectionTo: 5 }), { + shortcutEnabled: true, + }); + + const event = cmdKEvent(); + fireEvent(document.body, event); + + expect(event.defaultPrevented).toBe(true); + expect(bubbleFired).toBe(false); + } finally { + window.removeEventListener('keydown', bubbleListener); + } + }); + + test('leaves ⌘K for the palette when the editor is not focused', () => { + renderPopover( + makeEditor({ selectionEmpty: false, selectionFrom: 1, selectionTo: 5, viewFocused: false }), + { shortcutEnabled: true }, + ); + + const event = cmdKEvent(); + fireEvent(window, event); + + expect(event.defaultPrevented).toBe(false); + expect(screen.queryByRole('combobox', { name: 'Link URL' })).toBeNull(); + }); + + test('stays inert for pooled editors without shortcutEnabled', () => { + renderPopover(makeEditor({ selectionEmpty: false, selectionFrom: 1, selectionTo: 5 })); + + const event = cmdKEvent(); + fireEvent(window, event); + + expect(event.defaultPrevented).toBe(false); + expect(screen.queryByRole('combobox', { name: 'Link URL' })).toBeNull(); + }); + + test('does not claim ⌘⇧K', () => { + renderPopover(makeEditor({ selectionEmpty: false, selectionFrom: 1, selectionTo: 5 }), { + shortcutEnabled: true, + }); + + const event = cmdKEvent({ shiftKey: true }); + fireEvent(window, event); + + expect(event.defaultPrevented).toBe(false); + expect(screen.queryByRole('combobox', { name: 'Link URL' })).toBeNull(); + }); + + test('routes a caret inside a tracked link to the chip edit spine, not the popover', () => { + // Real mark-identity state needs a live PM view; stub the plugin state so + // the caret resolves to a stable id (same idiom as component-items tests). + const getStateSpy = spyOn(markIdentityKey, 'getState').mockReturnValue({ + byId: new Map([ + [ + 'm7', + { id: 'm7', markType: 'link', from: 1, to: 5, attrs: { href: 'https://example.com' } }, + ], + ]), + counter: 7, + } as never); + + // Capture rAF without invoking it: the deferred getInteractionLayer → + // setActiveNode needs a live editor view, out of scope for this unit. + let rafScheduled = false; + const originalRaf = globalThis.requestAnimationFrame; + globalThis.requestAnimationFrame = (() => { + rafScheduled = true; + return 0; + }) as typeof globalThis.requestAnimationFrame; + + try { + renderPopover(makeEditor({ selectionEmpty: true, selectionFrom: 3 }), { + shortcutEnabled: true, + }); + + const event = cmdKEvent(); + fireEvent(window, event); + + expect(event.defaultPrevented).toBe(true); + expect(consumePendingLinkEdit('m7')).toBe(true); + expect(rafScheduled).toBe(true); + expect(screen.queryByRole('combobox', { name: 'Link URL' })).toBeNull(); + } finally { + globalThis.requestAnimationFrame = originalRaf; + getStateSpy.mockRestore(); + _resetPendingLinkEditForTest(); + } + }); + + test('opens the URL input when the programmatic-open seam fires for the active editor', async () => { + renderPopover(makeEditor(), { shortcutEnabled: true }); + + act(() => { + emitOpenLinkEditPopover(); + }); + + await waitFor(() => { + expect(screen.getByRole('combobox', { name: 'Link URL' })).toBeTruthy(); + }); + }); + + test('ignores the programmatic-open seam when not the active editor instance', () => { + renderPopover(makeEditor()); + + act(() => { + emitOpenLinkEditPopover(); + }); + + expect(screen.queryByRole('combobox', { name: 'Link URL' })).toBeNull(); + }); +}); + +describe('LinkEditPopover clipboard pre-fill', () => { + test('pre-fills an allowlisted-scheme clipboard URL, selected, when the Link button opens an empty input', async () => { + const clip = stubClipboardRead(() => Promise.resolve('https://inkeep.com/docs')); + try { + renderPopover(makeEditor({ active: false })); + + fireEvent.mouseDown(screen.getByRole('button', { name: 'Insert link' })); + + const input = screen.getByRole('combobox', { name: 'Link URL' }) as HTMLInputElement; + await waitFor(() => { + expect(input.value).toBe('https://inkeep.com/docs'); + }); + await waitFor(() => { + expect(input.selectionStart).toBe(0); + expect(input.selectionEnd).toBe('https://inkeep.com/docs'.length); + }); + } finally { + clip.restore(); + } + }); + + test('pre-fills when the ⌘K claim opens the popover for a text selection', async () => { + const clip = stubClipboardRead(() => Promise.resolve('https://inkeep.com/')); + try { + renderPopover(makeEditor({ selectionEmpty: false, selectionFrom: 1, selectionTo: 5 }), { + shortcutEnabled: true, + }); + + fireEvent(window, cmdKEvent()); + + await waitFor(() => { + expect((screen.getByRole('combobox', { name: 'Link URL' }) as HTMLInputElement).value).toBe( + 'https://inkeep.com/', + ); + }); + } finally { + clip.restore(); + } + }); + + test('leaves the input empty when the clipboard holds prose, with no error surfaced', async () => { + let resolveRead!: (value: string) => void; + const clip = stubClipboardRead( + () => + new Promise((resolve) => { + resolveRead = resolve; + }), + ); + try { + renderPopover(makeEditor({ active: false })); + fireEvent.mouseDown(screen.getByRole('button', { name: 'Insert link' })); + const input = screen.getByRole('combobox', { name: 'Link URL' }) as HTMLInputElement; + + await act(async () => { + resolveRead('meeting notes for tuesday'); + await Promise.resolve(); + }); + + expect(input.value).toBe(''); + } finally { + clip.restore(); + } + }); + + test('degrades silently to an empty, functional input when the clipboard read is denied', async () => { + let rejectRead!: (reason: unknown) => void; + const clip = stubClipboardRead( + () => + new Promise((_resolve, reject) => { + rejectRead = reject; + }), + ); + try { + renderPopover(makeEditor({ active: false })); + fireEvent.mouseDown(screen.getByRole('button', { name: 'Insert link' })); + const input = screen.getByRole('combobox', { name: 'Link URL' }) as HTMLInputElement; + + await act(async () => { + rejectRead(new DOMException('Read permission denied.', 'NotAllowedError')); + await Promise.resolve(); + }); + + expect(input.value).toBe(''); + fireEvent.change(input, { target: { value: 'https://typed.example/' } }); + expect(input.value).toBe('https://typed.example/'); + } finally { + clip.restore(); + } + }); + + test('does not read the clipboard when opening on an existing link', async () => { + const clip = stubClipboardRead(() => Promise.resolve('https://clipboard.example/')); + try { + renderPopover(makeEditor({ href: 'https://example.com/docs' })); + fireEvent.mouseDown(screen.getByRole('button', { name: 'Insert link' })); + const input = screen.getByRole('combobox', { name: 'Link URL' }) as HTMLInputElement; + expect(input.value).toBe('https://example.com/docs'); + + await act(async () => { + await Promise.resolve(); + }); + + // The read itself is user-visible on the web (a clipboard permission + // prompt), so not-reading on an edit-open is the observable contract — + // alongside the existing href surviving untouched. + expect(clip.readText).not.toHaveBeenCalled(); + expect(input.value).toBe('https://example.com/docs'); + } finally { + clip.restore(); + } + }); + + test('a value the user typed before the clipboard resolved wins', async () => { + let resolveRead!: (value: string) => void; + const clip = stubClipboardRead( + () => + new Promise((resolve) => { + resolveRead = resolve; + }), + ); + try { + renderPopover(makeEditor({ active: false })); + fireEvent.mouseDown(screen.getByRole('button', { name: 'Insert link' })); + const input = screen.getByRole('combobox', { name: 'Link URL' }) as HTMLInputElement; + fireEvent.change(input, { target: { value: 'guides/install' } }); + + await act(async () => { + resolveRead('https://late.example/'); + await Promise.resolve(); + }); + + expect(input.value).toBe('guides/install'); + } finally { + clip.restore(); + } + }); +}); diff --git a/packages/app/src/editor/bubble-menu/LinkEditPopover.tsx b/packages/app/src/editor/bubble-menu/LinkEditPopover.tsx index b40c33a6..d399548b 100644 --- a/packages/app/src/editor/bubble-menu/LinkEditPopover.tsx +++ b/packages/app/src/editor/bubble-menu/LinkEditPopover.tsx @@ -1,16 +1,99 @@ import { Trans, useLingui } from '@lingui/react/macro'; import type { Editor } from '@tiptap/react'; import { ArrowUpRight, CornerDownLeft, Link, Trash2 } from 'lucide-react'; -import { type KeyboardEvent, useEffect, useRef, useState } from 'react'; +import { + type Dispatch, + type KeyboardEvent as ReactKeyboardEvent, + type RefObject, + type SetStateAction, + useEffect, + useRef, + useState, +} from 'react'; import { usePageList } from '@/components/PageListContext'; import { Button } from '@/components/ui/button'; import { Kbd } from '@/components/ui/kbd'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; -import { formatShortcut } from '@/lib/keyboard-shortcuts'; +import { formatShortcut, matchesKeyboardShortcut } from '@/lib/keyboard-shortcuts'; +import { detectClipboardPrefillUrl } from '../clipboard/lone-url'; +import { setPendingLinkEdit } from '../extensions/link-edit-autoopen'; +import { getInteractionLayer } from '../interaction-layer-host'; import { buildCurrentRelativeMarkdownHref, openHashHrefInNewTab } from '../internal-link-helpers'; import { type LinkPathSuggestion, LinkPathSuggestionInput } from '../link-path-suggestions'; +import { assertNeverAddLinkAction, resolveAddLinkShortcutAction } from './bubble-menu-state'; +import { + emitOpenLinkEditPopover, + subscribeToOpenLinkEditPopover, +} from './link-edit-popover-events'; -export function LinkEditPopover({ editor }: { editor: Editor }) { +function initialLinkInputUrl(editor: Editor): string { + return editor.state.selection.empty && editor.isActive('link') + ? (editor.getAttributes('link').href ?? '') + : ''; +} + +/** + * Best-effort clipboard pre-fill for a just-opened, still-empty URL input. + * Denied/unavailable clipboard and non-URL content degrade the same way — + * the input stays empty, no error surfaced. The functional guard applies + * the pre-fill only while the input is still untouched, so a value the + * user has begun typing always wins over a late clipboard resolve; the + * select() makes the guess replaceable in one gesture when it does land. + */ +async function prefillUrlFromClipboard( + inputRef: RefObject, + setUrl: Dispatch>, +): Promise { + let text: string; + try { + text = await navigator.clipboard.readText(); + } catch (error) { + // Expected denials (permission, insecure context, empty clipboard) arrive + // as DOMExceptions and degrade silently by design. Anything else — a + // polyfill or CSP failure that would make pre-fill always-empty — warns + // (debug is suppressed by default console filters) so it is + // field-diagnosable. + if (!(error instanceof DOMException)) { + console.warn('[link-popover] clipboard pre-fill read failed unexpectedly', error); + } + return; + } + const href = detectClipboardPrefillUrl(text); + if (href === null) return; + setUrl((prev) => (prev === '' ? href : prev)); + requestAnimationFrame(() => { + const input = inputRef.current; + if (input && input.value === href) { + input.select(); + } + }); +} + +// Shared open path for the Link button and the programmatic-open seam. The +// clipboard read fires only when the input opens empty (the add case): an +// edit-open already carries the existing href, and reading a value we would +// discard could still cost the user a browser permission prompt. +function openLinkInput( + editor: Editor, + inputRef: RefObject, + setUrl: Dispatch>, + setShowInput: Dispatch>, +): void { + const initial = initialLinkInputUrl(editor); + setUrl(initial); + setShowInput(true); + if (initial === '') { + void prefillUrlFromClipboard(inputRef, setUrl); + } +} + +export function LinkEditPopover({ + editor, + shortcutEnabled = false, +}: { + editor: Editor; + shortcutEnabled?: boolean; +}) { const { t } = useLingui(); const [showInput, setShowInput] = useState(false); const [url, setUrl] = useState(''); @@ -20,11 +103,65 @@ export function LinkEditPopover({ editor }: { editor: Editor }) { const isLinkActive = editor.state.selection.empty && editor.isActive('link'); const currentUrl = editor.getAttributes('link').href ?? ''; - function getInitialUrlForLinkInput() { - return editor.state.selection.empty && editor.isActive('link') - ? (editor.getAttributes('link').href ?? '') - : ''; - } + // Programmatic-open seam — same open path as the Link button below (focus + // rides the showInput effect's rAF). Gated on `shortcutEnabled` because the + // event is window-scoped and every pooled editor mounts its own popover — + // only the active document's instance may react. + useEffect(() => { + return subscribeToOpenLinkEditPopover(() => { + if (!shortcutEnabled) return; + openLinkInput(editor, inputRef, setUrl, setShowInput); + }); + }, [shortcutEnabled, editor]); + + // ⌘K dual-role claim. Capture phase deterministically beats the command + // palette's window-bubble listener; the claim stands only while this doc's + // WYSIWYG owns focus AND a link affordance applies (non-empty text + // selection → popover; caret inside a link → chip edit surface). Any other + // ⌘K — source mode, no selection, palette/input focus — falls through and + // stays the palette. Mirrors the Edit-with-AI capture-listener pattern. + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent): void => { + if (!shortcutEnabled) return; + if (!matchesKeyboardShortcut(event, 'add-link')) return; + // `editor.view` is a throwing proxy while `editor.editorView` is unset + // (PM construction, Activity recycle/remount) — and this window-capture + // listener is registered whenever the shortcut is enabled, so a ⌘K can + // land in exactly that window. Read the non-throwing field structurally + // (TipTap types it private; same duck-typed access as the + // InteractionLayer's getEditorDom): unset reads as "not focused" and + // falls through cleanly to the palette. + const liveView = (editor as unknown as { editorView?: { hasFocus(): boolean } | null }) + .editorView; + if (!liveView?.hasFocus()) return; + const action = resolveAddLinkShortcutAction(editor); + if (action === null) return; + + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + switch (action.kind) { + case 'open-popover': + emitOpenLinkEditPopover(); + return; + case 'edit-link': { + // Chip edit spine shared with the slash-command Link insert: flag + // the mark id for auto-edit, then activate its prop panel next frame. + const { markId } = action; + setPendingLinkEdit(markId); + requestAnimationFrame(() => { + getInteractionLayer(editor).setActiveNode(markId); + }); + return; + } + default: + assertNeverAddLinkAction(action); + } + }; + + window.addEventListener('keydown', handleKeyDown, { capture: true }); + return () => window.removeEventListener('keydown', handleKeyDown, { capture: true }); + }, [shortcutEnabled, editor]); // Reset link input when selection collapses (bubble menu hides) useEffect(() => { @@ -40,9 +177,34 @@ export function LinkEditPopover({ editor }: { editor: Editor }) { }, [editor]); useEffect(() => { - if (showInput) { - requestAnimationFrame(() => inputRef.current?.focus()); - } + if (!showInput) return; + // The input lives inside the floating bubble menu, which stays + // visibility:hidden (unfocusable — focus() is a silent no-op) until + // floating-ui finishes positioning, an unbounded number of frames after + // mount. So a single rAF focus never lands. Retry each frame until focus + // actually LANDS once, then stop permanently — never re-grab afterwards, + // so the loop can't fight the user. Cleanup cancels on close/unmount; the + // attempt cap is a backstop only. + let cancelled = false; + let frameId = 0; + const focusInput = (attempts: number): void => { + if (cancelled) return; + const el = inputRef.current; + if (el) { + el.focus(); + if (document.activeElement === el) return; + } + if (attempts < 60) { + frameId = requestAnimationFrame(() => focusInput(attempts + 1)); + } else { + console.warn('[link-popover] URL input never became focusable'); + } + }; + frameId = requestAnimationFrame(() => focusInput(0)); + return () => { + cancelled = true; + cancelAnimationFrame(frameId); + }; }, [showInput]); function applyLink() { @@ -63,7 +225,7 @@ export function LinkEditPopover({ editor }: { editor: Editor }) { setUrl(buildCurrentRelativeMarkdownHref(suggestion.path, null)); } - function handleKeyDown(e: KeyboardEvent) { + function handleKeyDown(e: ReactKeyboardEvent) { if (e.key === 'Enter') { e.preventDefault(); applyLink(); @@ -158,8 +320,7 @@ export function LinkEditPopover({ editor }: { editor: Editor }) { className={isLinkActive ? 'bg-accent text-primary' : 'text-accent-foreground'} onMouseDown={(e) => { e.preventDefault(); - setUrl(getInitialUrlForLinkInput()); - setShowInput(true); + openLinkInput(editor, inputRef, setUrl, setShowInput); }} > @@ -167,7 +328,7 @@ export function LinkEditPopover({ editor }: { editor: Editor }) { Link - {formatShortcut('command-palette')} + {formatShortcut('add-link')} ); diff --git a/packages/app/src/editor/bubble-menu/bubble-menu-state.test.ts b/packages/app/src/editor/bubble-menu/bubble-menu-state.test.ts new file mode 100644 index 00000000..a855d978 --- /dev/null +++ b/packages/app/src/editor/bubble-menu/bubble-menu-state.test.ts @@ -0,0 +1,120 @@ +/** + * resolveAddLinkShortcutAction — the ⌘K dual-role routing decision — against + * real headless editors (StarterKit + the fidelity link mark, plus the real + * mark-identity plugin where the caret branch needs it). The claim contract: + * a non-null action is returned exactly when the matching link affordance is + * reachable; everything else must return null so the keystroke falls through + * to the command palette. + */ + +import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test'; +import { LinkFidelity } from '@inkeep/open-knowledge-core'; +import { Editor, Extension } from '@tiptap/core'; +import { TextSelection } from '@tiptap/pm/state'; +import StarterKit from '@tiptap/starter-kit'; +import { markIdentityKey, markIdentityPlugin } from '../extensions/mark-identity'; +import { installDomGlobals } from '../walk-currency-test-harness'; +import { resolveAddLinkShortcutAction } from './bubble-menu-state'; + +let restoreDomGlobals: (() => void) | null = null; + +beforeAll(() => { + restoreDomGlobals = installDomGlobals(); +}); + +afterAll(() => { + restoreDomGlobals?.(); + restoreDomGlobals = null; +}); + +const MarkIdentityForTest = Extension.create({ + name: 'markIdentityForTest', + addProseMirrorPlugins() { + return [markIdentityPlugin({ markTypes: ['link'] })]; + }, +}); + +const editors: Editor[] = []; + +afterEach(() => { + for (const editor of editors.splice(0)) editor.destroy(); +}); + +function makeEditor(content: string, opts: { withIdentity?: boolean } = {}): Editor { + const host = document.createElement('div'); + document.body.appendChild(host); + const editor = new Editor({ + element: host, + content, + extensions: [ + // StarterKit v3 bundles its own Link; drop it so the fidelity mark is + // the only `link` in the schema (and keep its stock autolink off). + StarterKit.configure({ link: false }), + LinkFidelity.configure({ autolink: false }), + ...(opts.withIdentity ? [MarkIdentityForTest] : []), + ], + }); + editors.push(editor); + return editor; +} + +function select(editor: Editor, from: number, to: number = from): void { + editor.view.dispatch( + editor.state.tr.setSelection(TextSelection.create(editor.state.doc, from, to)), + ); +} + +describe('resolveAddLinkShortcutAction', () => { + test('routes a non-empty text selection to the popover', () => { + const editor = makeEditor('

hello world

'); + select(editor, 1, 6); + expect(resolveAddLinkShortcutAction(editor)).toEqual({ kind: 'open-popover' }); + }); + + test('routes a cross-block text selection to the popover', () => { + const editor = makeEditor('

one

two

'); + select(editor, 2, 7); + expect(resolveAddLinkShortcutAction(editor)).toEqual({ kind: 'open-popover' }); + }); + + test('falls through on a collapsed caret outside any link', () => { + const editor = makeEditor('

hello world

'); + select(editor, 3); + expect(resolveAddLinkShortcutAction(editor)).toBeNull(); + }); + + test("routes a caret inside a tracked link to that link's edit surface", () => { + const editor = makeEditor('

see docs now

', { + withIdentity: true, + }); + select(editor, 6); + + const identity = markIdentityKey.getState(editor.state); + const tracked = [...(identity?.byId.values() ?? [])].find((info) => info.markType === 'link'); + if (!tracked) throw new Error('link mark was not tracked by mark-identity'); + + expect(resolveAddLinkShortcutAction(editor)).toEqual({ + kind: 'edit-link', + markId: tracked.id, + }); + }); + + test('falls through on a caret inside a link when mark identity is not installed', () => { + const editor = makeEditor('

see docs now

'); + select(editor, 6); + expect(resolveAddLinkShortcutAction(editor)).toBeNull(); + }); + + test('falls through inside a code block', () => { + const editor = makeEditor('
const x = 1
'); + select(editor, 2, 6); + expect(resolveAddLinkShortcutAction(editor)).toBeNull(); + }); + + test('falls through on a whitespace-only selection', () => { + const editor = makeEditor('

'); + editor.view.dispatch(editor.state.tr.insertText('a b', 1, 1)); + select(editor, 2, 5); + expect(resolveAddLinkShortcutAction(editor)).toBeNull(); + }); +}); diff --git a/packages/app/src/editor/bubble-menu/bubble-menu-state.ts b/packages/app/src/editor/bubble-menu/bubble-menu-state.ts new file mode 100644 index 00000000..0a2bba37 --- /dev/null +++ b/packages/app/src/editor/bubble-menu/bubble-menu-state.ts @@ -0,0 +1,66 @@ +/** + * Pure editor-state predicates for the WYSIWYG bubble menu: when the bar is + * visible, and how a ⌘K press routes while a link affordance could apply. + * + * Kept out of `BubbleMenuBar` so `LinkEditPopover`'s shortcut listener can + * share the bar's exact visibility predicate without an import cycle + * (BubbleMenuBar → LinkEditPopover → this module). + */ + +import type { Editor } from '@tiptap/react'; +import { findMarkIdAt } from '../extensions/mark-identity'; +import { getFindReplaceState } from '../find-replace/tiptap-find-replace-extension'; +import { isFileNodeSelected } from './FileBubbleButtons'; +import { isImageNodeSelected } from './ImageAlignButtons'; + +export function shouldShowBubbleMenu({ editor }: { editor: Editor }): boolean { + if (getFindReplaceState(editor.state).query) return false; + if (editor.isActive('codeBlock')) return false; + // Image / File NodeSelection — show the menu so the per-type buttons + // (`ImageAlignButtons` / `FileBubbleButtons`) are reachable even though + // `textBetween` is empty across a leaf atom. Bypasses the text-bearing- + // selection guards below. + if (isImageNodeSelected(editor)) return true; + if (isFileNodeSelected(editor)) return true; + if (editor.state.selection.empty) return false; + const { from, to } = editor.state.selection; + const text = editor.state.doc.textBetween(from, to, ' '); + if (!text.trim()) return false; + return true; +} + +export type AddLinkShortcutAction = + | { kind: 'open-popover' } + | { kind: 'edit-link'; markId: string }; + +/** Compile-time exhaustiveness backstop for `AddLinkShortcutAction` consumers + * (same convention as `assertNeverLinkTarget`): a new variant fails the + * switch's `default` typecheck instead of silently mis-routing. */ +export function assertNeverAddLinkAction(value: never): never { + throw new Error(`Unhandled AddLinkShortcutAction variant: ${JSON.stringify(value as unknown)}`); +} + +/** + * How a ⌘K press should act on the current editor state, or null when no + * link affordance applies and the press should fall through to the command + * palette. + * + * - Collapsed caret inside an existing link → open that link's chip edit + * surface (id resolved from mark-identity state; null when untracked). + * - Non-empty text selection with the bar's text branch reachable → open the + * link popover. Mirrors `shouldShowBubbleMenu` minus its image/file bypass + * so a claim never eats the keystroke while the popover is unreachable + * (code block, whitespace-only selection, find-replace active, media + * NodeSelection). + */ +export function resolveAddLinkShortcutAction(editor: Editor): AddLinkShortcutAction | null { + const { selection } = editor.state; + if (selection.empty) { + if (!editor.isActive('link')) return null; + const markId = findMarkIdAt(editor.state, selection.from, 'link'); + return markId === null ? null : { kind: 'edit-link', markId }; + } + if (!shouldShowBubbleMenu({ editor })) return null; + if (isImageNodeSelected(editor) || isFileNodeSelected(editor)) return null; + return { kind: 'open-popover' }; +} diff --git a/packages/app/src/editor/bubble-menu/link-edit-popover-events.test.ts b/packages/app/src/editor/bubble-menu/link-edit-popover-events.test.ts new file mode 100644 index 00000000..81b1b633 --- /dev/null +++ b/packages/app/src/editor/bubble-menu/link-edit-popover-events.test.ts @@ -0,0 +1,53 @@ +/** + * The open-link-popover pub/sub contract: emit reaches a live subscriber, + * unsubscribe detaches it, and an injected EventTarget scopes the channel + * (the SSR-safe default target path is exercised by the dom suite). + */ + +import { expect, test } from 'bun:test'; +import { + emitOpenLinkEditPopover, + subscribeToOpenLinkEditPopover, +} from './link-edit-popover-events'; + +test('emit reaches a subscriber on the shared target', () => { + const target = new EventTarget(); + let calls = 0; + const unsubscribe = subscribeToOpenLinkEditPopover(() => { + calls++; + }, target); + try { + emitOpenLinkEditPopover(target); + emitOpenLinkEditPopover(target); + expect(calls).toBe(2); + } finally { + unsubscribe(); + } +}); + +test('unsubscribe detaches the listener', () => { + const target = new EventTarget(); + let calls = 0; + const unsubscribe = subscribeToOpenLinkEditPopover(() => { + calls++; + }, target); + emitOpenLinkEditPopover(target); + unsubscribe(); + emitOpenLinkEditPopover(target); + expect(calls).toBe(1); +}); + +test('subscribers on a different target never hear the emit', () => { + const targetA = new EventTarget(); + const targetB = new EventTarget(); + let calls = 0; + const unsubscribe = subscribeToOpenLinkEditPopover(() => { + calls++; + }, targetA); + try { + emitOpenLinkEditPopover(targetB); + expect(calls).toBe(0); + } finally { + unsubscribe(); + } +}); diff --git a/packages/app/src/editor/bubble-menu/link-edit-popover-events.ts b/packages/app/src/editor/bubble-menu/link-edit-popover-events.ts new file mode 100644 index 00000000..3933b593 --- /dev/null +++ b/packages/app/src/editor/bubble-menu/link-edit-popover-events.ts @@ -0,0 +1,35 @@ +/** + * Window-scoped pub/sub that asks the bubble menu's link popover to open its + * URL input and take focus. The ⌘K claim in `LinkEditPopover`'s capture + * listener fires this instead of flipping component state directly so the + * open+focus implementation lives in exactly one place (the component's + * subscriber), and any future caller outside the bubble-menu subtree can + * reuse it without a ref into it. + * + * Mirrors the `ask-ai-composer-events` idiom. The signal is intent-only — no + * payload; the popover derives its initial URL from the live selection. The + * subscriber gates on its own active-editor flag, so pooled hidden editors + * ignore broadcasts aimed at the active one. + */ + +const OPEN_LINK_EDIT_POPOVER_EVENT = 'open-knowledge:open-link-edit-popover'; + +export function emitOpenLinkEditPopover( + target: Pick | EventTarget = typeof window === 'undefined' + ? new EventTarget() + : window, +): void { + target.dispatchEvent(new CustomEvent(OPEN_LINK_EDIT_POPOVER_EVENT)); +} + +export function subscribeToOpenLinkEditPopover( + onRequest: () => void, + target: Pick | EventTarget = typeof window === + 'undefined' + ? new EventTarget() + : window, +): () => void { + const listener = () => onRequest(); + target.addEventListener(OPEN_LINK_EDIT_POPOVER_EVENT, listener as EventListener); + return () => target.removeEventListener(OPEN_LINK_EDIT_POPOVER_EVENT, listener as EventListener); +} diff --git a/packages/app/src/editor/clipboard/handle-drop.test.ts b/packages/app/src/editor/clipboard/handle-drop.test.ts index c00f5771..a9aead73 100644 --- a/packages/app/src/editor/clipboard/handle-drop.test.ts +++ b/packages/app/src/editor/clipboard/handle-drop.test.ts @@ -74,7 +74,7 @@ function fakeView(opts: { inCodeBlock?: boolean } = {}): any { }; return { state: { - selection: { $from }, + selection: { $from, empty: true }, schema: { nodes: { codeBlock: codeBlockType }, text: (s: string) => ({ textContent: s }), @@ -91,6 +91,9 @@ function fakeView(opts: { inCodeBlock?: boolean } = {}): any { replaceSelection: mock(function (this: unknown, _slice: unknown) { return this; }), + setMeta: mock(function (this: unknown, _key: unknown, _value: unknown) { + return this; + }), scrollIntoView: mock(function (this: unknown) { return this; }), @@ -346,4 +349,35 @@ describe('WYSIWYG drop dispatcher — paste/drop parity on canonical inputs', () expect(drop(view, evt)).toBe(false); expect(md.parse).not.toHaveBeenCalled(); }); + + test('drop of a lone GFM URL routes through the markdown parse, same as paste', () => { + // Address-bar drags carry the URL as text/plain (often with + // text/uri-list alongside); the shared branch tree gives drop the same + // lone-URL conversion paste gets. + const md = fakeMdManager(); + const drop = createHandleDrop({ + // biome-ignore lint/suspicious/noExplicitAny: narrow fake md manager + mdManager: md as any, + }); + const view = fakeView(); + const evt = fakeDropEvent({ data: { 'text/plain': 'https://inkeep.com\n' } }); + expect(drop(view, evt)).toBe(true); + expect(md.parse).toHaveBeenCalledWith('https://inkeep.com'); + }); + + test('shift-held drop of a lone URL inserts verbatim (no linkify)', () => { + const md = fakeMdManager(); + const drop = createHandleDrop({ + // biome-ignore lint/suspicious/noExplicitAny: narrow fake md manager + mdManager: md as any, + }); + const view = fakeView(); + const evt = fakeDropEvent({ + data: { 'text/plain': 'https://inkeep.com' }, + shiftKey: true, + }); + expect(drop(view, evt)).toBe(true); + expect(md.parse).not.toHaveBeenCalled(); + expect(view.state.tr.replaceSelectionWith).toHaveBeenCalled(); + }); }); diff --git a/packages/app/src/editor/clipboard/handle-paste.test.ts b/packages/app/src/editor/clipboard/handle-paste.test.ts index 73d7feec..03e885ce 100644 --- a/packages/app/src/editor/clipboard/handle-paste.test.ts +++ b/packages/app/src/editor/clipboard/handle-paste.test.ts @@ -4,6 +4,9 @@ * The dispatcher is a priority-ordered series of guards: * 0. Cmd+Shift+V escape hatch * 0. Cursor-in-codeBlock short-circuit + * 0. Lone-URL step: single-URL payloads linkify (over-selection keeps + * the selected text; cursor converts GFM shapes via the markdown + * parse); everything else falls through * A. vscode-editor-data → fenced code block * B. text/x-gfm → MarkdownManager.parse * B. Markdown-first tiebreak: plain (markdown-shaped) + html → mdManager.parse(plain). @@ -22,10 +25,15 @@ * `state.tr.*`, `dispatch`). */ -import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, mock, test } from 'bun:test'; import * as actualCore from '@inkeep/open-knowledge-core'; +import { LinkFidelity, MarkdownManager, sharedExtensions } from '@inkeep/open-knowledge-core'; +import { Editor, type Extensions } from '@tiptap/core'; +import StarterKit from '@tiptap/starter-kit'; import * as actualSonner from 'sonner'; +import { GfmAutolink } from '../gfm-autolink-plugin.ts'; +import { flushMicrotasksAndTimers, installDomGlobals } from '../walk-currency-test-harness.ts'; import { createHandlePaste } from './handle-paste.ts'; // Mock the shared pipeline so tests don't exercise the full rehype stack. @@ -75,7 +83,7 @@ function fakeView(opts: { inCodeBlock?: boolean } = {}): any { }; return { state: { - selection: { $from }, + selection: { $from, empty: true }, schema: { nodes: { codeBlock: codeBlockType }, text: (s: string) => ({ textContent: s }), @@ -92,6 +100,9 @@ function fakeView(opts: { inCodeBlock?: boolean } = {}): any { replaceSelection: mock(function (this: unknown, _slice: unknown) { return this; }), + setMeta: mock(function (this: unknown, _key: unknown, _value: unknown) { + return this; + }), scrollIntoView: mock(function (this: unknown) { return this; }), @@ -194,12 +205,46 @@ describe('WYSIWYG paste dispatcher — branch routing', () => { // payload. The exact branch isn't load-bearing here — the test pins // that the catch path returned false so the dispatcher could continue // (i.e., the throw didn't escape). - expect(() => paste(view, evt)).not.toThrow(); + expect(paste(view, evt)).toBe(true); // Branch A's codeBlock.create must NOT have been called — the throw // happened before dispatch. expect(view.state.schema.nodes.codeBlock.create).not.toHaveBeenCalled(); }); + test('Branch B: a throwing mdManager.parse falls through instead of escaping', () => { + // tryBranchMarkdown shares the parse->apply path with Branch E and the + // lone-URL cursor branch; its catch contract (return false, dispatcher + // continues) is as load-bearing as Branch A's. A narrowed/removed catch + // would lose the user's clipboard content with an uncaught throw. + const throwingMd = { + parse: mock(() => { + throw new Error('parse exploded'); + }), + }; + // biome-ignore lint/suspicious/noExplicitAny: narrow fake md manager + const paste = createHandlePaste({ mdManager: throwingMd as any }); + const view = fakeView(); + const evt = fakeDT({ 'text/x-gfm': '# heading', 'text/plain': '# heading' }); + expect(paste(view, evt)).toBe(true); + expect(throwingMd.parse).toHaveBeenCalled(); + }); + + test('lone-URL cursor paste: a throwing mdManager.parse falls through to plain insert', () => { + const throwingMd = { + parse: mock(() => { + throw new Error('parse exploded'); + }), + }; + // biome-ignore lint/suspicious/noExplicitAny: narrow fake md manager + const paste = createHandlePaste({ mdManager: throwingMd as any }); + const view = fakeView(); + const evt = fakeDT({ 'text/plain': 'https://example.com' }); + expect(paste(view, evt)).toBe(true); + expect(throwingMd.parse).toHaveBeenCalled(); + // The URL still reached the doc via a later branch's dispatch. + expect(view.dispatch).toHaveBeenCalled(); + }); + test('Branch C: data-pm-slice fingerprint returns false (PM handles)', () => { const paste = createHandlePaste({ // biome-ignore lint/suspicious/noExplicitAny: narrow fake md manager @@ -378,3 +423,287 @@ describe('WYSIWYG paste dispatcher — markdown-first tiebreak ordering (D5/D13) expect(md.parse).not.toHaveBeenCalled(); }); }); + +describe('WYSIWYG paste dispatcher — lone-URL routing', () => { + test('lone GFM URL at a cursor routes through the markdown parse (payload trimmed)', () => { + const md = fakeMdManager(); + const paste = createHandlePaste({ + // biome-ignore lint/suspicious/noExplicitAny: narrow fake md manager + mdManager: md as any, + }); + const view = fakeView(); + const evt = fakeDT({ 'text/plain': 'https://inkeep.com\n' }); + expect(paste(view, evt)).toBe(true); + expect(md.parse).toHaveBeenCalledWith('https://inkeep.com'); + }); + + test('lone GFM URL wins over a text/html sibling (browser link-copy shape)', () => { + // A browser URL copy carries an wrapper in text/html; without the + // lone-URL step it would take Branch D's html pipeline. The step runs + // first so the bytes come from the markdown parse of the plain URL. + const md = fakeMdManager(); + const paste = createHandlePaste({ + // biome-ignore lint/suspicious/noExplicitAny: narrow fake md manager + mdManager: md as any, + }); + const view = fakeView(); + const evt = fakeDT({ + 'text/plain': 'https://inkeep.com', + 'text/html': 'https://inkeep.com', + }); + expect(paste(view, evt)).toBe(true); + expect(md.parse).toHaveBeenCalledWith('https://inkeep.com'); + }); + + test('lone non-GFM token (bare domain) at a cursor inserts verbatim', () => { + const md = fakeMdManager(); + const paste = createHandlePaste({ + // biome-ignore lint/suspicious/noExplicitAny: narrow fake md manager + mdManager: md as any, + }); + const view = fakeView(); + const evt = fakeDT({ 'text/plain': 'example.com' }); + expect(paste(view, evt)).toBe(true); + expect(md.parse).not.toHaveBeenCalled(); + expect(view.state.tr.replaceSelectionWith).toHaveBeenCalled(); + }); + + test('URL inside plain prose inserts verbatim (not a lone URL)', () => { + const md = fakeMdManager(); + const paste = createHandlePaste({ + // biome-ignore lint/suspicious/noExplicitAny: narrow fake md manager + mdManager: md as any, + }); + const view = fakeView(); + const evt = fakeDT({ 'text/plain': 'see https://inkeep.com for the docs' }); + expect(paste(view, evt)).toBe(true); + expect(md.parse).not.toHaveBeenCalled(); + expect(view.state.tr.replaceSelectionWith).toHaveBeenCalled(); + }); + + test('Cmd+Shift+V of a lone URL pastes verbatim (plain-paste gate runs first)', () => { + const md = fakeMdManager(); + const paste = createHandlePaste({ + // biome-ignore lint/suspicious/noExplicitAny: narrow fake md manager + mdManager: md as any, + }); + const view = fakeView(); + const evt = fakeDT({ 'text/plain': 'https://inkeep.com' }); + Object.defineProperty(evt, 'shiftKey', { value: true, configurable: true }); + expect(paste(view, evt)).toBe(true); + expect(md.parse).not.toHaveBeenCalled(); + expect(view.state.tr.replaceSelectionWith).toHaveBeenCalled(); + }); + + test('lone URL pasted into a codeBlock inserts verbatim (code gate runs first)', () => { + const md = fakeMdManager(); + const paste = createHandlePaste({ + // biome-ignore lint/suspicious/noExplicitAny: narrow fake md manager + mdManager: md as any, + }); + const view = fakeView({ inCodeBlock: true }); + const evt = fakeDT({ 'text/plain': 'https://inkeep.com' }); + expect(paste(view, evt)).toBe(true); + expect(md.parse).not.toHaveBeenCalled(); + expect(view.state.tr.replaceSelectionWith).toHaveBeenCalled(); + }); +}); + +describe('WYSIWYG paste dispatcher — lone-URL linkification (real editor)', () => { + let restoreDomGlobals: (() => void) | null = null; + let mdManager: MarkdownManager; + + beforeAll(() => { + restoreDomGlobals = installDomGlobals(); + mdManager = new MarkdownManager({ extensions: sharedExtensions }); + }); + afterAll(() => { + restoreDomGlobals?.(); + restoreDomGlobals = null; + }); + + function makeRealEditor(content: string, extraExtensions: Extensions = []): Editor { + const host = document.createElement('div'); + document.body.appendChild(host); + return new Editor({ + element: host, + content, + extensions: [ + // StarterKit v3 bundles its own Link; drop it so the fidelity mark + // (which carries `linkStyle`) is the only `link` in the schema. + StarterKit.configure({ link: false }), + LinkFidelity.configure({ autolink: false }), + ...extraExtensions, + ], + }); + } + + function pasteInto(editor: Editor, data: Record): boolean { + const paste = createHandlePaste({ mdManager }); + return paste(editor.view, fakeDT(data)); + } + + /** Select `text` by content offset — valid for single-paragraph fixtures + * (the +1 crosses the paragraph's opening boundary). */ + function selectText(editor: Editor, text: string): void { + const idx = editor.state.doc.textContent.indexOf(text); + if (idx < 0) throw new Error(`selectText: "${text}" not in doc`); + editor.commands.setTextSelection({ from: idx + 1, to: idx + 1 + text.length }); + } + + function linkMarks(editor: Editor): Array<{ text: string; attrs: Record }> { + const found: Array<{ text: string; attrs: Record }> = []; + editor.state.doc.descendants((node) => { + if (!node.isText) return; + const mark = node.marks.find((m) => m.type.name === 'link'); + if (mark) found.push({ text: node.text ?? '', attrs: mark.attrs }); + }); + return found; + } + + test('cursor paste of a URL creates a gfm-autolink mark over the bare literal', () => { + const editor = makeRealEditor('

'); + try { + expect(pasteInto(editor, { 'text/plain': 'https://inkeep.com' })).toBe(true); + expect(editor.state.doc.textContent).toBe('https://inkeep.com'); + const marks = linkMarks(editor); + expect(marks).toHaveLength(1); + expect(marks[0]?.text).toBe('https://inkeep.com'); + expect(marks[0]?.attrs.href).toBe('https://inkeep.com'); + expect(marks[0]?.attrs.linkStyle).toBe('gfm-autolink'); + } finally { + editor.destroy(); + } + }); + + test('cursor paste of a bare domain stays plain text', () => { + const editor = makeRealEditor('

'); + try { + expect(pasteInto(editor, { 'text/plain': 'example.com' })).toBe(true); + expect(editor.state.doc.textContent).toBe('example.com'); + expect(linkMarks(editor)).toHaveLength(0); + } finally { + editor.destroy(); + } + }); + + test('paste over a selection keeps the selected text and links it', () => { + const editor = makeRealEditor('

read the docs today

'); + try { + selectText(editor, 'docs'); + expect(pasteInto(editor, { 'text/plain': 'https://inkeep.com' })).toBe(true); + expect(editor.state.doc.textContent).toBe('read the docs today'); + const marks = linkMarks(editor); + expect(marks).toHaveLength(1); + expect(marks[0]?.text).toBe('docs'); + expect(marks[0]?.attrs.href).toBe('https://inkeep.com'); + // Default style — serializes as [docs](https://inkeep.com). + expect(marks[0]?.attrs.linkStyle).toBe('inline'); + } finally { + editor.destroy(); + } + }); + + test.each([ + ['example.com', 'https://example.com'], + ['www.example.com', 'https://www.example.com'], + ['nick@inkeep.com', 'mailto:nick@inkeep.com'], + ])('paste of %s over a selection links to %s', (payload, expectedHref) => { + const editor = makeRealEditor('

read the docs today

'); + try { + selectText(editor, 'docs'); + expect(pasteInto(editor, { 'text/plain': payload })).toBe(true); + expect(editor.state.doc.textContent).toBe('read the docs today'); + expect(linkMarks(editor)[0]?.attrs.href).toBe(expectedHref); + } finally { + editor.destroy(); + } + }); + + test('paste of a non-allowlisted scheme over a selection never links — the payload lands as inert plain text', () => { + const editor = makeRealEditor('

read the docs today

'); + try { + selectText(editor, 'docs'); + pasteInto(editor, { 'text/plain': 'javascript:alert(1)' }); + expect(linkMarks(editor)).toHaveLength(0); + // Fall-through = ordinary replace: the string is text, never an href. + expect(editor.state.doc.textContent).toBe('read the javascript:alert(1) today'); + } finally { + editor.destroy(); + } + }); + + test('paste of a URL over a code-marked selection falls through to a plain replace', () => { + const editor = makeRealEditor('

run bun install now

'); + try { + selectText(editor, 'install'); + pasteInto(editor, { 'text/plain': 'https://inkeep.com' }); + expect(linkMarks(editor)).toHaveLength(0); + expect(editor.state.doc.textContent).toBe('run bun https://inkeep.com now'); + } finally { + editor.destroy(); + } + }); + + test('paste of a URL over a cross-block selection falls through (no link mark)', () => { + const editor = makeRealEditor('

one

two

'); + try { + editor.commands.setTextSelection({ from: 2, to: 8 }); + pasteInto(editor, { 'text/plain': 'https://inkeep.com' }); + expect(linkMarks(editor)).toHaveLength(0); + // The fall-through is the normal paste path: the cross-block selection + // is replaced by the URL as plain text — content delivered, not dropped. + expect(editor.state.doc.textContent).toContain('https://inkeep.com'); + } finally { + editor.destroy(); + } + }); + + test('paste of a URL over already-linked text re-points the link, keeping the text', () => { + const editor = makeRealEditor('

docs page

'); + try { + selectText(editor, 'docs'); + expect(pasteInto(editor, { 'text/plain': 'https://new.example' })).toBe(true); + expect(editor.state.doc.textContent).toBe('docs page'); + const marks = linkMarks(editor); + expect(marks).toHaveLength(1); + expect(marks[0]?.text).toBe('docs'); + expect(marks[0]?.attrs.href).toBe('https://new.example'); + } finally { + editor.destroy(); + } + }); + + test('pasted prose ending in a URL + space is never linkified by the typed-autolink plugin', async () => { + // The verbatim Branch E insert looks exactly like "typed a URL then a + // boundary" to the autolink plugin's changed-range scan. The dispatcher + // stamps its transactions with preventAutolink so paste output is never + // re-scanned as typing — pasted prose stays byte-identical. + const editor = makeRealEditor('

', [ + GfmAutolink.configure({ isActiveEditor: () => true }), + ]); + try { + expect(pasteInto(editor, { 'text/plain': 'see https://inkeep.com ' })).toBe(true); + await flushMicrotasksAndTimers(); + expect(linkMarks(editor)).toHaveLength(0); + expect(editor.state.doc.textContent).toBe('see https://inkeep.com '); + } finally { + editor.destroy(); + } + }); + + test('lone-URL cursor paste with the typed-autolink plugin active yields exactly one mark', async () => { + const editor = makeRealEditor('

', [ + GfmAutolink.configure({ isActiveEditor: () => true }), + ]); + try { + pasteInto(editor, { 'text/plain': 'https://inkeep.com' }); + await flushMicrotasksAndTimers(); + const marks = linkMarks(editor); + expect(marks).toHaveLength(1); + expect(marks[0]?.attrs.linkStyle).toBe('gfm-autolink'); + } finally { + editor.destroy(); + } + }); +}); diff --git a/packages/app/src/editor/clipboard/handle-paste.ts b/packages/app/src/editor/clipboard/handle-paste.ts index 788caa1d..748d7202 100644 --- a/packages/app/src/editor/clipboard/handle-paste.ts +++ b/packages/app/src/editor/clipboard/handle-paste.ts @@ -23,6 +23,20 @@ * codeBlock short-circuit: cursor inside a codeBlock → skip all branches, * insert text/plain verbatim. * + * Lone-URL step: after those two gates and before the MIME branches, a + * payload whose text/plain is a single URL token linkifies instead of + * falling into the branch tree. Over a one-block text selection the + * selected text is kept and link-marked (trust-the-gesture policy — see + * lone-url.ts); at a cursor only GFM autolink shapes convert, routed + * through MarkdownManager.parse so the mark and bytes are exactly what the + * pipeline itself produces. Everything else falls through unchanged. The + * step runs before Branch A/D so a browser link-copy (which also carries + * text/html) converts the selection rather than replacing it. + * + * Every dispatcher-minted transaction carries `preventAutolink` meta: + * paste output is never re-scanned by the typed-autolink plugin, so a URL + * inside pasted prose stays exactly as pasted. + * * Cmd+Shift+V (paste): detected via `pasteShiftHeld(event)` which checks * the most-recent keyboard event (real browsers don't set `shiftKey` on * ClipboardEvent) plus a Playwright-test-style injected property. Drop @@ -46,7 +60,9 @@ import type { MarkdownManager } from '@inkeep/open-knowledge-core'; import { htmlToMdast, mdastToMarkdown } from '@inkeep/open-knowledge-core'; import type { JSONContent } from '@tiptap/core'; +import { TextSelection } from '@tiptap/pm/state'; import type { EditorView } from '@tiptap/pm/view'; +import { PREVENT_AUTOLINK_META } from '../gfm-autolink-plugin.ts'; import { type ClipboardSource, detectSource } from './detect-source.ts'; import { type ClipboardBranch, @@ -56,6 +72,7 @@ import { logSourceDetected, } from './instrument.ts'; import { isMarkdown } from './is-markdown.ts'; +import { detectLoneGfmUrl, detectLoneTrustedUrl } from './lone-url.ts'; import { notifyPasteDegraded } from './paste-failure-toast.ts'; import { pasteShiftHeld } from './shift-tracker.ts'; @@ -135,6 +152,25 @@ function handleDropOrPaste( return true; } + // Lone-URL step (see file header). Non-matching payloads fall through. + if (plain) { + if (!view.state.selection.empty) { + const href = detectLoneTrustedUrl(plain); + if (href && linkifySelection(view, href, source)) { + logSourceDetected({ view: 'wysiwyg', branch: 'url', source }); + logIfSlow(start, { op: surface, view: 'wysiwyg', branch: 'url', source }); + return true; + } + } else { + const gfmToken = detectLoneGfmUrl(plain); + if (gfmToken && tryBranchMarkdown(view, gfmToken, deps, 'url', source)) { + logSourceDetected({ view: 'wysiwyg', branch: 'url', source }); + logIfSlow(start, { op: surface, view: 'wysiwyg', branch: 'url', source }); + return true; + } + } + } + // Branch A: VS Code with language metadata. const vscodeData = dt.getData('vscode-editor-data'); if (vscodeData && plain && tryBranchA(view, vscodeData, plain, source)) { @@ -226,10 +262,59 @@ function isCursorInCodeBlock(view: EditorView): boolean { return false; } +/** + * Add a link mark across the current selection, keeping the selected text + * (default `linkStyle` — serializes as `[selected text](url)`). Returns + * false — fall through to the normal branch tree, i.e. plain replace — when + * the selection isn't a single-textblock text selection, any of it carries + * an inline code mark, or the schema has no link mark. A selection that + * already carries a link keeps its text and gets the new href: pasting a + * URL onto linked text means "re-point this link" everywhere else, and the + * fall-through alternative would destroy the text outright. + */ +function linkifySelection(view: EditorView, href: string, source: ClipboardSource): boolean { + try { + const { state } = view; + const selection = state.selection; + if (!(selection instanceof TextSelection)) return false; + if (!selection.$from.sameParent(selection.$to)) return false; + const linkType = state.schema.marks.link; + if (!linkType) return false; + const codeType = state.schema.marks.code; + if (codeType && state.doc.rangeHasMark(selection.from, selection.to, codeType)) return false; + view.dispatch( + state.tr + .addMark(selection.from, selection.to, linkType.create({ href })) + .setMeta(PREVENT_AUTOLINK_META, true), + ); + return true; + } catch (err) { + logConversionFail({ + view: 'wysiwyg', + stage: 'linkifySelection', + source, + branch: 'url', + reason: `${(err as Error)?.message ?? 'unknown'} (href=${href})`, + errorClass: classifyError(err), + }); + // Same degradation contract as Branch D: tell the user, then fall through + // (return false) so the normal branch tree delivers the clipboard content + // as a standard paste. Claiming the paste here would drop the content + // entirely — the one outcome the file header forbids. + notifyPasteDegraded('wysiwyg', 'Pasted without linking — the link could not be applied.'); + return false; + } +} + function insertPlainText(view: EditorView, text: string): void { const { schema, tr } = view.state; if (!text) return; - view.dispatch(tr.replaceSelectionWith(schema.text(text)).scrollIntoView()); + view.dispatch( + tr + .replaceSelectionWith(schema.text(text)) + .setMeta(PREVENT_AUTOLINK_META, true) + .scrollIntoView(), + ); } // Narrow allowlist for fenced-code language idents so an attacker-controlled @@ -253,7 +338,12 @@ function tryBranchA( { language: lang }, text ? view.state.schema.text(text) : null, ); - view.dispatch(view.state.tr.replaceSelectionWith(codeNode).scrollIntoView()); + view.dispatch( + view.state.tr + .replaceSelectionWith(codeNode) + .setMeta(PREVENT_AUTOLINK_META, true) + .scrollIntoView(), + ); return true; } catch (err) { logConversionFail({ @@ -264,6 +354,7 @@ function tryBranchA( reason: (err as Error)?.message ?? 'unknown', errorClass: classifyError(err), }); + notifyPasteDegraded('wysiwyg'); return false; } } @@ -272,7 +363,7 @@ function tryBranchMarkdown( view: EditorView, markdown: string, deps: PasteDispatcherDeps, - branchLabel: 'B' | 'E', + branchLabel: 'B' | 'E' | 'url', source: ClipboardSource, ): boolean { let json: JSONContent; @@ -287,6 +378,7 @@ function tryBranchMarkdown( reason: (err as Error)?.message ?? 'unknown', errorClass: classifyError(err), }); + notifyPasteDegraded('wysiwyg'); return false; } return applyJsonSlice(view, json, source, branchLabel); @@ -363,10 +455,12 @@ function applyJsonSlice( htmlBytes?: number, ): boolean { try { - // biome-ignore lint/suspicious/noExplicitAny: schema.nodeFromJSON accepts loose JSONContent at runtime; the public type is narrower than what's actually valid - const node = view.state.schema.nodeFromJSON(json as any); + const node = view.state.schema.nodeFromJSON(json); view.dispatch( - view.state.tr.replaceSelection(node.slice(0, node.content.size)).scrollIntoView(), + view.state.tr + .replaceSelection(node.slice(0, node.content.size)) + .setMeta(PREVENT_AUTOLINK_META, true) + .scrollIntoView(), ); return true; } catch (err) { @@ -379,6 +473,7 @@ function applyJsonSlice( errorClass: classifyError(err), ...(htmlBytes != null ? { htmlBytes } : {}), }); + notifyPasteDegraded('wysiwyg'); return false; } } diff --git a/packages/app/src/editor/clipboard/instrument.ts b/packages/app/src/editor/clipboard/instrument.ts index 22d0e996..48c14c25 100644 --- a/packages/app/src/editor/clipboard/instrument.ts +++ b/packages/app/src/editor/clipboard/instrument.ts @@ -140,9 +140,11 @@ type ClipboardOp = 'copy' | 'cut' | 'paste' | 'drop'; * Dispatcher branch the event was emitted from. `A`–`E` match the WYSIWYG * paste dispatcher and source paste dispatcher. Source's `B-wrapper` and * Branch B both collapse into CM6's text/plain default, but stay distinct - * in telemetry. `shift` is the Cmd+Shift+V escape hatch; `codeblock` is the - * cursor-inside-code short-circuit; `serialize` is the copy/cut path where - * the concept of "paste branch" doesn't apply. + * in telemetry. `url` is the WYSIWYG lone-URL linkify step (paste/drop of + * a single URL token, cursor or over-selection); `shift` is the + * Cmd+Shift+V escape hatch; `codeblock` is the cursor-inside-code + * short-circuit; `serialize` is the copy/cut path where the concept of + * "paste branch" doesn't apply. */ export type ClipboardBranch = | 'A' @@ -151,6 +153,7 @@ export type ClipboardBranch = | 'C' | 'D' | 'E' + | 'url' | 'shift' | 'codeblock' | 'serialize'; @@ -159,7 +162,8 @@ export type ClipboardBranch = * Pipeline stage that produced a conversion failure. `htmlToMdast` is the * rehype walk; `mdastToMarkdown` is remark-stringify; `mdManagerParse` is * the markdown → PM conversion; `applyJsonSlice` is the PM dispatch; - * `branchA` is the VS-Code-fenced-block path; `chunkedYTextInsert` is the + * `branchA` is the VS-Code-fenced-block path; `linkifySelection` is the + * lone-URL paste-over-selection mark-add; `chunkedYTextInsert` is the * partial-insert failure (also surfaces as the typed `ChunkedInsertError`). */ type ClipboardStage = @@ -168,6 +172,7 @@ type ClipboardStage = | 'mdManagerParse' | 'applyJsonSlice' | 'branchA' + | 'linkifySelection' | 'chunkedYTextInsert'; /** Serialization path — `text` is text/plain, `html` is text/html. */ diff --git a/packages/app/src/editor/clipboard/lone-url.test.ts b/packages/app/src/editor/clipboard/lone-url.test.ts new file mode 100644 index 00000000..0e8af361 --- /dev/null +++ b/packages/app/src/editor/clipboard/lone-url.test.ts @@ -0,0 +1,162 @@ +/** + * Classification matrix for the paste dispatcher's two lone-URL policies. + * + * Cursor policy (`detectLoneGfmUrl`) must agree with the typed-autolink + * token policy — GFM autolink-literal shapes only — so a pasted token and a + * typed token convert (or don't) identically. Over-selection policy + * (`detectLoneTrustedUrl`) is deliberately broader (trust the gesture): + * every allowlisted scheme verbatim, emails as mailto:, dotted hosts + * https-prepended. + */ + +import { describe, expect, test } from 'bun:test'; +import { detectClipboardPrefillUrl, detectLoneGfmUrl, detectLoneTrustedUrl } from './lone-url.ts'; + +describe('detectLoneGfmUrl — cursor-paste policy (GFM shapes only)', () => { + test.each([ + ['https://example.com', 'https://example.com'], + ['http://example.com/path?q=1', 'http://example.com/path?q=1'], + ['HTTPS://EXAMPLE.COM', 'HTTPS://EXAMPLE.COM'], + ['www.example.com', 'www.example.com'], + ['nick@inkeep.com', 'nick@inkeep.com'], + ['https://en.wikipedia.org/wiki/Foo_(bar)', 'https://en.wikipedia.org/wiki/Foo_(bar)'], + // Explicit-scheme dotless hosts are GFM autolink literals (the dotted- + // domain rule is schemeless-only), so cursor paste converts them too. + ['http://localhost:5174/', 'http://localhost:5174/'], + ['http://127.0.0.1:8080/x', 'http://127.0.0.1:8080/x'], + ])('accepts %s', (raw, expected) => { + expect(detectLoneGfmUrl(raw)).toBe(expected); + }); + + test('trims clipboard whitespace padding before classifying', () => { + expect(detectLoneGfmUrl(' https://example.com\n')).toBe('https://example.com'); + }); + + test('keeps trailing punctuation on the returned token — the markdown parse applies the same GFM split at insert time', () => { + expect(detectLoneGfmUrl('https://example.com),')).toBe('https://example.com),'); + }); + + test.each([ + ['example.com'], + ['AGENTS.md'], + ['package.json'], + ['localhost:5173'], + ['localhost:5174'], + ['foo.bar'], + ['v1.2.3'], + ['192.168.1.1'], + // ftp is an allowlisted scheme but not a GFM autolink-literal shape. + ['ftp://host/file'], + ['see https://example.com now'], + [''], + [' \n'], + ])('rejects %s', (raw) => { + expect(detectLoneGfmUrl(raw)).toBeNull(); + }); +}); + +describe('detectLoneTrustedUrl — over-selection policy (trust the gesture)', () => { + test.each([ + ['https://inkeep.com', 'https://inkeep.com'], + [' https://inkeep.com\n', 'https://inkeep.com'], + ['ftp://host/file', 'ftp://host/file'], + ['mailto:nick@inkeep.com', 'mailto:nick@inkeep.com'], + ['tel:+15551234567', 'tel:+15551234567'], + ])('allowlisted explicit scheme passes verbatim: %s', (raw, expected) => { + expect(detectLoneTrustedUrl(raw)).toBe(expected); + }); + + test.each([ + ['javascript:alert(1)'], + ['data:text/html,x'], + ['vbscript:x'], + ['foo:bar'], + ])('non-allowlisted scheme is refused: %s', (raw) => { + expect(detectLoneTrustedUrl(raw)).toBeNull(); + }); + + test.each([ + ['example.com', 'https://example.com'], + // https (not GFM's http) — the result is an explicit link, not an + // autolink literal, so the modern default wins over pipeline parity. + ['www.example.com', 'https://www.example.com'], + // Any dotted host is trusted — including filename-shaped tokens the + // typed/cursor policies reject. The gesture disambiguates. + ['AGENTS.md', 'https://AGENTS.md'], + ['example.com/docs?q=1#frag', 'https://example.com/docs?q=1#frag'], + // `@` after the authority part is a path, not an email. + ['example.com/@user', 'https://example.com/@user'], + ])('schemeless dotted host gets https: %s', (raw, expected) => { + expect(detectLoneTrustedUrl(raw)).toBe(expected); + }); + + test('email becomes mailto:', () => { + expect(detectLoneTrustedUrl('nick@inkeep.com')).toBe('mailto:nick@inkeep.com'); + }); + + test('email-shaped token the GFM grammar rejects is refused rather than guessed at', () => { + // No dot in the domain → not a GFM email; https-prepending would mint + // an unintended userinfo URL, so the classifier refuses. + expect(detectLoneTrustedUrl('user@host')).toBeNull(); + }); + + test.each([ + // Scheme grammar collides with host:port shorthand → fail closed. + ['localhost:5173'], + ['example.com:8080'], + ['foo'], + ['./relative'], + ['/abs/path'], + ['#fragment'], + ['.hidden'], + ['trailing.'], + ['paste some prose'], + [''], + ])('refuses %s', (raw) => { + expect(detectLoneTrustedUrl(raw)).toBeNull(); + }); +}); + +describe('detectClipboardPrefillUrl — link-popover pre-fill policy (explicit scheme only)', () => { + test.each([ + ['https://inkeep.com/docs', 'https://inkeep.com/docs'], + [' https://inkeep.com\n', 'https://inkeep.com'], + ['http://example.com', 'http://example.com'], + ['mailto:nick@inkeep.com', 'mailto:nick@inkeep.com'], + ['tel:+15551234567', 'tel:+15551234567'], + ['ftp://host/file', 'ftp://host/file'], + ])('pre-fills an allowlisted explicit-scheme URL verbatim: %s', (raw, expected) => { + expect(detectClipboardPrefillUrl(raw)).toBe(expected); + }); + + test.each([ + ['javascript:alert(1)'], + ['data:text/html,x'], + ['vbscript:x'], + ])('non-allowlisted scheme never pre-fills: %s', (raw) => { + expect(detectClipboardPrefillUrl(raw)).toBeNull(); + }); + + test.each([ + // The over-selection policy converts all four of these; the speculative + // pre-fill deliberately does not — no prepending, no mailto: minting. + ['example.com'], + ['www.example.com'], + ['nick@inkeep.com'], + ['AGENTS.md'], + ])('schemeless token the trust-intent policy would convert stays out: %s', (raw) => { + expect(detectClipboardPrefillUrl(raw)).toBeNull(); + }); + + test.each([ + // Scheme grammar collides with host:port shorthand → fail closed. + ['localhost:5173'], + ['example.com:8080'], + ['see https://example.com now'], + ['https://a.com\nhttps://b.com'], + [''], + [' \n'], + ])('refuses %s', (raw) => { + expect(detectClipboardPrefillUrl(raw)).toBeNull(); + }); +}); diff --git a/packages/app/src/editor/clipboard/lone-url.ts b/packages/app/src/editor/clipboard/lone-url.ts new file mode 100644 index 00000000..31d82cff --- /dev/null +++ b/packages/app/src/editor/clipboard/lone-url.ts @@ -0,0 +1,113 @@ +/** + * Lone-URL classification for the paste/drop dispatcher and the link + * popover's clipboard pre-fill. + * + * A payload whose text/plain trims to a single whitespace-free token can be + * "a pasted URL" in two senses, and the dispatcher's two paste contexts + * deliberately apply two different policies: + * + * - At a cursor the conversion is autolink-shaped: only tokens the markdown + * pipeline's own parse would linkify (the GFM autolink-literal shapes) + * convert, so the inserted bytes are exactly what a markdown author would + * have typed and they re-parse to the identical link. Anything else — + * bare domains, filenames — inserts verbatim, unchanged from before this + * step existed. + * + * - Over a selected range the paste gesture itself carries link intent (the + * Notion/Docs "paste a URL onto selected text" idiom), so the policy is + * trust-the-gesture: any allowlisted-scheme URL verbatim, GFM-shape + * emails as `mailto:`, and schemeless dotted hosts (`example.com`, + * `www.example.com` — and filename-shaped tokens like `AGENTS.md`, a + * deliberately accepted edge of trusting the gesture) get `https://` + * prepended. This is + * deliberately looser than the typed-autolink policy because the result + * is an explicit `[text](url)` link whose bytes never need to re-parse as + * an autolink literal — and because an unwanted conversion is one undo + * away, on a gesture that rarely means anything but "link this". + * + * Both dispatcher classifiers return null for everything else; the + * dispatcher falls through to its normal branch tree, so a non-matching + * payload pastes exactly as it always did. + * + * The link popover's pre-fill applies a third, strictest policy: opening + * the popover is an authoring gesture, not a gesture at the clipboard, so + * the guess is speculative — only a token that is already unambiguously a + * URL (explicit allowlisted scheme) qualifies, verbatim. No https:// + * prepending, no mailto: minting: a wrong pre-fill costs a clear-and-retype + * with no gesture to blame, where the paste policies' conversions cost one + * undo on a gesture that asked for a link. + */ + +import { isAllowedLinkUri } from '@inkeep/open-knowledge-core'; +import { detectGfmLinkToken } from '../gfm-link-detector.ts'; + +// RFC 3986 scheme grammar. Note the collision with host:port shorthand: +// `example.com:8080` and `localhost:5173` both match as a "scheme", fail the +// allowlist, and therefore never convert — the URL grammar itself is +// ambiguous there, and fail-closed keeps port shorthands out of both paste +// policies just as they are out of the typed-autolink policy. +const EXPLICIT_SCHEME = /^[a-z][a-z0-9+.-]*:/i; + +function loneToken(raw: string): string | null { + const token = raw.trim(); + if (!token || /\s/.test(token)) return null; + return token; +} + +/** + * Cursor-paste classification: is the whole payload one GFM autolink-literal + * token? Returns the trimmed token — the caller feeds it through the + * markdown parse, which mints the link mark and canonical bare-literal + * bytes (including GFM's own trailing-punctuation split) — or null when the + * pipeline would leave the token as plain text. + */ +export function detectLoneGfmUrl(raw: string): string | null { + const token = loneToken(raw); + if (!token) return null; + return detectGfmLinkToken(token) ? token : null; +} + +/** + * Over-selection paste classification (trust-the-gesture policy). Returns + * the href to link the selected text to, or null when the payload should + * not convert the selection (the dispatcher then pastes normally). + */ +export function detectLoneTrustedUrl(raw: string): string | null { + const token = loneToken(raw); + if (!token) return null; + + if (EXPLICIT_SCHEME.test(token)) { + return isAllowedLinkUri(token) ? token : null; + } + + // Schemeless email → mailto:, reusing the GFM email grammar so the two + // paste policies (and the typed path) share one definition of "an email". + const gfm = detectGfmLinkToken(token); + if (gfm && gfm.text === token && gfm.href.startsWith('mailto:')) { + return gfm.href; + } + + // Bare-domain arm: the authority part (up to the first `/`, `?`, or `#`) + // must look like a dotted host. An `@` there is an email-shaped token the + // grammar above rejected — prepending https:// would mint a userinfo URL + // the user never intended, so refuse instead. An `@` later in the token is + // just a path (`example.com/@user`) and stays eligible. + const host = token.split(/[/?#]/, 1)[0] ?? ''; + if (host.includes('@')) return null; + if (!host.includes('.') || host.startsWith('.') || host.endsWith('.')) return null; + + const href = `https://${token}`; + return isAllowedLinkUri(href) ? href : null; +} + +/** + * Link-popover pre-fill classification: pre-fill iff the clipboard is + * already an explicit allowlisted-scheme URL, returned verbatim. See the + * header for why this is stricter than both paste policies. + */ +export function detectClipboardPrefillUrl(raw: string): string | null { + const token = loneToken(raw); + if (!token) return null; + if (!EXPLICIT_SCHEME.test(token)) return null; + return isAllowedLinkUri(token) ? token : null; +} diff --git a/packages/app/src/editor/clipboard/shift-tracker.test.ts b/packages/app/src/editor/clipboard/shift-tracker.test.ts index a06a8d3f..92e6ace8 100644 --- a/packages/app/src/editor/clipboard/shift-tracker.test.ts +++ b/packages/app/src/editor/clipboard/shift-tracker.test.ts @@ -51,9 +51,17 @@ let mod: any; beforeAll(async () => { mod = await import('./shift-tracker.ts'); + // The attach guard is a module singleton: a sibling test file in the same + // bun process (e.g. handle-paste.test.ts) may have already wired the + // tracker to ITS window, in which case installShiftTracker() would + // early-return and our fake window's dispatches would reach nobody. + // Detach from whatever came before so beforeEach re-attaches to the fake. + mod.__resetShiftTrackerForTests(); }); afterAll(() => { + // Leave the singleton detached so later files re-wire to their own window. + mod.__resetShiftTrackerForTests(); (globalThis as { window?: unknown }).window = origWindow; }); diff --git a/packages/app/src/editor/editor-rig.test-helper.ts b/packages/app/src/editor/editor-rig.test-helper.ts new file mode 100644 index 00000000..73b55ded --- /dev/null +++ b/packages/app/src/editor/editor-rig.test-helper.ts @@ -0,0 +1,99 @@ +/** + * Shared headless-editor rigs for app editor-plugin tests. + * + * Real ProseMirror EditorViews over jsdom globals (callers own the + * `installDomGlobals` lifecycle from the walk-currency harness), with the + * schema base every link-behavior test agrees on: StarterKit minus its bundled + * Link so the real fidelity mark (which carries `linkStyle`) is the only + * `link` in the schema, and that mark's stock autolink disabled so the plugin + * under test is the sole converter. Callers pass just the extension(s) under + * test. + */ + +import { LinkFidelity } from '@inkeep/open-knowledge-core'; +import { Editor, type Extensions } from '@tiptap/core'; +import Collaboration from '@tiptap/extension-collaboration'; +import StarterKit from '@tiptap/starter-kit'; +import { yUndoPluginKey } from '@tiptap/y-tiptap'; +import type * as Y from 'yjs'; + +export function mountLightEditor(options: { content?: string; extensions: Extensions }): Editor { + const host = document.createElement('div'); + document.body.appendChild(host); + return new Editor({ + element: host, + content: options.content ?? '

', + extensions: [ + StarterKit.configure({ link: false }), + LinkFidelity.configure({ autolink: false }), + ...options.extensions, + ], + }); +} + +/** + * A `Collaboration`-bound editor over `ydoc` — real y-sync binding, real + * y-undo manager. Collaboration owns history, so StarterKit's own undo/redo is + * dropped to avoid two history stacks. + */ +export function mountCollabEditor(ydoc: Y.Doc, extensions: Extensions): Editor { + const host = document.createElement('div'); + document.body.appendChild(host); + return new Editor({ + element: host, + extensions: [ + StarterKit.configure({ link: false, undoRedo: false }), + LinkFidelity.configure({ autolink: false }), + Collaboration.configure({ document: ydoc }), + ...extensions, + ], + }); +} + +/** Dispatch a local (non-sync) text insertion at an explicit position. */ +export function insertLocal(editor: Editor, text: string, at: number): void { + editor.view.dispatch(editor.state.tr.insertText(text, at, at)); +} + +/** The bound y-undo manager, or null when the editor has no Collaboration. */ +export function readUndoManager(editor: Editor): Y.UndoManager | null { + const pluginState: { undoManager?: Y.UndoManager } | undefined = yUndoPluginKey.getState( + editor.state, + ); + return pluginState?.undoManager ?? null; +} + +/** The href of the first link mark in document order, or null if none. */ +export function firstLinkHref(editor: Editor): string | null { + let href: string | null = null; + editor.state.doc.descendants((node) => { + if (href !== null) return false; + const link = node.marks.find((m) => m.type.name === 'link'); + if (link && typeof link.attrs.href === 'string') href = link.attrs.href; + return undefined; + }); + return href; +} + +/** Attrs of the first link mark, or null. */ +export function firstLinkAttrs(editor: Editor): Record | null { + let attrs: Record | null = null; + editor.state.doc.descendants((node) => { + if (attrs !== null) return false; + const link = node.marks.find((m) => m.type.name === 'link'); + if (link) attrs = link.attrs; + return undefined; + }); + return attrs; +} + +/** Distinct hrefs carried by link marks anywhere in the doc. */ +export function linkHrefs(editor: Editor): string[] { + const hrefs = new Set(); + editor.state.doc.descendants((node) => { + for (const m of node.marks) { + if (m.type.name === 'link' && typeof m.attrs.href === 'string') hrefs.add(m.attrs.href); + } + }); + return [...hrefs]; +} diff --git a/packages/app/src/editor/extensions/internal-link.ts b/packages/app/src/editor/extensions/internal-link.ts index cd83d9fc..7ea2238f 100644 --- a/packages/app/src/editor/extensions/internal-link.ts +++ b/packages/app/src/editor/extensions/internal-link.ts @@ -108,6 +108,13 @@ export const InternalLink = LinkFidelity.extend({ }, addProseMirrorPlugins() { + // Intentionally does NOT spread `this.parent?.()`: the stock TipTap link + // plugins (autolink, linkOnPaste, clickHandler) inherited from LinkFidelity + // are dropped here because OK supplies its own, purpose-built replacements — + // GfmAutolink for typed linkification, the clipboard dispatcher for paste, + // and the InteractionLayer bridge below for chip clicks. Re-adding the + // parent plugins would double-handle paste/click and reintroduce the stock + // autolinker (which lacks OK's CRDT origin guard). const docName = this.options.docName ?? ''; // Single source for chip primary-navigation. Referenced by both the // InteractionLayer (bare / Cmd / middle-click + Enter on the chip) and the diff --git a/packages/app/src/editor/extensions/mark-identity.ts b/packages/app/src/editor/extensions/mark-identity.ts index 3be333e1..fc46e71c 100644 --- a/packages/app/src/editor/extensions/mark-identity.ts +++ b/packages/app/src/editor/extensions/mark-identity.ts @@ -26,7 +26,7 @@ */ import type { Mark, Node as PmNode } from '@tiptap/pm/model'; -import { Plugin, PluginKey } from '@tiptap/pm/state'; +import { type EditorState, Plugin, PluginKey } from '@tiptap/pm/state'; /** * Minimal shape we consume from ProseMirror's `Mapping` — a `map(pos, bias)` @@ -195,12 +195,21 @@ function attrsEqual(a: Record, b: Record): boo const va = a[k]; const vb = b[k]; if (va === vb) continue; - // Allow nested comparison for arrays / plain objects. + // Allow nested comparison for arrays / plain objects — and ONLY those + // shapes. A key-walk over exotic objects lies: an array vs an index-keyed + // object would compare equal, and two Dates/Maps (no own enumerable keys) + // always would, silently reusing a mark ID across different marks. + if (typeof va !== 'object' || va === null || typeof vb !== 'object' || vb === null) { + return false; + } + const bothArrays = Array.isArray(va) && Array.isArray(vb); + const bothPlainObjects = + !Array.isArray(va) && + !Array.isArray(vb) && + Object.getPrototypeOf(va) === Object.prototype && + Object.getPrototypeOf(vb) === Object.prototype; if ( - typeof va === 'object' && - va !== null && - typeof vb === 'object' && - vb !== null && + (bothArrays || bothPlainObjects) && attrsEqual(va as Record, vb as Record) ) { continue; @@ -231,6 +240,25 @@ export function diffMarkIdentity( return nextIds; } +/** + * Resolve the stable id of a `markType` mark covering `pos` from this + * plugin's state. IDs are assigned synchronously during the dispatch that + * inserts the mark, so this reads them off a state immediately after an + * insert transaction applies. Returns null when the plugin isn't installed + * (non-app editors / tests) or no tracked mark covers `pos` — callers + * degrade gracefully. + */ +export function findMarkIdAt(state: EditorState, pos: number, markType: string): string | null { + const identity = markIdentityKey.getState(state); + if (!identity) return null; + for (const info of identity.byId.values()) { + if (info.markType === markType && info.from <= pos && pos < info.to) { + return info.id; + } + } + return null; +} + // --------------------------------------------------------------------------- // Plugin factory // --------------------------------------------------------------------------- diff --git a/packages/app/src/editor/extensions/shared.ts b/packages/app/src/editor/extensions/shared.ts index c298afb4..b13cbb0f 100644 --- a/packages/app/src/editor/extensions/shared.ts +++ b/packages/app/src/editor/extensions/shared.ts @@ -8,7 +8,9 @@ import { Extension } from '@tiptap/core'; import FileHandler from '@tiptap/extension-file-handler'; import { KeyboardNav } from '../block-ux/keyboard-nav'; import { TiptapFindReplace } from '../find-replace/tiptap-find-replace-extension'; +import { GfmAutolink } from '../gfm-autolink-plugin'; import { uploadAndInsert } from '../image-upload/index.ts'; +import { InlineLinkInputRule } from '../inline-link-input-rule'; import { getComponentItems, getInlineComponentItems } from '../slash-command/component-items'; import { getEmbedStarterItems } from '../slash-command/embed-starter-items'; import { getSlashCommandItems } from '../slash-command/items'; @@ -141,6 +143,14 @@ export const sharedExtensions = [ // BlockDragHandle; keyboard parity lives in the TableCellHandles dropdowns. TableInsertControls, SourceDirtyObserver, + // Typed-URL autolink. Origin-guarded (never converts CRDT-sync-tagged + // peer/agent/disk edits) and active-editor-gated (never converts in a pooled + // hidden editor), so it stays a client-side behavior of the foreground doc. + GfmAutolink, + // Typed `[text](url)` shorthand → inline link on the closing paren. Same + // client-side, foreground-only surface as GfmAutolink; keeps its own undo + // step so one Cmd+Z restores the literal. + InlineLinkInputRule, KeyboardNav, // Selection layer — must come after BridgeIdPlugin so ancestor-chain // lookups resolve stable IDs. Order is load-bearing only wrt BridgeId; diff --git a/packages/app/src/editor/gfm-autolink-plugin.test.ts b/packages/app/src/editor/gfm-autolink-plugin.test.ts new file mode 100644 index 00000000..0e3d24de --- /dev/null +++ b/packages/app/src/editor/gfm-autolink-plugin.test.ts @@ -0,0 +1,459 @@ +/** + * Typed-URL autolink plugin — origin guard, detection, exclusions, and the + * deferred separate-dispatch mechanism. + * + * Two rigs, both real ProseMirror EditorViews (jsdom globals via the shared + * walk-currency harness), so the plugin's `view()` hook, `view.composing`, + * and its microtask `view.dispatch` all run for real: + * + * - a light rig (StarterKit + the real link mark + this plugin) with an + * injected `isActiveEditor` predicate, for deterministic coverage of + * detection, the token policy, the exclusion contexts, the active-editor + * gate, IME, and the position re-validation abort; and + * - a `Collaboration`-bound rig where a peer edit arrives through the REAL + * y-sync plugin (a second Y.Doc synced via `Y.applyUpdate`), proving the + * origin guard skips a genuinely CRDT-tagged transaction while a local edit + * in the same binding still converts. The same rig's real y-undo manager + * carries the undo-isolation coverage: one undo removes only the + * conversion's mark (typed text and trailing space intact), redo re-applies + * it, and follow-up typing never merges into the mark's undo step. + */ + +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import type { Editor } from '@tiptap/core'; +import { ySyncPluginKey } from '@tiptap/y-tiptap'; +import * as Y from 'yjs'; +import { + firstLinkAttrs, + firstLinkHref, + insertLocal, + linkHrefs, + mountCollabEditor, + mountLightEditor, + readUndoManager, +} from './editor-rig.test-helper'; +import { GfmAutolink, PREVENT_AUTOLINK_META } from './gfm-autolink-plugin'; +import { + appendToFirstParagraph, + flushMicrotasksAndTimers, + installDomGlobals, + seedFragmentParagraph, +} from './walk-currency-test-harness'; + +let restoreDomGlobals: (() => void) | null = null; + +beforeAll(() => { + restoreDomGlobals = installDomGlobals(); +}); + +afterAll(() => { + restoreDomGlobals?.(); + restoreDomGlobals = null; +}); + +// --------------------------------------------------------------------------- +// Rigs + helpers +// --------------------------------------------------------------------------- + +function makeLightEditor(opts: { content?: string; isActiveEditor?: () => boolean } = {}): Editor { + return mountLightEditor({ + content: opts.content, + extensions: [GfmAutolink.configure({ isActiveEditor: opts.isActiveEditor ?? (() => true) })], + }); +} + +/** Collab rig with this plugin installed; `isActiveEditor: () => true` keeps + * the origin guard (not focus) as the only thing that can block an edit. */ +function makeCollabEditor(ydoc: Y.Doc): Editor { + return mountCollabEditor(ydoc, [GfmAutolink.configure({ isActiveEditor: () => true })]); +} + +// --------------------------------------------------------------------------- +// Detection + token policy +// --------------------------------------------------------------------------- + +describe('typed autolink — conversion', () => { + test('typing a URL then a space converts it to a gfm-autolink link mark', async () => { + const editor = makeLightEditor(); + try { + insertLocal(editor, 'https://example.com ', 1); + await flushMicrotasksAndTimers(); + + const attrs = firstLinkAttrs(editor); + expect(attrs?.href).toBe('https://example.com'); + expect(attrs?.linkStyle).toBe('gfm-autolink'); + // The typed text and its trailing space are untouched — only a mark added. + expect(editor.state.doc.textContent).toBe('https://example.com '); + } finally { + editor.destroy(); + } + }); + + test('pressing Enter after a URL converts the trailing token', async () => { + const editor = makeLightEditor(); + try { + insertLocal(editor, 'https://example.com', 1); + // Caret at the end of the URL, then split the block (Enter). + editor.commands.setTextSelection(editor.state.doc.content.size - 1); + editor.commands.splitBlock(); + await flushMicrotasksAndTimers(); + + expect(firstLinkAttrs(editor)?.linkStyle).toBe('gfm-autolink'); + expect(firstLinkHref(editor)).toBe('https://example.com'); + } finally { + editor.destroy(); + } + }); + + test('an explicit-scheme dotless host converts (dotted-domain rule is schemeless-only)', async () => { + const editor = makeLightEditor(); + try { + insertLocal(editor, 'http://localhost:5174/#/doc ', 1); + await flushMicrotasksAndTimers(); + expect(firstLinkHref(editor)).toBe('http://localhost:5174/#/doc'); + expect(firstLinkAttrs(editor)?.linkStyle).toBe('gfm-autolink'); + } finally { + editor.destroy(); + } + }); + + test('www and email tokens get the pipeline-correct href', async () => { + const www = makeLightEditor(); + try { + insertLocal(www, 'www.example.com ', 1); + await flushMicrotasksAndTimers(); + // The markdown pipeline prepends http:// (not https) to bare www tokens. + expect(firstLinkHref(www)).toBe('http://www.example.com'); + } finally { + www.destroy(); + } + + const email = makeLightEditor(); + try { + insertLocal(email, 'a@b.com ', 1); + await flushMicrotasksAndTimers(); + expect(firstLinkHref(email)).toBe('mailto:a@b.com'); + } finally { + email.destroy(); + } + }); + + test.each([ + 'AGENTS.md ', + 'example.com ', + 'localhost:5173 ', + 'v1.2.3 ', + ])('non-GFM token %j is left as plain text', async (typed) => { + const editor = makeLightEditor(); + try { + insertLocal(editor, typed, 1); + await flushMicrotasksAndTimers(); + expect(firstLinkAttrs(editor)).toBeNull(); + } finally { + editor.destroy(); + } + }); +}); + +// --------------------------------------------------------------------------- +// Origin + active-editor guards +// --------------------------------------------------------------------------- + +describe('typed autolink — guards', () => { + test('a transaction tagged with ySyncPluginKey meta never converts', async () => { + const editor = makeLightEditor(); + try { + const tr = editor.state.tr.insertText('https://example.com ', 1, 1); + // Stand in for a remote peer / agent / disk / observer transaction. + tr.setMeta(ySyncPluginKey, { isChangeOrigin: true }); + editor.view.dispatch(tr); + await flushMicrotasksAndTimers(); + + expect(firstLinkAttrs(editor)).toBeNull(); + expect(editor.state.doc.textContent).toBe('https://example.com '); + } finally { + editor.destroy(); + } + }); + + test('a transaction carrying preventAutolink meta never converts', async () => { + const editor = makeLightEditor(); + try { + const tr = editor.state.tr.insertText('https://example.com ', 1, 1); + tr.setMeta(PREVENT_AUTOLINK_META, true); + editor.view.dispatch(tr); + await flushMicrotasksAndTimers(); + expect(firstLinkAttrs(editor)).toBeNull(); + } finally { + editor.destroy(); + } + }); + + test('a non-active (backgrounded) editor never converts', async () => { + const editor = makeLightEditor({ isActiveEditor: () => false }); + try { + insertLocal(editor, 'https://example.com ', 1); + await flushMicrotasksAndTimers(); + expect(firstLinkAttrs(editor)).toBeNull(); + } finally { + editor.destroy(); + } + }); + + test('no conversion while IME composition is active at dispatch time', async () => { + const editor = makeLightEditor(); + try { + insertLocal(editor, 'https://example.com ', 1); + // Composition still active when the deferred flush runs -> abort. Shadow + // the PUBLIC `composing` getter on the view instance rather than mutating + // the private `input` slot it reads: if PM ever restructures its internal + // composition state, a slot mutation silently stops reaching the getter + // and this guard test goes false-green. + Object.defineProperty(editor.view, 'composing', { get: () => true, configurable: true }); + try { + await flushMicrotasksAndTimers(); + expect(firstLinkAttrs(editor)).toBeNull(); + } finally { + Reflect.deleteProperty(editor.view, 'composing'); + } + } finally { + editor.destroy(); + } + }); +}); + +// --------------------------------------------------------------------------- +// Exclusion contexts +// --------------------------------------------------------------------------- + +describe('typed autolink — exclusions', () => { + test('a boundary inside inline code does not convert', async () => { + const editor = makeLightEditor({ content: '

https://example.com

' }); + try { + insertLocal(editor, ' ', 20); + await flushMicrotasksAndTimers(); + expect(firstLinkAttrs(editor)).toBeNull(); + } finally { + editor.destroy(); + } + }); + + test('a boundary inside a code block does not convert', async () => { + const editor = makeLightEditor({ content: '
https://example.com
' }); + try { + insertLocal(editor, ' ', 20); + await flushMicrotasksAndTimers(); + expect(firstLinkAttrs(editor)).toBeNull(); + } finally { + editor.destroy(); + } + }); + + test('a boundary at the end of an existing link leaves that link untouched', async () => { + const editor = makeLightEditor({ + content: '

https://example.com

', + }); + try { + // Seeded links default to linkStyle 'inline'; a re-conversion would flip + // it to 'gfm-autolink'. + expect(firstLinkAttrs(editor)?.linkStyle).toBe('inline'); + insertLocal(editor, ' ', 20); + await flushMicrotasksAndTimers(); + expect(firstLinkAttrs(editor)?.linkStyle).toBe('inline'); + } finally { + editor.destroy(); + } + }); + + test('a wikilink atom next to a typed URL never converts and stays intact', async () => { + // The wikiLink node is an atom: `textBetween` renders it as leaf text, so + // the word scan can never see inside it. This pins that structural + // property — if WikiLink ever stops being an atom, this fails instead of + // the plugin silently starting to linkify wikilink internals. + const { WikiLink } = await import('@inkeep/open-knowledge-core'); + const editor = mountLightEditor({ + extensions: [WikiLink, GfmAutolink.configure({ isActiveEditor: () => true })], + }); + try { + editor.commands.insertContent({ type: 'wikiLink', attrs: { target: 'Some Page' } }); + // Type a URL + boundary right after the atom: only the URL converts. + const end = editor.state.doc.content.size - 1; + insertLocal(editor, ' https://after-atom.com ', end); + await flushMicrotasksAndTimers(); + expect(linkHrefs(editor)).toEqual(['https://after-atom.com']); + // The atom's target never became link-marked text. + let wikiTargets = 0; + editor.state.doc.descendants((node) => { + if (node.type.name === 'wikiLink') wikiTargets++; + return true; + }); + expect(wikiTargets).toBe(1); + } finally { + editor.destroy(); + } + }); +}); + +// --------------------------------------------------------------------------- +// Deferred separate dispatch + position re-validation +// --------------------------------------------------------------------------- + +describe('typed autolink — deferred dispatch', () => { + test('the conversion aborts silently when the range changed before the flush', async () => { + const editor = makeLightEditor(); + try { + insertLocal(editor, 'https://example.com ', 1); + // Before the queued microtask runs, delete the text the candidate targeted. + editor.view.dispatch(editor.state.tr.delete(1, editor.state.doc.content.size)); + await flushMicrotasksAndTimers(); + + expect(firstLinkAttrs(editor)).toBeNull(); + expect(editor.state.doc.textContent).toBe(''); + } finally { + editor.destroy(); + } + }); + + test('the mark is added by a later dispatch, not merged into the typing tr', async () => { + const editor = makeLightEditor(); + try { + insertLocal(editor, 'https://example.com ', 1); + // Synchronously — before the microtask — the mark is NOT present yet. + expect(firstLinkAttrs(editor)).toBeNull(); + await flushMicrotasksAndTimers(); + // It arrives on the deferred dispatch. + expect(firstLinkAttrs(editor)?.linkStyle).toBe('gfm-autolink'); + } finally { + editor.destroy(); + } + }); +}); + +// --------------------------------------------------------------------------- +// Undo isolation: the conversion is its own single-Cmd+Z step +// --------------------------------------------------------------------------- + +describe('typed autolink — undo isolation (real y-undo binding)', () => { + test('one undo removes only the link mark, keeping the text and trailing space', async () => { + const ydoc = new Y.Doc(); + seedFragmentParagraph(ydoc, 'seed'); + const editor = makeCollabEditor(ydoc); + try { + await flushMicrotasksAndTimers(); + // Close any binding-init capture so the typing below deterministically + // starts its own stack item (real typing happens long after doc open). + readUndoManager(editor)?.stopCapturing(); + + insertLocal(editor, ' https://example.com ', editor.state.doc.content.size - 1); + await flushMicrotasksAndTimers(); + expect(firstLinkHref(editor)).toBe('https://example.com'); + + editor.commands.undo(); + // The mark is gone but the typed text — trailing space included — stays. + expect(firstLinkAttrs(editor)).toBeNull(); + expect(editor.state.doc.textContent).toBe('seed https://example.com '); + + // The typing itself is the next distinct undo step. + editor.commands.undo(); + expect(editor.state.doc.textContent).toBe('seed'); + } finally { + editor.destroy(); + ydoc.destroy(); + } + }); + + test('redo after undoing a conversion re-applies the mark cleanly', async () => { + const ydoc = new Y.Doc(); + seedFragmentParagraph(ydoc, 'seed'); + const editor = makeCollabEditor(ydoc); + try { + await flushMicrotasksAndTimers(); + readUndoManager(editor)?.stopCapturing(); + + insertLocal(editor, ' https://example.com ', editor.state.doc.content.size - 1); + await flushMicrotasksAndTimers(); + expect(firstLinkHref(editor)).toBe('https://example.com'); + + editor.commands.undo(); + expect(firstLinkAttrs(editor)).toBeNull(); + + editor.commands.redo(); + const attrs = firstLinkAttrs(editor); + expect(attrs?.href).toBe('https://example.com'); + expect(attrs?.linkStyle).toBe('gfm-autolink'); + expect(editor.state.doc.textContent).toBe('seed https://example.com '); + } finally { + editor.destroy(); + ydoc.destroy(); + } + }); + + test("typing right after a conversion never merges into the mark's undo step", async () => { + const ydoc = new Y.Doc(); + seedFragmentParagraph(ydoc, 'seed'); + const editor = makeCollabEditor(ydoc); + try { + await flushMicrotasksAndTimers(); + readUndoManager(editor)?.stopCapturing(); + + insertLocal(editor, ' https://example.com ', editor.state.doc.content.size - 1); + await flushMicrotasksAndTimers(); + expect(firstLinkHref(editor)).toBe('https://example.com'); + + // Keep typing within the capture window of the mark dispatch. + insertLocal(editor, 'abc', editor.state.doc.content.size - 1); + await flushMicrotasksAndTimers(); + + editor.commands.undo(); + // Only the new typing reverts; the conversion survives. + expect(editor.state.doc.textContent).toBe('seed https://example.com '); + expect(firstLinkHref(editor)).toBe('https://example.com'); + } finally { + editor.destroy(); + ydoc.destroy(); + } + }); +}); + +// --------------------------------------------------------------------------- +// Real y-sync binding: origin guard against a genuine remote transaction +// --------------------------------------------------------------------------- + +describe('typed autolink — real CRDT binding', () => { + test('a remote y-sync edit is not linkified, but a local edit in the same binding is', async () => { + const ydoc = new Y.Doc(); + // Seed a paragraph with text so the remote append has an XmlText to grow. + seedFragmentParagraph(ydoc, 'seed'); + const editor = makeCollabEditor(ydoc); + + try { + await flushMicrotasksAndTimers(); + + // A peer appends a URL + space through the real y-sync path. The applied + // transaction carries ySyncPluginKey meta → origin guard skips it. + const remote = new Y.Doc(); + Y.applyUpdate(remote, Y.encodeStateAsUpdate(ydoc)); + remote.transact(() => { + appendToFirstParagraph(remote.getXmlFragment('default'), ' https://remote.example '); + }); + Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(remote, Y.encodeStateVector(ydoc)), remote); + remote.destroy(); + await flushMicrotasksAndTimers(); + + expect(editor.state.doc.textContent).toContain('https://remote.example'); + expect(linkHrefs(editor)).not.toContain('https://remote.example'); + + // A local edit in the SAME binding still converts. + const end = editor.state.doc.content.size - 1; + insertLocal(editor, ' https://local.example ', end); + await flushMicrotasksAndTimers(); + + expect(linkHrefs(editor)).toContain('https://local.example'); + // And the remote URL is still plain text. + expect(linkHrefs(editor)).not.toContain('https://remote.example'); + } finally { + editor.destroy(); + ydoc.destroy(); + } + }); +}); diff --git a/packages/app/src/editor/gfm-autolink-plugin.ts b/packages/app/src/editor/gfm-autolink-plugin.ts new file mode 100644 index 00000000..49eeb596 --- /dev/null +++ b/packages/app/src/editor/gfm-autolink-plugin.ts @@ -0,0 +1,290 @@ +/** + * Typed-URL autolink for the WYSIWYG editor. + * + * When a local user finishes a GFM-shape URL token and types a word boundary + * (space or Enter), the token becomes a link mark carrying + * `linkStyle: 'gfm-autolink'` so it serializes as a bare literal rather than + * `[url](url)`. Detection reuses stock TipTap's changed-range / last-word scan + * but swaps linkify's tokenizer for the GFM-parity recognizer, so only what the + * markdown pipeline itself would linkify ever converts. + * + * Two properties keep this safe in OK's multi-writer CRDT, and both are + * structural rather than conventional: + * + * - Origin guard. Peer, agent, disk-load, and observer-echo edits re-enter the + * editor as ProseMirror transactions tagged with `ySyncPluginKey` meta. + * Linkifying any of those would rewrite another writer's text across every + * connected client. The plugin skips the whole batch the moment any member + * carries that meta (fail-closed). A pooled, backgrounded editor also + * receives those updates and its provider is live even while hidden, so + * detection additionally requires the bound view to be the active/focused + * one — a stray local write into a hidden editor can never convert. This is + * the same `ySyncPluginKey` origin discipline as source-dirty-observer.ts. + * + * - Undo isolation. The mark is added as its OWN ProseMirror dispatch a + * microtask after the typing flush, never returned from `appendTransaction`. + * Returning it would weld the mark and the keystroke into a single Yjs + * transaction — inseparable by any undo-manager setting — and Y.UndoManager + * would still merge separate transactions landing within its default + * `captureTimeout` (500 ms — Y.js's default, not OK config) into one + * stack item. The separate dispatch undoes the first + * weld; `stopCapturing()` immediately before AND after the dispatch undoes + * the second, so one Cmd+Z removes just the link (typed text intact) and + * the user's next keystrokes never merge into the mark's step. The deferral + * also lets the flush read `view.composing` (unreachable inside + * appendTransaction) and re-validate the target range, which may have moved + * or vanished in the gap. + */ + +import type { LinkStyle } from '@inkeep/open-knowledge-core'; +import { + combineTransactionSteps, + Extension, + findChildrenInRange, + getChangedRanges, + getMarksBetween, + type NodeWithPos, +} from '@tiptap/core'; +import { type EditorState, Plugin, PluginKey, type Transaction } from '@tiptap/pm/state'; +import type { EditorView } from '@tiptap/pm/view'; +import { ySyncPluginKey } from '@tiptap/y-tiptap'; +import { detectGfmLinkToken } from './gfm-link-detector'; +import { dispatchAsOwnUndoStep } from './undo-isolation'; + +// TipTap's UNICODE_WHITESPACE_PATTERN (@tiptap/extension-link), the class +// stock autolink word-splits on, so the boundary notion here agrees with the +// tokenizer this plugin descends from rather than JS `\s`, which omits some +// of these code points. +const WHITESPACE_CLASS = '\\u0000-\\u0020\\u00A0\\u1680\\u180E\\u2000-\\u2029\\u205F\\u3000'; +const WHITESPACE_SPLIT = new RegExp(`[${WHITESPACE_CLASS}]`); +const TRAILING_WHITESPACE = new RegExp(`[${WHITESPACE_CLASS}]$`); + +/** The mark this plugin creates and the fidelity attr that makes it serialize + * to a bare literal. Kept as constants so the flush and its tests agree. */ +const LINK_MARK = 'link'; +const GFM_AUTOLINK_STYLE: LinkStyle = 'gfm-autolink'; + +/** Meta key for suppressing autolink on a transaction (same string value + * TipTap's Link extension uses internally, but defined and exported HERE) — + * set by the Link mark's setLink/unsetLink commands and by the clipboard + * dispatcher, both of which manage their own link bytes. */ +export const PREVENT_AUTOLINK_META = 'preventAutolink'; + +const gfmAutolinkPluginKey = new PluginKey('gfmAutolink'); + +interface LinkifyCandidate { + from: number; + to: number; + href: string; + /** The exact literal expected at [from, to] at dispatch time; a mismatch + * means the doc changed under us and the conversion is abandoned. */ + text: string; +} + +interface GfmAutolinkPluginOptions { + /** + * Whether the bound editor is the one that should linkify. Defaults to DOM + * focus, which is false for every pooled/background editor and true for the + * foreground editor a user is typing into. Overridable so headless tests can + * exercise the local-write path without driving real focus. + */ + isActiveEditor?: (view: EditorView) => boolean; +} + +function rangeHasCodeMark(view: EditorView, from: number, to: number): boolean { + const codeMark = view.state.schema.marks.code; + if (!codeMark) return false; + return view.state.doc.rangeHasMark(from, to, codeMark); +} + +/** + * Scan the batch's changed ranges for a GFM-shape token that a just-typed word + * boundary completed. Pure with respect to the document — it only reads state + * and returns ranges; the caller performs the (deferred) mutation. + */ +function detectCandidates( + oldState: EditorState, + newState: EditorState, + transactions: readonly Transaction[], +): LinkifyCandidate[] { + const results: LinkifyCandidate[] = []; + const transform = combineTransactionSteps(oldState.doc, [...transactions]); + const changes = getChangedRanges(transform); + + for (const { newRange } of changes) { + const nodesInChangedRanges = findChildrenInRange( + newState.doc, + newRange, + (node) => node.isTextblock, + ); + + let textBlock: NodeWithPos | undefined; + let textBeforeWhitespace: string | undefined; + + if (nodesInChangedRanges.length > 1) { + // Enter split a block: scan the first block's whole text (the trailing + // token now sits at its end). Stock takes this same ungated path. + textBlock = nodesInChangedRanges[0]; + textBeforeWhitespace = newState.doc.textBetween( + textBlock.pos, + textBlock.pos + textBlock.node.nodeSize, + undefined, + ' ', + ); + } else if (nodesInChangedRanges.length === 1) { + // Single block: only fire when the change ends in whitespace — the "a + // boundary key was just typed" signal. + const endText = newState.doc.textBetween(newRange.from, newRange.to, ' ', ' '); + if (!TRAILING_WHITESPACE.test(endText)) continue; + textBlock = nodesInChangedRanges[0]; + textBeforeWhitespace = newState.doc.textBetween(textBlock.pos, newRange.to, undefined, ' '); + } + + if (!textBlock || !textBeforeWhitespace) continue; + // Code blocks are plain-text-only; never linkify inside one. + if (textBlock.node.type.spec.code) continue; + + const words = textBeforeWhitespace.split(WHITESPACE_SPLIT).filter(Boolean); + const lastWord = words[words.length - 1]; + if (!lastWord) continue; + + const detected = detectGfmLinkToken(lastWord); + if (!detected) continue; + + // The recognizer head-anchors the linkified literal to the start of the + // word, so the span is [wordStart, wordStart + text.length]. +1 crosses the + // textblock's opening boundary into its content (stock's `link.start + 1`). + const from = textBlock.pos + textBeforeWhitespace.lastIndexOf(lastWord) + 1; + const to = from + detected.text.length; + results.push({ from, to, href: detected.href, text: detected.text }); + } + + return results; +} + +/** + * The ProseMirror plugin, as a factory so `isActiveEditor` can be injected. + * Tests drive it through the `GfmAutolink` extension (which threads + * `isActiveEditor` via its options), so the factory stays module-private. + */ +function gfmAutolinkPlugin(options: GfmAutolinkPluginOptions = {}): Plugin { + const isActiveEditor = options.isActiveEditor ?? ((view: EditorView) => view.hasFocus()); + + let boundView: EditorView | null = null; + let scheduled = false; + const pending: LinkifyCandidate[] = []; + + const flush = (): void => { + scheduled = false; + const view = boundView; + const candidates = pending.splice(0, pending.length); + if (!view || view.isDestroyed) return; + // No conversion mid-IME-composition, and never in a view that lost focus + // between detection and this microtask. + if (view.composing) return; + if (!isActiveEditor(view)) return; + + const markType = view.state.schema.marks[LINK_MARK]; + if (!markType) return; + + let tr = view.state.tr; + let changed = false; + const docSize = view.state.doc.content.size; + + for (const candidate of candidates) { + const { from, to, href, text } = candidate; + // Re-validate: the doc may have shifted, shrunk, or been rewritten in the + // gap. A range that no longer holds the exact detected literal is stale. + if (from < 0 || to > docSize || from >= to) continue; + if (view.state.doc.textBetween(from, to) !== text) continue; + // Skip if the span is already linked or lives inside inline code. + if (getMarksBetween(from, to, view.state.doc).some((m) => m.mark.type === markType)) continue; + if (rangeHasCodeMark(view, from, to)) continue; + + tr = tr.addMark(from, to, markType.create({ href, linkStyle: GFM_AUTOLINK_STYLE })); + changed = true; + } + + if (!changed) return; + // Tag our own mark-add so re-entering appendTransaction skips it. + tr = tr.setMeta(PREVENT_AUTOLINK_META, true); + + // The typing that triggered detection is < captureTimeout old, so the + // mark-add must land as its own undo stack item (see undo-isolation.ts + // for the split-before/close-after contract). flush() runs as a bare + // microtask — an escaping throw would be an uncaught async error and the + // conversion would just vanish, so degrade like the clipboard dispatcher: + // log and drop this batch (the typed text itself is untouched). + try { + dispatchAsOwnUndoStep(view, tr); + } catch (err) { + console.warn( + '[gfm-autolink] linkify dispatch failed', + { candidates: candidates.map((c) => ({ from: c.from, to: c.to, href: c.href })) }, + err, + ); + } + }; + + return new Plugin({ + key: gfmAutolinkPluginKey, + view(editorView) { + boundView = editorView; + return { + destroy() { + boundView = null; + pending.length = 0; + scheduled = false; + }, + }; + }, + appendTransaction(transactions, oldState, newState) { + // Fail-closed origin guard: any CRDT-sync-tagged transaction in the batch + // (remote peer, agent, disk load, observer echo) disqualifies the whole + // batch. Also honour explicit autolink suppression. + if (transactions.some((tr) => tr.getMeta(ySyncPluginKey))) return null; + if (transactions.some((tr) => tr.getMeta(PREVENT_AUTOLINK_META))) return null; + + const docChanged = transactions.some((tr) => tr.docChanged) && !oldState.doc.eq(newState.doc); + if (!docChanged) return null; + + // Only the active/focused editor linkifies — closes the pooled-hidden- + // editor local-write vector the origin guard alone can't see. + if (!boundView || !isActiveEditor(boundView)) return null; + + const candidates = detectCandidates(oldState, newState, transactions); + if (candidates.length === 0) return null; + + pending.push(...candidates); + if (!scheduled) { + scheduled = true; + queueMicrotask(flush); + } + // Never return the mark-add here — it must land as its own dispatch. + return null; + }, + }); +} + +export interface GfmAutolinkOptions { + isActiveEditor?: (view: EditorView) => boolean; +} + +/** + * App editor extension wrapper. Registered only in the app's editor extension + * list, never in the core/persistence set, so linkification stays a client-side + * behavior of the interactive editor. + */ +export const GfmAutolink = Extension.create({ + name: 'gfmAutolink', + + addOptions() { + return { + isActiveEditor: undefined, + }; + }, + + addProseMirrorPlugins() { + return [gfmAutolinkPlugin({ isActiveEditor: this.options.isActiveEditor })]; + }, +}); diff --git a/packages/app/src/editor/gfm-link-detector.test.ts b/packages/app/src/editor/gfm-link-detector.test.ts new file mode 100644 index 00000000..f7da332e --- /dev/null +++ b/packages/app/src/editor/gfm-link-detector.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, test } from 'bun:test'; +import { MarkdownManager, SAFE_URL_SCHEMES, sharedExtensions } from '@inkeep/open-knowledge-core'; +import type { JSONContent } from '@tiptap/core'; +import { detectGfmLinkToken, type GfmLinkToken } from './gfm-link-detector'; + +// Real pipeline, shared with production — the parity oracle. Constructing one +// manager pulls the same remark-gfm autolink transform the editor uses. +const mdManager = new MarkdownManager({ extensions: sharedExtensions }); + +/** Walk a parsed doc for the first text run carrying a link mark and return + * the exact {href, text} the pipeline produced (or null if nothing linked). */ +function parseFirstLink(token: string): GfmLinkToken | null { + function walk(node: JSONContent): GfmLinkToken | null { + const mark = node.marks?.find((m) => m.type === 'link'); + if (mark) { + return { href: String(mark.attrs?.href ?? ''), text: node.text ?? '' }; + } + for (const child of node.content ?? []) { + const found = walk(child); + if (found) return found; + } + return null; + } + return walk(mdManager.parse(token)); +} + +// The locked acceptance matrix. `null` = stays plain text. +const MATRIX: Array<{ token: string; expected: GfmLinkToken | null }> = [ + { + token: 'https://example.com', + expected: { href: 'https://example.com', text: 'https://example.com' }, + }, + { + token: 'www.example.com', + expected: { href: 'http://www.example.com', text: 'www.example.com' }, + }, + { token: 'a@b.com', expected: { href: 'mailto:a@b.com', text: 'a@b.com' } }, + { token: 'example.com', expected: null }, + { token: 'AGENTS.md', expected: null }, + { token: 'package.json', expected: null }, + { token: 'localhost:5173', expected: null }, + { token: 'foo.bar', expected: null }, + { token: 'v1.2.3', expected: null }, + { token: '192.168.1.1', expected: null }, + // Explicit-scheme dotless hosts: micromark's http_autolink skips the + // dotted-domain requirement, so these linkify and round-trip as bare + // literals (the schemeless rows above stay rejected). + { + token: 'http://localhost:5174', + expected: { href: 'http://localhost:5174', text: 'http://localhost:5174' }, + }, + { + token: 'http://localhost:5174/#/doc', + expected: { href: 'http://localhost:5174/#/doc', text: 'http://localhost:5174/#/doc' }, + }, + { token: 'http://localhost', expected: { href: 'http://localhost', text: 'http://localhost' } }, + { + token: 'http://127.0.0.1:8080/x', + expected: { href: 'http://127.0.0.1:8080/x', text: 'http://127.0.0.1:8080/x' }, + }, + { token: 'localhost:5174', expected: null }, + { token: 'http://foo_bar', expected: null }, + // Ported edge branches — splitUrl's trailing-punctuation / unbalanced-paren + // strips, isCorrectDomain's underscore reject, the email bad-tail reject, + // and their keep-side complements. In this array they get parity coverage + // against the real parse like every other row, which is what guards the + // hand-port against upstream drift. + { + token: 'https://example.com.', + expected: { href: 'https://example.com', text: 'https://example.com' }, + }, + { + token: 'https://example.com)', + expected: { href: 'https://example.com', text: 'https://example.com' }, + }, + { token: 'https://foo_bar.com', expected: null }, + { token: 'a@b.c_', expected: null }, + { token: 'user@host.com-', expected: null }, + { token: 'a@b.co1', expected: null }, + { token: 'a@b_c.com', expected: { href: 'mailto:a@b_c.com', text: 'a@b_c.com' } }, + { + token: 'www.a_b.example.com', + expected: { href: 'http://www.a_b.example.com', text: 'www.a_b.example.com' }, + }, +]; + +describe('detectGfmLinkToken — acceptance matrix', () => { + for (const { token, expected } of MATRIX) { + test(`${token} → ${expected ? `link ${expected.href}` : 'plain text'}`, () => { + expect(detectGfmLinkToken(token)).toEqual(expected); + }); + } +}); + +describe('detectGfmLinkToken — parity with MarkdownManager.parse', () => { + // The single-source-of-truth contract: for every matrix token the detector + // agrees with what the real pipeline linkifies (verdict AND href). + const parityTokens = [ + ...MATRIX.map((row) => row.token), + 'http://example.com', + 'HTTPS://Example.com', + 'WWW.Example.com', + 'a.b@c.co.uk', + 'ftp://files.example.com', + 'https://en.wikipedia.org/wiki/Foo_(bar)', + ]; + for (const token of parityTokens) { + test(`agrees with the pipeline for ${token}`, () => { + expect(detectGfmLinkToken(token)).toEqual(parseFirstLink(token)); + }); + } +}); + +describe('detectGfmLinkToken — bare domains are rejected structurally, not by TLD', () => { + // No TLD table ships. A bare domain never linkifies regardless of how + // real its suffix looks; only a scheme, `www.`, or `@` promotes it. + for (const token of [ + 'example.com', + 'foo.bar', + 'a.io', + 'my-site.dev', + 'stripe.com', + '192.168.1.1', + ]) { + test(`${token} stays plain text`, () => { + expect(detectGfmLinkToken(token)).toBeNull(); + }); + } +}); + +describe('detectGfmLinkToken — href scheme stays inside the allowlist', () => { + test('every produced href uses a SAFE_URL_SCHEMES scheme', () => { + const allowed = SAFE_URL_SCHEMES.map((s) => `${s}:`); + for (const token of [ + 'https://example.com', + 'http://example.com', + 'www.example.com', + 'a@b.com', + ]) { + const result = detectGfmLinkToken(token); + expect(result).not.toBeNull(); + const href = result?.href.toLowerCase() ?? ''; + expect(allowed.some((prefix) => href.startsWith(prefix))).toBe(true); + } + }); + + test('ftp:// is a safe scheme but not a GFM shape, so it does not convert', () => { + // Guards against widening the recognizer to the full scheme allowlist: + // the shape rules are GFM's (http/https/www/email), not SAFE_URL_SCHEMES. + expect(detectGfmLinkToken('ftp://files.example.com')).toBeNull(); + }); +}); + +describe('detectGfmLinkToken — href shape details', () => { + test('www. tokens get an http:// href, not https://', () => { + expect(detectGfmLinkToken('www.example.com')?.href).toBe('http://www.example.com'); + }); + + test('scheme match is case-insensitive and preserves original casing', () => { + expect(detectGfmLinkToken('HTTPS://Example.com')).toEqual({ + href: 'HTTPS://Example.com', + text: 'HTTPS://Example.com', + }); + expect(detectGfmLinkToken('WWW.Example.com')?.href).toBe('http://WWW.Example.com'); + }); + + test('email tokens become mailto: hrefs with the address as text', () => { + expect(detectGfmLinkToken('a.b@c.co.uk')).toEqual({ + href: 'mailto:a.b@c.co.uk', + text: 'a.b@c.co.uk', + }); + }); + + test('trailing sentence punctuation is split off the linkified span', () => { + expect(detectGfmLinkToken('https://example.com.')).toEqual({ + href: 'https://example.com', + text: 'https://example.com', + }); + }); + + test('a closing paren that balances an opening paren stays in the link', () => { + expect(detectGfmLinkToken('https://en.wikipedia.org/wiki/Foo_(bar)')?.text).toBe( + 'https://en.wikipedia.org/wiki/Foo_(bar)', + ); + }); + + test('empty string is not a link', () => { + expect(detectGfmLinkToken('')).toBeNull(); + }); +}); diff --git a/packages/app/src/editor/gfm-link-detector.ts b/packages/app/src/editor/gfm-link-detector.ts new file mode 100644 index 00000000..b9f3d283 --- /dev/null +++ b/packages/app/src/editor/gfm-link-detector.ts @@ -0,0 +1,168 @@ +/** + * Pure, editor-independent recognizer for the three GFM autolink-literal + * token shapes — protocol URLs (`https?://…`), `www.` domains, and bare + * emails (`local@domain`). Given a single whitespace-free token it answers: + * would the markdown pipeline's own parse turn this into a link, and to + * what href? + * + * Linkification only fires when GFM itself would, so a converted token + * serializes to a bare literal that re-parses to the identical link. That + * is why bare domains (`example.com`), filenames (`AGENTS.md`), and + * `localhost:5173` must NOT match: GFM never linkifies them, so the + * recognizer must not either. The dotted-domain requirement applies to the + * schemeless (`www.`) arm only — micromark's `http_autolink` production + * skips it when an explicit scheme is present (a documented divergence from + * the GFM spec prose), so `http://localhost:5174` linkifies and round-trips + * as a bare literal. The logic is ported from + * `mdast-util-gfm-autolink-literal` (the pipeline's own transform) so the + * two agree by construction; the co-located parity test pins the agreement + * against the real `MarkdownManager.parse`. + * + * Href transforms mirror GFM exactly: `www.` prepends `http://` (NOT + * https — the pipeline's own default), email prepends `mailto:`, and + * protocol URLs keep their scheme verbatim with original casing. Trailing + * sentence punctuation and unbalanced closing parens are excluded from the + * linkified span, matching how GFM splits them off. + * + * No ProseMirror / editor imports: `clipboard/lone-url.ts` (a plain string + * module) and unit tests both consume this as a pure string function. + * + * Coupled artifact: this is a hand-port of `mdast-util-gfm-autolink-literal` + * (which exposes no synchronous string classifier) and must track it on every + * markdown dependency bump. The drift guards are the parity tests in + * `gfm-link-detector.test.ts` (token matrix incl. trailing punctuation, + * balanced parens, structural bad-domain rejection, all checked against the + * real `MarkdownManager.parse`) — they run in `bun run check`. + */ + +import { SAFE_URL_SCHEMES } from '@inkeep/open-knowledge-core'; + +export type GfmLinkToken = { + /** Resolved link target: scheme-prepended for www/email, verbatim otherwise. */ + href: string; + /** The linkified literal — the display text and the bytes that serialize back. */ + text: string; +}; + +// Head-anchored ports of the two `transformGfmAutolinkLiterals` regexes. The +// token is already whitespace-delimited, so the upstream "previous character" +// guard (start / whitespace / punctuation) is always satisfied at index 0 and +// is omitted here. +const URL_HEAD = /^(https?:\/\/|www(?=\.))([-.\w]+)([^ \t\r\n]*)/i; +const EMAIL_HEAD = /^([-.\w+]+)@([-\w]+(?:\.[-\w]+)+)/; +// GFM rejects an email whose domain's final label ends in `-`, a digit, or `_`. +const EMAIL_LABEL_BAD_TAIL = /[-\d_]$/; + +const ALLOWED_SCHEME_PREFIXES = SAFE_URL_SCHEMES.map((scheme) => `${scheme}:`); + +function schemeAllowed(href: string): boolean { + const lower = href.toLowerCase(); + return ALLOWED_SCHEME_PREFIXES.some((prefix) => lower.startsWith(prefix)); +} + +function countChar(haystack: string, char: string): number { + let n = 0; + for (const c of haystack) { + if (c === char) n++; + } + return n; +} + +/** GFM's per-label check on the final two dot-separated labels: neither may + * contain `_` or lack an alphanumeric character. Applies to BOTH url arms — + * micromark keeps the underscore restriction even for scheme'd URLs. */ +function hasCorrectDomainLabels(domain: string): boolean { + const parts = domain.split('.'); + const last = parts[parts.length - 1]; + const penultimate = parts[parts.length - 2]; + if (last && (/_/.test(last) || !/[a-zA-Z\d]/.test(last))) return false; + if (penultimate && (/_/.test(penultimate) || !/[a-zA-Z\d]/.test(penultimate))) return false; + return true; +} + +/** The schemeless (`www.`) arm additionally requires a dotted host. An + * explicit `http(s)://` scheme skips this — micromark's `http_autolink` + * accepts dotless hosts (`http://localhost:5174`), and the detector must + * match the parse or typed conversions would diverge from round-trip. */ +function isCorrectSchemelessDomain(domain: string): boolean { + return domain.split('.').length >= 2 && hasCorrectDomainLabels(domain); +} + +/** GFM's trailing-punctuation split: peel a run of sentence punctuation off + * the end, but keep closing parens that balance an opening paren inside the + * URL (Wikipedia-style `…/Foo_(disambiguation)`). Returns [linkified, trailing]. */ +function splitUrl(url: string): [string, string] { + const trailMatch = /[!"&'),.:;<>?\]}]+$/.exec(url); + if (!trailMatch) return [url, '']; + + let head = url.slice(0, trailMatch.index); + let trail = trailMatch[0]; + let closingParenIndex = trail.indexOf(')'); + const openingParens = countChar(head, '('); + let closingParens = countChar(head, ')'); + + while (closingParenIndex !== -1 && openingParens > closingParens) { + head += trail.slice(0, closingParenIndex + 1); + trail = trail.slice(closingParenIndex + 1); + closingParenIndex = trail.indexOf(')'); + closingParens++; + } + + return [head, trail]; +} + +function detectUrl(token: string): GfmLinkToken | null { + const match = URL_HEAD.exec(token); + if (!match) return null; + + let protocol = match[1]; + let domain = match[2]; + const path = match[3]; + let prefix = ''; + + // A `www.` match carries no scheme in the source; fold it into the domain + // and let the pipeline's `http://` prefix supply one. + const schemeless = /^w/i.test(protocol); + if (schemeless) { + domain = protocol + domain; + protocol = ''; + prefix = 'http://'; + } + + if (schemeless ? !isCorrectSchemelessDomain(domain) : !hasCorrectDomainLabels(domain)) { + return null; + } + + const [core] = splitUrl(domain + path); + if (!core) return null; + + const text = protocol + core; + const href = prefix + protocol + core; + if (!schemeAllowed(href)) return null; + return { href, text }; +} + +function detectEmail(token: string): GfmLinkToken | null { + const match = EMAIL_HEAD.exec(token); + if (!match) return null; + + const local = match[1]; + const label = match[2]; + if (EMAIL_LABEL_BAD_TAIL.test(label)) return null; + + const text = `${local}@${label}`; + const href = `mailto:${text}`; + if (!schemeAllowed(href)) return null; + return { href, text }; +} + +/** + * Classify a completed token. Returns the `{ href, text }` to link, or null + * when GFM would leave the token as plain text. `text` is the exact literal + * to keep in the document (which may be shorter than the input token when + * trailing punctuation is split off); `href` is its resolved target. + */ +export function detectGfmLinkToken(token: string): GfmLinkToken | null { + if (!token) return null; + return detectUrl(token) ?? detectEmail(token); +} diff --git a/packages/app/src/editor/inline-link-input-rule.test.ts b/packages/app/src/editor/inline-link-input-rule.test.ts new file mode 100644 index 00000000..82006df3 --- /dev/null +++ b/packages/app/src/editor/inline-link-input-rule.test.ts @@ -0,0 +1,209 @@ +/** + * Typed `[text](url)` input rule — conversion + href policy, exclusion + * contexts, and the one-undo-restores-the-literal contract against a real + * y-undo binding. + * + * Input rules fire only from `handleTextInput`, so these rigs type through + * `view.someProp('handleTextInput', …)` with the same unhandled-fallback + * insertion the real DOM input path performs — a bare `insertText` dispatch + * never triggers a rule. + */ + +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import type { Editor } from '@tiptap/core'; +import * as Y from 'yjs'; +import { + firstLinkAttrs, + linkHrefs, + mountCollabEditor, + mountLightEditor, + readUndoManager, +} from './editor-rig.test-helper'; +import { InlineLinkInputRule } from './inline-link-input-rule'; +import { flushMicrotasksAndTimers, installDomGlobals } from './walk-currency-test-harness'; + +let restoreDomGlobals: (() => void) | null = null; + +beforeAll(() => { + restoreDomGlobals = installDomGlobals(); +}); + +afterAll(() => { + restoreDomGlobals?.(); + restoreDomGlobals = null; +}); + +function makeEditor(opts: { content?: string } = {}): Editor { + return mountLightEditor({ content: opts.content, extensions: [InlineLinkInputRule] }); +} + +/** + * Type text the way the DOM input path does: each character goes through + * `handleTextInput` (where input rules run) and falls back to a plain + * insertion when no rule claims it. + */ +function typeText(editor: Editor, text: string): void { + for (const char of text) { + const { from, to } = editor.state.selection; + const deflt = () => editor.state.tr.insertText(char, from, to); + const handled = editor.view.someProp('handleTextInput', (handleTextInput) => + handleTextInput(editor.view, from, to, char, deflt), + ); + if (!handled) { + editor.view.dispatch(deflt()); + } + } +} + +// --------------------------------------------------------------------------- +// Conversion +// --------------------------------------------------------------------------- + +describe('inline-link input rule — conversion', () => { + test('typing [text](url) converts to linked display text on the closing paren', async () => { + const editor = makeEditor(); + try { + typeText(editor, '[docs](https://example.com)'); + await flushMicrotasksAndTimers(); + + expect(editor.state.doc.textContent).toBe('docs'); + const attrs = firstLinkAttrs(editor); + expect(attrs?.href).toBe('https://example.com'); + expect(attrs?.linkStyle).toBe('inline'); + } finally { + editor.destroy(); + } + }); + + test('a relative target is allowed (internal-link contract)', async () => { + const editor = makeEditor(); + try { + typeText(editor, '[guide](/docs/start)'); + await flushMicrotasksAndTimers(); + + expect(editor.state.doc.textContent).toBe('guide'); + expect(firstLinkAttrs(editor)?.href).toBe('/docs/start'); + } finally { + editor.destroy(); + } + }); +}); + +// --------------------------------------------------------------------------- +// Href policy +// --------------------------------------------------------------------------- + +describe('inline-link input rule — href policy', () => { + test('an empty URL [text]() stays literal', async () => { + const editor = makeEditor(); + try { + typeText(editor, '[x]()'); + await flushMicrotasksAndTimers(); + expect(editor.state.doc.textContent).toBe('[x]()'); + expect(linkHrefs(editor)).toEqual([]); + } finally { + editor.destroy(); + } + }); + + test('a disallowed scheme leaves the literal untouched', async () => { + const editor = makeEditor(); + try { + typeText(editor, '[x](javascript:alert)'); + await flushMicrotasksAndTimers(); + + expect(editor.state.doc.textContent).toBe('[x](javascript:alert)'); + expect(linkHrefs(editor)).toEqual([]); + } finally { + editor.destroy(); + } + }); +}); + +// --------------------------------------------------------------------------- +// Exclusion contexts +// --------------------------------------------------------------------------- + +describe('inline-link input rule — exclusions', () => { + test('does not fire inside a code block', async () => { + const editor = makeEditor({ content: '
x
' }); + try { + // Caret at the end of the code block content. + editor.commands.setTextSelection(editor.state.doc.content.size - 1); + typeText(editor, '[a](https://b.com)'); + await flushMicrotasksAndTimers(); + + expect(editor.state.doc.textContent).toBe('x[a](https://b.com)'); + expect(linkHrefs(editor)).toEqual([]); + } finally { + editor.destroy(); + } + }); + + test('wikilink shorthand is excluded structurally', async () => { + const editor = makeEditor(); + try { + typeText(editor, '[[Page]]'); + await flushMicrotasksAndTimers(); + + expect(linkHrefs(editor)).toEqual([]); + } finally { + editor.destroy(); + } + }); +}); + +// --------------------------------------------------------------------------- +// Undo isolation (real y-undo binding) +// --------------------------------------------------------------------------- + +describe('inline-link input rule — one undo restores the literal', () => { + test('a single undo brings back [text](url) with the text intact', async () => { + const ydoc = new Y.Doc(); + const editor = mountCollabEditor(ydoc, [InlineLinkInputRule]); + try { + typeText(editor, '[docs](https://example.com)'); + await flushMicrotasksAndTimers(); + expect(editor.state.doc.textContent).toBe('docs'); + expect(linkHrefs(editor)).toEqual(['https://example.com']); + + const undoManager = readUndoManager(editor); + expect(undoManager).not.toBeNull(); + undoManager?.undo(); + await flushMicrotasksAndTimers(); + + expect(editor.state.doc.textContent).toBe('[docs](https://example.com)'); + expect(linkHrefs(editor)).toEqual([]); + } finally { + editor.destroy(); + ydoc.destroy(); + } + }); + + test('typing after a conversion stays its own undo step (no merge into the collapse)', async () => { + const ydoc = new Y.Doc(); + const editor = mountCollabEditor(ydoc, [InlineLinkInputRule]); + try { + typeText(editor, '[docs](https://example.com)'); + await flushMicrotasksAndTimers(); + expect(editor.state.doc.textContent).toBe('docs'); + + // Keystrokes after the collapse land within captureTimeout of it; the + // closing stopCapturing must keep them OUT of the collapse's undo item. + typeText(editor, ' more'); + await flushMicrotasksAndTimers(); + expect(editor.state.doc.textContent).toBe('docs more'); + + const undoManager = readUndoManager(editor); + undoManager?.undo(); + await flushMicrotasksAndTimers(); + + // Only the trailing typing is removed; the converted link survives. + expect(editor.state.doc.textContent).toBe('docs'); + expect(linkHrefs(editor)).toEqual(['https://example.com']); + } finally { + editor.destroy(); + ydoc.destroy(); + } + }); +}); diff --git a/packages/app/src/editor/inline-link-input-rule.ts b/packages/app/src/editor/inline-link-input-rule.ts new file mode 100644 index 00000000..62ffce6d --- /dev/null +++ b/packages/app/src/editor/inline-link-input-rule.ts @@ -0,0 +1,120 @@ +/** + * Typed `[text](url)` input rule for the WYSIWYG editor. + * + * When a local user closes a well-formed inline-link literal by typing the + * final `)`, the `[text](url)` span collapses to its display `text` carrying a + * `link` mark. The mark keeps the schema-default `linkStyle: 'inline'`, so it + * serializes straight back to `[text](url)` (unlike the bare-literal + * `gfm-autolink` style the typed-URL plugin uses). Scope is deliberately narrow + * — the one universal Markdown link shorthand editors share (Outline, Plate). + * + * Safety and undo, both structural: + * + * - Origin safety by construction. Input rules fire only from + * `handleTextInput` — a local, focused DOM keystroke — never from a y-sync + * transaction (remote peer, agent, disk load, observer echo) or a + * programmatic write into a pooled/background editor. So the "never convert + * another writer's content" invariant holds without an explicit origin + * guard, unlike the appendTransaction-based typed-URL plugin. + * - Href policy. The URL must pass `isAllowedLinkUri` (the shared scheme + * allowlist); a `javascript:`-style payload leaves the literal untouched. + * Relative targets resolve against the placeholder base and are allowed, + * matching the internal-link contract. + * - Undo isolation, mark kept in step with the trigger. The rule does NOT + * consume the `)`; it lets the paren land through normal typing (so the + * literal is complete in the doc) and collapses in a deferred separate + * dispatch a microtask later, split from the typing by `stopCapturing()` + * before and after. One Cmd+Z then reverts just the collapse, restoring the + * full `[text](url)` literal — the same trigger-char-kept, one-undo contract + * the gfm-autolink path has. Absent a y-undo binding there is no capture + * stack and nothing to split. + */ + +import { isAllowedLinkUri } from '@inkeep/open-knowledge-core'; +import { Extension, InputRule } from '@tiptap/core'; +import type { EditorView } from '@tiptap/pm/view'; +import { dispatchAsOwnUndoStep } from './undo-isolation'; + +/** `[text](url)` ending at the caret. `text` = one+ non-`]`; `url` = one+ + * non-`)`/non-whitespace (inline links whose URL needs spaces use `<>`, out + * of scope). The trailing `)` anchors the rule to the closing keystroke. + * `[[Page]]` wikilinks never match — they carry no `](` after the first `]`. */ +const INLINE_LINK_RE = /\[([^\]]+)\]\(([^)\s]+)\)$/; + +const LINK_MARK = 'link'; + +/** + * Collapse `[text](url)` (already complete in the doc, including the just-typed + * `)`) at `[from, from+len]` down to `text` carrying a link mark. Runs a + * microtask after the paren landed, so it re-validates the span first — the doc + * may have shifted or been rewritten in the gap. + */ +function collapseToLink( + view: EditorView, + from: number, + fullMatch: string, + text: string, + href: string, +): void { + if (view.isDestroyed || view.composing) return; + const { state } = view; + const markType = state.schema.marks[LINK_MARK]; + if (!markType) return; + + const to = from + fullMatch.length; + if (from < 0 || to > state.doc.content.size) return; + if (state.doc.textBetween(from, to) !== fullMatch) return; + // Don't nest inside an existing link. + if (state.doc.rangeHasMark(from, to, markType)) return; + + // The collapse must be its own undo stack item so one undo restores the + // literal (see undo-isolation.ts for the split-before/close-after contract). + // Only the dispatch is guarded — it crosses into third-party plugin hooks; + // the guards above are internal-trusted and should fail loud. + const linked = state.schema.text(text, [markType.create({ href })]); + try { + dispatchAsOwnUndoStep(view, state.tr.replaceRangeWith(from, to, linked)); + } catch (err) { + console.warn('[inline-link-rule] collapse dispatch failed', { from, text, href }, err); + } +} + +export const InlineLinkInputRule = Extension.create({ + name: 'inlineLinkInputRule', + + addInputRules() { + const editor = this.editor; + return [ + new InputRule({ + find: INLINE_LINK_RE, + // Returning null (no steps) means TipTap does not consume the match: + // the `)` inserts through the normal path and the deferred collapse + // below does the linkification. Code blocks and inline-code contexts + // are already refused upstream by TipTap's input-rule runner. + handler: ({ state, range, match }) => { + const text = match[1]; + const url = match[2]; + if (!text || !url) return null; + if (!isAllowedLinkUri(url)) return null; + + const markType = state.schema.marks[LINK_MARK]; + if (!markType) return null; + if (state.doc.rangeHasMark(range.from, range.to, markType)) return null; + + // range.from is the start of `[` in the pre-paren doc; positions + // before the caret are stable across the paren insertion, so the + // completed literal will span [range.from, range.from + match[0].length]. + const from = range.from; + const fullMatch = match[0]; + // Capture the view eagerly: input rules fire from handleTextInput + // (the view is mounted here), while `editor.view` re-read inside the + // microtask is a throwing proxy if a recycle starts in the gap. + // collapseToLink's isDestroyed guard covers teardown after capture. + const view = editor.view; + queueMicrotask(() => collapseToLink(view, from, fullMatch, text, url)); + return null; + }, + }), + ]; + }, +}); diff --git a/packages/app/src/editor/link-path-suggestions.dom.test.tsx b/packages/app/src/editor/link-path-suggestions.dom.test.tsx index be03b02a..e23ee6fb 100644 --- a/packages/app/src/editor/link-path-suggestions.dom.test.tsx +++ b/packages/app/src/editor/link-path-suggestions.dom.test.tsx @@ -220,6 +220,36 @@ describe('LinkPathSuggestionInput', () => { expect(screen.getByRole('listbox', { name: 'Path suggestions' })).toBeDefined(); }); + test('after Escape dismisses the panel, keys reach the parent onKeyDown', () => { + // Suggestions still EXIST for the value after dismissal — only the panel is + // hidden. Keys must not be swallowed against the invisible panel: the next + // Escape has to reach the parent popover/dialog (which closes on it) and + // Enter has to reach the parent's apply handler. + const seen: string[] = []; + render( + {}} + onKeyDown={(event) => seen.push(event.key)} + />, + ); + + const input = screen.getByRole('combobox', { name: 'Link target' }); + fireEvent.focus(input); + expect(screen.getByRole('listbox', { name: 'Path suggestions' })).toBeDefined(); + + fireEvent.keyDown(input, { key: 'Escape' }); + expect(screen.queryByRole('listbox', { name: 'Path suggestions' })).toBeNull(); + expect(seen).toEqual([]); + + fireEvent.keyDown(input, { key: 'Escape' }); + fireEvent.keyDown(input, { key: 'Enter' }); + expect(seen).toEqual(['Escape', 'Enter']); + }); + test('ArrowDown and ArrowUp update the active option', () => { render(); diff --git a/packages/app/src/editor/link-path-suggestions.tsx b/packages/app/src/editor/link-path-suggestions.tsx index 3084ab01..b749281d 100644 --- a/packages/app/src/editor/link-path-suggestions.tsx +++ b/packages/app/src/editor/link-path-suggestions.tsx @@ -210,7 +210,12 @@ export function LinkPathSuggestionInput({ } function handleKeyDown(event: KeyboardEvent) { - if (showSuggestionOptions) { + // Gate on the options being VISIBLE (panel open), not merely existing: + // suggestions still exist for the current value after an Escape dismissal, + // and swallowing keys against an invisible panel strands the user — the + // next Escape must reach the parent popover/dialog (as the branch below + // documents) and Enter must reach the parent's apply handler. + if (showSuggestions && showSuggestionOptions) { if (event.key === 'ArrowDown') { event.preventDefault(); setHighlightedIndex((current) => Math.min(current + 1, suggestions.length - 1)); diff --git a/packages/app/src/editor/slash-command/component-items.tsx b/packages/app/src/editor/slash-command/component-items.tsx index 52a2b069..e7dbb97d 100644 --- a/packages/app/src/editor/slash-command/component-items.tsx +++ b/packages/app/src/editor/slash-command/component-items.tsx @@ -23,7 +23,7 @@ import type { Editor } from '@tiptap/react'; import { CopyPlus, ExternalLink, FileUp, Hash, Link2 } from 'lucide-react'; import type { ReactNode } from 'react'; import { setPendingLinkEdit } from '../extensions/link-edit-autoopen'; -import { markIdentityKey } from '../extensions/mark-identity'; +import { findMarkIdAt } from '../extensions/mark-identity'; import { uploadAndInsert } from '../image-upload/index.ts'; import { getInteractionLayer } from '../interaction-layer-host'; import { resolveIcon } from '../registry/icons.ts'; @@ -656,26 +656,6 @@ function openFilePickerAndUpload(editor: Editor): void { input.click(); } -/** - * Resolve the stable mark id of a `link` mark covering `pos` from the - * mark-identity plugin state. IDs are assigned synchronously during the - * dispatch that inserts the mark, so this reads them off `editor.state` - * immediately after the insert chain runs. Returns null when the plugin - * isn't installed (non-app editors / tests) or no link mark covers `pos` — - * callers degrade gracefully (the link is still inserted; only the - * auto-open is skipped). - */ -function findLinkMarkIdAt(editor: Editor, pos: number): string | null { - const state = markIdentityKey.getState(editor.state); - if (!state) return null; - for (const info of state.byId.values()) { - if (info.markType === 'link' && info.from <= pos && pos < info.to) { - return info.id; - } - } - return null; -} - /** * Slash-menu items for inline-only PM atoms. Block components flow * through the descriptor registry (`getComponentItems()`); inline atoms @@ -744,7 +724,7 @@ export function getInlineComponentItems(): SlashCommandItem[] { }) .run(); - const markId = findLinkMarkIdAt(editor, insertPos); + const markId = findMarkIdAt(editor.state, insertPos, 'link'); if (!markId) return; setPendingLinkEdit(markId); requestAnimationFrame(() => { diff --git a/packages/app/src/editor/undo-isolation.test.ts b/packages/app/src/editor/undo-isolation.test.ts new file mode 100644 index 00000000..376fccf5 --- /dev/null +++ b/packages/app/src/editor/undo-isolation.test.ts @@ -0,0 +1,62 @@ +/** + * The exception-safety property of `dispatchAsOwnUndoStep`: the CLOSING + * `stopCapturing()` must run even when `view.dispatch` throws — a + * split-open-but-never-closed capture merges the user's next keystrokes into + * the failed item, and one undo then strips them together. The happy path is + * covered by the collab-rig undo suites in gfm-autolink-plugin.test.ts and + * inline-link-input-rule.test.ts; this file pins the throw path (the only + * path where the `finally` matters — a try/catch refactor would break it). + */ + +import { afterAll, beforeAll, expect, test } from 'bun:test'; +import type { EditorView } from '@tiptap/pm/view'; +import * as Y from 'yjs'; +import { mountCollabEditor, readUndoManager } from './editor-rig.test-helper'; +import { dispatchAsOwnUndoStep } from './undo-isolation'; +import { installDomGlobals } from './walk-currency-test-harness'; + +let restoreDomGlobals: (() => void) | null = null; + +beforeAll(() => { + restoreDomGlobals = installDomGlobals(); +}); + +afterAll(() => { + restoreDomGlobals?.(); + restoreDomGlobals = null; +}); + +test('a throwing dispatch still closes the capture (stopCapturing runs twice)', () => { + const ydoc = new Y.Doc(); + const editor = mountCollabEditor(ydoc, []); + try { + const undoManager = readUndoManager(editor); + expect(undoManager).not.toBeNull(); + if (!undoManager) return; + + let stops = 0; + const originalStop = undoManager.stopCapturing.bind(undoManager); + undoManager.stopCapturing = () => { + stops++; + originalStop(); + }; + + // dispatchAsOwnUndoStep reads only `state` and `dispatch` from the view; + // hand it the real editor state with a dispatch that throws mid-step. + const throwingView = { + state: editor.state, + dispatch: () => { + throw new Error('plugin hook exploded'); + }, + } as unknown as EditorView; + + expect(() => dispatchAsOwnUndoStep(throwingView, editor.state.tr)).toThrow( + 'plugin hook exploded', + ); + // Split before + close after — BOTH ran despite the throw. + expect(stops).toBe(2); + } finally { + editor.destroy(); + ydoc.destroy(); + } +}); diff --git a/packages/app/src/editor/undo-isolation.ts b/packages/app/src/editor/undo-isolation.ts new file mode 100644 index 00000000..4c6e5b6d --- /dev/null +++ b/packages/app/src/editor/undo-isolation.ts @@ -0,0 +1,35 @@ +/** + * Dispatch a transaction as its OWN y-undo stack item. + * + * The WYSIWYG linkify surfaces add their mark within `captureTimeout` of the + * typing that triggered them (500 ms — Y.js's default, not OK config), so + * without a capture split Y.UndoManager + * merges the mark into the typing's stack item — one undo then deletes the + * typed text too. `stopCapturing()` before the dispatch splits the item off + * the preceding typing; `stopCapturing()` after closes it so the user's NEXT + * keystrokes don't merge in either (undoing them would strip the mark as a + * side effect). Both calls are synchronous — the correctness-critical sequence + * lives here, in one place, so every mark-producing surface shares the exact + * same contract. Absent a y-undo binding (non-collaborative editors) there is + * no capture stack and the dispatch happens plain. + */ + +import type { Transaction } from '@tiptap/pm/state'; +import type { EditorView } from '@tiptap/pm/view'; +import { yUndoPluginKey } from '@tiptap/y-tiptap'; +import type { UndoManager } from 'yjs'; + +export function dispatchAsOwnUndoStep(view: EditorView, tr: Transaction): void { + const undoState: { undoManager?: UndoManager } | undefined = yUndoPluginKey.getState(view.state); + const undoManager = undoState?.undoManager; + undoManager?.stopCapturing(); + try { + view.dispatch(tr); + } finally { + // `dispatch` runs third-party plugin hooks and can throw. The closing + // stopCapturing must run regardless — a split-open-but-never-closed + // capture would merge the user's NEXT keystrokes into the failed item, + // and a later undo would strip them together (silent undo corruption). + undoManager?.stopCapturing(); + } +} diff --git a/packages/app/src/lib/keyboard-shortcuts.test.ts b/packages/app/src/lib/keyboard-shortcuts.test.ts index 75593fbd..2387dfc2 100644 --- a/packages/app/src/lib/keyboard-shortcuts.test.ts +++ b/packages/app/src/lib/keyboard-shortcuts.test.ts @@ -309,14 +309,37 @@ describe('keyboard shortcut registry', () => { ).toBe(true); }); - test('matches command palette with allowed extra modifiers and rejects missing mod', () => { + test('matches command palette on exact Cmd/Ctrl+K only', () => { expect( matchesKeyboardShortcut( - { metaKey: true, ctrlKey: false, altKey: false, shiftKey: true, key: 'k' }, + { metaKey: true, ctrlKey: false, altKey: false, key: 'k' }, 'command-palette', 'mac', ), ).toBe(true); + expect( + matchesKeyboardShortcut( + { metaKey: false, ctrlKey: true, altKey: false, key: 'k' }, + 'command-palette', + 'windowsLinux', + ), + ).toBe(true); + // ⇧⌘K must NOT open the palette — that chord belongs to CodeMirror's + // delete-line in source mode. + expect( + matchesKeyboardShortcut( + { metaKey: true, ctrlKey: false, altKey: false, shiftKey: true, key: 'k' }, + 'command-palette', + 'mac', + ), + ).toBe(false); + expect( + matchesKeyboardShortcut( + { metaKey: true, ctrlKey: false, altKey: true, key: 'k' }, + 'command-palette', + 'mac', + ), + ).toBe(false); expect( matchesKeyboardShortcut( { metaKey: false, ctrlKey: false, altKey: false, key: 'k' }, @@ -326,6 +349,53 @@ describe('keyboard shortcut registry', () => { ).toBe(false); }); + test('add-link shares the exact ⌘K chord with the palette and excludes extra modifiers', () => { + expect(formatShortcut('add-link', 'mac')).toBe('⌘ K'); + expect(formatShortcut('add-link', 'windowsLinux')).toBe('Ctrl K'); + expect( + matchesKeyboardShortcut( + { metaKey: true, ctrlKey: false, altKey: false, key: 'k' }, + 'add-link', + 'mac', + ), + ).toBe(true); + expect( + matchesKeyboardShortcut( + { metaKey: false, ctrlKey: true, altKey: false, key: 'k' }, + 'add-link', + 'windowsLinux', + ), + ).toBe(true); + // Wrong platform modifier: Ctrl+K on macOS must NOT match (mod is exact). + expect( + matchesKeyboardShortcut( + { metaKey: false, ctrlKey: true, altKey: false, key: 'k' }, + 'add-link', + 'mac', + ), + ).toBe(false); + expect( + matchesKeyboardShortcut( + { metaKey: true, ctrlKey: false, altKey: false, shiftKey: true, key: 'k' }, + 'add-link', + 'mac', + ), + ).toBe(false); + expect( + matchesKeyboardShortcut( + { metaKey: true, ctrlKey: false, altKey: true, key: 'k' }, + 'add-link', + 'mac', + ), + ).toBe(false); + // One exact chord, two consumers: matching is identical for both ids; + // routing between them is contextual (capture-phase claim in the editor + // vs. the palette's window-bubble fallthrough). + const exactCmdK = { metaKey: true, ctrlKey: false, altKey: false, key: 'k' }; + expect(matchesKeyboardShortcut(exactCmdK, 'add-link', 'mac')).toBe(true); + expect(matchesKeyboardShortcut(exactCmdK, 'command-palette', 'mac')).toBe(true); + }); + test('matches source-aware replace shortcuts per platform', () => { expect( matchesKeyboardShortcut( diff --git a/packages/app/src/lib/keyboard-shortcuts.ts b/packages/app/src/lib/keyboard-shortcuts.ts index 6f5881a1..d4e2682e 100644 --- a/packages/app/src/lib/keyboard-shortcuts.ts +++ b/packages/app/src/lib/keyboard-shortcuts.ts @@ -86,16 +86,27 @@ export const SHORTCUT_CATEGORY_ORDER = Object.keys(SHORTCUT_CATEGORY_LABELS) as const KEYBOARD_SHORTCUT_DEFINITIONS = [ { + // Exact ⌘K only (no extra-modifier tolerance) — a deliberate narrowing + // that ships with the dual-role ⌘K: it keeps the add-link claim + // unambiguous and frees ⌘⇧K (which would otherwise shadow CodeMirror's + // delete-line in source mode). ⌘⇧K intentionally does NOT open the + // palette; a regression test pins that. + // + // Phase-ordering contract for the shared ⌘K chord: this palette listener + // is window BUBBLE phase; the add-link claim (LinkEditPopover) is window + // CAPTURE phase and stops propagation when it applies. Any future + // capture-phase ⌘K handler must slot into that ordering consciously — + // the registry itself has no phase concept. id: 'command-palette', category: 'general', title: msg`Command palette`, - description: msg`Search files, commands, projects, and AI handoff actions.`, + description: msg`Search files, commands, projects, and AI handoff actions. With text selected in the visual editor, this chord adds a link instead.`, scope: msg`Global`, bindings: [ { mac: '⌘ K', windowsLinux: 'Ctrl K', - match: { key: 'k', mod: true, allowExtraModifiers: true }, + match: { key: 'k', mod: true }, }, ], }, @@ -431,6 +442,25 @@ const KEYBOARD_SHORTCUT_DEFINITIONS = [ scope: msg`Visual editor`, bindings: [{ mac: '⇧⌘ H', windowsLinux: 'Ctrl Shift H' }], }, + { + // Same chord as `command-palette` by design: a capture-phase window + // listener in the WYSIWYG bubble menu claims exact ⌘K first when a link + // affordance applies (focused editor + text selection, or caret inside a + // link); everywhere else the event falls through to the palette's + // window-bubble listener. + id: 'add-link', + category: 'wysiwyg', + title: msg`Add link`, + description: msg`Link the selected text, or edit the link under the caret.`, + scope: msg`Visual editor selection`, + bindings: [ + { + mac: '⌘ K', + windowsLinux: 'Ctrl K', + match: { key: 'k', mod: true }, + }, + ], + }, { id: 'edit-with-ai', category: 'general', diff --git a/packages/app/src/locales/en/messages.json b/packages/app/src/locales/en/messages.json index fd97de36..e8123301 100644 --- a/packages/app/src/locales/en/messages.json +++ b/packages/app/src/locales/en/messages.json @@ -1001,6 +1001,9 @@ "ODGGlj": ["Folder name: ", ["detail"]], "OETysD": ["No hub pages yet. Hubs appear once pages accumulate inbound graph links."], "OEu_0l": ["Failed to load skills: ", ["0"]], + "OFBmkg": [ + "Search files, commands, projects, and AI handoff actions. With text selected in the visual editor, this chord adds a link instead." + ], "OJTOUj": ["Requires ", ["brand"], "."], "OJeiSH": ["Failed to add property"], "OLBz-_": ["Mermaid diagram failed to render."], @@ -1053,7 +1056,6 @@ "PR9FSv": ["Move to the next visual-editor find result."], "PRnH8G": ["of ", ["0"]], "PVUn_c": ["Open folder"], - "PZt1uN": ["Search files, commands, projects, and AI handoff actions."], "P_bX4O": ["How to set up"], "PdA9NZ": [["ahead"], " ahead"], "PdJZ5p": ["No skills yet."], @@ -1262,6 +1264,7 @@ "VuxVSP": ["Start source completion"], "VvS_k_": ["Config sharing is now local-only."], "VxO6wR": ["Delete selected files or folders"], + "VzGJRl": ["Add link"], "W-ntoW": ["heading-slug"], "W0i24j": ["Object"], "W1qRuM": ["This document is already active in the editor. Use Open to collapse the graph."], @@ -2228,6 +2231,7 @@ "u3wRF-": ["Published"], "u6Hp4N": ["Markdown"], "u7H-Wn": ["Show panel (", ["panelShortcutLabel"], ")"], + "u7U_Ij": ["Link the selected text, or edit the link under the caret."], "uA_jXp": ["Switch and update branch"], "uCaIdy": ["GitHub sign-in is missing or expired. Reconnect to resume syncing."], "uDlyqd": [ @@ -2278,6 +2282,7 @@ { "one": ["Uploaded one file"], "other": ["Uploaded ", ["uploadedCount"], " files"] } ] ], + "v77jrg": ["Visual editor selection"], "v7hK0U": ["Use lowercase letters, digits, and hyphens only."], "v7lbnC": ["Find next match"], "vAYoCZ": ["Couldn't open the worktree. Try again."], diff --git a/packages/app/src/locales/en/messages.po b/packages/app/src/locales/en/messages.po index bb3a1883..3d833f41 100644 --- a/packages/app/src/locales/en/messages.po +++ b/packages/app/src/locales/en/messages.po @@ -682,6 +682,10 @@ msgstr "Add item" msgid "Add item to {keyName}" msgstr "Add item to {keyName}" +#: src/lib/keyboard-shortcuts.ts +msgid "Add link" +msgstr "Add link" + #: src/components/settings/SearchSection.tsx msgid "Add meaning-based ranking to search so conceptually-related pages surface even when they share no keywords. This setting applies only to this computer." msgstr "Add meaning-based ranking to search so conceptually-related pages surface even when they share no keywords. This setting applies only to this computer." @@ -3867,6 +3871,10 @@ msgstr "Link copied." msgid "Link target" msgstr "Link target" +#: src/lib/keyboard-shortcuts.ts +msgid "Link the selected text, or edit the link under the caret." +msgstr "Link the selected text, or edit the link under the caret." + #: src/editor/slash-command/component-items.tsx msgid "Link to a page or external URL." msgstr "Link to a page or external URL." @@ -5959,8 +5967,8 @@ msgid "Search failed." msgstr "Search failed." #: src/lib/keyboard-shortcuts.ts -msgid "Search files, commands, projects, and AI handoff actions." -msgstr "Search files, commands, projects, and AI handoff actions." +msgid "Search files, commands, projects, and AI handoff actions. With text selected in the visual editor, this chord adds a link instead." +msgstr "Search files, commands, projects, and AI handoff actions. With text selected in the visual editor, this chord adds a link instead." #: src/components/CommandPalette.tsx msgid "Search files, folders, and commands for the current workspace." @@ -7583,6 +7591,10 @@ msgstr "Visual editor list item" msgid "Visual editor replace input focused" msgstr "Visual editor replace input focused" +#: src/lib/keyboard-shortcuts.ts +msgid "Visual editor selection" +msgstr "Visual editor selection" + #: src/lib/keyboard-shortcuts.ts msgid "Visual editor table" msgstr "Visual editor table" diff --git a/packages/app/src/locales/pseudo/messages.json b/packages/app/src/locales/pseudo/messages.json index af02b67c..027f0dc2 100644 --- a/packages/app/src/locales/pseudo/messages.json +++ b/packages/app/src/locales/pseudo/messages.json @@ -1001,6 +1001,9 @@ "ODGGlj": ["Ƒōĺďēŕ ńàḿē: ", ["detail"]], "OETysD": ["Ńō ĥũƀ ƥàĝēś ŷēţ. Ĥũƀś àƥƥēàŕ ōńćē ƥàĝēś àććũḿũĺàţē ĩńƀōũńď ĝŕàƥĥ ĺĩńķś."], "OEu_0l": ["Ƒàĩĺēď ţō ĺōàď śķĩĺĺś: ", ["0"]], + "OFBmkg": [ + "Śēàŕćĥ ƒĩĺēś, ćōḿḿàńďś, ƥŕōĴēćţś, àńď ÀĨ ĥàńďōƒƒ àćţĩōńś. Ŵĩţĥ ţēxţ śēĺēćţēď ĩń ţĥē vĩśũàĺ ēďĩţōŕ, ţĥĩś ćĥōŕď àďďś à ĺĩńķ ĩńśţēàď." + ], "OJTOUj": ["Ŕēǫũĩŕēś ", ["brand"], "."], "OJeiSH": ["Ƒàĩĺēď ţō àďď ƥŕōƥēŕţŷ"], "OLBz-_": ["Ḿēŕḿàĩď ďĩàĝŕàḿ ƒàĩĺēď ţō ŕēńďēŕ."], @@ -1053,7 +1056,6 @@ "PR9FSv": ["Ḿōvē ţō ţĥē ńēxţ vĩśũàĺ-ēďĩţōŕ ƒĩńď ŕēśũĺţ."], "PRnH8G": ["ōƒ ", ["0"]], "PVUn_c": ["Ōƥēń ƒōĺďēŕ"], - "PZt1uN": ["Śēàŕćĥ ƒĩĺēś, ćōḿḿàńďś, ƥŕōĴēćţś, àńď ÀĨ ĥàńďōƒƒ àćţĩōńś."], "P_bX4O": ["Ĥōŵ ţō śēţ ũƥ"], "PdA9NZ": [["ahead"], " àĥēàď"], "PdJZ5p": ["Ńō śķĩĺĺś ŷēţ."], @@ -1262,6 +1264,7 @@ "VuxVSP": ["Śţàŕţ śōũŕćē ćōḿƥĺēţĩōń"], "VvS_k_": ["Ćōńƒĩĝ śĥàŕĩńĝ ĩś ńōŵ ĺōćàĺ-ōńĺŷ."], "VxO6wR": ["Ďēĺēţē śēĺēćţēď ƒĩĺēś ōŕ ƒōĺďēŕś"], + "VzGJRl": ["Àďď ĺĩńķ"], "W-ntoW": ["ĥēàďĩńĝ-śĺũĝ"], "W0i24j": ["ŌƀĴēćţ"], "W1qRuM": ["Ţĥĩś ďōćũḿēńţ ĩś àĺŕēàďŷ àćţĩvē ĩń ţĥē ēďĩţōŕ. Ũśē Ōƥēń ţō ćōĺĺàƥśē ţĥē ĝŕàƥĥ."], @@ -2228,6 +2231,7 @@ "u3wRF-": ["Ƥũƀĺĩśĥēď"], "u6Hp4N": ["Ḿàŕķďōŵń"], "u7H-Wn": ["Śĥōŵ ƥàńēĺ (", ["panelShortcutLabel"], ")"], + "u7U_Ij": ["Ĺĩńķ ţĥē śēĺēćţēď ţēxţ, ōŕ ēďĩţ ţĥē ĺĩńķ ũńďēŕ ţĥē ćàŕēţ."], "uA_jXp": ["Śŵĩţćĥ àńď ũƥďàţē ƀŕàńćĥ"], "uCaIdy": ["ĜĩţĤũƀ śĩĝń-ĩń ĩś ḿĩśśĩńĝ ōŕ ēxƥĩŕēď. Ŕēćōńńēćţ ţō ŕēśũḿē śŷńćĩńĝ."], "uDlyqd": [ @@ -2278,6 +2282,7 @@ { "one": ["Ũƥĺōàďēď ōńē ƒĩĺē"], "other": ["Ũƥĺōàďēď ", ["uploadedCount"], " ƒĩĺēś"] } ] ], + "v77jrg": ["Vĩśũàĺ ēďĩţōŕ śēĺēćţĩōń"], "v7hK0U": ["Ũśē ĺōŵēŕćàśē ĺēţţēŕś, ďĩĝĩţś, àńď ĥŷƥĥēńś ōńĺŷ."], "v7lbnC": ["Ƒĩńď ńēxţ ḿàţćĥ"], "vAYoCZ": ["Ćōũĺďń'ţ ōƥēń ţĥē ŵōŕķţŕēē. Ţŕŷ àĝàĩń."], diff --git a/packages/app/src/locales/pseudo/messages.po b/packages/app/src/locales/pseudo/messages.po index eb8558d8..1330e8b3 100644 --- a/packages/app/src/locales/pseudo/messages.po +++ b/packages/app/src/locales/pseudo/messages.po @@ -677,6 +677,10 @@ msgstr "" msgid "Add item to {keyName}" msgstr "" +#: src/lib/keyboard-shortcuts.ts +msgid "Add link" +msgstr "" + #: src/components/settings/SearchSection.tsx msgid "Add meaning-based ranking to search so conceptually-related pages surface even when they share no keywords. This setting applies only to this computer." msgstr "" @@ -3862,6 +3866,10 @@ msgstr "" msgid "Link target" msgstr "" +#: src/lib/keyboard-shortcuts.ts +msgid "Link the selected text, or edit the link under the caret." +msgstr "" + #: src/editor/slash-command/component-items.tsx msgid "Link to a page or external URL." msgstr "" @@ -5954,7 +5962,7 @@ msgid "Search failed." msgstr "" #: src/lib/keyboard-shortcuts.ts -msgid "Search files, commands, projects, and AI handoff actions." +msgid "Search files, commands, projects, and AI handoff actions. With text selected in the visual editor, this chord adds a link instead." msgstr "" #: src/components/CommandPalette.tsx @@ -7578,6 +7586,10 @@ msgstr "" msgid "Visual editor replace input focused" msgstr "" +#: src/lib/keyboard-shortcuts.ts +msgid "Visual editor selection" +msgstr "" + #: src/lib/keyboard-shortcuts.ts msgid "Visual editor table" msgstr "" diff --git a/packages/app/tests/stress/_helpers/editor-state.ts b/packages/app/tests/stress/_helpers/editor-state.ts index 8a6084f3..11a8e6cd 100644 --- a/packages/app/tests/stress/_helpers/editor-state.ts +++ b/packages/app/tests/stress/_helpers/editor-state.ts @@ -132,6 +132,62 @@ export async function focusEditor(page: Page, timeoutMs = 5_000): Promise ); } +/** + * Select the first occurrence of `text` in the active PM editor and wait + * until `editor.state.selection` actually covers it. + * + * Routed through TipTap's `setTextSelection` command on + * `window.__activeEditor` rather than synthesized mouse drags or + * platform-specific word-selection chords — deterministic under CI worker + * contention. The search walks text nodes and matches within a single one, + * so `text` must not span a mark/node boundary (fine for word-level + * selections; extend when a caller needs a cross-node range — the helper + * throws loudly rather than selecting a partial match). + * + * The trailing `waitForFunction` is the "selection took" signal callers + * need before dispatching selection-dependent events (paste, keyboard + * chords): `setTextSelection` updates PM state synchronously, but polling + * the state from the test context is what proves the evaluate round-trip + * completed and the range maps to the expected characters. + */ +export async function selectText(page: Page, text: string): Promise { + await page.evaluate((target) => { + const editor = window.__activeEditor; + if (!editor) throw new Error('selectText: window.__activeEditor not set'); + let from = -1; + editor.state.doc.descendants((node, pos) => { + if (from !== -1) return false; + const nodeText = node.isText ? node.text : undefined; + if (nodeText) { + const idx = nodeText.indexOf(target); + if (idx !== -1) { + from = pos + idx; + return false; + } + } + return true; + }); + if (from === -1) { + throw new Error(`selectText: "${target}" not found within a single text node`); + } + editor + .chain() + .focus() + .setTextSelection({ from, to: from + target.length }) + .run(); + }, text); + await page.waitForFunction( + (target) => { + const editor = window.__activeEditor; + if (!editor) return false; + const { from, to } = editor.state.selection; + return to > from && editor.state.doc.textBetween(from, to) === target; + }, + text, + { timeout: 5_000 }, + ); +} + /** * Wait until ProseMirror's `editor.state.selection` has an ancestor of the * given `nodeType` name — i.e. the cursor is INSIDE that node type per PM's diff --git a/packages/app/tests/stress/_helpers/index.ts b/packages/app/tests/stress/_helpers/index.ts index f1be6b50..6826ec67 100644 --- a/packages/app/tests/stress/_helpers/index.ts +++ b/packages/app/tests/stress/_helpers/index.ts @@ -14,6 +14,7 @@ export { resetContentToFixtureBaseline } from './content-reset.ts'; export { focusEditor, selectAllAndWaitForSelection, + selectText, waitForPmSelectionInNode, } from './editor-state.ts'; export { filterCriticalErrors, type LogEntry } from './error-filters.ts'; diff --git a/packages/app/tests/stress/link-authoring-apex.e2e.ts b/packages/app/tests/stress/link-authoring-apex.e2e.ts new file mode 100644 index 00000000..a892dd16 --- /dev/null +++ b/packages/app/tests/stress/link-authoring-apex.e2e.ts @@ -0,0 +1,370 @@ +/** + * Apex E2E for the link-authoring feature — the release gate for the + * cross-writer hazard and the ⌘K dual-role contract. + * + * Four concerns, each proven in the real app rather than at a unit rung: + * + * 1. Cross-writer safety across two live clients. A boundary-terminated URL + * that reaches a client via CRDT sync (another writer's content) is NEVER + * linkified; only a client's OWN locally-typed URL + boundary converts. + * This is the origin guard (ySyncPluginKey) observed end-to-end. + * 2. Cross-writer safety for a pooled/backgrounded editor. A hidden Activity's + * editor still has a live provider and receives remote writes; it must + * never linkify them (origin guard + active-editor gate). + * 3. ⌘K routing matrix: non-empty selection → link popover; collapsed caret → + * palette; caret inside a link → chip edit surface; source pane → palette; + * ⌘⇧K → NOT the palette (the exact-⌘K narrowing). + * 4. Clipboard pre-fill degrades silently: with clipboard-read withheld + * (real permission gate), the popover opens empty and stays functional. + * + * Byte-shape oracles live in link-authoring-bytes.e2e.ts; this file asserts + * link-mark presence/absence and which UI surface a shortcut routes to. + * + * Run: + * cd packages/app && bunx playwright test tests/stress/link-authoring-apex.e2e.ts + */ + +import { randomUUID } from 'node:crypto'; +import type { Page } from '@playwright/test'; +import type { Node as PMNode } from '@tiptap/pm/model'; +import { expect, focusEditor, selectText, test, waitForActiveProviderSynced } from './_helpers'; + +const EDITOR = '.ProseMirror:not(.composer-prosemirror)'; +const LINK_CHIP = `${EDITOR} span[data-link]`; +const PALETTE = '[cmdk-root]'; + +/** Does the active editor's doc carry any link mark? */ +async function pmHasLink(page: Page): Promise { + return page.evaluate(() => + JSON.stringify(window.__activeEditor?.state.doc.toJSON() ?? {}).includes('"type":"link"'), + ); +} + +async function waitForYTextToContain(page: Page, needle: string): Promise { + await page.waitForFunction( + (n: string) => + (window.__activeProvider?.document?.getText('source')?.toString() ?? '').includes(n), + needle, + { timeout: 10_000 }, + ); +} + +// ─────────────────────────────────────────────────────────────────────────── +// Cross-writer: two live clients — a synced URL is never linkified by the receiver +// ─────────────────────────────────────────────────────────────────────────── + +test.describe('apex — cross-writer linkification never fires', () => { + test('a boundary-less URL typed by a peer stays plain on the receiver; only a client’s own boundary-typed URL converts', async ({ + browser, + api, + baseURL, + }) => { + const docName = `test-link-apex-fr1-${randomUUID().slice(0, 8)}`; + await api.createPage(`${docName}.md`); + + const ctxA = await browser.newContext({ baseURL }); + const ctxB = await browser.newContext({ baseURL }); + const pageA = await ctxA.newPage(); + const pageB = await ctxB.newPage(); + + try { + await Promise.all([pageA.goto(`/#/${docName}`), pageB.goto(`/#/${docName}`)]); + await Promise.all([ + pageA.waitForFunction(() => Boolean(window.__activeProvider), null, { timeout: 15_000 }), + pageB.waitForFunction(() => Boolean(window.__activeProvider), null, { timeout: 15_000 }), + ]); + await Promise.all([pageA.waitForSelector(EDITOR), pageB.waitForSelector(EDITOR)]); + + // Client A LIVE-TYPES a GFM-shaped URL with NO trailing boundary key. + // Live typing (not a markdown/agent write, which would parse the bare URL + // into a link) leaves it as plain text: A's own plugin needs a boundary + // to fire, and none was typed. The plain text syncs to B as another + // writer's content. + await pageA.locator(EDITOR).click(); + await pageA.keyboard.type('https://a-side.com'); + await waitForYTextToContain(pageB, 'a-side.com'); + + // Neither client linkified A's boundary-less URL — B must not convert + // content that arrived via CRDT sync, and A never typed a boundary. + expect(await pmHasLink(pageA)).toBe(false); + expect(await pmHasLink(pageB)).toBe(false); + await expect(pageA.locator(LINK_CHIP)).toHaveCount(0); + await expect(pageB.locator(LINK_CHIP)).toHaveCount(0); + + // B types its OWN URL + space at the START of the doc — its boundary + // (the trailing space) lands after B's token, never adjacent to A's URL, + // so only B's token converts. (A boundary typed right after A's URL would + // legitimately convert it too: that is B's own local edit completing the + // token, not a cross-writer linkification.) + await pageB.locator(EDITOR).click(); + await pageB.evaluate(() => window.__activeEditor?.commands.focus('start')); + await pageB.keyboard.type('https://b-own.com '); + + await pageB.waitForFunction( + () => + JSON.stringify(window.__activeEditor?.state.doc.toJSON() ?? {}).includes('"type":"link"'), + null, + { timeout: 5_000 }, + ); + + // Exactly one link on B: its own URL. A's synced URL is still plain. + await expect(pageB.locator(`${LINK_CHIP}[aria-label="Link: https://b-own.com"]`)).toHaveCount( + 1, + ); + await expect(pageB.locator(LINK_CHIP)).toHaveCount(1); + + // The mark syncs back to A; A shows exactly the same one link (B's), and + // still nothing on its own boundary-less URL. + await waitForYTextToContain(pageA, 'b-own.com'); + await expect(pageA.locator(`${LINK_CHIP}[aria-label="Link: https://b-own.com"]`)).toHaveCount( + 1, + ); + await expect(pageA.locator(LINK_CHIP)).toHaveCount(1); + } finally { + await ctxA.close(); + await ctxB.close(); + } + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// A pooled/backgrounded editor never linkifies remote writes +// ─────────────────────────────────────────────────────────────────────────── + +test.describe('apex — backgrounded editor never linkifies', () => { + test('a peer’s boundary-less URL reaches a hidden Activity’s editor and stays plain', async ({ + browser, + api, + baseURL, + }) => { + const docX = `test-link-apex-hidx-${randomUUID().slice(0, 8)}`; + const docY = `test-link-apex-hidy-${randomUUID().slice(0, 8)}`; + await api.createPage(`${docX}.md`); + await api.createPage(`${docY}.md`); + + // Context H holds X, then navigates to Y — X's editor flips to + // but keeps a live provider in the pool. + const ctxH = await browser.newContext({ baseURL }); + const ctxM = await browser.newContext({ baseURL }); + const pageH = await ctxH.newPage(); + const pageM = await ctxM.newPage(); + + try { + await pageH.goto(`/#/${docX}`); + await pageH.waitForFunction(() => Boolean(window.__activeProvider), null, { + timeout: 15_000, + }); + await pageH.waitForSelector(EDITOR); + await pageH.goto(`/#/${docY}`); + await pageH.waitForFunction(() => Boolean(window.__activeProvider), null, { + timeout: 15_000, + }); + await pageH.waitForSelector(EDITOR); + + // Context M opens X (foreground) and live-types a boundary-less URL. It + // stays plain (no boundary → M's own plugin doesn't fire) and syncs into + // X's Y.Doc, reaching H's HIDDEN X editor while it is backgrounded. + await pageM.goto(`/#/${docX}`); + await pageM.waitForFunction(() => Boolean(window.__activeProvider), null, { + timeout: 15_000, + }); + await pageM.waitForSelector(EDITOR); + await pageM.locator(EDITOR).click(); + await pageM.keyboard.type('https://while-hidden.com'); + await waitForYTextToContain(pageM, 'while-hidden.com'); + + // Deterministic delivery signal: poll H's HIDDEN pooled doc (via the + // provider pool's read-only peek) until the peer's URL reaches it while + // X is still backgrounded — the exact window the active-editor gate + // must hold through. Only then return to X. + await pageH.waitForFunction( + (doc: string) => + ( + window.__providerPool?.peek(doc)?.provider?.document?.getText('source')?.toString() ?? + '' + ).includes('while-hidden.com'), + docX, + { timeout: 10_000 }, + ); + await pageH.goto(`/#/${docX}`); + await pageH.waitForFunction(() => Boolean(window.__activeProvider), null, { + timeout: 15_000, + }); + await waitForYTextToContain(pageH, 'while-hidden.com'); + + // The URL is present but the hidden editor never linkified it. + expect(await pmHasLink(pageH)).toBe(false); + await expect(pageH.locator(LINK_CHIP)).toHaveCount(0); + } finally { + await ctxH.close(); + await ctxM.close(); + } + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// ⌘K routing matrix +// ─────────────────────────────────────────────────────────────────────────── + +test.describe('apex — ⌘K dual-role routing', () => { + let docName: string; + + test.beforeEach(async ({ page, api }) => { + docName = `test-link-apex-cmdk-${randomUUID().slice(0, 8)}`; + await api.createPage(`${docName}.md`); + await page.goto(`/#/${docName}`); + await waitForActiveProviderSynced(page); + await page.waitForSelector(EDITOR); + // Seed plain text (for selection / collapsed-caret cases) plus an existing + // link (for the caret-inside-link case). + await api.replaceDoc( + docName, + 'edit this text and visit [the docs](https://example.com) often\n', + ); + await waitForYTextToContain(page, 'the docs'); + await expect(page.locator(LINK_CHIP)).toHaveCount(1); + }); + + test('non-empty selection routes ⌘K to the link popover, not the palette', async ({ page }) => { + await selectText(page, 'this text'); + await focusEditor(page); + await page.keyboard.press('ControlOrMeta+k'); + + const input = page.getByLabel('Link URL'); + await expect(input).toBeVisible({ timeout: 2_000 }); + await expect(page.locator(PALETTE)).toHaveCount(0); + + // Keyboard contract: the input takes focus on open (the popover lives in + // the floating bubble menu, which is unfocusable until positioned — the + // app retries until focus lands). Then two-stage Escape per the combobox + // convention: the first dismisses the path-suggestion panel, the second + // closes the popover and returns focus to the editor. + await expect + .poll(() => page.evaluate(() => document.activeElement?.getAttribute('aria-label')), { + timeout: 2_000, + }) + .toBe('Link URL'); + await page.keyboard.press('Escape'); + await expect(input).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(input).toBeHidden({ timeout: 2_000 }); + await expect + .poll(() => page.evaluate(() => window.__activeEditor?.view.hasFocus() ?? false), { + timeout: 2_000, + }) + .toBe(true); + }); + + test('collapsed caret in plain text routes ⌘K to the palette', async ({ page }) => { + // Caret inside "often" — plain text, not a link. + await page.evaluate(() => { + const ed = window.__activeEditor; + if (!ed) throw new Error('no active editor'); + const idx = ed.state.doc.textContent.indexOf('often'); + ed.chain() + .focus() + .setTextSelection(idx + 2) + .run(); + }); + await focusEditor(page); + await page.keyboard.press('ControlOrMeta+k'); + + await expect(page.locator(PALETTE)).toBeVisible({ timeout: 2_000 }); + }); + + test('caret inside a link routes ⌘K to the chip edit surface, not the palette', async ({ + page, + }) => { + // Collapse the caret inside the link mark's range. + await page.evaluate(() => { + const ed = window.__activeEditor; + if (!ed) throw new Error('no active editor'); + let pos = -1; + ed.state.doc.descendants((node: PMNode, at: number) => { + if (pos !== -1) return false; + if (node.isText && node.marks.some((m) => m.type.name === 'link')) { + pos = at + 1; + return false; + } + return true; + }); + if (pos === -1) throw new Error('no link mark found to place caret in'); + ed.chain().focus().setTextSelection(pos).run(); + }); + await focusEditor(page); + await page.keyboard.press('ControlOrMeta+k'); + + await expect(page.getByRole('combobox', { name: 'Link target' })).toBeVisible({ + timeout: 2_000, + }); + await expect(page.locator(PALETTE)).toHaveCount(0); + }); + + test('⌘⇧K does NOT open the palette (exact-⌘K narrowing)', async ({ page }) => { + await page.locator(EDITOR).click(); + await focusEditor(page); + await page.keyboard.press('ControlOrMeta+Shift+k'); + await expect(page.locator(PALETTE)).toHaveCount(0); + + // Condition-based negative proof (no wall-clock wait): exact ⌘K on the + // same keystroke pipeline DOES open the palette. That positive signal + // confirms key handling processed events after the ⌘⇧K press — if ⌘⇧K + // had opened (or toggled) the palette, this visibility wait would fail. + await page.keyboard.press('ControlOrMeta+k'); + await expect(page.locator(PALETTE)).toBeVisible({ timeout: 2_000 }); + }); + + test('⌘K in the source pane routes to the palette (WYSIWYG lacks focus)', async ({ page }) => { + await page.getByRole('radio', { name: 'Markdown source' }).click(); + const cm = page.locator('.cm-content'); + await expect(cm).toBeVisible({ timeout: 5_000 }); + await cm.click(); + await page.keyboard.press('ControlOrMeta+k'); + + await expect(page.locator(PALETTE)).toBeVisible({ timeout: 2_000 }); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// Clipboard pre-fill degrades silently under permission denial +// ─────────────────────────────────────────────────────────────────────────── + +test.describe('apex — clipboard pre-fill under real permission denial', () => { + test('with clipboard-read withheld, the popover opens empty and stays functional', async ({ + browser, + api, + baseURL, + }) => { + // A fresh context with NO permissions granted — navigator.clipboard.readText + // rejects, exercising the real gate rather than a stub. + const ctx = await browser.newContext({ baseURL }); + const page = await ctx.newPage(); + try { + const docName = `test-link-apex-clip-${randomUUID().slice(0, 8)}`; + await api.createPage(`${docName}.md`); + await page.goto(`/#/${docName}`); + await waitForActiveProviderSynced(page); + await page.waitForSelector(EDITOR); + await api.replaceDoc(docName, 'select me and link\n'); + await waitForYTextToContain(page, 'select me'); + + await selectText(page, 'select me'); + await focusEditor(page); + await page.keyboard.press('ControlOrMeta+k'); + + const input = page.getByLabel('Link URL'); + await expect(input).toBeVisible({ timeout: 2_000 }); + // Denial degraded to an empty input — no error surfaced, no pre-fill. + await expect(input).toHaveValue(''); + + // Still functional: typing a URL and applying it creates the link. + await input.fill('https://typed-by-hand.com'); + await page.keyboard.press('Enter'); + await expect( + page.locator(`${LINK_CHIP}[aria-label="Link: https://typed-by-hand.com"]`), + ).toHaveCount(1, { timeout: 5_000 }); + } finally { + await ctx.close(); + } + }); +}); diff --git a/packages/app/tests/stress/link-authoring-bytes.e2e.ts b/packages/app/tests/stress/link-authoring-bytes.e2e.ts new file mode 100644 index 00000000..9b9c0e0a --- /dev/null +++ b/packages/app/tests/stress/link-authoring-bytes.e2e.ts @@ -0,0 +1,242 @@ +/** + * Byte-pinning E2E for WYSIWYG linkification: exact Y.Text oracles (never + * substring matches) for the three link-creation paths — typed URL + space, + * lone-URL paste at cursor, lone-URL paste over a selection — plus the + * per-path single-undo contract. + * + * Exact bytes are the point. A plain-text URL serializes GFM-escaped + * (`https\://…`) so it stays prose on re-parse, while a linkified URL + * serializes as the bare literal; a `toContain('inkeep.com')` cannot tell + * those apart, whole-string equality on Y.Text can. + * + * Two serialization behaviors of the bridge shape the oracles below — both + * pre-existing, neither introduced by linkification: + * + * - Paragraph-trailing whitespace does not round-trip: `'AGENTS.md '` + * serializes as `'AGENTS.md\n'`. Expected bytes never carry a trailing + * space before the newline. + * + * - A mark-only change can be byte-invisible. Server Observer A settles + * without rewriting Y.Text when the new fragment serialization is within + * `normalizeBridge` tolerance of the settled bytes, and the CommonMark + * escape-collapse class makes `https\://x` and `https://x` compare equal + * (they are parse-equivalent: escaped autolink-shaped bytes re-parse to + * the same link). So a typed conversion — which only ADDS a mark over + * text that is already in Y.Text — leaves the escaped bytes at rest + * until the next content-bearing edit re-serializes the paragraph, at + * which point the bare literal lands. The typed-path tests pin exactly + * that lifecycle: mark first (fragment truth), bare bytes on the next + * edit (Y.Text truth). Paste paths insert or restructure content, so + * their bytes settle immediately. + * + * Link marks render as `` chips (InternalLink + * deliberately emits no `` — anchor navigation would race the + * InteractionLayer), so DOM assertions target the chip. + * + * Clipboard injection: DataTransfer + dispatchEvent (same pattern as + * paste-fidelity.e2e.ts) — bypasses the navigator.clipboard permission + * gate on headless Chromium. + * + * Run: + * cd packages/app && bunx playwright test tests/stress/link-authoring-bytes.e2e.ts + */ + +import { randomUUID } from 'node:crypto'; +import type { Page } from '@playwright/test'; +import { + expect, + focusEditor, + selectText, + simulateCopyAndRead, + test, + waitForActiveProviderSynced as waitForProvider, +} from './_helpers'; + +const EDITOR = '.ProseMirror:not(.composer-prosemirror)'; +const LINK_CHIP = `${EDITOR} span[data-link]`; +const URL_LITERAL = 'https://inkeep.com'; + +async function getYText(page: Page): Promise { + return page.evaluate(() => { + const provider = window.__activeProvider; + return provider?.document?.getText('source')?.toString() ?? ''; + }); +} + +/** Paste a text/plain-only payload into the WYSIWYG editor. */ +async function pasteText(page: Page, text: string) { + await page.evaluate((content) => { + const editor = document.querySelector('.ProseMirror:not(.composer-prosemirror)'); + if (!editor) throw new Error('ProseMirror editor not found'); + const dt = new DataTransfer(); + dt.setData('text/plain', content); + const event = new ClipboardEvent('paste', { + clipboardData: dt, + bubbles: true, + cancelable: true, + }); + editor.dispatchEvent(event); + }, text); +} + +/** PM-layer truth: does the active editor's doc carry a link mark, and what + * is its plain text? Byte oracles cannot see a mark whose serialization is + * within bridge tolerance (file header), so mark-level assertions read the + * fragment side directly. */ +async function pmLinkSnapshot(page: Page): Promise<{ hasLink: boolean; text: string }> { + return page.evaluate(() => { + const ed = window.__activeEditor; + return { + hasLink: JSON.stringify(ed?.state.doc.toJSON() ?? {}).includes('"type":"link"'), + text: ed?.state.doc.textContent ?? '', + }; + }); +} + +/** Wait until the active editor's doc gains (or is confirmed to hold) a link + * mark — the typed path lands it a microtask after the boundary keystroke. */ +async function waitForPmLink(page: Page): Promise { + await page.waitForFunction( + () => JSON.stringify(window.__activeEditor?.state.doc.toJSON() ?? {}).includes('"type":"link"'), + null, + { timeout: 5_000 }, + ); +} + +// ─── lone-URL paste at cursor ─── + +test.describe('lone-URL paste at cursor — bare-literal bytes', () => { + let docName: string; + + test.beforeEach(async ({ page, api }) => { + docName = `test-linkbytes-cursor-${randomUUID().slice(0, 8)}`; + await api.createPage(`${docName}.md`); + await page.goto(`/#/${docName}`); + await waitForProvider(page); + await page.waitForSelector(EDITOR); + await page.click(EDITOR); + }); + + test('pasted lone URL lands as a link with exactly the bare-literal bytes', async ({ page }) => { + await pasteText(page, URL_LITERAL); + await expect.poll(() => getYText(page), { timeout: 5_000 }).toBe('https://inkeep.com\n'); + await expect(page.locator(`${LINK_CHIP}[aria-label="Link: ${URL_LITERAL}"]`)).toHaveCount(1); + }); + + test('WYSIWYG copy of the pasted link round-trips clean text/plain', async ({ page }) => { + await pasteText(page, URL_LITERAL); + await expect.poll(() => getYText(page), { timeout: 5_000 }).toBe('https://inkeep.com\n'); + const out = await simulateCopyAndRead(page, 'wysiwyg'); + expect(out.plain).toBe('https://inkeep.com\n'); + }); + + test('one undo removes the pasted link entirely', async ({ page }) => { + await pasteText(page, URL_LITERAL); + await expect.poll(() => getYText(page), { timeout: 5_000 }).toBe('https://inkeep.com\n'); + await focusEditor(page); + await page.keyboard.press('ControlOrMeta+z'); + await expect.poll(() => getYText(page), { timeout: 5_000 }).toBe(''); + await expect(page.locator(LINK_CHIP)).toHaveCount(0); + }); + + test('explicit-scheme dotless host (localhost) pastes as a link with bare-literal bytes', async ({ + page, + }) => { + // The dotted-domain rule is schemeless-only: http://localhost is a GFM + // autolink literal, so it converts and its bytes stay the bare URL. + const localUrl = 'http://localhost:5174/#/some-doc'; + await pasteText(page, localUrl); + await expect.poll(() => getYText(page), { timeout: 5_000 }).toBe(`${localUrl}\n`); + await expect(page.locator(`${LINK_CHIP}[aria-label="Link: ${localUrl}"]`)).toHaveCount(1); + }); +}); + +// ─── lone-URL paste over a selection ─── + +test.describe('lone-URL paste over a selection — [text](url) bytes', () => { + let docName: string; + + test.beforeEach(async ({ page, api }) => { + docName = `test-linkbytes-sel-${randomUUID().slice(0, 8)}`; + await api.createPage(`${docName}.md`); + await page.goto(`/#/${docName}`); + await waitForProvider(page); + await page.waitForSelector(EDITOR); + // Seed through the agent-write path (a remote, undo-untracked origin) so + // the linkify mark is the first locally-tracked undo item — mirrors real + // usage, where the selected text long predates the paste. + await api.replaceDoc(docName, 'inkeep docs\n'); + await expect.poll(() => getYText(page), { timeout: 5_000 }).toBe('inkeep docs\n'); + await expect(page.locator(`${EDITOR} p`).first()).toContainText('inkeep docs'); + }); + + test('pasting a URL over a selected word keeps the text and links it', async ({ page }) => { + await selectText(page, 'docs'); + await pasteText(page, URL_LITERAL); + await expect + .poll(() => getYText(page), { timeout: 5_000 }) + .toBe('inkeep [docs](https://inkeep.com)\n'); + await expect(page.locator(`${LINK_CHIP}[aria-label="Link: ${URL_LITERAL}"]`)).toHaveCount(1); + }); + + test('one undo restores the pre-paste unlinked text', async ({ page }) => { + await selectText(page, 'docs'); + await pasteText(page, URL_LITERAL); + await expect + .poll(() => getYText(page), { timeout: 5_000 }) + .toBe('inkeep [docs](https://inkeep.com)\n'); + await focusEditor(page); + await page.keyboard.press('ControlOrMeta+z'); + await expect.poll(() => getYText(page), { timeout: 5_000 }).toBe('inkeep docs\n'); + await expect(page.locator(LINK_CHIP)).toHaveCount(0); + }); +}); + +// ─── typed URL + space ─── + +test.describe('typed URL + space — GFM autolink byte contract', () => { + let docName: string; + + test.beforeEach(async ({ page, api }) => { + docName = `test-linkbytes-typed-${randomUUID().slice(0, 8)}`; + await api.createPage(`${docName}.md`); + await page.goto(`/#/${docName}`); + await waitForProvider(page); + await page.waitForSelector(EDITOR); + await page.click(EDITOR); + }); + + test('typed GFM URL converts on space; bytes settle bare on the next edit', async ({ page }) => { + await page.keyboard.type('https://inkeep.com '); + // Conversion truth lives in the fragment: the mark lands immediately, but + // the mark-only change is within bridge tolerance of the escaped bytes + // already at rest (file header), so Y.Text is asserted after the next + // content-bearing edit forces a re-serialization. + await waitForPmLink(page); + await expect(page.locator(`${LINK_CHIP}[aria-label="Link: ${URL_LITERAL}"]`)).toHaveCount(1); + await page.keyboard.type('done'); + await expect.poll(() => getYText(page), { timeout: 5_000 }).toBe('https://inkeep.com done\n'); + }); + + test('typed filename-shaped token stays plain and serializes unlinked', async ({ page }) => { + await page.keyboard.type('AGENTS.md '); + await expect.poll(() => getYText(page), { timeout: 5_000 }).toBe('AGENTS.md\n'); + await expect(page.locator(LINK_CHIP)).toHaveCount(0); + }); + + test('one undo removes only the mark — text intact, bytes re-escape', async ({ page }) => { + await page.keyboard.type('https://inkeep.com '); + await waitForPmLink(page); + await page.keyboard.press('ControlOrMeta+z'); + // Mark gone, typed text and trailing space intact at the fragment layer. + await expect + .poll(() => pmLinkSnapshot(page), { timeout: 5_000 }) + .toEqual({ hasLink: false, text: 'https://inkeep.com ' }); + await expect(page.locator(LINK_CHIP)).toHaveCount(0); + // The next edit re-serializes the now-unmarked paragraph: the URL escapes + // back to prose form — the sharp proof that the undone doc's canonical + // bytes are the escaped ones (not merely that they never changed). + await page.keyboard.type('x'); + await expect.poll(() => getYText(page), { timeout: 5_000 }).toBe('https\\://inkeep.com x\n'); + }); +}); diff --git a/packages/core/src/extensions/link-fidelity.ts b/packages/core/src/extensions/link-fidelity.ts index 0bfa8964..b93cd344 100644 --- a/packages/core/src/extensions/link-fidelity.ts +++ b/packages/core/src/extensions/link-fidelity.ts @@ -1,9 +1,12 @@ /** * Link mark override for source-text fidelity. * - * Extends @tiptap/extension-link (preserving autolink, linkOnPaste, - * and click handling plugins) and adds fidelity attributes for link - * style (inline, full, collapsed, shortcut) and reference label. + * Extends @tiptap/extension-link and adds fidelity attributes for link + * style (inline, full, collapsed, shortcut) and reference label. LinkFidelity + * itself inherits the stock link plugins (autolink, linkOnPaste, click + * handling), but the app-side `InternalLink` subclass overrides + * `addProseMirrorPlugins` to drop them in favour of OK's own linkify/paste/ + * click surfaces — so in the app those stock plugins are not active. * * Markdown parsing/serialization is handled by the unified pipeline (packages/core/src/markdown/). */ @@ -16,15 +19,25 @@ const ALLOWED_LINK_SCHEMES: ReadonlySet = new Set(SAFE_URL_SCHEMES.map(( const PLACEHOLDER_BASE = 'https://placeholder.invalid'; /** - * Allowlist gate for TipTap's autolinker / linkOnPaste / setLink input - * paths. Bare relative URLs (e.g. `/foo`, `./bar`, `#hash`) parse against - * `PLACEHOLDER_BASE` and inherit `https:`, so they pass without a special - * case. Storage-layer mdast→PM (`MarkdownManager`) intentionally bypasses - * this hook — see AGENTS.md "Storage never sanitizes; render-time layers - * do." Render-time defenses (`rehypeSanitizeUrls`, `isSafeNavigationUrl`, - * `sanitizeComponentProps`) cover egress. + * Known `linkStyle` values. The producer side (e.g. the app's typed-autolink + * plugin) and the PM→mdast serializer branch on these string literals; typing + * both against this union turns a drifted value (rename/typo) into a compile + * error instead of a silently mis-serialized link. */ -function isAllowedLinkUri(url: string): boolean { +export type LinkStyle = 'inline' | 'full' | 'collapsed' | 'shortcut' | 'autolink' | 'gfm-autolink'; + +/** + * Allowlist gate for link-mark creation paths: TipTap's autolinker / + * linkOnPaste / setLink inputs and the app clipboard dispatcher's lone-URL + * paste classifier. Bare relative URLs (e.g. `/foo`, `./bar`, `#hash`) + * parse against `PLACEHOLDER_BASE` and inherit `https:`, so they pass + * without a special case. Storage-layer mdast→PM (`MarkdownManager`) + * intentionally bypasses this hook — see AGENTS.md "Storage never + * sanitizes; render-time layers do." Render-time defenses + * (`rehypeSanitizeUrls`, `isSafeNavigationUrl`, `sanitizeComponentProps`) + * cover egress. + */ +export function isAllowedLinkUri(url: string): boolean { try { const parsed = new URL(url, PLACEHOLDER_BASE); return ALLOWED_LINK_SCHEMES.has(parsed.protocol.toLowerCase()); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9dee6588..f0753aa3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -282,7 +282,7 @@ export { HtmlBlockFidelity } from './extensions/html-block-fidelity.ts'; export { ImageSrcFidelity } from './extensions/image-src-fidelity.ts'; export { JsxComponent } from './extensions/jsx-component.ts'; export { JsxInline } from './extensions/jsx-inline.ts'; -export { LinkFidelity } from './extensions/link-fidelity.ts'; +export { isAllowedLinkUri, LinkFidelity, type LinkStyle } from './extensions/link-fidelity.ts'; export { LinkRefDefFidelity } from './extensions/link-ref-def-fidelity.ts'; export { List, ListItem, ListItemNode, ListNode } from './extensions/list.ts'; export { MathInline } from './extensions/math-inline.ts'; diff --git a/packages/core/src/markdown/index.ts b/packages/core/src/markdown/index.ts index cdbc4c87..58be3530 100644 --- a/packages/core/src/markdown/index.ts +++ b/packages/core/src/markdown/index.ts @@ -53,6 +53,7 @@ import { createStructuralFreshnessChecker, type StructuralFreshnessChecker, } from '../bridge/structural-freshness.ts'; +import type { LinkStyle } from '../extensions/link-fidelity.ts'; import { isValidSourceLiteralRaw } from '../extensions/source-literal-mark.ts'; import { createRegistry } from '../registry/index.ts'; import type { PropDef } from '../registry/types.ts'; @@ -1538,7 +1539,7 @@ function buildPmToMdastHandlers( children: [{ type: 'text' as const, value: label }], } as unknown as MdastNodes; } - const style = mark.attrs.linkStyle; + const style: LinkStyle | undefined = mark.attrs.linkStyle; if (style === 'autolink') { return { type: 'link' as const,