@liminis/editor 0.4.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/README.md +77 -8
- package/dist/app/editor/LinkClickPlugin.js +5 -1
- package/dist/app/editor/WikiLinkExistencePlugin.d.ts +13 -3
- package/dist/app/editor/WikiLinkExistencePlugin.js +91 -32
- package/dist/app/editor/editorNodes.js +2 -1
- package/dist/app/editor/nodes/CustomLinkNode.d.ts +14 -0
- package/dist/app/editor/nodes/CustomLinkNode.js +37 -0
- package/dist/app/editor/nodes/TransclusionComponent.d.ts +8 -0
- package/dist/app/editor/nodes/TransclusionComponent.js +65 -0
- package/dist/app/editor/nodes/TransclusionNode.d.ts +48 -0
- package/dist/app/editor/nodes/TransclusionNode.js +141 -0
- package/dist/app/editor/nodes/index.d.ts +2 -0
- package/dist/app/editor/nodes/index.js +1 -0
- package/dist/app/editor/nodes/transclusion-loading.d.ts +27 -0
- package/dist/app/editor/nodes/transclusion-loading.js +29 -0
- package/dist/app/editor/nodes/transclusion-render.d.ts +49 -0
- package/dist/app/editor/nodes/transclusion-render.js +186 -0
- package/dist/app/mapper/lexicalToMdast.js +55 -6
- package/dist/app/mapper/mdastToLexical.js +67 -1
- package/dist/host/defaults.js +1 -0
- package/dist/host/messages.d.ts +7 -1
- package/dist/host/messages.js +3 -3
- package/dist/host/types.d.ts +14 -1
- package/dist/markdown/parse.js +225 -1
- package/dist/markdown/stringify.js +30 -10
- package/dist/markdown/vendor/mdast-util-wiki-link/README.md +17 -0
- package/dist/markdown/vendor/mdast-util-wiki-link/from-markdown.d.ts +8 -1
- package/dist/markdown/vendor/mdast-util-wiki-link/from-markdown.js +26 -1
- package/dist/markdown/vendor/mdast-util-wiki-link/to-markdown.d.ts +13 -7
- package/dist/markdown/vendor/mdast-util-wiki-link/to-markdown.js +3 -1
- package/dist/styles.css +48 -0
- package/dist/types.d.ts +1 -0
- package/docs/architecture.md +80 -0
- package/docs/decisions/adr-119-block-transclusion.md +247 -0
- package/docs/diagrams/architecture-1-dark.svg +1 -0
- package/docs/diagrams/architecture-1.svg +1 -0
- package/docs/diagrams/architecture-2-dark.svg +1 -0
- package/docs/diagrams/architecture-2.svg +1 -0
- package/docs/editor-api.md +1 -0
- package/docs/markdown-pipeline.md +110 -6
- package/package.json +6 -4
package/README.md
CHANGED
|
@@ -8,14 +8,16 @@ It is the editor from [Liminis](https://github.com/verveguy/liminis), extracted
|
|
|
8
8
|
into this repository so it can stand on its own. MIT-licensed.
|
|
9
9
|
|
|
10
10
|
Its history, and how to read the `ADR-0NNN`, `FR-NNN` and `#NNN` references that
|
|
11
|
-
travelled with the code, are recorded in [`docs/provenance.md`](./docs/provenance.md)
|
|
11
|
+
travelled with the code, are recorded in [`docs/provenance.md`](./docs/provenance.md)
|
|
12
|
+
([on the docs site](https://v3rv.com/liminis-editor/guide/provenance/)).
|
|
12
13
|
|
|
13
14
|
## What you get
|
|
14
15
|
|
|
15
16
|
- **`<Editor>`** — a WYSIWYG markdown editor. Tables, task lists (including in
|
|
16
17
|
ordered lists), footnotes, definition lists, callouts, toggles, code blocks
|
|
17
18
|
with Prism highlighting, images, LaTeX equations, Mermaid diagrams, C4
|
|
18
|
-
diagrams, YAML frontmatter, and wiki-links
|
|
19
|
+
diagrams, YAML frontmatter, and wiki-links — including block-scoped links
|
|
20
|
+
and live transclusion (`[[file#^id]]` / `![[file#^id]]`).
|
|
19
21
|
- **A markdown pipeline** — `parseMarkdown` / `stringifyMarkdown` and the mdast
|
|
20
22
|
↔ Lexical mappers, usable with no editor mounted.
|
|
21
23
|
- **An annotation mechanism** — range-anchored markers over document text that
|
|
@@ -441,6 +443,49 @@ read-only editor still shows the floating toolbar with the configured
|
|
|
441
443
|
affordance (formatting controls are omitted there, since they would be
|
|
442
444
|
inert).
|
|
443
445
|
|
|
446
|
+
## Block-scoped links and transclusion
|
|
447
|
+
|
|
448
|
+
Wiki-links (`[[target]]` / `[[target|alias]]`) extend to an optional
|
|
449
|
+
Obsidian-style block-id fragment: `[[file#^id]]` links to one specific block
|
|
450
|
+
inside a file rather than the file as a whole, and `![[file#^id]]`
|
|
451
|
+
**transcludes** it — renders that block's actual, current content inline at
|
|
452
|
+
the reference site. This is a live view, not a copy: if the source block's
|
|
453
|
+
text changes, every transclusion of it reflects that on next render.
|
|
454
|
+
|
|
455
|
+
Resolving `file#^id` to content is host work, through one optional injected
|
|
456
|
+
function:
|
|
457
|
+
|
|
458
|
+
```tsx
|
|
459
|
+
<EditorHostProvider services={{ resolveTransclusion: async (file, blockId) => {
|
|
460
|
+
// look up the block by id across your whole corpus — ids are workspace-global,
|
|
461
|
+
// not scoped to one file (matching Liminis's own `^ULID` convention)
|
|
462
|
+
return lookupBlockContent(file, blockId) // string | null
|
|
463
|
+
} }}>
|
|
464
|
+
<Editor initialContent={markdown} onChange={setMarkdown} />
|
|
465
|
+
</EditorHostProvider>
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
With no resolver injected, or one that returns `null`, `![[file#^id]]`
|
|
469
|
+
renders a clearly marked "unresolved" placeholder rather than throwing —
|
|
470
|
+
consistent with every other host service in this package. A transclusion
|
|
471
|
+
cycle (block A transcludes B, which transcludes A — directly or through a
|
|
472
|
+
longer chain) is detected and rendered as a "circular transclusion"
|
|
473
|
+
indicator rather than hanging; nested transclusion is supported to a bounded
|
|
474
|
+
depth, beyond which it degrades to a clear fallback rather than a crash.
|
|
475
|
+
|
|
476
|
+
**If you are maintaining this package: do not remove the cycle/depth guard**
|
|
477
|
+
in `src/app/editor/nodes/transclusion-render.tsx`, and **do not loosen the
|
|
478
|
+
embed-marker detection** in `src/markdown/parse.ts` to a bare `!` lookahead
|
|
479
|
+
— see `docs/markdown-pipeline.md`'s "Block-scoped links and transclusion"
|
|
480
|
+
section for what each guards against and the regression fixture that pins
|
|
481
|
+
it down.
|
|
482
|
+
|
|
483
|
+
`[[file#^id]]` (link-only, no `!`) instead requests navigation to that
|
|
484
|
+
specific block when the host supports it, and degrades no worse than
|
|
485
|
+
today's file-only wiki-link navigation when it doesn't — no extra host
|
|
486
|
+
wiring required beyond the resolver above, which also backs its
|
|
487
|
+
"does this block exist" styling.
|
|
488
|
+
|
|
444
489
|
## Documentation
|
|
445
490
|
|
|
446
491
|
- [`docs/editor-api.md`](./docs/editor-api.md) — the `<Editor>` props and the
|
|
@@ -490,12 +535,35 @@ over the same round-trip fixture corpus the package's own test suite uses, so
|
|
|
490
535
|
every fixture-representable node class renders somewhere in it. See
|
|
491
536
|
[`examples/demo/README.md`](./examples/demo/README.md).
|
|
492
537
|
|
|
493
|
-
`examples/demo` is also the
|
|
538
|
+
`examples/demo` is also the demo on the public GitHub Pages site, at
|
|
539
|
+
**[v3rv.com/liminis-editor/demo/](https://v3rv.com/liminis-editor/demo/)** — a
|
|
494
540
|
`release`-triggered (not merge-triggered) build of this same shell, showing a
|
|
495
|
-
visible version badge for the published release it represents
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
541
|
+
visible version badge for the published release it represents. Between releases
|
|
542
|
+
the deployed site stays on the last published version even as `main` keeps
|
|
543
|
+
moving — that staleness is intentional, not a bug (see
|
|
544
|
+
`docs/decisions/adr-082.md`).
|
|
545
|
+
|
|
546
|
+
## Documentation site
|
|
547
|
+
|
|
548
|
+
**[v3rv.com/liminis-editor](https://v3rv.com/liminis-editor/)** publishes the
|
|
549
|
+
pages in [`docs/`](./docs/) with search, cross-links, and the C4 diagrams
|
|
550
|
+
rendered live rather than as pictures.
|
|
551
|
+
|
|
552
|
+
`docs/*.md` remains the source of truth and is not moved or modified for the
|
|
553
|
+
site: it ships inside the published tarball, `tests/adr-citations.test.ts` walks
|
|
554
|
+
it, and it is cited by path from ADRs, the CHANGELOG and source comments.
|
|
555
|
+
`site/scripts/sync-docs.mjs` generates the site's copy from it, taking each
|
|
556
|
+
page's title from its H1.
|
|
557
|
+
|
|
558
|
+
```bash
|
|
559
|
+
pnpm --dir site demo # build the demo and stage it at /demo/ (once)
|
|
560
|
+
pnpm --dir site dev # serve the docs at http://localhost:4321/liminis-editor/
|
|
561
|
+
pnpm --dir site diagrams # re-render the committed SVGs after editing a ```c4 fence
|
|
562
|
+
```
|
|
563
|
+
|
|
564
|
+
`site/` is its own package, deliberately not a workspace member — the same
|
|
565
|
+
reasoning as `examples/`. An Astro toolchain has no business in the dev install
|
|
566
|
+
CI runs for lint, typecheck and test.
|
|
499
567
|
|
|
500
568
|
## Electron e2e shell
|
|
501
569
|
|
|
@@ -510,7 +578,8 @@ application, to exist. See
|
|
|
510
578
|
```bash
|
|
511
579
|
pnpm build:examples # builds and packs the package once, then builds both
|
|
512
580
|
# examples/demo and examples/electron against it
|
|
513
|
-
pnpm build:site # builds examples/demo only —
|
|
581
|
+
pnpm build:site # builds examples/demo only — wrapped by the site's
|
|
582
|
+
# `pnpm --dir site demo`, which the release-triggered
|
|
514
583
|
# Pages deploy runs (see the Demo section above)
|
|
515
584
|
```
|
|
516
585
|
|
|
@@ -43,7 +43,11 @@ export function LinkClickPlugin({ editable = true }) {
|
|
|
43
43
|
// URL is stored in data-href (not href) to prevent webview interception
|
|
44
44
|
const url = linkElement.getAttribute('data-href');
|
|
45
45
|
if (url) {
|
|
46
|
-
|
|
46
|
+
// Block-scoped link (#119): additive, so a host that hasn't
|
|
47
|
+
// implemented block-aware navigation still gets `url` and opens
|
|
48
|
+
// the file exactly as before (FR-004).
|
|
49
|
+
const blockId = linkElement.getAttribute('data-block-id') ?? undefined;
|
|
50
|
+
openLink(url, blockId);
|
|
47
51
|
}
|
|
48
52
|
}
|
|
49
53
|
// In editable mode without modifier: No action needed - since there's no href,
|
|
@@ -2,14 +2,24 @@
|
|
|
2
2
|
* WikiLinkExistencePlugin - Checks wiki-links and marks broken ones
|
|
3
3
|
*
|
|
4
4
|
* This plugin scans for wiki-links in the editor DOM and checks if their
|
|
5
|
-
* target files
|
|
6
|
-
*
|
|
5
|
+
* target files (or, for a block-scoped link, target block — #119) exist
|
|
6
|
+
* anywhere in the workspace. Links that don't resolve get a CSS class
|
|
7
|
+
* applied to render them in red.
|
|
7
8
|
*
|
|
8
|
-
*
|
|
9
|
+
* A plain file-only link (`data-wiki-target`, no `data-block-id`) uses the
|
|
10
|
+
* host-supplied `resolveWikiLinks` service, which handles:
|
|
9
11
|
* - Directory links (e.g., "entities/teams/") → resolves to index.md or README.md
|
|
10
12
|
* - File links with extension (e.g., "notes.md") → checks directly
|
|
11
13
|
* - File links without extension (e.g., "notes") → tries .md, .mdc
|
|
12
14
|
*
|
|
15
|
+
* A block-scoped link (`data-block-id` present, `[[file#^id]]`) instead
|
|
16
|
+
* checks via `resolveTransclusion` — the same host resolver transclusion
|
|
17
|
+
* uses for content, per the Plan's "one resolver, two consumers" decision.
|
|
18
|
+
* `resolveTransclusion` returns content, not a boolean, but existence is
|
|
19
|
+
* exactly "did this resolve to something non-null" (FR-004's edge case: an
|
|
20
|
+
* id that doesn't exist is unresolved, not an error, with the same styling
|
|
21
|
+
* an unresolved file-only wikilink already gets).
|
|
22
|
+
*
|
|
13
23
|
* The check is performed:
|
|
14
24
|
* - When the document is loaded
|
|
15
25
|
* - When the document content changes (debounced)
|
|
@@ -5,21 +5,31 @@ import { useEditorHost } from '../../host/context.js';
|
|
|
5
5
|
* WikiLinkExistencePlugin - Checks wiki-links and marks broken ones
|
|
6
6
|
*
|
|
7
7
|
* This plugin scans for wiki-links in the editor DOM and checks if their
|
|
8
|
-
* target files
|
|
9
|
-
*
|
|
8
|
+
* target files (or, for a block-scoped link, target block — #119) exist
|
|
9
|
+
* anywhere in the workspace. Links that don't resolve get a CSS class
|
|
10
|
+
* applied to render them in red.
|
|
10
11
|
*
|
|
11
|
-
*
|
|
12
|
+
* A plain file-only link (`data-wiki-target`, no `data-block-id`) uses the
|
|
13
|
+
* host-supplied `resolveWikiLinks` service, which handles:
|
|
12
14
|
* - Directory links (e.g., "entities/teams/") → resolves to index.md or README.md
|
|
13
15
|
* - File links with extension (e.g., "notes.md") → checks directly
|
|
14
16
|
* - File links without extension (e.g., "notes") → tries .md, .mdc
|
|
15
17
|
*
|
|
18
|
+
* A block-scoped link (`data-block-id` present, `[[file#^id]]`) instead
|
|
19
|
+
* checks via `resolveTransclusion` — the same host resolver transclusion
|
|
20
|
+
* uses for content, per the Plan's "one resolver, two consumers" decision.
|
|
21
|
+
* `resolveTransclusion` returns content, not a boolean, but existence is
|
|
22
|
+
* exactly "did this resolve to something non-null" (FR-004's edge case: an
|
|
23
|
+
* id that doesn't exist is unresolved, not an error, with the same styling
|
|
24
|
+
* an unresolved file-only wikilink already gets).
|
|
25
|
+
*
|
|
16
26
|
* The check is performed:
|
|
17
27
|
* - When the document is loaded
|
|
18
28
|
* - When the document content changes (debounced)
|
|
19
29
|
*/
|
|
20
30
|
export function WikiLinkExistencePlugin() {
|
|
21
31
|
const [editor] = useLexicalComposerContext();
|
|
22
|
-
const { resolveWikiLinks } = useEditorHost();
|
|
32
|
+
const { resolveWikiLinks, resolveTransclusion } = useEditorHost();
|
|
23
33
|
const checkTimeoutRef = useRef(null);
|
|
24
34
|
const lastCheckedRef = useRef(new Set());
|
|
25
35
|
useEffect(() => {
|
|
@@ -27,47 +37,48 @@ export function WikiLinkExistencePlugin() {
|
|
|
27
37
|
if (!rootElement)
|
|
28
38
|
return;
|
|
29
39
|
const checkWikiLinks = async () => {
|
|
30
|
-
// Find all wiki-link elements
|
|
40
|
+
// Find all wiki-link elements, split into plain (file-only) and
|
|
41
|
+
// block-scoped (carrying data-block-id) groups.
|
|
31
42
|
const wikiLinks = rootElement.querySelectorAll('a[data-wiki-link="true"]');
|
|
32
43
|
if (wikiLinks.length === 0)
|
|
33
44
|
return;
|
|
34
|
-
|
|
35
|
-
const
|
|
45
|
+
const plainTargets = new Set();
|
|
46
|
+
const blockRefs = new Set();
|
|
36
47
|
wikiLinks.forEach((link) => {
|
|
37
48
|
const target = link.getAttribute('data-wiki-target');
|
|
38
|
-
if (target)
|
|
39
|
-
|
|
49
|
+
if (!target)
|
|
50
|
+
return;
|
|
51
|
+
const blockId = link.getAttribute('data-block-id');
|
|
52
|
+
if (blockId) {
|
|
53
|
+
blockRefs.add(`${target}#^${blockId}`);
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
plainTargets.add(target);
|
|
40
57
|
}
|
|
41
58
|
});
|
|
42
|
-
if (
|
|
59
|
+
if (plainTargets.size === 0 && blockRefs.size === 0)
|
|
43
60
|
return;
|
|
44
|
-
// Skip if we've already checked
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
if (lastCheckedRef.current.has(targetKey)) {
|
|
61
|
+
// Skip if we've already checked this exact combination of targets.
|
|
62
|
+
const checkKey = [...plainTargets, ...blockRefs].sort().join('|');
|
|
63
|
+
if (lastCheckedRef.current.has(checkKey)) {
|
|
48
64
|
return;
|
|
49
65
|
}
|
|
50
|
-
// Use the host-supplied resolver which handles directory links, etc.
|
|
51
66
|
try {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
}
|
|
56
|
-
// Resolve all wiki-link paths
|
|
57
|
-
const resolved = await resolveWikiLinks(targetsArray);
|
|
58
|
-
lastCheckedRef.current.add(targetKey);
|
|
67
|
+
const plainResolved = await resolvePlainTargets(plainTargets, resolveWikiLinks);
|
|
68
|
+
const blockResolved = await resolveBlockRefs(blockRefs, resolveTransclusion);
|
|
69
|
+
lastCheckedRef.current.add(checkKey);
|
|
59
70
|
// Update CSS classes on wiki-links
|
|
60
71
|
wikiLinks.forEach((link) => {
|
|
61
72
|
const target = link.getAttribute('data-wiki-target');
|
|
62
|
-
if (target)
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
73
|
+
if (!target)
|
|
74
|
+
return;
|
|
75
|
+
const blockId = link.getAttribute('data-block-id');
|
|
76
|
+
const exists = blockId ? blockResolved.get(`${target}#^${blockId}`) : plainResolved.get(target);
|
|
77
|
+
if (exists === false) {
|
|
78
|
+
link.classList.add('editor-link-broken');
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
link.classList.remove('editor-link-broken');
|
|
71
82
|
}
|
|
72
83
|
});
|
|
73
84
|
}
|
|
@@ -99,6 +110,54 @@ export function WikiLinkExistencePlugin() {
|
|
|
99
110
|
clearTimeout(checkTimeoutRef.current);
|
|
100
111
|
}
|
|
101
112
|
};
|
|
102
|
-
}, [editor, resolveWikiLinks]);
|
|
113
|
+
}, [editor, resolveWikiLinks, resolveTransclusion]);
|
|
103
114
|
return null;
|
|
104
115
|
}
|
|
116
|
+
/**
|
|
117
|
+
* Resolve plain (file-only) wiki-link targets via `resolveWikiLinks`.
|
|
118
|
+
* Absent the resolver (or no targets to check), every target is left
|
|
119
|
+
* unresolved-status `undefined` so the caller leaves its styling alone
|
|
120
|
+
* rather than marking it broken — consistent with the rest of this host
|
|
121
|
+
* seam's "service missing = feature unavailable, do nothing" convention.
|
|
122
|
+
*/
|
|
123
|
+
async function resolvePlainTargets(targets, resolveWikiLinks) {
|
|
124
|
+
const result = new Map();
|
|
125
|
+
if (targets.size === 0 || !resolveWikiLinks) {
|
|
126
|
+
return result;
|
|
127
|
+
}
|
|
128
|
+
const resolved = await resolveWikiLinks([...targets]);
|
|
129
|
+
for (const target of targets) {
|
|
130
|
+
// A missing key (as opposed to an explicit `null`) is treated as
|
|
131
|
+
// existing, matching this plugin's pre-#119 behavior — resolveWikiLinks
|
|
132
|
+
// isn't contractually required to return an entry for every target, and
|
|
133
|
+
// FR-014 requires plain wikilink resolution to be unaffected by this
|
|
134
|
+
// feature.
|
|
135
|
+
result.set(target, resolved[target] !== null);
|
|
136
|
+
}
|
|
137
|
+
return result;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Resolve block-scoped references (`file#^blockId` keys) via
|
|
141
|
+
* `resolveTransclusion`, one call per unique reference (the resolver's
|
|
142
|
+
* contract is single-reference, unlike `resolveWikiLinks`'s batch shape).
|
|
143
|
+
*/
|
|
144
|
+
async function resolveBlockRefs(refs, resolveTransclusion) {
|
|
145
|
+
const result = new Map();
|
|
146
|
+
if (refs.size === 0 || !resolveTransclusion) {
|
|
147
|
+
return result;
|
|
148
|
+
}
|
|
149
|
+
await Promise.all([...refs].map(async (ref) => {
|
|
150
|
+
const separatorIndex = ref.indexOf('#^');
|
|
151
|
+
const file = ref.slice(0, separatorIndex);
|
|
152
|
+
const blockId = ref.slice(separatorIndex + 2);
|
|
153
|
+
try {
|
|
154
|
+
const content = await resolveTransclusion(file, blockId);
|
|
155
|
+
result.set(ref, content !== null && content !== undefined);
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
// FR-009: a rejected resolver is treated as "unresolved", not an error.
|
|
159
|
+
result.set(ref, false);
|
|
160
|
+
}
|
|
161
|
+
}));
|
|
162
|
+
return result;
|
|
163
|
+
}
|
|
@@ -3,7 +3,7 @@ import { CodeNode, CodeHighlightNode } from '@lexical/code';
|
|
|
3
3
|
import { AutoLinkNode } from '@lexical/link';
|
|
4
4
|
import { MarkNode } from '@lexical/mark';
|
|
5
5
|
import { TableNode, TableRowNode, TableCellNode } from '@lexical/table';
|
|
6
|
-
import { CalloutNode, ToggleContainerNode, ToggleTitleNode, ToggleContentNode, ImageNode, HorizontalRuleNode, EquationNode, MermaidNode, C4Node, FrontmatterNode, FootnoteNode, HtmlNode, ListItemParagraphBreakNode, CustomLinkNode, CustomListNode, DefinitionListNode, DefinitionTermNode, DefinitionDescriptionNode, CustomListItemNode, } from './nodes/index.js';
|
|
6
|
+
import { CalloutNode, ToggleContainerNode, ToggleTitleNode, ToggleContentNode, ImageNode, HorizontalRuleNode, EquationNode, MermaidNode, C4Node, FrontmatterNode, FootnoteNode, HtmlNode, ListItemParagraphBreakNode, CustomLinkNode, CustomListNode, DefinitionListNode, DefinitionTermNode, DefinitionDescriptionNode, CustomListItemNode, TransclusionNode, } from './nodes/index.js';
|
|
7
7
|
export const editorNodes = [
|
|
8
8
|
HeadingNode,
|
|
9
9
|
QuoteNode,
|
|
@@ -24,6 +24,7 @@ export const editorNodes = [
|
|
|
24
24
|
HorizontalRuleNode,
|
|
25
25
|
EquationNode,
|
|
26
26
|
MermaidNode,
|
|
27
|
+
TransclusionNode,
|
|
27
28
|
C4Node,
|
|
28
29
|
FrontmatterNode,
|
|
29
30
|
FootnoteNode,
|
|
@@ -3,6 +3,10 @@ import { DOMConversionMap, EditorConfig, LexicalNode } from 'lexical';
|
|
|
3
3
|
export type SerializedCustomLinkNode = SerializedLinkNode & {
|
|
4
4
|
wikiAliasState?: 'empty';
|
|
5
5
|
wikiLinkOrigin?: true;
|
|
6
|
+
/** Obsidian-style `#^blockId` fragment (#119), carried as a field separate
|
|
7
|
+
* from `url` so it never has to round-trip through the lossy `.md#`
|
|
8
|
+
* URL-string channel the plain anchor-link path uses. */
|
|
9
|
+
blockId?: string;
|
|
6
10
|
};
|
|
7
11
|
/**
|
|
8
12
|
* CustomLinkNode - Extends Lexical's LinkNode to prevent VS Code webview link interception
|
|
@@ -24,6 +28,14 @@ export declare class CustomLinkNode extends LinkNode {
|
|
|
24
28
|
* @internal
|
|
25
29
|
*/
|
|
26
30
|
__wikiLinkOrigin: boolean;
|
|
31
|
+
/**
|
|
32
|
+
* Obsidian-style `#^blockId` fragment (#119), when this link is a
|
|
33
|
+
* block-scoped wiki-link (`[[file#^id]]`). `null` for an ordinary
|
|
34
|
+
* file-only or heading-anchor wiki-link. Deliberately a field separate
|
|
35
|
+
* from `__url` — see `mdastToLexical.ts`/`lexicalToMdast.ts` for why.
|
|
36
|
+
* @internal
|
|
37
|
+
*/
|
|
38
|
+
__blockId: string | null;
|
|
27
39
|
constructor(url: string, attributes?: {
|
|
28
40
|
rel?: null | string;
|
|
29
41
|
target?: null | string;
|
|
@@ -48,6 +60,8 @@ export declare class CustomLinkNode extends LinkNode {
|
|
|
48
60
|
getWikiAliasState(): 'empty' | null;
|
|
49
61
|
setWikiLinkOrigin(origin: boolean): void;
|
|
50
62
|
getWikiLinkOrigin(): boolean;
|
|
63
|
+
setBlockId(blockId: string | null): void;
|
|
64
|
+
getBlockId(): string | null;
|
|
51
65
|
}
|
|
52
66
|
export declare function $createCustomLinkNode(url: string, attributes?: {
|
|
53
67
|
rel?: null | string;
|
|
@@ -19,10 +19,19 @@ export class CustomLinkNode extends LinkNode {
|
|
|
19
19
|
* @internal
|
|
20
20
|
*/
|
|
21
21
|
__wikiLinkOrigin;
|
|
22
|
+
/**
|
|
23
|
+
* Obsidian-style `#^blockId` fragment (#119), when this link is a
|
|
24
|
+
* block-scoped wiki-link (`[[file#^id]]`). `null` for an ordinary
|
|
25
|
+
* file-only or heading-anchor wiki-link. Deliberately a field separate
|
|
26
|
+
* from `__url` — see `mdastToLexical.ts`/`lexicalToMdast.ts` for why.
|
|
27
|
+
* @internal
|
|
28
|
+
*/
|
|
29
|
+
__blockId;
|
|
22
30
|
constructor(url, attributes, key) {
|
|
23
31
|
super(url, attributes, key);
|
|
24
32
|
this.__wikiAliasState = null;
|
|
25
33
|
this.__wikiLinkOrigin = false;
|
|
34
|
+
this.__blockId = null;
|
|
26
35
|
}
|
|
27
36
|
static getType() {
|
|
28
37
|
return 'link'; // Use same type to replace the default LinkNode
|
|
@@ -31,6 +40,7 @@ export class CustomLinkNode extends LinkNode {
|
|
|
31
40
|
const cloned = new CustomLinkNode(node.__url, { rel: node.__rel, target: node.__target, title: node.__title }, node.__key);
|
|
32
41
|
cloned.__wikiAliasState = node.__wikiAliasState;
|
|
33
42
|
cloned.__wikiLinkOrigin = node.__wikiLinkOrigin;
|
|
43
|
+
cloned.__blockId = node.__blockId;
|
|
34
44
|
return cloned;
|
|
35
45
|
}
|
|
36
46
|
createDOM(config) {
|
|
@@ -54,6 +64,9 @@ export class CustomLinkNode extends LinkNode {
|
|
|
54
64
|
if (this.isWikiLink()) {
|
|
55
65
|
element.setAttribute('data-wiki-link', 'true');
|
|
56
66
|
element.setAttribute('data-wiki-target', this.__url);
|
|
67
|
+
if (this.__blockId) {
|
|
68
|
+
element.setAttribute('data-block-id', this.__blockId);
|
|
69
|
+
}
|
|
57
70
|
}
|
|
58
71
|
else if (this.isExternalLink()) {
|
|
59
72
|
// External links get blue styling to differentiate from wiki-links
|
|
@@ -97,6 +110,7 @@ export class CustomLinkNode extends LinkNode {
|
|
|
97
110
|
else {
|
|
98
111
|
anchor.removeAttribute('data-wiki-link');
|
|
99
112
|
anchor.removeAttribute('data-wiki-target');
|
|
113
|
+
anchor.removeAttribute('data-block-id');
|
|
100
114
|
anchor.classList.remove('editor-link-broken');
|
|
101
115
|
if (this.isExternalLink()) {
|
|
102
116
|
anchor.classList.add('editor-link-external');
|
|
@@ -106,6 +120,14 @@ export class CustomLinkNode extends LinkNode {
|
|
|
106
120
|
}
|
|
107
121
|
}
|
|
108
122
|
}
|
|
123
|
+
if (this.__blockId !== prevNode.__blockId) {
|
|
124
|
+
if (this.__blockId && this.isWikiLink()) {
|
|
125
|
+
anchor.setAttribute('data-block-id', this.__blockId);
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
anchor.removeAttribute('data-block-id');
|
|
129
|
+
}
|
|
130
|
+
}
|
|
109
131
|
if (target !== prevNode.__target) {
|
|
110
132
|
if (target) {
|
|
111
133
|
anchor.target = target;
|
|
@@ -155,6 +177,9 @@ export class CustomLinkNode extends LinkNode {
|
|
|
155
177
|
if (serializedNode.wikiLinkOrigin) {
|
|
156
178
|
node.__wikiLinkOrigin = true;
|
|
157
179
|
}
|
|
180
|
+
if (serializedNode.blockId) {
|
|
181
|
+
node.__blockId = serializedNode.blockId;
|
|
182
|
+
}
|
|
158
183
|
node.setFormat(serializedNode.format);
|
|
159
184
|
node.setIndent(serializedNode.indent);
|
|
160
185
|
node.setDirection(serializedNode.direction);
|
|
@@ -167,6 +192,7 @@ export class CustomLinkNode extends LinkNode {
|
|
|
167
192
|
version: 1,
|
|
168
193
|
wikiAliasState: this.__wikiAliasState ?? undefined,
|
|
169
194
|
wikiLinkOrigin: this.__wikiLinkOrigin ? true : undefined,
|
|
195
|
+
blockId: this.__blockId ?? undefined,
|
|
170
196
|
};
|
|
171
197
|
}
|
|
172
198
|
setWikiAliasState(state) {
|
|
@@ -183,6 +209,13 @@ export class CustomLinkNode extends LinkNode {
|
|
|
183
209
|
getWikiLinkOrigin() {
|
|
184
210
|
return this.__wikiLinkOrigin;
|
|
185
211
|
}
|
|
212
|
+
setBlockId(blockId) {
|
|
213
|
+
const writable = this.getWritable();
|
|
214
|
+
writable.__blockId = blockId;
|
|
215
|
+
}
|
|
216
|
+
getBlockId() {
|
|
217
|
+
return this.__blockId;
|
|
218
|
+
}
|
|
186
219
|
}
|
|
187
220
|
function convertAnchorElement(domNode) {
|
|
188
221
|
let node = null;
|
|
@@ -195,6 +228,10 @@ function convertAnchorElement(domNode) {
|
|
|
195
228
|
target: domNode.getAttribute('target'),
|
|
196
229
|
title: domNode.getAttribute('title'),
|
|
197
230
|
});
|
|
231
|
+
const blockId = domNode.getAttribute('data-block-id');
|
|
232
|
+
if (blockId) {
|
|
233
|
+
node.__blockId = blockId;
|
|
234
|
+
}
|
|
198
235
|
}
|
|
199
236
|
}
|
|
200
237
|
return { node };
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TransclusionComponent - resolves and renders a block transclusion
|
|
3
|
+
* (`![[file#^id]]`, #119)
|
|
4
|
+
*
|
|
5
|
+
* Mirrors `MermaidComponent`'s split: `TransclusionNode` is a static,
|
|
6
|
+
* serializable value (`file`/`blockId`/`alias`); the actual host-resolver
|
|
7
|
+
* call happens here, inside the lazily-loaded component, since resolution is
|
|
8
|
+
* `Promise`-based and the mdast<->Lexical mapper is synchronous.
|
|
9
|
+
*/
|
|
10
|
+
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
|
11
|
+
import { useEffect, useRef, useState } from 'react';
|
|
12
|
+
import { useEditorHost } from '../../../host/context.js';
|
|
13
|
+
import { resolveAndRenderTransclusion, renderTransclusionState, } from './transclusion-render.js';
|
|
14
|
+
import { renderTransclusionLoading } from './transclusion-loading.js';
|
|
15
|
+
// Matches WikiLinkExistencePlugin's debounce window for the same reason:
|
|
16
|
+
// both re-run a host resolver on every dirty editor update, and without
|
|
17
|
+
// debouncing that fires one resolver call (potentially I/O-bound) per
|
|
18
|
+
// keystroke per visible reference.
|
|
19
|
+
const RESOLVE_DEBOUNCE_MS = 300;
|
|
20
|
+
export default function TransclusionComponent({ file, blockId }) {
|
|
21
|
+
const [editor] = useLexicalComposerContext();
|
|
22
|
+
const { resolveTransclusion } = useEditorHost();
|
|
23
|
+
const [state, setState] = useState(null);
|
|
24
|
+
// Guards against a stale resolution landing after a newer one already
|
|
25
|
+
// started (e.g. file/blockId changed, or two update-listener firings
|
|
26
|
+
// overlap) — only the most recent request is allowed to commit state.
|
|
27
|
+
const generationRef = useRef(0);
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
let cancelled = false;
|
|
30
|
+
let debounceTimeout = null;
|
|
31
|
+
const resolve = async () => {
|
|
32
|
+
const generation = ++generationRef.current;
|
|
33
|
+
const result = await resolveAndRenderTransclusion(file, blockId, resolveTransclusion, []);
|
|
34
|
+
if (!cancelled && generation === generationRef.current) {
|
|
35
|
+
setState(result);
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
void resolve();
|
|
39
|
+
// FR-006/SC-002: re-resolve on every document change so an edit to the
|
|
40
|
+
// source block — in this document, or elsewhere once the host's own
|
|
41
|
+
// resolver reflects it — shows up without the host having to remount
|
|
42
|
+
// the editor. Pull-based and unmemoized (every dirty update re-resolves
|
|
43
|
+
// every visible transclusion): an accepted v1 cost, not a correctness
|
|
44
|
+
// gap — see the Plan's "no push/invalidation channel" risk note. The
|
|
45
|
+
// debounce below only bounds *how often* that cost is paid per burst of
|
|
46
|
+
// edits, mirroring WikiLinkExistencePlugin's existing convention.
|
|
47
|
+
const unregister = editor.registerUpdateListener(({ dirtyElements, dirtyLeaves }) => {
|
|
48
|
+
if (dirtyElements.size > 0 || dirtyLeaves.size > 0) {
|
|
49
|
+
if (debounceTimeout)
|
|
50
|
+
clearTimeout(debounceTimeout);
|
|
51
|
+
debounceTimeout = setTimeout(() => { void resolve(); }, RESOLVE_DEBOUNCE_MS);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
return () => {
|
|
55
|
+
cancelled = true;
|
|
56
|
+
if (debounceTimeout)
|
|
57
|
+
clearTimeout(debounceTimeout);
|
|
58
|
+
unregister();
|
|
59
|
+
};
|
|
60
|
+
}, [editor, file, blockId, resolveTransclusion]);
|
|
61
|
+
if (state === null) {
|
|
62
|
+
return renderTransclusionLoading();
|
|
63
|
+
}
|
|
64
|
+
return renderTransclusionState(state);
|
|
65
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TransclusionNode - Live-rendered block transclusion (`![[file#^id]]`, #119)
|
|
3
|
+
*
|
|
4
|
+
* An **inline** DecoratorNode, unlike the block-level `MermaidNode`: a
|
|
5
|
+
* `wikiEmbed` mdast node is phrasing content (same family as `wikiLink`/
|
|
6
|
+
* `image`), so this matches where it actually sits in the tree rather than
|
|
7
|
+
* forcing paragraph-promotion logic to accommodate it.
|
|
8
|
+
*
|
|
9
|
+
* `file`/`blockId` are the sole identity the node carries; `alias` is stored
|
|
10
|
+
* only for byte-identical round-trip (`![[file#^id|alias]]`) — an embed
|
|
11
|
+
* renders the resolved block's *live content*, never the alias text, so
|
|
12
|
+
* nothing here displays it. Content resolution is async and host-resolver-
|
|
13
|
+
* driven (see `transclusion-render.tsx`), so — mirroring `MermaidNode` — the
|
|
14
|
+
* actual resolver call happens inside the lazily-loaded `TransclusionComponent`
|
|
15
|
+
* at decorate-time, not here: this node is a static, serializable value.
|
|
16
|
+
*/
|
|
17
|
+
import { DecoratorNode, DOMConversionMap, DOMExportOutput, LexicalNode, NodeKey, SerializedLexicalNode, Spread } from 'lexical';
|
|
18
|
+
export type SerializedTransclusionNode = Spread<{
|
|
19
|
+
file: string;
|
|
20
|
+
blockId: string;
|
|
21
|
+
alias: string | null;
|
|
22
|
+
emptyAlias: boolean;
|
|
23
|
+
}, SerializedLexicalNode>;
|
|
24
|
+
export declare class TransclusionNode extends DecoratorNode<JSX.Element> {
|
|
25
|
+
__file: string;
|
|
26
|
+
__blockId: string;
|
|
27
|
+
__alias: string | null;
|
|
28
|
+
__emptyAlias: boolean;
|
|
29
|
+
static getType(): string;
|
|
30
|
+
static clone(node: TransclusionNode): TransclusionNode;
|
|
31
|
+
constructor(file: string, blockId: string, alias?: string | null, emptyAlias?: boolean, key?: NodeKey);
|
|
32
|
+
static importJSON(serializedNode: SerializedTransclusionNode): TransclusionNode;
|
|
33
|
+
exportJSON(): SerializedTransclusionNode;
|
|
34
|
+
createDOM(): HTMLElement;
|
|
35
|
+
exportDOM(): DOMExportOutput;
|
|
36
|
+
static importDOM(): DOMConversionMap | null;
|
|
37
|
+
updateDOM(): boolean;
|
|
38
|
+
isInline(): boolean;
|
|
39
|
+
getFile(): string;
|
|
40
|
+
getBlockId(): string;
|
|
41
|
+
getAlias(): string | null;
|
|
42
|
+
setAlias(alias: string | null): void;
|
|
43
|
+
getEmptyAlias(): boolean;
|
|
44
|
+
setEmptyAlias(emptyAlias: boolean): void;
|
|
45
|
+
decorate(): JSX.Element;
|
|
46
|
+
}
|
|
47
|
+
export declare function $createTransclusionNode(file: string, blockId: string, alias?: string | null, emptyAlias?: boolean): TransclusionNode;
|
|
48
|
+
export declare function $isTransclusionNode(node: LexicalNode | null | undefined): node is TransclusionNode;
|