@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.
Files changed (41) hide show
  1. package/README.md +66 -1
  2. package/dist/app/editor/LinkClickPlugin.js +5 -1
  3. package/dist/app/editor/WikiLinkExistencePlugin.d.ts +13 -3
  4. package/dist/app/editor/WikiLinkExistencePlugin.js +91 -32
  5. package/dist/app/editor/editorNodes.js +3 -1
  6. package/dist/app/editor/nodes/BlockAnchorComponent.d.ts +5 -0
  7. package/dist/app/editor/nodes/BlockAnchorComponent.js +37 -0
  8. package/dist/app/editor/nodes/BlockAnchorNode.d.ts +42 -0
  9. package/dist/app/editor/nodes/BlockAnchorNode.js +151 -0
  10. package/dist/app/editor/nodes/CustomLinkNode.d.ts +14 -0
  11. package/dist/app/editor/nodes/CustomLinkNode.js +37 -0
  12. package/dist/app/editor/nodes/TransclusionComponent.d.ts +8 -0
  13. package/dist/app/editor/nodes/TransclusionComponent.js +65 -0
  14. package/dist/app/editor/nodes/TransclusionNode.d.ts +48 -0
  15. package/dist/app/editor/nodes/TransclusionNode.js +141 -0
  16. package/dist/app/editor/nodes/index.d.ts +4 -0
  17. package/dist/app/editor/nodes/index.js +2 -0
  18. package/dist/app/editor/nodes/transclusion-loading.d.ts +27 -0
  19. package/dist/app/editor/nodes/transclusion-loading.js +29 -0
  20. package/dist/app/editor/nodes/transclusion-render.d.ts +49 -0
  21. package/dist/app/editor/nodes/transclusion-render.js +186 -0
  22. package/dist/app/mapper/lexicalToMdast.js +116 -22
  23. package/dist/app/mapper/mdastToLexical.js +76 -4
  24. package/dist/host/defaults.js +1 -0
  25. package/dist/host/messages.d.ts +7 -1
  26. package/dist/host/messages.js +3 -3
  27. package/dist/host/types.d.ts +14 -1
  28. package/dist/markdown/parse.js +535 -1
  29. package/dist/markdown/stringify.js +35 -10
  30. package/dist/markdown/vendor/mdast-util-wiki-link/README.md +17 -0
  31. package/dist/markdown/vendor/mdast-util-wiki-link/from-markdown.d.ts +8 -1
  32. package/dist/markdown/vendor/mdast-util-wiki-link/from-markdown.js +26 -1
  33. package/dist/markdown/vendor/mdast-util-wiki-link/to-markdown.d.ts +13 -7
  34. package/dist/markdown/vendor/mdast-util-wiki-link/to-markdown.js +3 -1
  35. package/dist/styles.css +48 -0
  36. package/dist/types.d.ts +1 -0
  37. package/docs/decisions/adr-119-block-transclusion.md +247 -0
  38. package/docs/decisions/adr-122-block-anchor-badge.md +462 -0
  39. package/docs/editor-api.md +1 -0
  40. package/docs/markdown-pipeline.md +323 -6
  41. 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
- openLink(url);
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 exist anywhere in the workspace. Links to non-existent files
6
- * get a CSS class applied to render them in red.
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
- * Uses the host-supplied `resolveWikiLinks` service, which handles:
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 exist anywhere in the workspace. Links to non-existent files
9
- * get a CSS class applied to render them in red.
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
- * Uses the host-supplied `resolveWikiLinks` service, which handles:
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
- // Collect unique target paths
35
- const targets = new Set();
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
- targets.add(target);
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 (targets.size === 0)
59
+ if (plainTargets.size === 0 && blockRefs.size === 0)
43
60
  return;
44
- // Skip if we've already checked these exact targets
45
- const targetsArray = Array.from(targets);
46
- const targetKey = targetsArray.sort().join('|');
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
- if (!resolveWikiLinks) {
53
- console.warn('[WikiLinkExistencePlugin] resolveWikiLinks host service not available');
54
- return;
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
- // Link exists if resolver returned a non-null path
64
- const exists = resolved[target] !== null;
65
- if (!exists) {
66
- link.classList.add('editor-link-broken');
67
- }
68
- else {
69
- link.classList.remove('editor-link-broken');
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,5 @@
1
+ interface BlockAnchorComponentProps {
2
+ id: string;
3
+ }
4
+ export default function BlockAnchorComponent({ id }: BlockAnchorComponentProps): JSX.Element;
5
+ export {};
@@ -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;