mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-21 18:13:26 +00:00
fix: robust wiki-link round-trip and navigable popover (BUG-586 follow-ups)
Follow-up to the BUG-586 fix that surfaced several edge cases under
real-world use. Covers three related improvements to the wiki-link
experience in the editor.
Reference-based wiki-link storage
Previously `[Title](/url)` round-tripped to `[[Title]]`. That form
broke for titles containing `[`, `]`, `/`, or `|`. Storage now uses
the item's opaque ref (e.g. `[[BUG-586]]`, or `[[BUG-586|Custom]]`
when the visible text differs from the item's current title).
`wikiLinksToMarkdown` accepts three forms in preference order:
ref-only, ref-with-display-override, and legacy title lookup.
Titles can now contain any characters and links survive renames.
Escape-aware parsing
Both `markdownToWikiLinks` and `wikiLinksToMarkdown` now recognize
`\.` escape sequences inside their capture groups. tiptap-markdown
emits `\[`, `\]`, `\\` in link text when the text contains literal
brackets, so the prior regexes (`[^\]]+`) terminated prematurely and
missed valid links. Helper functions escape/unescape the markdown
link-text layer and the wiki-link body layer separately so `]`, `|`,
and `\` can appear in display-override text.
Leave unresolved [[X]] untouched
The `[[…]]` regex is greedy and can match spans that were never
intended as wiki-links — notably `[[` sequences inside another
markdown link's text. On miss, the function now returns the original
match verbatim instead of emitting `[…](broken)`, which previously
hijacked surrounding content and accumulated corruption on each
save cycle. Broken items heal themselves on the next auto-save.
Picker: show ref + align URL with the route
The `[[` picker now lists the ref badge next to the title and keys
`{#each}` by `doc.id` so duplicate titles don't collide. `execLink`
now reads `page.params.username`/`page.params.workspace` from the
live route (previously `workspaceStore.current`, which could be
empty), so the inserted `href` matches the URL shape that the
round-trip expects.
Clickable link popover
The popover's URL label is now a real `<a href="…">`. Plain click →
`goto()` for internal paths, full navigation for external. Ctrl /
Cmd / middle-click pass through to the browser so "new tab" and
"copy link" work naturally. `onmousedown.stopPropagation` keeps the
outer popover's focus-trap from swallowing the click.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy, untrack } from 'svelte';
|
||||
import { page } from '$app/state';
|
||||
import { Editor, mergeAttributes } from '@tiptap/core';
|
||||
import { Plugin } from '@tiptap/pm/state';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
@@ -205,8 +206,9 @@
|
||||
});
|
||||
import { Markdown } from 'tiptap-markdown';
|
||||
import { unescapeDocLinks } from '$lib/utils/markdown';
|
||||
import { formatItemRef } from '$lib/types';
|
||||
import { formatItemRef, itemUrlId, type Item } from '$lib/types';
|
||||
import { collectionStore } from '$lib/stores/collections.svelte';
|
||||
import { workspaceStore } from '$lib/stores/workspace.svelte';
|
||||
import { BlockDragHandle } from './block-drag-handle';
|
||||
import { SLASH_ITEMS } from './block-types';
|
||||
|
||||
@@ -299,11 +301,41 @@
|
||||
.slice(0, 10);
|
||||
}
|
||||
|
||||
function execLink(title: string) {
|
||||
function execLink(doc: Item) {
|
||||
if (!editor) return;
|
||||
// Build the URL in the same shape wikiLinksToMarkdown produces so the
|
||||
// save round-trip (markdownToWikiLinks) reliably converts it back to
|
||||
// [[Title]]. We read from the live route params (not workspaceStore)
|
||||
// because that's what the slug page uses when converting wiki-links —
|
||||
// keeping the two in sync is what lets the round-trip work.
|
||||
const routeUsername = page.params.username ?? '';
|
||||
const routeWorkspace = page.params.workspace ?? workspaceStore.current?.slug ?? '';
|
||||
const collSlug = doc.collection_slug ?? '';
|
||||
const idSeg = itemUrlId(doc);
|
||||
const prefix = routeUsername ? `/${routeUsername}/${routeWorkspace}` : `/${routeWorkspace}`;
|
||||
const href = collSlug && idSeg && routeWorkspace ? `${prefix}/${collSlug}/${idSeg}` : '';
|
||||
|
||||
// Delete the [[ and any query text typed so far
|
||||
const to = editor.state.selection.from;
|
||||
editor.chain().focus().deleteRange({ from: linkStartPos, to }).insertContent(`[[${title}]]`).run();
|
||||
const chain = editor.chain().focus().deleteRange({ from: linkStartPos, to });
|
||||
|
||||
if (href) {
|
||||
// Insert the title as a real Tiptap link mark so it's clickable
|
||||
// immediately (no reload needed). On save, tiptap-markdown emits
|
||||
// [Title](href), which markdownToWikiLinks converts back to [[Title]].
|
||||
chain.insertContent([
|
||||
{
|
||||
type: 'text',
|
||||
text: doc.title,
|
||||
marks: [{ type: 'link', attrs: { href } }],
|
||||
},
|
||||
// Trailing space drops the link mark so subsequent typing is plain text.
|
||||
{ type: 'text', text: ' ' },
|
||||
]).run();
|
||||
} else {
|
||||
// Fall back to [[Title]] text if we can't resolve a URL.
|
||||
chain.insertContent(`[[${doc.title}]]`).run();
|
||||
}
|
||||
closeLink();
|
||||
}
|
||||
|
||||
@@ -418,7 +450,7 @@
|
||||
const items = getFilteredLinks();
|
||||
if (event.key === 'ArrowDown') { event.preventDefault(); linkIdx = (linkIdx + 1) % Math.max(items.length, 1); return true; }
|
||||
if (event.key === 'ArrowUp') { event.preventDefault(); linkIdx = (linkIdx - 1 + items.length) % Math.max(items.length, 1); return true; }
|
||||
if (event.key === 'Enter') { event.preventDefault(); if (items[linkIdx]) execLink(items[linkIdx].title); return true; }
|
||||
if (event.key === 'Enter') { event.preventDefault(); if (items[linkIdx]) execLink(items[linkIdx]); return true; }
|
||||
if (event.key === 'Escape') { event.preventDefault(); closeLink(); return true; }
|
||||
return false;
|
||||
}
|
||||
@@ -675,13 +707,13 @@
|
||||
class="slash-item"
|
||||
class:selected={i === linkIdx}
|
||||
onmouseenter={() => linkIdx = i}
|
||||
onclick={() => execLink(doc.title)}
|
||||
onclick={() => execLink(doc)}
|
||||
>
|
||||
<span class="slash-icon">{doc.collection_icon ?? '📄'}</span>
|
||||
<span class="slash-title">{doc.title}</span>
|
||||
{#if formatItemRef(doc)}
|
||||
<span class="slash-ref">{formatItemRef(doc)}</span>
|
||||
{/if}
|
||||
<span class="slash-title">{doc.title}</span>
|
||||
</button>
|
||||
{:else}
|
||||
<div class="slash-item" style="color: var(--text-muted); cursor: default;">No matching documents</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { Editor } from '@tiptap/core';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
let {
|
||||
editor,
|
||||
@@ -106,6 +107,27 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Navigate in the current tab. Uses SvelteKit's goto() for internal
|
||||
// (path-relative) URLs so the transition is a SPA navigation; external
|
||||
// URLs fall back to a full-page load. The handler is attached to a
|
||||
// real <a> element so middle-click / cmd-click / "copy link" behave
|
||||
// naturally.
|
||||
function handleHrefClick(e: MouseEvent) {
|
||||
if (!href) return;
|
||||
// Let the browser handle new-tab modifiers and middle-click itself.
|
||||
if (e.button !== 0 || e.ctrlKey || e.metaKey || e.shiftKey) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const target = href;
|
||||
visible = false;
|
||||
editing = false;
|
||||
if (target.startsWith('/') && !target.startsWith('//')) {
|
||||
goto(target);
|
||||
} else {
|
||||
window.location.assign(target);
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit() {
|
||||
editValue = href;
|
||||
editing = true;
|
||||
@@ -179,7 +201,13 @@
|
||||
|
||||
{#if !editing}
|
||||
<div class="link-display">
|
||||
<span class="link-href" title={href}>{truncatedHref}</span>
|
||||
<a
|
||||
class="link-href"
|
||||
href={href}
|
||||
title="Open link in this tab — {href}"
|
||||
onclick={handleHrefClick}
|
||||
onmousedown={(e) => e.stopPropagation()}
|
||||
>{truncatedHref}</a>
|
||||
<div class="link-actions">
|
||||
<button class="link-btn" onclick={openUrl} title="Open link">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
@@ -273,6 +301,10 @@
|
||||
}
|
||||
|
||||
.link-href {
|
||||
display: inline-block;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
|
||||
font-size: 0.82em;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
@@ -280,7 +312,17 @@
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 180px;
|
||||
padding: var(--space-1) 0;
|
||||
padding: var(--space-1);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.link-href:hover {
|
||||
color: var(--accent-blue);
|
||||
text-decoration: underline;
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.link-href:focus-visible {
|
||||
outline: 2px solid var(--accent-blue);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.link-actions {
|
||||
|
||||
+138
-29
@@ -70,61 +70,170 @@ export function unescapeDocLinks(markdown: string): string {
|
||||
return markdown.replace(/\\\[\\\[([^\]]+)\\\]\\\]/g, '[[$1]]');
|
||||
}
|
||||
|
||||
// Wiki-link reference pattern: uppercase/alphanumeric prefix, hyphen, digits.
|
||||
// Matches the item ref format produced by formatItemRef (e.g. TASK-5, BUG-585).
|
||||
// Anchored so it rejects anything else and falls back to title-based lookup,
|
||||
// which keeps legacy [[Title]] links working unchanged.
|
||||
const REF_PATTERN = /^[A-Za-z][A-Za-z0-9]*-\d+$/;
|
||||
|
||||
/**
|
||||
* Convert [[Item Title]] to markdown links for Tiptap rendering.
|
||||
* Tiptap doesn't understand [[]] syntax, so we convert to standard
|
||||
* markdown links before feeding content to the editor.
|
||||
* Convert wiki-link storage syntax into markdown links for Tiptap rendering.
|
||||
* Supports three forms, in preference order:
|
||||
* - [[REF-123]] → ref lookup; visible text = current item title
|
||||
* - [[REF-123|Display Text]] → ref lookup; visible text = Display Text
|
||||
* - [[Title]] → legacy title lookup (also accepts [[coll/Title]])
|
||||
* The ref-based forms are safe for titles containing any characters (brackets,
|
||||
* slashes, quotes, etc.) because the stored key is the opaque item ref.
|
||||
*/
|
||||
export function wikiLinksToMarkdown(content: string, items: Item[], workspaceSlug: string, username?: string): string {
|
||||
return content.replace(/\[\[([^\]]+)\]\]/g, (_match, title: string) => {
|
||||
// Support optional collection/ prefix: [[tasks/My Task]]
|
||||
let searchTitle = title;
|
||||
let collFilter: string | null = null;
|
||||
if (title.includes('/')) {
|
||||
const [coll, ...rest] = title.split('/');
|
||||
collFilter = coll;
|
||||
searchTitle = rest.join('/');
|
||||
// Body may contain backslash-escaped chars (`\]`, `\\`, `\|`) so the tokens
|
||||
// we emit can carry arbitrary display text. The capture is (\\.|[^\]\\])+,
|
||||
// i.e. "a backslash-escaped char OR any non-`]`/non-`\` char".
|
||||
return content.replace(/\[\[((?:\\.|[^\]\\])+)\]\]/g, (_match, body: string) => {
|
||||
const prefix = username ? `/${username}/${workspaceSlug}` : `/${workspaceSlug}`;
|
||||
|
||||
// Split optional display override on the FIRST unescaped pipe.
|
||||
const { key: rawKey, displayOverride: rawDisplay } = splitWikiBody(body);
|
||||
const key = unescapeWikiBody(rawKey);
|
||||
const displayOverride = rawDisplay == null ? null : unescapeWikiBody(rawDisplay);
|
||||
|
||||
// 1. Ref-based lookup (e.g. [[BUG-585]]) — the preferred form.
|
||||
if (REF_PATTERN.test(key.trim())) {
|
||||
const ref = key.trim();
|
||||
const byRef = items.find(i =>
|
||||
!!i.item_number && !!i.collection_prefix &&
|
||||
`${i.collection_prefix}-${i.item_number}`.toLowerCase() === ref.toLowerCase()
|
||||
);
|
||||
if (byRef && byRef.collection_slug) {
|
||||
const text = displayOverride ?? byRef.title;
|
||||
return `[${escapeMarkdownLinkText(text)}](${prefix}/${byRef.collection_slug}/${itemUrlId(byRef)})`;
|
||||
}
|
||||
// Unresolved ref — keep the original [[…]] so it round-trips.
|
||||
return _match;
|
||||
}
|
||||
|
||||
const item = items.find(i => {
|
||||
const titleMatch = i.title.toLowerCase() === searchTitle.toLowerCase();
|
||||
if (collFilter && i.collection_slug) {
|
||||
return titleMatch && i.collection_slug === collFilter;
|
||||
// 2. Legacy: exact full-title match. Titles can contain slashes, so
|
||||
// this must win over the collection-filter fallback below.
|
||||
const titleLower = key.toLowerCase();
|
||||
let item = items.find(i => i.title.toLowerCase() === titleLower);
|
||||
let displayText = displayOverride ?? key;
|
||||
|
||||
// 3. Legacy: the [[collection/Title]] disambiguation syntax.
|
||||
if (!item && key.includes('/')) {
|
||||
const [collFilter, ...rest] = key.split('/');
|
||||
const searchTitle = rest.join('/');
|
||||
const found = items.find(i =>
|
||||
i.title.toLowerCase() === searchTitle.toLowerCase() &&
|
||||
i.collection_slug === collFilter
|
||||
);
|
||||
if (found) {
|
||||
item = found;
|
||||
if (displayOverride == null) displayText = searchTitle;
|
||||
}
|
||||
return titleMatch;
|
||||
});
|
||||
}
|
||||
|
||||
if (item && item.collection_slug) {
|
||||
const prefix = username ? `/${username}/${workspaceSlug}` : `/${workspaceSlug}`;
|
||||
return `[${searchTitle}](${prefix}/${item.collection_slug}/${itemUrlId(item)})`;
|
||||
return `[${escapeMarkdownLinkText(displayText)}](${prefix}/${item.collection_slug}/${itemUrlId(item)})`;
|
||||
}
|
||||
// Unresolved — render as styled text (editor will show it as plain text)
|
||||
return `[${searchTitle}](broken)`;
|
||||
// Unresolved: leave the original [[X]] text alone. Emitting a
|
||||
// [text](broken) link here would hijack content that legitimately
|
||||
// contains `[[` — for example a `[[` that appears inside another
|
||||
// markdown link's text span. The regex is greedy and may grab a
|
||||
// range that was never intended as a wiki-link, so the safe thing
|
||||
// on miss is to restore the match verbatim.
|
||||
return _match;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert markdown links back to [[Item Title]] syntax for storage.
|
||||
* Reverses wikiLinksToMarkdown() so we store [[]] not []() in the database.
|
||||
* Convert markdown links back to wiki-link storage syntax.
|
||||
* When the link's URL resolves to an item with a ref, emit [[REF]] (or
|
||||
* [[REF|Display]] if the visible text differs from the item's current
|
||||
* title). Ref-based storage is preferred because it survives item renames
|
||||
* and is robust against special characters in titles.
|
||||
* Items without a ref fall back to the legacy [[Title]] form.
|
||||
*/
|
||||
export function markdownToWikiLinks(markdown: string, items: Item[]): string {
|
||||
// Match [Title](/username/workspace/collection/slug-or-REF) or [Title](/workspace/collection/slug-or-REF) pattern
|
||||
return markdown.replace(/\[([^\]]+)\]\(\/(?:[^/]+\/){2,3}([^)]+)\)/g, (_match, title: string, slugOrRef: string) => {
|
||||
// Match [Title](/username/workspace/collection/slug-or-REF). Title may
|
||||
// contain backslash-escaped chars (\[, \], \\) that tiptap-markdown emits
|
||||
// when serializing link text. The capture allows `\.` sequences so we
|
||||
// don't terminate on an escaped `]` that's really part of the display.
|
||||
return markdown.replace(/\[((?:\\.|[^\]\\])+)\]\(\/(?:[^/]+\/){2,3}([^)]+)\)/g, (_match, rawText: string, slugOrRef: string) => {
|
||||
const item = items.find(i => {
|
||||
if (i.slug === slugOrRef) return true;
|
||||
// Also match PREFIX-NUMBER refs
|
||||
if (i.item_number && i.collection_prefix) {
|
||||
return `${i.collection_prefix}-${i.item_number}` === slugOrRef;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (item) {
|
||||
return `[[${title}]]`;
|
||||
if (!item) return _match;
|
||||
|
||||
// tiptap-markdown emits backslash-escaped brackets in the link text
|
||||
// (e.g. "Use \[\[ to link"); unescape before comparing/emitting.
|
||||
const displayText = unescapeMarkdownLinkText(rawText);
|
||||
|
||||
const ref = (item.item_number && item.collection_prefix)
|
||||
? `${item.collection_prefix}-${item.item_number}`
|
||||
: null;
|
||||
|
||||
if (ref) {
|
||||
// Prefer ref-based storage. Omit |Display if it matches the
|
||||
// current item title (renaming the item updates the link text
|
||||
// automatically on next load).
|
||||
if (displayText === item.title) {
|
||||
return `[[${ref}]]`;
|
||||
}
|
||||
return `[[${ref}|${escapeWikiBody(displayText)}]]`;
|
||||
}
|
||||
return _match;
|
||||
// Legacy fallback for items without a ref.
|
||||
return `[[${escapeWikiBody(displayText)}]]`;
|
||||
});
|
||||
}
|
||||
|
||||
// Escape the characters that would terminate or unbalance a markdown link's
|
||||
// text span. `\` must be doubled first so it doesn't interfere with the
|
||||
// subsequent bracket escapes.
|
||||
function escapeMarkdownLinkText(s: string): string {
|
||||
return s.replace(/\\/g, '\\\\').replace(/([\[\]])/g, '\\$1');
|
||||
}
|
||||
|
||||
// Escape the characters that would terminate a [[...]] wiki-link body, or
|
||||
// collide with the `|` display separator. Order matters: backslash first.
|
||||
function escapeWikiBody(s: string): string {
|
||||
return s.replace(/\\/g, '\\\\').replace(/([\]|])/g, '\\$1');
|
||||
}
|
||||
|
||||
// Inverse of escapeWikiBody. Accepts `\]`, `\|`, and `\\` escapes.
|
||||
function unescapeWikiBody(s: string): string {
|
||||
return s.replace(/\\(\\|\]|\|)/g, '$1');
|
||||
}
|
||||
|
||||
// Split a wiki-link body on the FIRST unescaped `|`. Returns the raw key
|
||||
// and the raw display override (both still escape-encoded — caller should
|
||||
// unescape them). If there's no pipe, displayOverride is null.
|
||||
function splitWikiBody(body: string): { key: string; displayOverride: string | null } {
|
||||
let i = 0;
|
||||
while (i < body.length) {
|
||||
const ch = body[i];
|
||||
if (ch === '\\' && i + 1 < body.length) {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (ch === '|') {
|
||||
return { key: body.slice(0, i), displayOverride: body.slice(i + 1) };
|
||||
}
|
||||
i++;
|
||||
}
|
||||
return { key: body, displayOverride: null };
|
||||
}
|
||||
|
||||
// Inverse of escapeMarkdownLinkText. Also undoes the \[\[ / \]\] escapes that
|
||||
// tiptap-markdown inserts to prevent its own output from looking like our
|
||||
// wiki-link sentinels.
|
||||
function unescapeMarkdownLinkText(s: string): string {
|
||||
return s.replace(/\\(\[|\]|\\)/g, '$1');
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert [[broken]] placeholder links back to wiki syntax
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user