@llui/markdown-editor 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/dist/editor.d.ts +15 -2
  2. package/dist/editor.d.ts.map +1 -1
  3. package/dist/editor.js +12 -2
  4. package/dist/editor.js.map +1 -1
  5. package/dist/index.d.ts +6 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +15 -1
  8. package/dist/index.js.map +1 -1
  9. package/dist/plugins/block-drag.d.ts +74 -0
  10. package/dist/plugins/block-drag.d.ts.map +1 -0
  11. package/dist/plugins/block-drag.js +983 -0
  12. package/dist/plugins/block-drag.js.map +1 -0
  13. package/dist/plugins/code-language.d.ts +60 -0
  14. package/dist/plugins/code-language.d.ts.map +1 -0
  15. package/dist/plugins/code-language.js +308 -0
  16. package/dist/plugins/code-language.js.map +1 -0
  17. package/dist/plugins/frontmatter.d.ts +45 -0
  18. package/dist/plugins/frontmatter.d.ts.map +1 -0
  19. package/dist/plugins/frontmatter.js +281 -0
  20. package/dist/plugins/frontmatter.js.map +1 -0
  21. package/dist/plugins/overlay.d.ts +1 -0
  22. package/dist/plugins/overlay.d.ts.map +1 -1
  23. package/dist/plugins/overlay.js +7 -0
  24. package/dist/plugins/overlay.js.map +1 -1
  25. package/dist/plugins/wikilink.d.ts +110 -0
  26. package/dist/plugins/wikilink.d.ts.map +1 -0
  27. package/dist/plugins/wikilink.js +692 -0
  28. package/dist/plugins/wikilink.js.map +1 -0
  29. package/dist/styles/block-drag.css +118 -0
  30. package/dist/styles/editor.css +112 -0
  31. package/dist/transformers/code.d.ts +24 -0
  32. package/dist/transformers/code.d.ts.map +1 -0
  33. package/dist/transformers/code.js +167 -0
  34. package/dist/transformers/code.js.map +1 -0
  35. package/dist/transformers/gfm.d.ts.map +1 -1
  36. package/dist/transformers/gfm.js +7 -2
  37. package/dist/transformers/gfm.js.map +1 -1
  38. package/dist/transformers/registry.d.ts +3 -0
  39. package/dist/transformers/registry.d.ts.map +1 -1
  40. package/dist/transformers/registry.js +26 -0
  41. package/dist/transformers/registry.js.map +1 -1
  42. package/package.json +42 -28
  43. package/src/editor.ts +27 -4
  44. package/src/index.ts +58 -1
  45. package/src/plugins/block-drag.ts +1175 -0
  46. package/src/plugins/code-language.ts +378 -0
  47. package/src/plugins/frontmatter.ts +324 -0
  48. package/src/plugins/overlay.ts +7 -0
  49. package/src/plugins/wikilink.ts +843 -0
  50. package/src/styles/block-drag.css +118 -0
  51. package/src/styles/editor.css +112 -0
  52. package/src/transformers/code.ts +174 -0
  53. package/src/transformers/gfm.ts +6 -2
  54. package/src/transformers/registry.ts +27 -0
@@ -0,0 +1,692 @@
1
+ // Wikilink plugin — `[[target]]` / `[[target|alias]]` inline references.
2
+ //
3
+ // The ALIAS is what the reader sees; the TARGET is what the link points at.
4
+ // Semantics are lifted from lance's markdown-it inline rule: a wikilink opens
5
+ // with `[[`, closes at the FIRST `]]`, splits on the FIRST `|`, and is rejected
6
+ // when the inner content is empty or contains a nested `[[`.
7
+ //
8
+ // ── Why a custom TextNode and NOT `@lexical/link` ────────────────────────────
9
+ //
10
+ // Reusing the link infrastructure looks tempting (a wikilink IS a link) but is
11
+ // wrong here on four counts:
12
+ //
13
+ // 1. A `LinkNode` is an ELEMENT node: the display text lives in its children,
14
+ // the destination in `getURL()`. A wikilink's target is not a URL — it is
15
+ // a document name resolved by the host. Storing it in `url` means every
16
+ // wikilink flows through this package's link-security layer
17
+ // (`sanitizeLinkUrl` + the global `registerLinkSanitizer` node transform,
18
+ // see `../security.ts`), which exists precisely to rewrite/unwrap hrefs it
19
+ // does not recognize. A wikilink would have to be carved out of the one
20
+ // enforcement point the editor relies on — a security seam we refuse to cut.
21
+ // 2. Because the display text is a child text node, editing it desyncs the
22
+ // alias from the target silently: type inside `[[Page|alias]]` and you get
23
+ // a link whose text no longer matches anything the exporter can round-trip.
24
+ // A `TextNode` subclass in `token` mode is ATOMIC — it is selected, moved
25
+ // and deleted as one unit, so target/alias can never drift.
26
+ // 3. `$toggleLink`, the link dialog, the floating link toolbar and autolink
27
+ // would all treat a wikilink as an ordinary hyperlink and happily rewrite it.
28
+ // 4. Both directions of the markdown conversion then live in ONE text-match
29
+ // transformer over ONE node type, instead of being split across the LINK
30
+ // transformer with a sentinel-scheme escape hatch.
31
+ //
32
+ // A `DecoratorNode` (the `LLuiDecoratorNode` bridge used by math/mermaid) was
33
+ // the other candidate and is also wrong: a decorator is an opaque, non-editable
34
+ // island. A wikilink is inline prose — the caret must move through and around
35
+ // it naturally, and it must inherit the surrounding text format. `TextNode` is
36
+ // the node kind that gives that for free.
37
+ //
38
+ // ── Transformer ordering ─────────────────────────────────────────────────────
39
+ //
40
+ // `transformers/registry.ts` ranks by type (text-match last) and, within a rank,
41
+ // by plugin array order. `@lexical/markdown` resolves competing text-match
42
+ // transformers with `findOutermostTextMatchTransformer`, which prefers the
43
+ // EARLIEST/outermost match and falls back to array order on a tie.
44
+ //
45
+ // * Typing (`registerMarkdownShortcuts`) indexes transformers by their single
46
+ // `trigger` character. Ours is `]`, LINK's is `)`, so they can never collide.
47
+ // * On IMPORT, LINK's `importRegExp` requires a `](`, which `[[a]]` and
48
+ // `[[a|b]]` never contain — plain wikilinks are safe at any position.
49
+ // * The ambiguity is a wikilink on a line that also contains a `](` — either
50
+ // immediately (`[[a|b]](c)`) or LATER on the same line
51
+ // (`**[[A]]** and [b](url)`). LINK's import pattern has a LAZY label
52
+ // (`\[(.+?)\]\(…\)`), so it can match starting at the WIKILINK's own `[` and
53
+ // span across to that later `](`. Both rules then match at the same start
54
+ // index, and a tie is broken by array order.
55
+ //
56
+ // That made fidelity depend on plugin order, and the failure was silent: with
57
+ // `wikilinkPlugin()` listed after `corePlugin()`, LINK swallowed the `**`
58
+ // delimiters and re-exported them ESCAPED, losing the bold outright.
59
+ //
60
+ // This is now resolved STRUCTURALLY, not by documentation: the transformer
61
+ // declares `setTransformerPrecedence(WIKILINK_TRANSFORMER, -10)` (see the
62
+ // bottom of this file), so the registry orders it ahead of LINK in EITHER
63
+ // plugin order. `test/composition.test.ts` pins both orders. To get the
64
+ // CommonMark reading of `[[a|b]](c)` instead, omit `wikilinkPlugin()`.
65
+ import { $applyNodeReplacement, $createTextNode, $getNearestNodeFromDOMNode, $getSelection, $isRangeSelection, $isTextNode, CLICK_COMMAND, COMMAND_PRIORITY_HIGH, COMMAND_PRIORITY_LOW, KEY_ARROW_DOWN_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ENTER_COMMAND, KEY_ESCAPE_COMMAND, TextNode, } from 'lexical';
66
+ import { mergeRegister } from '@lexical/utils';
67
+ import { div, each, text } from '@llui/dom';
68
+ import { setTransformerPrecedence } from '../transformers/registry.js';
69
+ import { definePluginUI } from './ui.js';
70
+ import { OVERLAY_Z, overlayRoot } from './overlay.js';
71
+ const PLUGIN = 'wikilink';
72
+ // ── Parsing ──────────────────────────────────────────────────────────────────
73
+ /**
74
+ * Matches `[[inner]]` where `inner` is non-empty and contains neither `[[` nor
75
+ * `]]`. Encoding both rejections IN the pattern (rather than validating inside
76
+ * `replace`) is what reproduces lance's scan-and-retry behaviour: markdown-it
77
+ * returns `false` at a bad `[[` and re-attempts one character later, so
78
+ * `[[a[[b]]` yields the INNER `[[b]]`. A regex that matched greedily at index 0
79
+ * and then bailed in `replace` would swallow the valid inner link instead.
80
+ */
81
+ const WIKILINK_RE_SOURCE = String.raw `\[\[((?:(?!\[\[|\]\])[\s\S])+)\]\]`;
82
+ const WIKILINK_IMPORT_RE = new RegExp(WIKILINK_RE_SOURCE);
83
+ /** Same pattern anchored at the caret, for the live typing shortcut. */
84
+ const WIKILINK_TYPING_RE = new RegExp(`${WIKILINK_RE_SOURCE}$`);
85
+ /**
86
+ * Parse the content BETWEEN the brackets. Returns `null` when the content is not
87
+ * a valid wikilink body.
88
+ *
89
+ * Deliberate choices, each load-bearing for exact round-tripping:
90
+ * * split on the FIRST `|` only, so `[[a|b|c]]` has alias `b|c` and re-exports
91
+ * byte-identically;
92
+ * * an EMPTY alias (`[[a|]]`) normalizes to no alias — the alternative
93
+ * (keeping `alias: ''`) would render a zero-width, unclickable node;
94
+ * * NO trimming. `[[ a ]]` keeps its spaces, because trimming would make
95
+ * import→export lossy. Presentation trimming is the host's call in
96
+ * `onNavigate`/`resolve`, not the document's.
97
+ */
98
+ export function parseWikiLinkInner(inner) {
99
+ if (inner.length === 0 || inner.includes('[['))
100
+ return null;
101
+ const pipe = inner.indexOf('|');
102
+ const target = pipe >= 0 ? inner.slice(0, pipe) : inner;
103
+ // A blank target is as unusable as an empty one: `[[ ]]` would build a
104
+ // token-mode node whose only glyph is a space — invisible, unclickable and
105
+ // (being a token) uneditable except by deleting it wholesale. Same rejection
106
+ // the empty case gets. Note this is a BLANK check, not a trim: a target that
107
+ // has any non-space content keeps its spaces verbatim, so `[[ a ]]` still
108
+ // round-trips byte-identically.
109
+ if (target.trim().length === 0)
110
+ return null;
111
+ const rawAlias = pipe >= 0 ? inner.slice(pipe + 1) : null;
112
+ return { target, alias: rawAlias !== null && rawAlias.trim().length > 0 ? rawAlias : null };
113
+ }
114
+ /**
115
+ * Constrain a target/alias to the values the `[[…]]` syntax can actually
116
+ * express, so that {@link formatWikiLink} is INJECTIVE.
117
+ *
118
+ * Without this, `formatWikiLink` is not the inverse it claims to be: a target
119
+ * of `a|b` emits `[[a|b]]`, which reads back as target `a` with alias `b` — the
120
+ * link silently repoints itself and its visible text changes on the next load.
121
+ * A target of `a]]b` emits `[[a]]b]]`, which reads back as a wikilink to `a`
122
+ * followed by the literal text `b]]`.
123
+ *
124
+ * ── Why sanitize rather than introduce an escape dialect ─────────────────────
125
+ *
126
+ * Backslash-escaping `|`/`[`/`]` would make every value representable, but it
127
+ * forks the syntax away from the markdown-it/Obsidian rule this plugin exists
128
+ * to match: `[[a\|b]]` is not a wikilink to `a|b` anywhere else in the
129
+ * ecosystem, it is a wikilink to the literal `a\` with alias `b`. Emitting it
130
+ * would corrupt the document for every OTHER reader of the same file, which is
131
+ * strictly worse than declining to represent a target no wikilink dialect can
132
+ * express. Documents are shared; our in-memory model is not.
133
+ *
134
+ * So the unrepresentable characters are removed at the point a link is BUILT
135
+ * (`$createWikiLinkNode`, `setTarget`, `setAlias`, the insert command) — every
136
+ * route by which a value can enter the document. Everything downstream may then
137
+ * assume `parse(format(link)) === link`.
138
+ */
139
+ function sanitizePart(raw, stripPipe) {
140
+ let out = raw
141
+ // Markdown is line-oriented: a newline inside `[[…]]` cannot survive an
142
+ // export/import cycle at all (neither half of the split matches).
143
+ .replace(/\s+/g, ' ')
144
+ // `[[` would open a nested link (which the import rule rejects outright);
145
+ // `]]` would close this one early and spill the remainder as literal text.
146
+ .replace(/\[\[+/g, '[')
147
+ .replace(/\]\]+/g, ']');
148
+ if (stripPipe)
149
+ out = out.replace(/\|/g, '');
150
+ return out;
151
+ }
152
+ /** Sanitize a target. Returns `null` when nothing usable survives. */
153
+ export function sanitizeWikiLinkTarget(raw) {
154
+ const out = sanitizePart(raw, true);
155
+ return out.trim().length > 0 ? out : null;
156
+ }
157
+ /** Sanitize an alias. Returns `null` when nothing usable survives. */
158
+ export function sanitizeWikiLinkAlias(raw) {
159
+ if (raw === null)
160
+ return null;
161
+ // A pipe is legal in an alias: the parser splits on the FIRST pipe only, so
162
+ // `[[a|b|c]]` unambiguously means alias `b|c` and re-exports byte-identically.
163
+ const out = sanitizePart(raw, false);
164
+ return out.trim().length > 0 ? out : null;
165
+ }
166
+ /**
167
+ * Serialize a wikilink back to markdown. Inverse of {@link parseWikiLinkInner}
168
+ * for every link built through this module's constructors — see
169
+ * {@link sanitizeWikiLinkTarget} for why that qualifier is load-bearing.
170
+ */
171
+ export function formatWikiLink(link) {
172
+ return link.alias === null ? `[[${link.target}]]` : `[[${link.target}|${link.alias}]]`;
173
+ }
174
+ /** The text a wikilink displays: the alias when present, else the target. */
175
+ function displayText(link) {
176
+ return link.alias ?? link.target;
177
+ }
178
+ /**
179
+ * What a screen reader announces. An aliased link must name BOTH halves: the
180
+ * visible text is the alias, so the destination is otherwise unhearable.
181
+ */
182
+ function ariaLabel(link) {
183
+ return link.alias === null
184
+ ? `${link.target}, wiki link`
185
+ : `${link.alias}, wiki link to ${link.target}`;
186
+ }
187
+ /**
188
+ * An atomic inline wikilink. Extends `TextNode` so the caret, selection and
189
+ * text formats behave exactly as they do for prose, while `token` mode keeps it
190
+ * indivisible: the user can delete it or move past it, but never edit its
191
+ * interior into a state where the visible alias disagrees with `__target`.
192
+ */
193
+ export class WikiLinkNode extends TextNode {
194
+ __target;
195
+ __alias;
196
+ static getType() {
197
+ return 'wikilink';
198
+ }
199
+ static clone(node) {
200
+ return new WikiLinkNode(node.__target, node.__alias, node.__text, node.__key);
201
+ }
202
+ constructor(target, alias, text, key) {
203
+ super(text ?? displayText({ target, alias }), key);
204
+ this.__target = target;
205
+ this.__alias = alias;
206
+ }
207
+ static importJSON(serializedNode) {
208
+ return $createWikiLinkNode(serializedNode.target, serializedNode.alias).updateFromJSON(serializedNode);
209
+ }
210
+ updateFromJSON(serializedNode) {
211
+ const self = super.updateFromJSON(serializedNode);
212
+ const writable = self.getWritable();
213
+ writable.__target = serializedNode.target;
214
+ writable.__alias = serializedNode.alias;
215
+ return writable;
216
+ }
217
+ exportJSON() {
218
+ return { ...super.exportJSON(), alias: this.getAlias(), target: this.getTarget() };
219
+ }
220
+ createDOM(config, editor) {
221
+ const dom = super.createDOM(config, editor);
222
+ // `data-scope` / `data-part` is this package's styling contract (see the
223
+ // other plugins); `data-wikilink` additionally carries the target so a host
224
+ // stylesheet can react to it and the click handler can find the element.
225
+ dom.setAttribute('data-scope', 'md-wikilink');
226
+ dom.setAttribute('data-part', 'link');
227
+ dom.setAttribute('data-wikilink', this.__target);
228
+ if (this.__alias !== null)
229
+ dom.setAttribute('data-wikilink-alias', this.__alias);
230
+ // Without a role, assistive tech reads a wikilink as ordinary prose, and an
231
+ // aliased one gives no way to hear where it points.
232
+ dom.setAttribute('role', 'link');
233
+ dom.setAttribute('aria-label', ariaLabel(this.getLink()));
234
+ return dom;
235
+ }
236
+ updateDOM(prevNode, dom, config) {
237
+ const recreated = super.updateDOM(prevNode, dom, config);
238
+ if (recreated)
239
+ return true;
240
+ if (prevNode.__target !== this.__target)
241
+ dom.setAttribute('data-wikilink', this.__target);
242
+ if (prevNode.__alias !== this.__alias) {
243
+ if (this.__alias === null)
244
+ dom.removeAttribute('data-wikilink-alias');
245
+ else
246
+ dom.setAttribute('data-wikilink-alias', this.__alias);
247
+ }
248
+ if (prevNode.__target !== this.__target || prevNode.__alias !== this.__alias) {
249
+ dom.setAttribute('aria-label', ariaLabel(this.getLink()));
250
+ }
251
+ return false;
252
+ }
253
+ getTarget() {
254
+ return this.getLatest().__target;
255
+ }
256
+ /**
257
+ * Repoint the link. The display text follows only when there is no alias.
258
+ * The value is sanitized (see {@link sanitizeWikiLinkTarget}); a target with
259
+ * nothing representable in it leaves the link untouched rather than producing
260
+ * one that corrupts the document on export.
261
+ */
262
+ setTarget(target) {
263
+ const clean = sanitizeWikiLinkTarget(target);
264
+ if (clean === null)
265
+ return this.getWritable();
266
+ const writable = this.getWritable();
267
+ writable.__target = clean;
268
+ if (writable.__alias === null)
269
+ writable.setTextContent(clean);
270
+ return writable;
271
+ }
272
+ getAlias() {
273
+ return this.getLatest().__alias;
274
+ }
275
+ /** Set (or clear, with `null`) the alias; the display text follows. */
276
+ setAlias(alias) {
277
+ const normalized = sanitizeWikiLinkAlias(alias);
278
+ const writable = this.getWritable();
279
+ writable.__alias = normalized;
280
+ writable.setTextContent(displayText({ target: writable.__target, alias: normalized }));
281
+ return writable;
282
+ }
283
+ getLink() {
284
+ const self = this.getLatest();
285
+ return { target: self.__target, alias: self.__alias };
286
+ }
287
+ // An atomic token: typing against either edge must produce a sibling text
288
+ // node, never extend the wikilink's text (which would desync it from target).
289
+ canInsertTextBefore() {
290
+ return false;
291
+ }
292
+ canInsertTextAfter() {
293
+ return false;
294
+ }
295
+ }
296
+ /**
297
+ * Build a wikilink node. `target`/`alias` are sanitized to values the `[[…]]`
298
+ * syntax can express (see {@link sanitizeWikiLinkTarget}); a target with nothing
299
+ * usable left falls back to the literal text `Page` rather than yielding an
300
+ * invisible token.
301
+ */
302
+ export function $createWikiLinkNode(target, alias = null) {
303
+ const cleanTarget = sanitizeWikiLinkTarget(target) ?? 'Page';
304
+ return $applyNodeReplacement(new WikiLinkNode(cleanTarget, sanitizeWikiLinkAlias(alias)).setMode('token'));
305
+ }
306
+ export function $isWikiLinkNode(node) {
307
+ return node instanceof WikiLinkNode;
308
+ }
309
+ // ── Transformer ──────────────────────────────────────────────────────────────
310
+ const WIKILINK_TRANSFORMER = {
311
+ dependencies: [WikiLinkNode],
312
+ // The THIRD parameter is `exportFormat`, not the `selection` that
313
+ // `ElementTransformer['export']` takes — see `TextMatchTransformer` in
314
+ // @lexical/markdown's MarkdownTransformers.ts, and the call site in
315
+ // MarkdownExport.ts `$exportChildren`, which supplies
316
+ // `(textNode, textContent) => exportTextFormat(...)`.
317
+ //
318
+ // Ignoring it exports a bold wikilink as a bare one, and — worse — TEARS any
319
+ // surrounding formatted run in two, because `$exportChildren` emits our
320
+ // unformatted string in the middle of it: `a **b [[P]] c** d` came back as
321
+ // `a **b** [[P]] **c** d`.
322
+ export: (node, _exportChildren, exportFormat) => {
323
+ if (!$isWikiLinkNode(node))
324
+ return null;
325
+ const markdown = formatWikiLink(node.getLink());
326
+ // Only route through `exportFormat` when there IS a format to apply.
327
+ // `exportTextFormat` backslash-escapes `*_\`~\\` in its input, which for an
328
+ // unformatted link would rewrite targets containing those characters; the
329
+ // raw string keeps the byte-exact round-trip the plugin guarantees.
330
+ return node.getFormat() === 0 ? markdown : exportFormat(node, markdown);
331
+ },
332
+ importRegExp: WIKILINK_IMPORT_RE,
333
+ regExp: WIKILINK_TYPING_RE,
334
+ trigger: ']',
335
+ replace: (textNode, match) => {
336
+ const link = parseWikiLinkInner(match[1] ?? '');
337
+ if (!link)
338
+ return;
339
+ // `LexicalNode.replace()` does NOT carry `__format`/`__style` onto the
340
+ // replacement (verified in lexical@0.48.0 LexicalNode.ts) and
341
+ // `$createWikiLinkNode` builds a node with format 0, so without this the
342
+ // node silently loses bold/italic/strikethrough/underline — falsifying this
343
+ // module's own stated reason for subclassing TextNode ("it must inherit the
344
+ // surrounding text format").
345
+ const replacement = $createWikiLinkNode(link.target, link.alias);
346
+ replacement.setFormat(textNode.getFormat());
347
+ replacement.setStyle(textNode.getStyle());
348
+ textNode.replace(replacement);
349
+ // Returning nothing (rather than the new node) stops `importTextTransformers`
350
+ // from recursing INTO the wikilink: an alias is literal text, so `[[a|**b**]]`
351
+ // must display `**b**`, not bold `b`.
352
+ },
353
+ type: 'text-match',
354
+ };
355
+ // A wikilink must beat upstream's LINK when both match at the SAME index, in
356
+ // EITHER plugin order. LINK's import pattern is `\[(.+?)\]\(…\)` with a LAZY
357
+ // label, so on a line like `**[[A]]** and [b](url)` it matches starting at the
358
+ // wikilink's own `[`, spanning all the way to the later `](`. Tied start index →
359
+ // resolved by array order → listing `wikilinkPlugin()` after `corePlugin()`
360
+ // handed the span to LINK, which swallowed the `**` delimiters and re-exported
361
+ // them ESCAPED: the bold was silently lost.
362
+ //
363
+ // Declaring the precedence makes the wikilink reading structural rather than a
364
+ // documented ordering requirement a consumer can get wrong. The CommonMark
365
+ // reading of `[[a|b]](c)` is still reachable — omit `wikilinkPlugin()`.
366
+ setTransformerPrecedence(WIKILINK_TRANSFORMER, -10);
367
+ const MAX_RESULTS = 8;
368
+ /** Debounce before firing the host's (possibly async) `search` for a query. */
369
+ const SEARCH_DEBOUNCE_MS = 120;
370
+ function toRows(items, index) {
371
+ return items.map((c, i) => ({
372
+ target: c.target,
373
+ title: c.title ?? c.target,
374
+ snippet: c.snippet ?? '',
375
+ preview: c.preview ?? '',
376
+ active: i === index,
377
+ }));
378
+ }
379
+ /** The `[[` query immediately before a collapsed caret, or `null` when the caret
380
+ * is not inside an open `[[…` (no `[[`, or a `]`/`[`/newline already closed it). */
381
+ function $readWikiQuery() {
382
+ const selection = $getSelection();
383
+ if (!$isRangeSelection(selection) || !selection.isCollapsed())
384
+ return null;
385
+ const node = selection.anchor.getNode();
386
+ if (!$isTextNode(node))
387
+ return null;
388
+ const before = node.getTextContent().slice(0, selection.anchor.offset);
389
+ const match = before.match(/\[\[([^[\]\n|]*)$/);
390
+ return match ? (match[1] ?? '') : null;
391
+ }
392
+ /** Viewport point just below the caret, for anchoring the panel. */
393
+ function caretXY() {
394
+ if (typeof window === 'undefined')
395
+ return { x: 0, y: 0 };
396
+ const sel = window.getSelection();
397
+ if (sel && sel.rangeCount > 0) {
398
+ const rect = sel.getRangeAt(0).getBoundingClientRect();
399
+ return { x: rect.left, y: rect.bottom + 4 };
400
+ }
401
+ return { x: 0, y: 0 };
402
+ }
403
+ /** Resolve the wikilink a click landed on, if any. Must run in an editor scope. */
404
+ function $wikiLinkFromEvent(event) {
405
+ const target = event.target;
406
+ if (!(target instanceof Node))
407
+ return null;
408
+ const direct = $getNearestNodeFromDOMNode(target);
409
+ if ($isWikiLinkNode(direct))
410
+ return direct;
411
+ // A click can land on the inner text node of a formatted wikilink; climb to
412
+ // the nearest element carrying our marker attribute and map that back.
413
+ const element = target instanceof Element ? target : target.parentElement;
414
+ const marked = element?.closest('[data-wikilink]');
415
+ if (!marked)
416
+ return null;
417
+ const node = $getNearestNodeFromDOMNode(marked);
418
+ return $isWikiLinkNode(node) ? node : null;
419
+ }
420
+ /**
421
+ * The wikilink the caret is on or immediately beside, if any. Must run in an
422
+ * editor scope. A token-mode node is selected as a whole, so the anchor lands
423
+ * either ON it or on an adjacent sibling.
424
+ */
425
+ function $selectedWikiLink() {
426
+ const selection = $getSelection();
427
+ if (!$isRangeSelection(selection))
428
+ return null;
429
+ const anchor = selection.anchor.getNode();
430
+ if ($isWikiLinkNode(anchor))
431
+ return anchor;
432
+ for (const candidate of [anchor.getPreviousSibling(), anchor.getNextSibling()]) {
433
+ if ($isWikiLinkNode(candidate))
434
+ return candidate;
435
+ }
436
+ return null;
437
+ }
438
+ export function wikilinkPlugin(opts = {}) {
439
+ const placeholderTarget = opts.placeholderTarget ?? 'Page';
440
+ const item = {
441
+ id: PLUGIN,
442
+ label: 'Document link',
443
+ icon: 'wikilink',
444
+ group: 'inline',
445
+ keywords: ['document', 'doc', 'link', 'wiki', 'wikilink', 'backlink', 'reference', '[['],
446
+ run: (editor) => editor.update(() => {
447
+ const selection = $getSelection();
448
+ if (!$isRangeSelection(selection))
449
+ return;
450
+ // A range selection's text can span blocks (newlines) and can contain
451
+ // `|` or `]]` — none of which a `[[…]]` can carry. Sanitizing here, at
452
+ // the boundary where user input enters the document, is what stops the
453
+ // command from minting a link that destroys itself on the next reload.
454
+ const selected = sanitizeWikiLinkTarget(selection.getTextContent());
455
+ // Selected prose becomes the alias of a same-named target, so the
456
+ // sentence reads unchanged and the author only has to fix the target.
457
+ const node = $createWikiLinkNode(selected ?? placeholderTarget, null);
458
+ selection.insertNodes([node]);
459
+ node.selectNext(0, 0);
460
+ }),
461
+ surfaces: ['toolbar', 'floating', 'slash', 'context'],
462
+ };
463
+ return {
464
+ name: PLUGIN,
465
+ nodes: [WikiLinkNode],
466
+ transformers: [WIKILINK_TRANSFORMER],
467
+ items: [item],
468
+ register: (editor, ctx) => {
469
+ const emit = (msg) => ctx.emit({ type: 'plugin', name: PLUGIN, msg });
470
+ const activate = (node) => {
471
+ const { target, alias } = node.getLink();
472
+ emit({ type: 'activate', target, alias });
473
+ return true;
474
+ };
475
+ const navigation = mergeRegister(editor.registerCommand(CLICK_COMMAND, (event) => {
476
+ const node = $wikiLinkFromEvent(event);
477
+ if (!node)
478
+ return false;
479
+ event.preventDefault();
480
+ return activate(node);
481
+ }, COMMAND_PRIORITY_LOW),
482
+ // Keyboard parity. A wikilink is token-mode text, so the caret can rest
483
+ // on it, but CLICK_COMMAND is unreachable without a pointer — activation
484
+ // was mouse-only. Mod+Enter (rather than bare Enter) keeps the ordinary
485
+ // paragraph-splitting Enter intact while the caret sits beside a link.
486
+ editor.registerCommand(KEY_ENTER_COMMAND, (event) => {
487
+ if (!event || !(event.metaKey || event.ctrlKey))
488
+ return false;
489
+ const node = $selectedWikiLink();
490
+ if (!node)
491
+ return false;
492
+ event.preventDefault();
493
+ return activate(node);
494
+ }, COMMAND_PRIORITY_LOW));
495
+ // Document-search typeahead is opt-in: with no `search` seam the panel never
496
+ // opens and `[[target]]` still works by typing the closing `]]`.
497
+ const search = opts.search;
498
+ if (search === undefined)
499
+ return navigation;
500
+ let seq = 0;
501
+ let timer = null;
502
+ const clearTimer = () => {
503
+ if (timer !== null) {
504
+ clearTimeout(timer);
505
+ timer = null;
506
+ }
507
+ };
508
+ const activeQuery = () => editor.getEditorState().read(() => $readWikiQuery());
509
+ const isActive = () => activeQuery() !== null;
510
+ const refresh = () => {
511
+ const query = activeQuery();
512
+ if (query === null) {
513
+ clearTimer();
514
+ emit({ type: 'searchHide' });
515
+ return;
516
+ }
517
+ clearTimer();
518
+ const mine = ++seq;
519
+ timer = setTimeout(() => {
520
+ void Promise.resolve(search(query))
521
+ .then((results) => {
522
+ // Drop a superseded response (a later keystroke won) or one whose
523
+ // query the caret has since left/changed.
524
+ if (mine !== seq || activeQuery() !== query)
525
+ return;
526
+ const { x, y } = caretXY();
527
+ emit({ type: 'searchShow', query, items: results.slice(0, MAX_RESULTS), x, y });
528
+ })
529
+ .catch(() => {
530
+ /* a failed search simply shows nothing */
531
+ });
532
+ }, SEARCH_DEBOUNCE_MS);
533
+ };
534
+ const nav = (delta, e) => {
535
+ if (!isActive())
536
+ return false;
537
+ e?.preventDefault();
538
+ emit({ type: 'searchMove', delta });
539
+ return true;
540
+ };
541
+ return mergeRegister(navigation, editor.registerUpdateListener(() => refresh()), editor.registerCommand(KEY_ARROW_DOWN_COMMAND, (e) => nav(1, e), COMMAND_PRIORITY_HIGH), editor.registerCommand(KEY_ARROW_UP_COMMAND, (e) => nav(-1, e), COMMAND_PRIORITY_HIGH), editor.registerCommand(KEY_ENTER_COMMAND, (e) => {
542
+ if (!isActive())
543
+ return false;
544
+ e?.preventDefault();
545
+ emit({ type: 'searchChoose' });
546
+ return true;
547
+ }, COMMAND_PRIORITY_HIGH), editor.registerCommand(KEY_ESCAPE_COMMAND, () => {
548
+ if (!isActive())
549
+ return false;
550
+ emit({ type: 'searchHide' });
551
+ return true;
552
+ }, COMMAND_PRIORITY_HIGH), clearTimer);
553
+ },
554
+ ui: definePluginUI({
555
+ init: () => ({
556
+ last: null,
557
+ searchOpen: false,
558
+ searchQuery: '',
559
+ searchItems: [],
560
+ searchIndex: 0,
561
+ searchX: 0,
562
+ searchY: 0,
563
+ }),
564
+ update: (state, msg) => {
565
+ switch (msg.type) {
566
+ case 'activate': {
567
+ const link = { target: msg.target, alias: msg.alias };
568
+ return [{ ...state, last: link }, [{ type: 'navigate', link }]];
569
+ }
570
+ case 'searchShow':
571
+ return {
572
+ ...state,
573
+ searchOpen: msg.items.length > 0,
574
+ searchQuery: msg.query,
575
+ searchItems: toRows(msg.items, 0),
576
+ searchIndex: 0,
577
+ searchX: msg.x,
578
+ searchY: msg.y,
579
+ };
580
+ case 'searchHide':
581
+ return state.searchOpen ? { ...state, searchOpen: false } : state;
582
+ case 'searchMove': {
583
+ if (!state.searchOpen || state.searchItems.length === 0)
584
+ return state;
585
+ const i = (state.searchIndex + msg.delta + state.searchItems.length) % state.searchItems.length;
586
+ return {
587
+ ...state,
588
+ searchIndex: i,
589
+ searchItems: state.searchItems.map((r, idx) => ({ ...r, active: idx === i })),
590
+ };
591
+ }
592
+ case 'searchHover': {
593
+ if (msg.index < 0 || msg.index >= state.searchItems.length)
594
+ return state;
595
+ return {
596
+ ...state,
597
+ searchIndex: msg.index,
598
+ searchItems: state.searchItems.map((r, idx) => ({ ...r, active: idx === msg.index })),
599
+ };
600
+ }
601
+ case 'searchChoose': {
602
+ const row = state.searchItems[state.searchIndex];
603
+ if (!state.searchOpen || !row)
604
+ return { ...state, searchOpen: false };
605
+ return [
606
+ { ...state, searchOpen: false },
607
+ [{ type: 'insert', target: row.target, query: state.searchQuery }],
608
+ ];
609
+ }
610
+ case 'searchClick': {
611
+ const row = state.searchItems[msg.index];
612
+ if (!row)
613
+ return state;
614
+ return [
615
+ { ...state, searchOpen: false },
616
+ [{ type: 'insert', target: row.target, query: state.searchQuery }],
617
+ ];
618
+ }
619
+ }
620
+ },
621
+ onEffect: (effect, ctx) => {
622
+ if (effect.type === 'navigate') {
623
+ opts.onNavigate?.(effect.link);
624
+ return;
625
+ }
626
+ // insert: replace the typed `[[query` with a wikilink node + a space.
627
+ const editor = ctx.editor();
628
+ if (!editor)
629
+ return;
630
+ editor.update(() => {
631
+ const selection = $getSelection();
632
+ if (!$isRangeSelection(selection) || !selection.isCollapsed())
633
+ return;
634
+ const node = selection.anchor.getNode();
635
+ if (!$isTextNode(node))
636
+ return;
637
+ const offset = selection.anchor.offset;
638
+ // Remove the `[[` (2 chars) plus the query text.
639
+ const start = offset - effect.query.length - 2;
640
+ if (start < 0)
641
+ return;
642
+ node.spliceText(start, effect.query.length + 2, '', true);
643
+ const sel = $getSelection();
644
+ if (!$isRangeSelection(sel))
645
+ return;
646
+ sel.insertNodes([$createWikiLinkNode(effect.target, null), $createTextNode(' ')]);
647
+ });
648
+ },
649
+ view: ({ state, send }) => overlayRoot({
650
+ open: state.at('searchOpen'),
651
+ x: state.at('searchX'),
652
+ y: state.at('searchY'),
653
+ zIndex: OVERLAY_Z.typeahead,
654
+ attrs: { 'data-scope': 'md-wikilink', 'data-part': 'panel-root' },
655
+ children: () => [
656
+ div({ 'data-scope': 'md-wikilink', 'data-part': 'panel' }, [
657
+ div({ 'data-scope': 'md-wikilink', 'data-part': 'results', role: 'listbox' }, [
658
+ each(state.at('searchItems'), {
659
+ key: (r) => r.target,
660
+ render: (item, index) => [
661
+ div({
662
+ 'data-scope': 'md-wikilink',
663
+ 'data-part': 'result',
664
+ role: 'option',
665
+ 'data-active': item.map((r) => (r.active ? '' : undefined)),
666
+ onMouseDown: (e) => {
667
+ e.preventDefault();
668
+ send({ type: 'searchClick', index: index.peek() });
669
+ },
670
+ onMouseEnter: () => send({ type: 'searchHover', index: index.peek() }),
671
+ }, [
672
+ div({ 'data-scope': 'md-wikilink', 'data-part': 'result-title' }, [
673
+ text(item.map((r) => r.title)),
674
+ ]),
675
+ div({ 'data-scope': 'md-wikilink', 'data-part': 'result-snippet' }, [
676
+ text(item.map((r) => r.snippet)),
677
+ ]),
678
+ ]),
679
+ ],
680
+ }),
681
+ ]),
682
+ // The reference pane: the active candidate's content preview.
683
+ div({ 'data-scope': 'md-wikilink', 'data-part': 'preview' }, [
684
+ text(state.map((s) => s.searchItems[s.searchIndex]?.preview ?? '')),
685
+ ]),
686
+ ]),
687
+ ],
688
+ }),
689
+ }),
690
+ };
691
+ }
692
+ //# sourceMappingURL=wikilink.js.map