diff --git a/src/lib/editor/extensions/CLAUDE.md b/src/lib/editor/extensions/CLAUDE.md index 92e6dad..9b6d78d 100644 --- a/src/lib/editor/extensions/CLAUDE.md +++ b/src/lib/editor/extensions/CLAUDE.md @@ -26,7 +26,7 @@ Five topics are large enough to have their own deep-dive — read the file befor - **`pageBreak.ts`** — the text-flow attrs of a paragraph/heading: `breakBefore: 'page'`, plus `keepNext`/`keepLines` (see `docs/architecture/pagination.md`). Owns `Mod-Enter`. - **`formattingMarks.ts`** (`FormattingMarks`) — decorations marking spaces (`·`) and tabs (`→`) when `.paper.show-formatting-marks` is set. - **`bulletList.ts`** / **`orderedList.ts`** — the TipTap lists plus a marker-type attr: `setBulletChar` (innermost list, `null` = default cycle) and the ordered numbering cycle (`utils/orderedListTypes.ts`, multilevel targets the outermost list). -- **`link.ts`** — hyperlink mark; owns `Mod-k`, which fires the event `Toolbar.svelte` listens for to open the link dialog. A `#name` href targets a bookmark: `Editor.svelte`'s `handleClick` scrolls to it instead of opening a tab. +- **`link.ts`** — hyperlink mark; owns `Mod-k`, which fires the event `Toolbar.svelte` listens for to open the link dialog. A `#name` href targets a bookmark: `Editor.svelte`'s `handleClick` scrolls to it instead of opening a tab. The `plain` attr (`data-plain`, `editor.css`) suppresses the editor's own blue: Word paints a link through its **Hyperlink character style**, which arrives as an ordinary mark, so every DOCX link is plain and one carrying no style is drawn like the text around it — while LibreOffice paints every ODF `text:a` styled or not (probed), so those never are. The DOCX exporter paints `#0563C1` only on a non-plain link, which is how a round trip tells its own links from a file's. - **`bookmark.ts`** / **`crossReference.ts`** — a named range of text (a mark) and the live references to it (`REF`/`PAGEREF`). `ToolbarExpanded.svelte` owns both dialogs, as it does the link one. - **`searchReplace.ts`** — Find & Replace: decorations highlight all matches, commands navigate/replace. Touches the document only for actual replacements. - **`spellCheck.ts`** — decorations from `spell/controller.ts`; `spellErrorAt` feeds the context menu. diff --git a/src/lib/editor/extensions/link.ts b/src/lib/editor/extensions/link.ts index 52baa13..6cf834f 100644 --- a/src/lib/editor/extensions/link.ts +++ b/src/lib/editor/extensions/link.ts @@ -15,6 +15,19 @@ export const Link = LinkBase.configure({ defaultProtocol: 'https', HTMLAttributes: { rel: 'noopener noreferrer nofollow', target: '_blank' }, }).extend({ + addAttributes() { + return { + ...this.parent?.(), + // A link a file gives no look of its own (Word writes the blue as a character + // style, so a run without one is drawn like the text around it). ODF has no such + // link: LibreOffice paints every text:a, styled or not — probed. + plain: { + default: false, + parseHTML: (el: HTMLElement) => el.hasAttribute('data-plain'), + renderHTML: (attrs: Record) => (attrs.plain ? { 'data-plain': '' } : {}), + }, + }; + }, addKeyboardShortcuts() { return { [DEFAULT_SHORTCUTS.link]: () => { diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts index 99924c8..88818e6 100644 --- a/src/lib/export/docx.ts +++ b/src/lib/export/docx.ts @@ -261,8 +261,9 @@ function runPropsFromMarks(marks: TiptapNode['marks'] = [], force: TextProps = { const h = hexColor(String(hl.attrs.color)); if (h) props.shading = { type: ShadingType.CLEAR, fill: h }; } - // Visible hyperlink styling (the ExternalHyperlink wrapper carries the target). - if (markPresent(marks, 'link')) { + // Visible hyperlink styling (the ExternalHyperlink wrapper carries the target), unless + // the link came in without one — Word draws those like the text around them. + if (marks?.some((m) => m.type === 'link' && !m.attrs?.plain)) { if (!props.color) props.color = '0563C1'; if (!props.underline) props.underline = { type: UnderlineType.SINGLE }; } diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index 49630f8..8bfaaeb 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -1024,7 +1024,13 @@ function convertInline(p: Element, ctx: Ctx, baseRun: RunProps, defaults: BlockD ctx.usedCharStyles.add(charId!); marks.push({ type: 'charStyle', attrs: { name: charName } }); } - if (linkHref) marks.push({ type: 'link', attrs: { href: linkHref } }); + if (linkHref) { + // Word never paints a hyperlink for being one: the blue is its Hyperlink character + // style, which arrives as an ordinary run mark, so the editor must add nothing of + // its own. Only our exporter's blue is an editor link — it is stripped above. + const ours = hexColor(props.color)?.toUpperCase() === LINK_BLUE; + marks.push({ type: 'link', attrs: { href: linkHref, plain: !ours } }); + } return marks; }; diff --git a/src/styles/editor.css b/src/styles/editor.css index 062f211..f617e37 100644 --- a/src/styles/editor.css +++ b/src/styles/editor.css @@ -192,10 +192,17 @@ } /* Hyperlinks (link.ts). Ctrl/Cmd+click opens them (Editor.svelte). */ +/* The browser's own link colour is off first: a link Word left unstyled is drawn like the + text around it, since there the blue is a character style, not a property of being a + link (link.ts). Every other link — ODF paints them all — takes the visual back. */ .paper .tiptap a { + color: inherit; + text-decoration: none; + cursor: text; +} +.paper .tiptap a:not([data-plain]) { color: #1a56db; text-decoration: underline; - cursor: text; } /* Find & Replace match highlights (searchReplace.ts); current match is stronger. */ diff --git a/tests/unit/link-plain.test.ts b/tests/unit/link-plain.test.ts new file mode 100644 index 0000000..a57bace --- /dev/null +++ b/tests/unit/link-plain.test.ts @@ -0,0 +1,27 @@ +import { describe, it, expect } from 'vitest'; +import { importDocx } from '../../src/lib/import/docx'; +import { buildDocx } from '../../src/lib/export/docx'; + +// Word paints a hyperlink through its Hyperlink character style, so a run carrying none +// is drawn like the text around it. `plain` carries that; ODF links are never plain +// (LibreOffice paints every text:a — probed). +type N = { type: string; attrs?: any; content?: N[]; marks?: any[]; text?: string }; + +function texts(node: N, out: N[] = []): N[] { + if (node.type === 'text') out.push(node); + for (const c of node.content ?? []) texts(c, out); + return out; +} + +describe('an editor link round-trips as an editor link', () => { + it('keeps its blue through DOCX', async () => { + const doc: N = { type: 'doc', content: [{ type: 'paragraph', content: [ + { type: 'text', text: 'site', marks: [{ type: 'link', attrs: { href: 'https://example.com' } }] }, + ] }] }; + const bytes = await buildDocx(doc as any, { top: 2, bottom: 2, left: 2, right: 2 }, 'portrait'); + const back = importDocx(bytes).content as unknown as N; + const link = texts(back)[0]?.marks?.find((m: any) => m.type === 'link'); + expect(link?.attrs.href).toBe('https://example.com'); + expect(link?.attrs.plain).toBe(false); + }); +});