@liminis/editor 0.4.1 → 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 +45 -1
- 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/decisions/adr-119-block-transclusion.md +247 -0
- package/docs/editor-api.md +1 -0
- package/docs/markdown-pipeline.md +110 -6
- package/package.json +5 -3
package/README.md
CHANGED
|
@@ -16,7 +16,8 @@ travelled with the code, are recorded in [`docs/provenance.md`](./docs/provenanc
|
|
|
16
16
|
- **`<Editor>`** — a WYSIWYG markdown editor. Tables, task lists (including in
|
|
17
17
|
ordered lists), footnotes, definition lists, callouts, toggles, code blocks
|
|
18
18
|
with Prism highlighting, images, LaTeX equations, Mermaid diagrams, C4
|
|
19
|
-
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]]`).
|
|
20
21
|
- **A markdown pipeline** — `parseMarkdown` / `stringifyMarkdown` and the mdast
|
|
21
22
|
↔ Lexical mappers, usable with no editor mounted.
|
|
22
23
|
- **An annotation mechanism** — range-anchored markers over document text that
|
|
@@ -442,6 +443,49 @@ read-only editor still shows the floating toolbar with the configured
|
|
|
442
443
|
affordance (formatting controls are omitted there, since they would be
|
|
443
444
|
inert).
|
|
444
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
|
+
|
|
445
489
|
## Documentation
|
|
446
490
|
|
|
447
491
|
- [`docs/editor-api.md`](./docs/editor-api.md) — the `<Editor>` props and the
|
|
@@ -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;
|