@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 +22 -1
- package/dist/app/editor/editorNodes.js +2 -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/index.d.ts +2 -0
- package/dist/app/editor/nodes/index.js +1 -0
- package/dist/app/mapper/lexicalToMdast.js +62 -17
- package/dist/app/mapper/mdastToLexical.js +10 -4
- package/dist/markdown/parse.js +310 -0
- package/dist/markdown/stringify.js +5 -0
- package/docs/decisions/adr-122-block-anchor-badge.md +462 -0
- package/docs/markdown-pipeline.md +213 -0
- package/package.json +1 -1
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,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
|
|
381
|
-
* inline phrasing path; every other child goes to the
|
|
382
|
-
* has nowhere to put a phrasing token. Hoisting a
|
|
383
|
-
* would drop the token silently, and
|
|
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
|
|
387
|
-
* than a fix: an image, inline equation, footnote
|
|
388
|
-
* inside a list item is *itself* block-promoted by
|
|
389
|
-
* does not survive a round trip inline (`- The
|
|
390
|
-
* three blocks) and any range over it is
|
|
391
|
-
* `locateLiveMarkdownRange`'s slice check. Repairing that
|
|
392
|
-
* pre-existing round-trip defect must not silently regress
|
|
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
|
}
|