@liminis/editor 0.5.0 → 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 CHANGED
@@ -17,7 +17,8 @@ travelled with the code, are recorded in [`docs/provenance.md`](./docs/provenanc
17
17
  ordered lists), footnotes, definition lists, callouts, toggles, code blocks
18
18
  with Prism highlighting, images, LaTeX equations, Mermaid diagrams, C4
19
19
  diagrams, YAML frontmatter, and wiki-links — including block-scoped links
20
- and live transclusion (`[[file#^id]]` / `![[file#^id]]`).
20
+ and live transclusion (`[[file#^id]]` / `![[file#^id]]`), with a `^ULID`
21
+ block anchor at its definition site rendering as a compact, copyable badge.
21
22
  - **A markdown pipeline** — `parseMarkdown` / `stringifyMarkdown` and the mdast
22
23
  ↔ Lexical mappers, usable with no editor mounted.
23
24
  - **An annotation mechanism** — range-anchored markers over document text that
@@ -486,6 +487,26 @@ today's file-only wiki-link navigation when it doesn't — no extra host
486
487
  wiring required beyond the resolver above, which also backs its
487
488
  "does this block exist" styling.
488
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
+
489
510
  ## Documentation
490
511
 
491
512
  - [`docs/editor-api.md`](./docs/editor-api.md) — the `<Editor>` props and the
@@ -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, TransclusionNode, } 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,
@@ -28,6 +28,7 @@ export const editorNodes = [
28
28
  C4Node,
29
29
  FrontmatterNode,
30
30
  FootnoteNode,
31
+ BlockAnchorNode,
31
32
  DefinitionListNode,
32
33
  DefinitionTermNode,
33
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
+ }
@@ -24,6 +24,8 @@ export { CustomListItemNode, $createCustomListItemNode, $isCustomListItemNode }
24
24
  export type { SerializedCustomListItemNode } from './CustomListItemNode.js';
25
25
  export { FootnoteNode, $createFootnoteNode, $isFootnoteNode } from './FootnoteNode.js';
26
26
  export type { SerializedFootnoteNode } from './FootnoteNode.js';
27
+ export { BlockAnchorNode, $createBlockAnchorNode, $isBlockAnchorNode } from './BlockAnchorNode.js';
28
+ export type { SerializedBlockAnchorNode } from './BlockAnchorNode.js';
27
29
  export { DefinitionListNode, DefinitionTermNode, DefinitionDescriptionNode, $createDefinitionListNode, $createDefinitionTermNode, $createDefinitionDescriptionNode, $isDefinitionListNode, $isDefinitionTermNode, $isDefinitionDescriptionNode, } from './DefinitionListNode.js';
28
30
  export type { SerializedDefinitionListNode, SerializedDefinitionTermNode, SerializedDefinitionDescriptionNode, } from './DefinitionListNode.js';
29
31
  export { HtmlNode, $createHtmlNode, $isHtmlNode } from './HtmlNode.js';
@@ -13,6 +13,7 @@ export { CustomLinkNode, $createCustomLinkNode, $isCustomLinkNode } from './Cust
13
13
  export { CustomListNode, $createCustomListNode, $isCustomListNode } from './CustomListNode.js';
14
14
  export { CustomListItemNode, $createCustomListItemNode, $isCustomListItemNode } from './CustomListItemNode.js';
15
15
  export { FootnoteNode, $createFootnoteNode, $isFootnoteNode } from './FootnoteNode.js';
16
+ export { BlockAnchorNode, $createBlockAnchorNode, $isBlockAnchorNode } from './BlockAnchorNode.js';
16
17
  export { DefinitionListNode, DefinitionTermNode, DefinitionDescriptionNode, $createDefinitionListNode, $createDefinitionTermNode, $createDefinitionDescriptionNode, $isDefinitionListNode, $isDefinitionTermNode, $isDefinitionDescriptionNode, } from './DefinitionListNode.js';
17
18
  export { HtmlNode, $createHtmlNode, $isHtmlNode } from './HtmlNode.js';
18
19
  export { ListItemParagraphBreakNode, $createListItemParagraphBreakNode, $isListItemParagraphBreakNode, } from './ListItemParagraphBreakNode.js';
@@ -5,7 +5,7 @@ import { $isCodeNode } from '@lexical/code';
5
5
  import { $isLinkNode } from '@lexical/link';
6
6
  import { $isMarkNode } from '@lexical/mark';
7
7
  import { $isTableNode, $isTableRowNode, $isTableCellNode } from '@lexical/table';
8
- import { $isHorizontalRuleNode, $isImageNode, $isCalloutNode, $isToggleContainerNode, $isToggleTitleNode, $isToggleContentNode, $isEquationNode, $isMermaidNode, $isC4Node, $isFrontmatterNode, $isFootnoteNode, $isCustomListNode, $isDefinitionListNode, $isDefinitionTermNode, $isDefinitionDescriptionNode, $isCustomListItemNode, $isHtmlNode, $isListItemParagraphBreakNode, $isTransclusionNode, } from '../editor/nodes/index.js';
8
+ import { $isHorizontalRuleNode, $isImageNode, $isCalloutNode, $isToggleContainerNode, $isToggleTitleNode, $isToggleContentNode, $isEquationNode, $isMermaidNode, $isC4Node, $isFrontmatterNode, $isFootnoteNode, $isBlockAnchorNode, $isCustomListNode, $isDefinitionListNode, $isDefinitionTermNode, $isDefinitionDescriptionNode, $isCustomListItemNode, $isHtmlNode, $isListItemParagraphBreakNode, $isTransclusionNode, } from '../editor/nodes/index.js';
9
9
  import { SENTINEL_CLOSE_END, SENTINEL_CLOSE_START, SENTINEL_OPEN_END, SENTINEL_OPEN_START, stripAnnotateSentinels, } from '../../markdown/annotate-sentinels.js';
10
10
  // Convert Lexical editor state to mdast tree
11
11
  export function exportLexicalToMdast(editor, options = {}) {
@@ -245,6 +245,7 @@ function isHoistableConstruct(node) {
245
245
  $isImageNode(node) ||
246
246
  $isEquationNode(node) ||
247
247
  $isFootnoteNode(node) ||
248
+ $isBlockAnchorNode(node) ||
248
249
  $isHtmlNode(node) ||
249
250
  $isTransclusionNode(node));
250
251
  }
@@ -377,26 +378,32 @@ function canCarryHoistedTokens(parent) {
377
378
  * The same question asked of the *construct* rather than its container, because
378
379
  * one container routes its children unevenly.
379
380
  *
380
- * `convertListItemNode` sends only text runs, line breaks and links through the
381
- * inline phrasing path; every other child goes to the block dispatcher, which
382
- * has nowhere to put a phrasing token. Hoisting a boundary onto one of those
383
- * would drop the token silently, and `locateLiveMarkdownRange` would then fail
384
- * to find the mark at all.
381
+ * `convertListItemNode` sends only text runs, line breaks, links and block
382
+ * anchors through the inline phrasing path; every other child goes to the
383
+ * block dispatcher, which has nowhere to put a phrasing token. Hoisting a
384
+ * boundary onto one of those would drop the token silently, and
385
+ * `locateLiveMarkdownRange` would then fail to find the mark at all.
385
386
  *
386
- * No document reaches that state today, which is why this is a guard rather
387
- * than a fix: an image, inline equation, footnote reference or inline HTML
388
- * inside a list item is *itself* block-promoted by that same dispatcher, so it
389
- * does not survive a round trip inline (`- The value $x^2$ matters` exports as
390
- * three blocks) and any range over it is already rejected by
391
- * `locateLiveMarkdownRange`'s slice check. Repairing that unrelated,
392
- * pre-existing round-trip defect must not silently regress annotation ranges as
393
- * its side effect.
387
+ * No document reaches that state today for the remaining types, which is why
388
+ * this is a guard rather than a fix: an image, inline equation, footnote
389
+ * reference or inline HTML inside a list item is *itself* block-promoted by
390
+ * that same dispatcher, so it does not survive a round trip inline (`- The
391
+ * value $x^2$ matters` exports as three blocks) and any range over it is
392
+ * already rejected by `locateLiveMarkdownRange`'s slice check. Repairing that
393
+ * unrelated, pre-existing round-trip defect must not silently regress
394
+ * annotation ranges as its side effect.
395
+ *
396
+ * Block anchors are the one exception (#122): `- [ ] ... ^ULID` is the
397
+ * primary real-world shape this issue targets, so `convertListItemNode` keeps
398
+ * a `BlockAnchorNode` inline rather than letting it fall to the block
399
+ * dispatcher — see the `$isBlockAnchorNode` branch there — and a hoisted
400
+ * boundary onto one genuinely does reach the output.
394
401
  */
395
402
  function hoistedTokenReachesOutput(node) {
396
403
  let parent = node.getParent();
397
404
  while (parent && $isMarkNode(parent))
398
405
  parent = parent.getParent();
399
- return $isListItemNode(parent) ? $isLinkNode(node) : true;
406
+ return $isListItemNode(parent) ? ($isLinkNode(node) || $isBlockAnchorNode(node)) : true;
400
407
  }
401
408
  /**
402
409
  * The node a mark's boundary token should be emitted outside of, or null to
@@ -716,6 +723,9 @@ function convertFootnoteInlineChildren(labelNode) {
716
723
  label: child.getFootnoteId(),
717
724
  });
718
725
  }
726
+ else if ($isBlockAnchorNode(child)) {
727
+ contentChildren.push({ type: 'blockAnchor', id: child.getId() });
728
+ }
719
729
  contentChildren.push(...hoistedTokenNodesFor(child, 'after'));
720
730
  restIndex++;
721
731
  child = rest[restIndex] ?? null;
@@ -967,6 +977,30 @@ function convertListItemNode(node, _ordered, spread) {
967
977
  // be emitted here too.
968
978
  inlineChildren.push(...hoistedTokenNodesFor(child, 'before'), convertLinkNode(child), ...hoistedTokenNodesFor(child, 'after'));
969
979
  }
980
+ else if ($isBlockAnchorNode(child)) {
981
+ // A block anchor sitting directly under a ListItemNode — the primary
982
+ // real-world shape this issue targets (`- [ ] ... ^ULID`, #122) — must
983
+ // stay inline rather than fall to the block dispatcher below: unlike
984
+ // image/equation/footnote/html (a documented, pre-existing gap this
985
+ // issue does not touch — see `hoistedTokenReachesOutput`'s docstring),
986
+ // a dropped anchor here would break the checkbox-action-item pattern
987
+ // the spec calls out as the primary usage.
988
+ //
989
+ // Unlike the $isLinkNode branch above, a link's format lives on its
990
+ // text children (applied on import via applyFormatToLinkChildren), but
991
+ // a BlockAnchorNode carries its own format bits directly (like
992
+ // FootnoteNode/EquationNode) — so they must be wrapped here explicitly,
993
+ // the same way the general inline path does via getMergeableFormat +
994
+ // buildFormattedContent, or a bold/italic badge would silently lose its
995
+ // markers on export.
996
+ flushTextRun();
997
+ const anchorMdast = { type: 'blockAnchor', id: child.getId() };
998
+ const format = getMergeableFormat(child) ?? 0;
999
+ const anchorContent = format
1000
+ ? wrapWithFormat([anchorMdast], format, child.getStrongMarker(), child.getEmphasisMarker())
1001
+ : anchorMdast;
1002
+ inlineChildren.push(...hoistedTokenNodesFor(child, 'before'), anchorContent, ...hoistedTokenNodesFor(child, 'after'));
1003
+ }
970
1004
  else {
971
1005
  // Any other block-type child (ParagraphNode, CodeNode, TableNode,
972
1006
  // QuoteNode, etc.) — flush accumulated inline text first, then delegate
@@ -1304,7 +1338,7 @@ function getMergeableFormat(child) {
1304
1338
  if ($isTextNode(child)) {
1305
1339
  return child.getFormat() & MERGEABLE_FORMAT_MASK;
1306
1340
  }
1307
- if ($isEquationNode(child) || $isFootnoteNode(child)) {
1341
+ if ($isEquationNode(child) || $isFootnoteNode(child) || $isBlockAnchorNode(child)) {
1308
1342
  return child.getFormat() & MERGEABLE_FORMAT_MASK;
1309
1343
  }
1310
1344
  return null;
@@ -1348,6 +1382,10 @@ function convertSingleInlineChild(child) {
1348
1382
  label: child.getFootnoteId(),
1349
1383
  }];
1350
1384
  }
1385
+ else if ($isBlockAnchorNode(child)) {
1386
+ // Block anchor badge (#122): convert back to a blockAnchor mdast node
1387
+ return [{ type: 'blockAnchor', id: child.getId() }];
1388
+ }
1351
1389
  else if ($isHtmlNode(child)) {
1352
1390
  // Inline HTML preserved opaquely: convert back to a phrasing html mdast node
1353
1391
  return [{ type: 'html', value: child.getHtml() }];
@@ -1431,6 +1469,10 @@ function convertLeavesRaw(nodes) {
1431
1469
  label: node.getFootnoteId(),
1432
1470
  });
1433
1471
  }
1472
+ else if ($isBlockAnchorNode(node)) {
1473
+ flushCode();
1474
+ content.push({ type: 'blockAnchor', id: node.getId() });
1475
+ }
1434
1476
  }
1435
1477
  flushCode();
1436
1478
  return content;
@@ -1457,7 +1499,7 @@ function resolveMarkers(nodes) {
1457
1499
  strongMarker = strongMarker ?? getMarkdownMarker(style, '--md-strong-marker');
1458
1500
  emphasisMarker = emphasisMarker ?? getMarkdownMarker(style, '--md-emphasis-marker');
1459
1501
  }
1460
- else if ($isEquationNode(node) || $isFootnoteNode(node)) {
1502
+ else if ($isEquationNode(node) || $isFootnoteNode(node) || $isBlockAnchorNode(node)) {
1461
1503
  strongMarker = strongMarker ?? node.getStrongMarker();
1462
1504
  emphasisMarker = emphasisMarker ?? node.getEmphasisMarker();
1463
1505
  }
@@ -1837,6 +1879,9 @@ function convertLinkNode(node) {
1837
1879
  label: child.getFootnoteId(),
1838
1880
  });
1839
1881
  }
1882
+ else if ($isBlockAnchorNode(child)) {
1883
+ children.push({ type: 'blockAnchor', id: child.getId() });
1884
+ }
1840
1885
  else if ($isHtmlNode(child)) {
1841
1886
  children.push({ type: 'html', value: child.getHtml() });
1842
1887
  }
@@ -2,7 +2,7 @@ import { $createParagraphNode, $createTextNode, $createLineBreakNode, $isLineBre
2
2
  import { $createHeadingNode, $createQuoteNode } from '@lexical/rich-text';
3
3
  import { $createCodeNode } from '@lexical/code';
4
4
  import { $isLinkNode } from '@lexical/link';
5
- import { $createHorizontalRuleNode, $createImageNode, $isImageNode, $createCalloutNode, $createToggleContainerNode, $createToggleTitleNode, $createToggleContentNode, $createEquationNode, $isEquationNode, $createFootnoteNode, $isFootnoteNode, $createHtmlNode, $isHtmlNode, $createMermaidNode, $createC4Node, $createFrontmatterNode, $createCustomLinkNode, $createCustomListNode, $createDefinitionListNode, $createDefinitionTermNode, $createDefinitionDescriptionNode, $createCustomListItemNode, $createListItemParagraphBreakNode, $createTransclusionNode, } from '../editor/nodes/index.js';
5
+ import { $createHorizontalRuleNode, $createImageNode, $isImageNode, $createCalloutNode, $createToggleContainerNode, $createToggleTitleNode, $createToggleContentNode, $createEquationNode, $isEquationNode, $createFootnoteNode, $isFootnoteNode, $createBlockAnchorNode, $isBlockAnchorNode, $createHtmlNode, $isHtmlNode, $createMermaidNode, $createC4Node, $createFrontmatterNode, $createCustomLinkNode, $createCustomListNode, $createDefinitionListNode, $createDefinitionTermNode, $createDefinitionDescriptionNode, $createCustomListItemNode, $createListItemParagraphBreakNode, $createTransclusionNode, } from '../editor/nodes/index.js';
6
6
  import { parseFormattedAlias } from '../editor/MarkdownShortcutsPlugin.js';
7
7
  import { $createTableNode, $createTableRowNode, $createTableCellNode, TableCellHeaderStates } from '@lexical/table';
8
8
  import { getFileType } from '../../utils/file-types.js';
@@ -845,6 +845,12 @@ function convertInlineNode(node) {
845
845
  const fnRef = node;
846
846
  return [$createFootnoteNode(fnRef.identifier)];
847
847
  }
848
+ case 'blockAnchor': {
849
+ // Block anchor badge (#122): render `^ULID` as a compact badge instead
850
+ // of raw text, using BlockAnchorNode to preserve the id for round-trip.
851
+ const anchor = node;
852
+ return [$createBlockAnchorNode(anchor.id)];
853
+ }
848
854
  case 'wikiLink': {
849
855
  // Wiki-links from mdast-util-wiki-link: [[path|alias]]
850
856
  const wikiLink = node;
@@ -991,7 +997,7 @@ function convertStrong(node) {
991
997
  applyFormatToLinkChildren(n, 'bold', marker);
992
998
  nodes.push(n);
993
999
  }
994
- else if ($isEquationNode(n) || $isFootnoteNode(n)) {
1000
+ else if ($isEquationNode(n) || $isFootnoteNode(n) || $isBlockAnchorNode(n)) {
995
1001
  if (!n.hasFormat('bold')) {
996
1002
  n.toggleFormat('bold');
997
1003
  }
@@ -1026,7 +1032,7 @@ function convertEmphasis(node) {
1026
1032
  applyFormatToLinkChildren(n, 'italic', marker);
1027
1033
  nodes.push(n);
1028
1034
  }
1029
- else if ($isEquationNode(n) || $isFootnoteNode(n)) {
1035
+ else if ($isEquationNode(n) || $isFootnoteNode(n) || $isBlockAnchorNode(n)) {
1030
1036
  if (!n.hasFormat('italic')) {
1031
1037
  n.toggleFormat('italic');
1032
1038
  }
@@ -1125,7 +1131,7 @@ function convertDelete(node) {
1125
1131
  applyFormatToLinkChildren(n, 'strikethrough');
1126
1132
  nodes.push(n);
1127
1133
  }
1128
- else if ($isEquationNode(n) || $isFootnoteNode(n)) {
1134
+ else if ($isEquationNode(n) || $isFootnoteNode(n) || $isBlockAnchorNode(n)) {
1129
1135
  if (!n.hasFormat('strikethrough')) {
1130
1136
  n.toggleFormat('strikethrough');
1131
1137
  }