feat: add hyperlinks
This commit is contained in:
1 parent
d1b342dc7f
commit
98e7ee2458
11 files changed
+396
-17
No files matched your search
Generated
+24
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -599,6 +645,12 @@
|
||||
onClose={closeSpellMenu}
|
||||
/>
|
||||
{/if}
|
||||
{#if linkTip}
|
||||
<div class="link-tooltip" style="top: {linkTip.top}px; left: {linkTip.left}px;">
|
||||
<span class="link-tooltip-url">{linkTip.href}</span>
|
||||
<span class="link-tooltip-hint">{linkHintKey}+Click to open link</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@@ -608,6 +660,37 @@
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
/* Hover hint for links (Word/LibreOffice style): URL + modifier-click tip, sitting
|
||||
just above the link. A child of .editor so it scrolls with content but isn't scaled
|
||||
by the zoom transform; pointer-events:none keeps the link itself clickable. */
|
||||
.link-tooltip {
|
||||
position: absolute;
|
||||
z-index: 160;
|
||||
transform: translateY(calc(-100% - 6px));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
max-width: 28rem;
|
||||
padding: 0.3rem 0.55rem;
|
||||
background: var(--color-tooltip-bg, #2b2f36);
|
||||
color: #fff;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.25);
|
||||
pointer-events: none;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.link-tooltip-url {
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.link-tooltip-hint { opacity: 0.75; }
|
||||
|
||||
/* Overlay for the table page-break bands, inside .paper (inset:0) so it scales with
|
||||
the page background. pointer-events:none keeps the editor clickable. z-index clears
|
||||
the resize handles (20) so the band also masks a too-tall cell's handle in the gap. */
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
<script lang="ts">
|
||||
// Small popover to enter/edit a hyperlink URL. The parent owns `open` and decides how
|
||||
// to apply the URL to the selection (insert vs. set vs. extend); this just collects it.
|
||||
let {
|
||||
open,
|
||||
initialUrl = '',
|
||||
canRemove = false,
|
||||
onApply,
|
||||
onRemove,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
initialUrl?: string;
|
||||
canRemove?: boolean;
|
||||
onApply: (url: string) => void;
|
||||
onRemove: () => void;
|
||||
onClose: () => void;
|
||||
} = $props();
|
||||
|
||||
let url = $state('');
|
||||
let input = $state<HTMLInputElement | null>(null);
|
||||
|
||||
// Reset the field to the current link each time the popover opens, then focus it.
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
url = initialUrl;
|
||||
queueMicrotask(() => input?.focus());
|
||||
queueMicrotask(() => input?.select());
|
||||
}
|
||||
});
|
||||
|
||||
function apply() {
|
||||
onApply(url);
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') { e.preventDefault(); apply(); }
|
||||
else if (e.key === 'Escape') { e.preventDefault(); onClose(); }
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<div class="link-dialog" role="dialog" aria-label="Insert link">
|
||||
<input
|
||||
bind:this={input}
|
||||
bind:value={url}
|
||||
type="text"
|
||||
placeholder="https://example.com"
|
||||
onkeydown={onKeydown}
|
||||
spellcheck="false"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<div class="link-actions">
|
||||
{#if canRemove}
|
||||
<button class="link-remove" onclick={onRemove}>Remove</button>
|
||||
{/if}
|
||||
<span class="link-spacer"></span>
|
||||
<button class="link-cancel" onclick={onClose}>Cancel</button>
|
||||
<button class="link-apply" onclick={apply} disabled={!url.trim()}>Apply</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.link-dialog {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
z-index: 300;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
width: 18rem;
|
||||
padding: 0.6rem;
|
||||
background: var(--color-toolbar-bg, #fff);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 0.4rem 0.5rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--color-page-bg, #fff);
|
||||
color: var(--color-text);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.link-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.link-spacer { flex: 1; }
|
||||
|
||||
.link-actions button {
|
||||
height: 1.8rem;
|
||||
padding: 0 0.7rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.link-actions button:hover:not(:disabled) { background: var(--color-hover, rgba(0, 0, 0, 0.06)); }
|
||||
.link-apply { border-color: var(--color-accent, #1a56db) !important; color: var(--color-accent, #1a56db); }
|
||||
.link-remove { color: #c0392b; }
|
||||
.link-actions button:disabled { opacity: 0.5; cursor: default; }
|
||||
</style>
|
||||
@@ -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}
|
||||
/>
|
||||
<div class="link-wrap" use:linkClickOutside>
|
||||
<button
|
||||
class:active={isLink}
|
||||
onclick={openLinkDialog}
|
||||
disabled={!!hfActive}
|
||||
title={hfActive ? 'Links are not available in headers/footers' : 'Insert link (Ctrl+K)'}
|
||||
aria-label="Insert link"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={linkDialogOpen}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<path d="M6.5 9.5l3-3" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>
|
||||
<path d="M8.5 5l.9-.9a2.5 2.5 0 0 1 3.5 3.5l-.9.9" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M7.5 11l-.9.9a2.5 2.5 0 0 1-3.5-3.5l.9-.9" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<LinkDialog
|
||||
open={linkDialogOpen}
|
||||
initialUrl={linkInitialUrl}
|
||||
canRemove={isLink}
|
||||
onApply={applyLink}
|
||||
onRemove={removeLink}
|
||||
onClose={() => (linkDialogOpen = false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-separator"></div>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 <text:a xlink:href> (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;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
+12
-2
@@ -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 <text:a> 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+14
-10
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 <img>, resize handles and rotate grip, rotating together. */
|
||||
.paper .tiptap .image-node {
|
||||
|
||||
+22
-3
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user