diff --git a/package-lock.json b/package-lock.json index 9a7e5d7..7eba1b9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@tiptap/extension-highlight": "3.23.6", "@tiptap/extension-history": "^3.23.6", "@tiptap/extension-italic": "^3.23.6", + "@tiptap/extension-link": "^3.23.6", "@tiptap/extension-list-item": "^3.23.6", "@tiptap/extension-ordered-list": "^3.23.6", "@tiptap/extension-paragraph": "^3.23.6", @@ -834,6 +835,23 @@ "@tiptap/core": "3.23.6" } }, + "node_modules/@tiptap/extension-link": { + "version": "3.23.6", + "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.23.6.tgz", + "integrity": "sha512-KNZz7z7P2/qbQsx5bPAbSPjrKDg1VHsedGlLHJCr8U2VRD5VgmDLkMpkouP1CsDg15qgyUKv/nDib5KgPpLNWA==", + "license": "MIT", + "dependencies": { + "linkifyjs": "^4.3.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.23.6", + "@tiptap/pm": "3.23.6" + } + }, "node_modules/@tiptap/extension-list": { "version": "3.23.6", "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.23.6.tgz", @@ -1958,6 +1976,12 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/linkifyjs": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz", + "integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==", + "license": "MIT" + }, "node_modules/locate-character": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", diff --git a/package.json b/package.json index 5a0e40e..af29804 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "@tiptap/extension-highlight": "3.23.6", "@tiptap/extension-history": "^3.23.6", "@tiptap/extension-italic": "^3.23.6", + "@tiptap/extension-link": "3.23.6", "@tiptap/extension-list-item": "^3.23.6", "@tiptap/extension-ordered-list": "^3.23.6", "@tiptap/extension-paragraph": "^3.23.6", diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte index fb81f51..4cad4d3 100644 --- a/src/lib/components/Editor.svelte +++ b/src/lib/components/Editor.svelte @@ -143,6 +143,38 @@ editor?.view.focus(); } + // --- Link hover hint (Word/LibreOffice style) --- + // Hovering a link shows its URL + a modifier-click hint; the link only follows on + // Ctrl/Cmd+click (handleClick in editorProps), so a plain click can edit the text. + const linkHintKey = + typeof navigator !== 'undefined' && /Mac|iP(hone|ad|od)/.test(navigator.platform) ? '⌘' : 'Ctrl'; + let linkTip = $state<{ top: number; left: number; href: string } | null>(null); + + function showLinkTip(a: HTMLAnchorElement) { + const href = a.getAttribute('href'); + if (!href || !editorContainer) return; + const aRect = a.getBoundingClientRect(); + const cRect = editorContainer.getBoundingClientRect(); + linkTip = { + top: aRect.top - cRect.top + editorContainer.scrollTop, + left: aRect.left - cRect.left + editorContainer.scrollLeft, + href, + }; + } + + function onEditorPointerOver(e: MouseEvent) { + const a = (e.target as HTMLElement | null)?.closest?.('a[href]') as HTMLAnchorElement | null; + if (a && editorContainer?.contains(a)) showLinkTip(a); + } + + function onEditorPointerOut(e: MouseEvent) { + const a = (e.target as HTMLElement | null)?.closest?.('a[href]') as HTMLAnchorElement | null; + if (!a) return; + const to = e.relatedTarget as Node | null; + if (to && a.contains(to)) return; // moving within the same link + linkTip = null; + } + // --- Floating table-editing toolbar --- // Shown when the selection is inside a table; positioned just above that table. let tableUi = $state<{ visible: boolean; top: number; left: number }>({ visible: false, top: 0, left: 0 }); @@ -459,6 +491,15 @@ // Our SpellCheck extension draws squiggles; turn off the browser's so // they don't double up. attributes: { spellcheck: 'false' }, + // Ctrl/Cmd+click opens a hyperlink (a plain click just places the cursor). + handleClick: (_view, _pos, event) => { + if (!(event.metaKey || event.ctrlKey)) return false; + const a = (event.target as HTMLElement | null)?.closest?.('a[href]') as HTMLAnchorElement | null; + const href = a?.getAttribute('href'); + if (!href) return false; + window.open(href, '_blank', 'noopener,noreferrer'); + return true; + }, handleDOMEvents: { contextmenu: (view, event) => openSpellMenu(view, event), drop: (view, event) => { @@ -521,6 +562,8 @@ element.addEventListener('pm-pagecount', onPageCount); editorContainer.addEventListener('scroll', onEditorScroll); + editorContainer.addEventListener('mouseover', onEditorPointerOver); + editorContainer.addEventListener('mouseout', onEditorPointerOut); // Seed the scaled footprint before the first paint (so the page is centered, not // briefly left-aligned), then refine it once layout settles. The pageBreaks plugin // also fires pm-pagecount shortly after with the precise document height. @@ -531,6 +574,7 @@ function onEditorScroll() { updateCurrentPage(); scheduleTableUi(); + if (linkTip) linkTip = null; } onDestroy(() => { @@ -541,6 +585,8 @@ resetHistoryLog(); element?.removeEventListener('pm-pagecount', onPageCount); editorContainer?.removeEventListener('scroll', onEditorScroll); + editorContainer?.removeEventListener('mouseover', onEditorPointerOver); + editorContainer?.removeEventListener('mouseout', onEditorPointerOut); }); @@ -599,6 +645,12 @@ onClose={closeSpellMenu} /> {/if} + {#if linkTip} + + {/if} diff --git a/src/lib/components/ToolbarExpanded.svelte b/src/lib/components/ToolbarExpanded.svelte index dc27280..26a1c11 100644 --- a/src/lib/components/ToolbarExpanded.svelte +++ b/src/lib/components/ToolbarExpanded.svelte @@ -4,6 +4,8 @@ import ColorPicker from './ColorPicker.svelte'; import TablePicker from './TablePicker.svelte'; import SpecialCharPicker from './SpecialCharPicker.svelte'; + import LinkDialog from './LinkDialog.svelte'; + import { OPEN_LINK_DIALOG_EVENT } from '../editor/extensions/link'; import { CANDIDATE_FONTS, detectAvailableFonts, @@ -715,6 +717,60 @@ reader.readAsDataURL(file); } + // --- Link insertion (link.ts) --- + let isLink = $derived(tick >= 0 && !!editor?.isActive('link')); + let linkDialogOpen = $state(false); + let linkInitialUrl = $state(''); + + function openLinkDialog() { + if (!editor || hfActive) return; // body-only; HF has no link mark + linkInitialUrl = (editor.getAttributes('link').href as string) ?? ''; + linkDialogOpen = true; + } + + // Add a scheme/mailto when the user types a bare host or e-mail (like Word/LO). + function normalizeUrl(raw: string): string { + const t = raw.trim(); + if (!t) return ''; + if (/^(https?:|mailto:|tel:|ftp:|#|\/)/i.test(t)) return t; + if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(t)) return `mailto:${t}`; + return `https://${t}`; + } + + function applyLink(rawUrl: string) { + linkDialogOpen = false; + if (!editor) return; + const href = normalizeUrl(rawUrl); + if (!href) return; + const chain = editor.chain().focus().extendMarkRange('link'); + if (editor.state.selection.empty && !editor.isActive('link')) { + // No selection and not inside a link: insert the URL as the link text. + chain.insertContent({ type: 'text', text: href, marks: [{ type: 'link', attrs: { href } }] }).run(); + } else { + chain.setLink({ href }).run(); + } + } + + function removeLink() { + linkDialogOpen = false; + editor?.chain().focus().extendMarkRange('link').unsetLink().run(); + } + + function linkClickOutside(node: HTMLElement) { + function handler(e: MouseEvent) { + if (!node.contains(e.target as Node)) linkDialogOpen = false; + } + window.addEventListener('mousedown', handler); + return { destroy() { window.removeEventListener('mousedown', handler); } }; + } + + // Ctrl/Cmd+K (from the Link extension) opens the dialog. + $effect(() => { + const open = () => openLinkDialog(); + window.addEventListener(OPEN_LINK_DIALOG_EVENT, open); + return () => window.removeEventListener(OPEN_LINK_DIALOG_EVENT, open); + }); + // --- Indent (Einzug) --- // In a list, step the list point one level (indentListForward/Backward — the Tab // keymap's commands, see indent.ts); outside a list, step the paragraph's left indent. @@ -1169,6 +1225,31 @@ style="display:none" onchange={onImageFile} /> +
@@ -1281,15 +1362,23 @@ /* Plain toolbar buttons gain a primary outline on hover (combobox triggers and split buttons handle their own; menu items keep border:none → no-op). */ - .toolbar-group > button { + .toolbar-group > button, + .link-wrap > button { border: 1px solid transparent; transition: background 0.15s, border-color 0.15s; } - .toolbar-group > button:hover:not(:disabled) { + .toolbar-group > button:hover:not(:disabled), + .link-wrap > button:hover:not(:disabled) { border-color: var(--color-primary); } + /* Anchor for the link popover (LinkDialog), like .font-picker. */ + .link-wrap { + position: relative; + display: inline-flex; + } + .font-picker { position: relative; } diff --git a/src/lib/editor/extensions.ts b/src/lib/editor/extensions.ts index c98e1a6..c016612 100644 --- a/src/lib/editor/extensions.ts +++ b/src/lib/editor/extensions.ts @@ -8,6 +8,7 @@ import Strike from '@tiptap/extension-strike'; import Subscript from '@tiptap/extension-subscript'; import Superscript from '@tiptap/extension-superscript'; import Highlight from '@tiptap/extension-highlight'; +import { Link } from './extensions/link'; import { TextStyle, FontFamily, FontSize } from '@tiptap/extension-text-style'; import { FontWeight } from './extensions/fontWeight'; import { FontColor } from './extensions/fontColor'; @@ -42,6 +43,7 @@ export const extensions = [ Italic, Underline, Strike, + Link, Subscript, Superscript, TextStyle, diff --git a/src/lib/editor/extensions/link.ts b/src/lib/editor/extensions/link.ts new file mode 100644 index 0000000..9457ea2 --- /dev/null +++ b/src/lib/editor/extensions/link.ts @@ -0,0 +1,25 @@ +import LinkBase from '@tiptap/extension-link'; + +// Event the toolbar listens for to open the link dialog (Ctrl/Cmd+K). The extension +// can't reach Svelte state directly, so it dispatches this on window instead. +export const OPEN_LINK_DIALOG_EVENT = 'odf-open-link-dialog'; + +// Hyperlink mark. Round-trips to ODF (odf-kit's native run path +// and export/odt.ts applyRuns; import/odt.ts convertInline). openOnClick is off so a +// click just places the cursor — Ctrl/Cmd+click opens the URL (Editor.svelte). +export const Link = LinkBase.configure({ + openOnClick: false, + autolink: true, + linkOnPaste: true, + defaultProtocol: 'https', + HTMLAttributes: { rel: 'noopener noreferrer nofollow', target: '_blank' }, +}).extend({ + addKeyboardShortcuts() { + return { + 'Mod-k': () => { + window.dispatchEvent(new CustomEvent(OPEN_LINK_DIALOG_EVENT)); + return true; + }, + }; + }, +}); diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index 616a128..c7bc244 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -965,12 +965,22 @@ function formattingFromMarks(marks: TiptapNode['marks'] = []): TextFormatting { return fmt; } -// Emit each text node as an odf-kit run. +// Hyperlink target of a run, if any. odf-kit's native run path handles the link mark +// itself; this covers the custom emitters (CUST_P/_H, cells) that bypass it. +function linkHrefOf(marks: TiptapNode['marks'] = []): string | undefined { + const href = marks.find(m => m.type === 'link')?.attrs?.href; + return href ? String(href) : undefined; +} + +// Emit each text node as an odf-kit run; link-marked runs become via addLink. function applyRuns(p: ParagraphBuilder | CellBuilder, content: TiptapNode[] = []) { for (const node of content) { if (node.type !== 'text' || !node.text) continue; const fmt = formattingFromMarks(node.marks); - p.addText(node.text, Object.keys(fmt).length ? fmt : undefined); + const f = Object.keys(fmt).length ? fmt : undefined; + const href = linkHrefOf(node.marks); + if (href) p.addLink(node.text, href, f); + else p.addText(node.text, f); } } diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index 08b2c37..6e7259d 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -383,7 +383,7 @@ function snapPt(v: number): number { function convertInline(root: Element, ctx: Ctx, baseProps: PropMap, headingLevel: number | null, hfFields = false): Node[] { const out: Node[] = []; - const pushText = (text: string, props: PropMap) => { + const pushText = (text: string, props: PropMap, linkHref?: string) => { // Strip our export sentinels (SEG/LBR) defensively — never legitimate text. let clean = text.replace(/[-]/g, ''); if (clean.includes('\n')) { @@ -394,15 +394,16 @@ function convertInline(root: Element, ctx: Ctx, baseProps: PropMap, headingLevel } if (!clean) return; const marks = marksFor(props, ctx.resolver, headingLevel); + if (linkHref) marks.push({ type: 'link', attrs: { href: linkHref } }); const node: Node = { type: 'text', text: clean }; if (marks.length) node.marks = marks; out.push(node); }; - const walk = (el: Element, props: PropMap) => { + const walk = (el: Element, props: PropMap, linkHref?: string) => { for (const child of Array.from(el.childNodes)) { if (child.nodeType === 3 /* text */) { - pushText(child.nodeValue ?? '', props); + pushText(child.nodeValue ?? '', props, linkHref); continue; } if (child.nodeType !== 1) continue; @@ -411,23 +412,26 @@ function convertInline(root: Element, ctx: Ctx, baseProps: PropMap, headingLevel if (e.namespaceURI === NS.text) { switch (e.localName) { case 'span': - walk(e, layerTextProps(props, ctx.resolver.spanTextProps(e.getAttributeNS(NS.text, 'style-name')))); + walk(e, layerTextProps(props, ctx.resolver.spanTextProps(e.getAttributeNS(NS.text, 'style-name'))), linkHref); continue; case 's': { const c = parseInt(e.getAttributeNS(NS.text, 'c') ?? '1', 10); - pushText(' '.repeat(Number.isFinite(c) && c > 0 ? c : 1), props); + pushText(' '.repeat(Number.isFinite(c) && c > 0 ? c : 1), props, linkHref); continue; } case 'tab': - pushText('\t', props); + pushText('\t', props, linkHref); continue; case 'line-break': out.push({ type: 'hardBreak' }); continue; - case 'a': - ctx.warnings.add('Hyperlinks were converted to plain text'); - walk(e, props); + case 'a': { + // ODF hyperlink → link mark on the contained text (internal #bookmark + // links are kept as-is though the editor has no bookmark targets). + const href = e.getAttributeNS(NS.xlink, 'href') ?? ''; + walk(e, props, href || linkHref); continue; + } case 'note': ctx.warnings.add('Footnotes and endnotes were removed'); continue; @@ -451,7 +455,7 @@ function convertInline(root: Element, ctx: Ctx, baseProps: PropMap, headingLevel } // Other text fields (date, title, …) store their evaluated value as // element text — keep what the source document showed. - if (e.textContent) pushText(e.textContent, props); + if (e.textContent) pushText(e.textContent, props, linkHref); continue; } } diff --git a/src/styles/editor.css b/src/styles/editor.css index 46bc5c1..f62a72e 100644 --- a/src/styles/editor.css +++ b/src/styles/editor.css @@ -147,6 +147,13 @@ margin-bottom: 0.212cm; } +/* Hyperlinks (link.ts). Ctrl/Cmd+click opens them (Editor.svelte). */ +.paper .tiptap a { + color: #1a56db; + text-decoration: underline; + cursor: text; +} + /* Inline, as-character images (image.ts). The wrapper reserves the rotated bounding box; the rotor holds the , resize handles and rotate grip, rotating together. */ .paper .tiptap .image-node { diff --git a/tests/roundtrip.test.ts b/tests/roundtrip.test.ts index 7cd4b56..cb87b13 100644 --- a/tests/roundtrip.test.ts +++ b/tests/roundtrip.test.ts @@ -52,6 +52,13 @@ const fixture: N = { T('red', { type: 'textStyle', attrs: { color: '#C00000' } }), T(' marked', { type: 'highlight', attrs: { color: '#FFFF00' } }), ), + // Links: native run path, plus a bold link inside a styled (CUST_P) paragraph. + P(null, T('Visit '), T('our site', { type: 'link', attrs: { href: 'https://example.com/' } }), T(' today.')), + P({ lineHeight: '1.5' }, + T('A '), + T('bold link', { type: 'bold' }, { type: 'link', attrs: { href: 'https://styled.example/' } }), + T(' here.'), + ), P({ textAlign: 'justify', lineHeight: '1.5', spaceBefore: 12, spaceAfter: 18 }, T('spaced and justified paragraph')), P({ indent: 2.5 }, T('indented paragraph')), P(null, T('first line'), { type: 'hardBreak' }, T('second line')), @@ -92,7 +99,7 @@ const fixture: N = { ] }, { type: 'tableRow', content: [ CELL([120], P(null, T('A2'))), - CELL([240], P(null, T('B2'))), + CELL([240], P(null, T('B2 '), T('cell link', { type: 'link', attrs: { href: 'https://cell.example/' } }))), ] }, ] }, P({ breakBefore: 'page', textAlign: 'center', lineHeight: '1.5' }, T('Forced page (styled)')), @@ -199,6 +206,16 @@ describe('Leg 1: editor → buildOdt → importOdt', () => { const tbImg = collectImages(res.content).find((i: N) => i.attrs?.wrap === 'topBottom'); check('image wrap=topBottom round-trips', !!tbImg, tbImg?.attrs); + // Hyperlinks: collect every link mark's href from the imported doc (body + cell). + const links: string[] = []; + (function walkLinks(n: N) { + for (const m of n.marks ?? []) if (m.type === 'link' && m.attrs?.href) links.push(m.attrs.href); + for (const c of n.content ?? []) walkLinks(c); + })(res.content); + check('native-path link round-trips', links.includes('https://example.com/'), links); + check('styled-path (CUST_P) bold link round-trips', links.includes('https://styled.example/'), links); + check('table-cell link round-trips', links.includes('https://cell.example/'), links); + const indented = (res.content.content ?? []).find((n: N) => n.content?.[0]?.text === 'indented paragraph'); check('indent → fo:margin-left round-trips (2.5cm)', indented?.attrs?.indent != null && Math.abs(indented.attrs.indent - 2.5) < 0.02, indented?.attrs); @@ -317,7 +334,8 @@ describe('Leg 2: foreign (LibreOffice/Word-style) .odt → importOdt', () => { check('foreign: lowercase color → #FF0000', p2.content![0].marks?.some((m: N) => m.type === 'textStyle' && m.attrs?.color === '#FF0000'), p2.content![0]); check('foreign: highlight #FFFF00', p2.content![0].marks?.some((m: N) => m.type === 'highlight' && m.attrs?.color === '#FFFF00'), p2.content![0]); check('foreign: line-break → hardBreak', p2.content!.some((n: N) => n.type === 'hardBreak')); - check('foreign: link flattened to text', p2.content!.some((n: N) => n.text === 'a link')); + check('foreign: text:a → link mark (href preserved)', + p2.content!.some((n: N) => n.text === 'a link' && n.marks?.some((m: N) => m.type === 'link' && m.attrs?.href === 'https://x.example')), p2.content); const mono = c[3]; check('foreign: font-face resolves Courier New', mono.content![0].marks?.some((m: N) => m.type === 'textStyle' && m.attrs?.fontFamily === 'Courier New' && m.attrs?.fontSize === '10pt'), mono.content![0]); @@ -340,8 +358,9 @@ describe('Leg 2: foreign (LibreOffice/Word-style) .odt → importOdt', () => { check('foreign: spanned cell colwidth [500,1000]', JSON.stringify(row.content![0].attrs?.colwidth) === JSON.stringify([500, 1000]), row.content![0].attrs); check('foreign: min-row-height 2cm → 76px', row.attrs?.rowHeight === 76, row.attrs); - const expectWarn = ['Hyperlinks were converted to plain text', 'Footnotes and endnotes were removed']; + const expectWarn = ['Footnotes and endnotes were removed']; check('foreign: warnings reported', expectWarn.every(w => f.warnings.includes(w)), f.warnings); + check('foreign: no hyperlink warning (now round-tripped)', !f.warnings.includes('Hyperlinks were converted to plain text'), f.warnings); }); });