@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.
- package/dist/editor.d.ts +15 -2
- package/dist/editor.d.ts.map +1 -1
- package/dist/editor.js +12 -2
- package/dist/editor.js.map +1 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15 -1
- package/dist/index.js.map +1 -1
- package/dist/plugins/block-drag.d.ts +74 -0
- package/dist/plugins/block-drag.d.ts.map +1 -0
- package/dist/plugins/block-drag.js +983 -0
- package/dist/plugins/block-drag.js.map +1 -0
- package/dist/plugins/code-language.d.ts +60 -0
- package/dist/plugins/code-language.d.ts.map +1 -0
- package/dist/plugins/code-language.js +308 -0
- package/dist/plugins/code-language.js.map +1 -0
- package/dist/plugins/frontmatter.d.ts +45 -0
- package/dist/plugins/frontmatter.d.ts.map +1 -0
- package/dist/plugins/frontmatter.js +281 -0
- package/dist/plugins/frontmatter.js.map +1 -0
- package/dist/plugins/overlay.d.ts +1 -0
- package/dist/plugins/overlay.d.ts.map +1 -1
- package/dist/plugins/overlay.js +7 -0
- package/dist/plugins/overlay.js.map +1 -1
- package/dist/plugins/wikilink.d.ts +110 -0
- package/dist/plugins/wikilink.d.ts.map +1 -0
- package/dist/plugins/wikilink.js +692 -0
- package/dist/plugins/wikilink.js.map +1 -0
- package/dist/styles/block-drag.css +118 -0
- package/dist/styles/editor.css +112 -0
- package/dist/transformers/code.d.ts +24 -0
- package/dist/transformers/code.d.ts.map +1 -0
- package/dist/transformers/code.js +167 -0
- package/dist/transformers/code.js.map +1 -0
- package/dist/transformers/gfm.d.ts.map +1 -1
- package/dist/transformers/gfm.js +7 -2
- package/dist/transformers/gfm.js.map +1 -1
- package/dist/transformers/registry.d.ts +3 -0
- package/dist/transformers/registry.d.ts.map +1 -1
- package/dist/transformers/registry.js +26 -0
- package/dist/transformers/registry.js.map +1 -1
- package/package.json +42 -28
- package/src/editor.ts +27 -4
- package/src/index.ts +58 -1
- package/src/plugins/block-drag.ts +1175 -0
- package/src/plugins/code-language.ts +378 -0
- package/src/plugins/frontmatter.ts +324 -0
- package/src/plugins/overlay.ts +7 -0
- package/src/plugins/wikilink.ts +843 -0
- package/src/styles/block-drag.css +118 -0
- package/src/styles/editor.css +112 -0
- package/src/transformers/code.ts +174 -0
- package/src/transformers/gfm.ts +6 -2
- 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
|