@liminis/editor 0.4.1 → 0.6.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 +66 -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 +3 -1
- package/dist/app/editor/nodes/BlockAnchorComponent.d.ts +5 -0
- package/dist/app/editor/nodes/BlockAnchorComponent.js +37 -0
- package/dist/app/editor/nodes/BlockAnchorNode.d.ts +42 -0
- package/dist/app/editor/nodes/BlockAnchorNode.js +151 -0
- 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 +4 -0
- package/dist/app/editor/nodes/index.js +2 -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 +116 -22
- package/dist/app/mapper/mdastToLexical.js +76 -4
- 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 +535 -1
- package/dist/markdown/stringify.js +35 -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/decisions/adr-122-block-anchor-badge.md +462 -0
- package/docs/editor-api.md +1 -0
- package/docs/markdown-pipeline.md +323 -6
- package/package.json +5 -3
package/README.md
CHANGED
|
@@ -16,7 +16,9 @@ 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]]`), with a `^ULID`
|
|
21
|
+
block anchor at its definition site rendering as a compact, copyable badge.
|
|
20
22
|
- **A markdown pipeline** — `parseMarkdown` / `stringifyMarkdown` and the mdast
|
|
21
23
|
↔ Lexical mappers, usable with no editor mounted.
|
|
22
24
|
- **An annotation mechanism** — range-anchored markers over document text that
|
|
@@ -442,6 +444,69 @@ read-only editor still shows the floating toolbar with the configured
|
|
|
442
444
|
affordance (formatting controls are omitted there, since they would be
|
|
443
445
|
inert).
|
|
444
446
|
|
|
447
|
+
## Block-scoped links and transclusion
|
|
448
|
+
|
|
449
|
+
Wiki-links (`[[target]]` / `[[target|alias]]`) extend to an optional
|
|
450
|
+
Obsidian-style block-id fragment: `[[file#^id]]` links to one specific block
|
|
451
|
+
inside a file rather than the file as a whole, and `![[file#^id]]`
|
|
452
|
+
**transcludes** it — renders that block's actual, current content inline at
|
|
453
|
+
the reference site. This is a live view, not a copy: if the source block's
|
|
454
|
+
text changes, every transclusion of it reflects that on next render.
|
|
455
|
+
|
|
456
|
+
Resolving `file#^id` to content is host work, through one optional injected
|
|
457
|
+
function:
|
|
458
|
+
|
|
459
|
+
```tsx
|
|
460
|
+
<EditorHostProvider services={{ resolveTransclusion: async (file, blockId) => {
|
|
461
|
+
// look up the block by id across your whole corpus — ids are workspace-global,
|
|
462
|
+
// not scoped to one file (matching Liminis's own `^ULID` convention)
|
|
463
|
+
return lookupBlockContent(file, blockId) // string | null
|
|
464
|
+
} }}>
|
|
465
|
+
<Editor initialContent={markdown} onChange={setMarkdown} />
|
|
466
|
+
</EditorHostProvider>
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
With no resolver injected, or one that returns `null`, `![[file#^id]]`
|
|
470
|
+
renders a clearly marked "unresolved" placeholder rather than throwing —
|
|
471
|
+
consistent with every other host service in this package. A transclusion
|
|
472
|
+
cycle (block A transcludes B, which transcludes A — directly or through a
|
|
473
|
+
longer chain) is detected and rendered as a "circular transclusion"
|
|
474
|
+
indicator rather than hanging; nested transclusion is supported to a bounded
|
|
475
|
+
depth, beyond which it degrades to a clear fallback rather than a crash.
|
|
476
|
+
|
|
477
|
+
**If you are maintaining this package: do not remove the cycle/depth guard**
|
|
478
|
+
in `src/app/editor/nodes/transclusion-render.tsx`, and **do not loosen the
|
|
479
|
+
embed-marker detection** in `src/markdown/parse.ts` to a bare `!` lookahead
|
|
480
|
+
— see `docs/markdown-pipeline.md`'s "Block-scoped links and transclusion"
|
|
481
|
+
section for what each guards against and the regression fixture that pins
|
|
482
|
+
it down.
|
|
483
|
+
|
|
484
|
+
`[[file#^id]]` (link-only, no `!`) instead requests navigation to that
|
|
485
|
+
specific block when the host supports it, and degrades no worse than
|
|
486
|
+
today's file-only wiki-link navigation when it doesn't — no extra host
|
|
487
|
+
wiring required beyond the resolver above, which also backs its
|
|
488
|
+
"does this block exist" styling.
|
|
489
|
+
|
|
490
|
+
## Block anchor badges
|
|
491
|
+
|
|
492
|
+
A block anchor — a bare `^ULID` at the point it was defined, most commonly
|
|
493
|
+
trailing an action-item checkbox (`- [ ] ... ^01M00VDX0S4JHMDNA7F776Y8R8`) —
|
|
494
|
+
renders as a compact badge instead of the raw 26-character id sitting inline
|
|
495
|
+
in the prose. Hover the badge to see the full id, or click it to copy the
|
|
496
|
+
exact id to the clipboard — the same id a `[[file#^id]]` reference above
|
|
497
|
+
needs. The underlying markdown, and the id itself, are never changed by
|
|
498
|
+
this: it is a display concern only, and the badge edits and deletes like
|
|
499
|
+
ordinary text.
|
|
500
|
+
|
|
501
|
+
Detection accepts every id form `[[file#^id]]` and its resolver accept —
|
|
502
|
+
ULIDs, raw-decimal snowflake ids, NanoID-style ids, mixed-case base62, UUIDs,
|
|
503
|
+
short alphanumeric ids — so the badge and the resolver agree on what counts
|
|
504
|
+
as an anchor. A caret in ordinary prose (`x^2`, `2^10`, `mc^2`, `a ^ b`) is
|
|
505
|
+
never mistaken for one, by the same position rule the resolver itself uses:
|
|
506
|
+
the caret must start a token, and the id must run to end of line — see
|
|
507
|
+
`docs/markdown-pipeline.md`'s "Block anchor badges" section for the full
|
|
508
|
+
rationale.
|
|
509
|
+
|
|
445
510
|
## Documentation
|
|
446
511
|
|
|
447
512
|
- [`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, BlockAnchorNode, 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,9 +24,11 @@ export const editorNodes = [
|
|
|
24
24
|
HorizontalRuleNode,
|
|
25
25
|
EquationNode,
|
|
26
26
|
MermaidNode,
|
|
27
|
+
TransclusionNode,
|
|
27
28
|
C4Node,
|
|
28
29
|
FrontmatterNode,
|
|
29
30
|
FootnoteNode,
|
|
31
|
+
BlockAnchorNode,
|
|
30
32
|
DefinitionListNode,
|
|
31
33
|
DefinitionTermNode,
|
|
32
34
|
DefinitionDescriptionNode,
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* BlockAnchorComponent — the compact badge a `BlockAnchorNode` decorates
|
|
4
|
+
* itself with (#122).
|
|
5
|
+
*
|
|
6
|
+
* User Story 2 (the id must remain readable and copyable) is satisfied with
|
|
7
|
+
* no new interaction pattern: the full id is exposed via the native `title`
|
|
8
|
+
* tooltip on hover, and a click copies it via `navigator.clipboard.writeText`
|
|
9
|
+
* with brief "Copied" feedback — mirroring `CodeBlockPlugin.tsx`'s existing
|
|
10
|
+
* copy-button pattern.
|
|
11
|
+
*/
|
|
12
|
+
import { useCallback, useState } from 'react';
|
|
13
|
+
const COPIED_FEEDBACK_MS = 1500;
|
|
14
|
+
export default function BlockAnchorComponent({ id }) {
|
|
15
|
+
const [copied, setCopied] = useState(false);
|
|
16
|
+
const handleClick = useCallback(() => {
|
|
17
|
+
void navigator.clipboard.writeText(id).then(() => {
|
|
18
|
+
setCopied(true);
|
|
19
|
+
setTimeout(() => setCopied(false), COPIED_FEEDBACK_MS);
|
|
20
|
+
});
|
|
21
|
+
}, [id]);
|
|
22
|
+
return (_jsx("button", { type: "button", className: "block-anchor-badge", title: id, "aria-label": `Block anchor ${id}. Click to copy.`, onClick: handleClick, contentEditable: false, style: {
|
|
23
|
+
display: 'inline-block',
|
|
24
|
+
cursor: 'pointer',
|
|
25
|
+
border: 'none',
|
|
26
|
+
font: 'inherit',
|
|
27
|
+
fontSize: '0.75em',
|
|
28
|
+
lineHeight: '1.4em',
|
|
29
|
+
padding: '0 0.4em',
|
|
30
|
+
marginLeft: '0.3em',
|
|
31
|
+
borderRadius: '1em',
|
|
32
|
+
backgroundColor: 'var(--liminis-editor-muted-100)',
|
|
33
|
+
color: 'var(--liminis-editor-muted-foreground)',
|
|
34
|
+
userSelect: 'none',
|
|
35
|
+
verticalAlign: 'middle',
|
|
36
|
+
}, children: copied ? 'Copied' : '^' }));
|
|
37
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BlockAnchorNode — Inline decorator node for a block anchor (`^ULID`, #122).
|
|
3
|
+
* Renders a compact badge at the anchor's definition site instead of the raw
|
|
4
|
+
* `^ULID` text, and preserves the id for byte-identical round-trip back to
|
|
5
|
+
* markdown (`stringify.ts`'s `blockAnchor` handler emits `^` + this id, and
|
|
6
|
+
* nothing else).
|
|
7
|
+
*/
|
|
8
|
+
import { DecoratorNode, DOMConversionMap, DOMExportOutput, LexicalNode, NodeKey, SerializedLexicalNode, Spread, TextFormatType } from 'lexical';
|
|
9
|
+
export type SerializedBlockAnchorNode = Spread<{
|
|
10
|
+
id: string;
|
|
11
|
+
format?: number;
|
|
12
|
+
strongMarker?: '_' | '*' | null;
|
|
13
|
+
emphasisMarker?: '_' | '*' | null;
|
|
14
|
+
}, SerializedLexicalNode>;
|
|
15
|
+
export declare class BlockAnchorNode extends DecoratorNode<JSX.Element> {
|
|
16
|
+
__id: string;
|
|
17
|
+
__format: number;
|
|
18
|
+
__strongMarker: '_' | '*' | null;
|
|
19
|
+
__emphasisMarker: '_' | '*' | null;
|
|
20
|
+
static getType(): string;
|
|
21
|
+
static clone(node: BlockAnchorNode): BlockAnchorNode;
|
|
22
|
+
constructor(id: string, key?: NodeKey);
|
|
23
|
+
getId(): string;
|
|
24
|
+
isInline(): boolean;
|
|
25
|
+
static importJSON(serializedNode: SerializedBlockAnchorNode): BlockAnchorNode;
|
|
26
|
+
exportJSON(): SerializedBlockAnchorNode;
|
|
27
|
+
getFormat(): number;
|
|
28
|
+
hasFormat(type: TextFormatType): boolean;
|
|
29
|
+
setFormat(format: number): this;
|
|
30
|
+
toggleFormat(type: TextFormatType): this;
|
|
31
|
+
getStrongMarker(): '_' | '*' | null;
|
|
32
|
+
setStrongMarker(marker: '_' | '*' | null): this;
|
|
33
|
+
getEmphasisMarker(): '_' | '*' | null;
|
|
34
|
+
setEmphasisMarker(marker: '_' | '*' | null): this;
|
|
35
|
+
createDOM(): HTMLElement;
|
|
36
|
+
updateDOM(): boolean;
|
|
37
|
+
exportDOM(): DOMExportOutput;
|
|
38
|
+
static importDOM(): DOMConversionMap | null;
|
|
39
|
+
decorate(): JSX.Element;
|
|
40
|
+
}
|
|
41
|
+
export declare function $createBlockAnchorNode(id: string): BlockAnchorNode;
|
|
42
|
+
export declare function $isBlockAnchorNode(node: LexicalNode | null | undefined): node is BlockAnchorNode;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BlockAnchorNode — Inline decorator node for a block anchor (`^ULID`, #122).
|
|
3
|
+
* Renders a compact badge at the anchor's definition site instead of the raw
|
|
4
|
+
* `^ULID` text, and preserves the id for byte-identical round-trip back to
|
|
5
|
+
* markdown (`stringify.ts`'s `blockAnchor` handler emits `^` + this id, and
|
|
6
|
+
* nothing else).
|
|
7
|
+
*/
|
|
8
|
+
import { DecoratorNode, TEXT_TYPE_TO_FORMAT, toggleTextFormatType, $applyNodeReplacement, } from 'lexical';
|
|
9
|
+
import { createElement } from 'react';
|
|
10
|
+
import BlockAnchorComponent from './BlockAnchorComponent.js';
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// DOM conversion (copy/paste support)
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
function $convertBlockAnchorElement(domNode) {
|
|
15
|
+
const id = domNode.getAttribute('data-block-anchor-id');
|
|
16
|
+
if (id) {
|
|
17
|
+
return { node: $createBlockAnchorNode(id) };
|
|
18
|
+
}
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// BlockAnchorNode
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
export class BlockAnchorNode extends DecoratorNode {
|
|
25
|
+
__id;
|
|
26
|
+
__format;
|
|
27
|
+
__strongMarker;
|
|
28
|
+
__emphasisMarker;
|
|
29
|
+
static getType() {
|
|
30
|
+
return 'blockAnchor';
|
|
31
|
+
}
|
|
32
|
+
static clone(node) {
|
|
33
|
+
const cloned = new BlockAnchorNode(node.__id, node.__key);
|
|
34
|
+
cloned.__format = node.__format;
|
|
35
|
+
cloned.__strongMarker = node.__strongMarker;
|
|
36
|
+
cloned.__emphasisMarker = node.__emphasisMarker;
|
|
37
|
+
return cloned;
|
|
38
|
+
}
|
|
39
|
+
constructor(id, key) {
|
|
40
|
+
super(key);
|
|
41
|
+
this.__id = id;
|
|
42
|
+
this.__format = 0;
|
|
43
|
+
this.__strongMarker = null;
|
|
44
|
+
this.__emphasisMarker = null;
|
|
45
|
+
}
|
|
46
|
+
getId() {
|
|
47
|
+
return this.__id;
|
|
48
|
+
}
|
|
49
|
+
// Inline node — sits within text flow
|
|
50
|
+
isInline() {
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
// Serialization
|
|
54
|
+
static importJSON(serializedNode) {
|
|
55
|
+
const node = $createBlockAnchorNode(serializedNode.id).setFormat(serializedNode.format ?? 0);
|
|
56
|
+
if (serializedNode.strongMarker) {
|
|
57
|
+
node.setStrongMarker(serializedNode.strongMarker);
|
|
58
|
+
}
|
|
59
|
+
if (serializedNode.emphasisMarker) {
|
|
60
|
+
node.setEmphasisMarker(serializedNode.emphasisMarker);
|
|
61
|
+
}
|
|
62
|
+
return node;
|
|
63
|
+
}
|
|
64
|
+
exportJSON() {
|
|
65
|
+
return {
|
|
66
|
+
type: 'blockAnchor',
|
|
67
|
+
version: 1,
|
|
68
|
+
id: this.__id,
|
|
69
|
+
format: this.__format,
|
|
70
|
+
strongMarker: this.__strongMarker,
|
|
71
|
+
emphasisMarker: this.__emphasisMarker,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
// Mirrors TextNode's format bitmask API so this node can carry
|
|
75
|
+
// bold/italic/strikethrough state through the mdast<->Lexical round-trip.
|
|
76
|
+
getFormat() {
|
|
77
|
+
return this.getLatest().__format;
|
|
78
|
+
}
|
|
79
|
+
hasFormat(type) {
|
|
80
|
+
const formatFlag = TEXT_TYPE_TO_FORMAT[type];
|
|
81
|
+
return (this.getFormat() & formatFlag) !== 0;
|
|
82
|
+
}
|
|
83
|
+
setFormat(format) {
|
|
84
|
+
const self = this.getWritable();
|
|
85
|
+
self.__format = format;
|
|
86
|
+
return self;
|
|
87
|
+
}
|
|
88
|
+
toggleFormat(type) {
|
|
89
|
+
const format = this.getFormat();
|
|
90
|
+
const newFormat = toggleTextFormatType(format, type, null);
|
|
91
|
+
return this.setFormat(newFormat);
|
|
92
|
+
}
|
|
93
|
+
// Mirrors TextNode's --md-strong-marker/--md-emphasis-marker style hooks
|
|
94
|
+
// (via setMarkdownMarker/getMarkdownMarker in the mappers) so a bare
|
|
95
|
+
// anchor sitting inside `**bold**`/`_italic_` still round-trips its
|
|
96
|
+
// original underscore-vs-asterisk marker on export.
|
|
97
|
+
getStrongMarker() {
|
|
98
|
+
return this.getLatest().__strongMarker;
|
|
99
|
+
}
|
|
100
|
+
setStrongMarker(marker) {
|
|
101
|
+
const self = this.getWritable();
|
|
102
|
+
self.__strongMarker = marker;
|
|
103
|
+
return self;
|
|
104
|
+
}
|
|
105
|
+
getEmphasisMarker() {
|
|
106
|
+
return this.getLatest().__emphasisMarker;
|
|
107
|
+
}
|
|
108
|
+
setEmphasisMarker(marker) {
|
|
109
|
+
const self = this.getWritable();
|
|
110
|
+
self.__emphasisMarker = marker;
|
|
111
|
+
return self;
|
|
112
|
+
}
|
|
113
|
+
// DOM creation (editor view)
|
|
114
|
+
createDOM() {
|
|
115
|
+
const el = document.createElement('span');
|
|
116
|
+
el.className = 'block-anchor';
|
|
117
|
+
return el;
|
|
118
|
+
}
|
|
119
|
+
updateDOM() {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
// DOM export (copy/paste)
|
|
123
|
+
exportDOM() {
|
|
124
|
+
const el = document.createElement('span');
|
|
125
|
+
el.setAttribute('data-block-anchor-id', this.__id);
|
|
126
|
+
el.textContent = `^${this.__id}`;
|
|
127
|
+
return { element: el };
|
|
128
|
+
}
|
|
129
|
+
static importDOM() {
|
|
130
|
+
return {
|
|
131
|
+
span: (domNode) => {
|
|
132
|
+
if (!domNode.hasAttribute('data-block-anchor-id'))
|
|
133
|
+
return null;
|
|
134
|
+
return { conversion: $convertBlockAnchorElement, priority: 1 };
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
// Render as React element
|
|
139
|
+
decorate() {
|
|
140
|
+
return createElement(BlockAnchorComponent, { id: this.__id });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
// Factory + type guard
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
export function $createBlockAnchorNode(id) {
|
|
147
|
+
return $applyNodeReplacement(new BlockAnchorNode(id));
|
|
148
|
+
}
|
|
149
|
+
export function $isBlockAnchorNode(node) {
|
|
150
|
+
return node instanceof BlockAnchorNode;
|
|
151
|
+
}
|
|
@@ -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;
|