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}
+
+ {linkTip.href}
+ {linkHintKey}+Click to open link
+
+ {/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}
/>
+
+
+
(linkDialogOpen = false)}
+ />
+
@@ -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);
});
});