fix: a Word hyperlink keeps the look its file gives it
Word paints a link through its Hyperlink character style, never for being one, so the editor's own blue overwrote both the file's #0000FF and the plain black of a link carrying no style at all. The DOCX importer marks those `plain` and the CSS leaves them alone; LibreOffice paints every ODF text:a, so those keep the visual.
This commit is contained in:
1 parent
8db6214c11
commit
95d174501d
6 files changed
+59
-5
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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<string, unknown>) => (attrs.plain ? { 'data-plain': '' } : {}),
|
||||
},
|
||||
};
|
||||
},
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
[DEFAULT_SHORTCUTS.link]: () => {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user