@intentface/ui 0.0.0-stage → 0.7.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/LICENSE +21 -0
- package/README.md +147 -2
- package/dist/ask-user/ask-user.d.ts +104 -0
- package/dist/ask-user/ask-user.js +245 -0
- package/dist/ask-user/index.d.ts +3 -0
- package/dist/ask-user/index.js +3 -0
- package/dist/ask-user/index.parts.d.ts +1 -0
- package/dist/ask-user/index.parts.js +5 -0
- package/dist/attachments/attachments.d.ts +73 -0
- package/dist/attachments/attachments.js +121 -0
- package/dist/attachments/index.d.ts +3 -0
- package/dist/attachments/index.js +3 -0
- package/dist/attachments/index.parts.d.ts +1 -0
- package/dist/attachments/index.parts.js +5 -0
- package/dist/chip/chip.d.ts +45 -0
- package/dist/chip/chip.js +43 -0
- package/dist/chip/index.d.ts +2 -0
- package/dist/chip/index.js +2 -0
- package/dist/chip/index.parts.d.ts +1 -0
- package/dist/chip/index.parts.js +5 -0
- package/dist/chip-markdown.d.ts +25 -0
- package/dist/chip-markdown.js +54 -0
- package/dist/composer/actions.d.ts +35 -0
- package/dist/composer/actions.js +76 -0
- package/dist/composer/ask-user-machine.d.ts +52 -0
- package/dist/composer/ask-user-machine.js +164 -0
- package/dist/composer/attachments-machine.d.ts +34 -0
- package/dist/composer/attachments-machine.js +91 -0
- package/dist/composer/attachments.d.ts +16 -0
- package/dist/composer/attachments.js +32 -0
- package/dist/composer/command-list.d.ts +49 -0
- package/dist/composer/command-list.js +360 -0
- package/dist/composer/commands.d.ts +28 -0
- package/dist/composer/commands.js +56 -0
- package/dist/composer/container.d.ts +3 -0
- package/dist/composer/container.js +32 -0
- package/dist/composer/controller.d.ts +3 -0
- package/dist/composer/controller.js +6 -0
- package/dist/composer/editor-dom.d.ts +57 -0
- package/dist/composer/editor-dom.js +517 -0
- package/dist/composer/fuzzy.d.ts +4 -0
- package/dist/composer/fuzzy.js +49 -0
- package/dist/composer/index.d.ts +25 -0
- package/dist/composer/index.js +13 -0
- package/dist/composer/index.parts.d.ts +11 -0
- package/dist/composer/index.parts.js +15 -0
- package/dist/composer/internals.d.ts +30 -0
- package/dist/composer/internals.js +102 -0
- package/dist/composer/keyboard.d.ts +61 -0
- package/dist/composer/keyboard.js +75 -0
- package/dist/composer/panel.d.ts +28 -0
- package/dist/composer/panel.js +76 -0
- package/dist/composer/placeholder.d.ts +10 -0
- package/dist/composer/placeholder.js +8 -0
- package/dist/composer/popover.d.ts +12 -0
- package/dist/composer/popover.js +62 -0
- package/dist/composer/prefix-detection.d.ts +23 -0
- package/dist/composer/prefix-detection.js +67 -0
- package/dist/composer/root.d.ts +21 -0
- package/dist/composer/root.js +124 -0
- package/dist/composer/segments.d.ts +108 -0
- package/dist/composer/segments.js +366 -0
- package/dist/composer/store.d.ts +98 -0
- package/dist/composer/store.js +457 -0
- package/dist/composer/textarea.d.ts +39 -0
- package/dist/composer/textarea.js +64 -0
- package/dist/composer/trigger-tracker.d.ts +12 -0
- package/dist/composer/trigger-tracker.js +90 -0
- package/dist/composer/types.d.ts +127 -0
- package/dist/composer/types.js +4 -0
- package/dist/composer/use-composer-editor.d.ts +43 -0
- package/dist/composer/use-composer-editor.js +653 -0
- package/dist/generative/catalog.d.ts +177 -0
- package/dist/generative/catalog.js +20 -0
- package/dist/generative/index.d.ts +26 -0
- package/dist/generative/index.js +20 -0
- package/dist/generative/json-schema.d.ts +45 -0
- package/dist/generative/json-schema.js +216 -0
- package/dist/generative/markdown.d.ts +21 -0
- package/dist/generative/markdown.js +76 -0
- package/dist/generative/tree.d.ts +56 -0
- package/dist/generative/tree.js +365 -0
- package/dist/generative/use-tree.d.ts +12 -0
- package/dist/generative/use-tree.js +64 -0
- package/dist/internal/change-event-details.d.ts +12 -0
- package/dist/internal/change-event-details.js +16 -0
- package/dist/internal/collapsible.d.ts +22 -0
- package/dist/internal/collapsible.js +94 -0
- package/dist/internal/collection.d.ts +32 -0
- package/dist/internal/collection.js +72 -0
- package/dist/internal/iso-layout-effect.d.ts +2 -0
- package/dist/internal/iso-layout-effect.js +6 -0
- package/dist/internal/portal.d.ts +6 -0
- package/dist/internal/portal.js +18 -0
- package/dist/internal/primitive-props.d.ts +4 -0
- package/dist/internal/primitive-props.js +3 -0
- package/dist/internal/render/baseTypes.d.ts +18 -0
- package/dist/internal/render/baseTypes.js +4 -0
- package/dist/internal/render/empty.d.ts +3 -0
- package/dist/internal/render/empty.js +5 -0
- package/dist/internal/render/getReactElementRef.d.ts +5 -0
- package/dist/internal/render/getReactElementRef.js +15 -0
- package/dist/internal/render/getStateAttributesProps.d.ts +4 -0
- package/dist/internal/render/getStateAttributesProps.js +23 -0
- package/dist/internal/render/mergeObjects.d.ts +1 -0
- package/dist/internal/render/mergeObjects.js +14 -0
- package/dist/internal/render/mergeProps.d.ts +55 -0
- package/dist/internal/render/mergeProps.js +183 -0
- package/dist/internal/render/positioning.d.ts +33 -0
- package/dist/internal/render/positioning.js +112 -0
- package/dist/internal/render/reactVersion.d.ts +3 -0
- package/dist/internal/render/reactVersion.js +7 -0
- package/dist/internal/render/resolveClassName.d.ts +8 -0
- package/dist/internal/render/resolveClassName.js +12 -0
- package/dist/internal/render/resolveStyle.d.ts +8 -0
- package/dist/internal/render/resolveStyle.js +12 -0
- package/dist/internal/render/transition.d.ts +47 -0
- package/dist/internal/render/transition.js +259 -0
- package/dist/internal/render/types.d.ts +74 -0
- package/dist/internal/render/types.js +3 -0
- package/dist/internal/render/useMergedRefs.d.ts +21 -0
- package/dist/internal/render/useMergedRefs.js +102 -0
- package/dist/internal/render/useRefWithInit.d.ts +10 -0
- package/dist/internal/render/useRefWithInit.js +12 -0
- package/dist/internal/render/useRender.d.ts +71 -0
- package/dist/internal/render/useRender.js +12 -0
- package/dist/internal/render/useRenderElement.d.ts +62 -0
- package/dist/internal/render/useRenderElement.js +157 -0
- package/dist/internal/render/warn.d.ts +1 -0
- package/dist/internal/render/warn.js +15 -0
- package/dist/internal/safe-polygon.d.ts +21 -0
- package/dist/internal/safe-polygon.js +179 -0
- package/dist/internal/scroll-into-view-within.d.ts +1 -0
- package/dist/internal/scroll-into-view-within.js +26 -0
- package/dist/internal/standard-schema.d.ts +38 -0
- package/dist/internal/standard-schema.js +4 -0
- package/dist/internal/state-mappings.d.ts +8 -0
- package/dist/internal/state-mappings.js +17 -0
- package/dist/internal/use-controlled.d.ts +19 -0
- package/dist/internal/use-controlled.js +33 -0
- package/dist/message/index.d.ts +3 -0
- package/dist/message/index.js +3 -0
- package/dist/message/index.parts.d.ts +1 -0
- package/dist/message/index.parts.js +5 -0
- package/dist/message/message.d.ts +58 -0
- package/dist/message/message.js +93 -0
- package/dist/message-utils.d.ts +55 -0
- package/dist/message-utils.js +79 -0
- package/dist/nav/index.d.ts +4 -0
- package/dist/nav/index.js +5 -0
- package/dist/nav/index.parts.d.ts +3 -0
- package/dist/nav/index.parts.js +7 -0
- package/dist/nav/nav.d.ts +152 -0
- package/dist/nav/nav.js +660 -0
- package/dist/nav/store.d.ts +50 -0
- package/dist/nav/store.js +95 -0
- package/dist/reasoning/index.d.ts +3 -0
- package/dist/reasoning/index.js +3 -0
- package/dist/reasoning/index.parts.d.ts +1 -0
- package/dist/reasoning/index.parts.js +5 -0
- package/dist/reasoning/reasoning.d.ts +38 -0
- package/dist/reasoning/reasoning.js +67 -0
- package/dist/shell/index.d.ts +5 -0
- package/dist/shell/index.js +5 -0
- package/dist/shell/index.parts.d.ts +3 -0
- package/dist/shell/index.parts.js +7 -0
- package/dist/shell/shell.d.ts +108 -0
- package/dist/shell/shell.js +390 -0
- package/dist/shell/store.d.ts +112 -0
- package/dist/shell/store.js +134 -0
- package/dist/steps/index.d.ts +2 -0
- package/dist/steps/index.js +2 -0
- package/dist/steps/index.parts.d.ts +1 -0
- package/dist/steps/index.parts.js +5 -0
- package/dist/steps/steps.d.ts +90 -0
- package/dist/steps/steps.js +116 -0
- package/dist/tabs/anchored.d.ts +59 -0
- package/dist/tabs/anchored.js +109 -0
- package/dist/tabs/hover.d.ts +33 -0
- package/dist/tabs/hover.js +169 -0
- package/dist/tabs/index.d.ts +5 -0
- package/dist/tabs/index.js +4 -0
- package/dist/tabs/index.parts.d.ts +4 -0
- package/dist/tabs/index.parts.js +8 -0
- package/dist/tabs/store.d.ts +131 -0
- package/dist/tabs/store.js +248 -0
- package/dist/tabs/tabs.d.ts +217 -0
- package/dist/tabs/tabs.js +458 -0
- package/dist/thread/geometry.d.ts +23 -0
- package/dist/thread/geometry.js +48 -0
- package/dist/thread/index.d.ts +4 -0
- package/dist/thread/index.js +3 -0
- package/dist/thread/index.parts.d.ts +1 -0
- package/dist/thread/index.parts.js +5 -0
- package/dist/thread/stores.d.ts +21 -0
- package/dist/thread/stores.js +63 -0
- package/dist/thread/thread.d.ts +57 -0
- package/dist/thread/thread.js +663 -0
- package/dist/types.d.ts +58 -0
- package/dist/types.js +17 -0
- package/package.json +125 -4
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
// Command-list structural primitives used internally by the composer's command
|
|
4
|
+
// palette (see command-list.tsx). Pure markup with part-attribute hooks — selection
|
|
5
|
+
// and keyboard behavior live in the composer's command plumbing; styling belongs
|
|
6
|
+
// to the styled layer. Not a public export.
|
|
7
|
+
import { createContext, use, useId } from "react";
|
|
8
|
+
import { useRenderElement } from "../internal/render/useRenderElement.js";
|
|
9
|
+
const CommandsRoot = ({ className, render, style, ...elementProps }) => useRenderElement("div", { className, render, style }, { props: [{ "data-command-list": "" }, elementProps] });
|
|
10
|
+
// Group → its label association: the group mints the id, the label stamps it.
|
|
11
|
+
// role="group" keeps listbox→option ownership intact through the wrapper div.
|
|
12
|
+
const GroupLabelIdContext = createContext("");
|
|
13
|
+
const CommandsGroup = ({ className, render, style, ...elementProps }) => {
|
|
14
|
+
const labelId = useId();
|
|
15
|
+
const element = useRenderElement("div", { className, render, style }, {
|
|
16
|
+
props: [
|
|
17
|
+
{ role: "group", "aria-labelledby": labelId, "data-command-group": "" },
|
|
18
|
+
elementProps,
|
|
19
|
+
],
|
|
20
|
+
});
|
|
21
|
+
return _jsx(GroupLabelIdContext, { value: labelId, children: element });
|
|
22
|
+
};
|
|
23
|
+
const CommandsGroupLabel = ({ className, render, style, ...elementProps }) => {
|
|
24
|
+
const labelId = use(GroupLabelIdContext);
|
|
25
|
+
return useRenderElement("div", { className, render, style }, { props: [{ id: labelId || undefined, "data-command-group-label": "" }, elementProps] });
|
|
26
|
+
};
|
|
27
|
+
const CommandsItem = ({ highlighted = false, disabled = false, className, render, style, ...elementProps }) => useRenderElement("button", { className, render, style }, {
|
|
28
|
+
state: { highlighted, disabled },
|
|
29
|
+
// Options, not buttons: keyboard selection stays in the editor
|
|
30
|
+
// (activedescendant pattern), so rows must never be tab stops or
|
|
31
|
+
// announce as "button". Disabled is aria-disabled, never the native
|
|
32
|
+
// attribute — a natively disabled button swallows the mousedown that
|
|
33
|
+
// keeps focus in the editor.
|
|
34
|
+
props: [
|
|
35
|
+
{
|
|
36
|
+
role: "option",
|
|
37
|
+
tabIndex: -1,
|
|
38
|
+
"aria-disabled": disabled || undefined,
|
|
39
|
+
"data-command-item": "",
|
|
40
|
+
},
|
|
41
|
+
elementProps,
|
|
42
|
+
],
|
|
43
|
+
});
|
|
44
|
+
const CommandsItemLabel = ({ className, render, style, ...elementProps }) => useRenderElement("span", { className, render, style }, { props: [{ "data-command-item-label": "" }, elementProps] });
|
|
45
|
+
const CommandsItemDescription = ({ className, render, style, ...elementProps }) => useRenderElement("span", { className, render, style }, { props: [{ "data-command-item-description": "" }, elementProps] });
|
|
46
|
+
const CommandsEmpty = ({ children, className, render, style, ...elementProps }) => useRenderElement("div", { className, render, style }, { props: [{ "data-command-empty": "", children }, elementProps] });
|
|
47
|
+
const CommandsFooter = ({ className, render, style, ...elementProps }) => useRenderElement("div", { className, render, style }, { props: [{ "data-command-footer": "" }, elementProps] });
|
|
48
|
+
export const Commands = Object.assign(CommandsRoot, {
|
|
49
|
+
Group: CommandsGroup,
|
|
50
|
+
GroupLabel: CommandsGroupLabel,
|
|
51
|
+
Item: CommandsItem,
|
|
52
|
+
ItemLabel: CommandsItemLabel,
|
|
53
|
+
ItemDescription: CommandsItemDescription,
|
|
54
|
+
Empty: CommandsEmpty,
|
|
55
|
+
Footer: CommandsFooter,
|
|
56
|
+
});
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { PrimitiveProps } from "../internal/primitive-props.js";
|
|
2
|
+
export type ComposerContainerProps = PrimitiveProps<"div">;
|
|
3
|
+
export declare const ComposerContainer: ({ className, render, style, ...elementProps }: ComposerContainerProps) => import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>>;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useRenderElement } from "../internal/render/useRenderElement.js";
|
|
3
|
+
import { useComposerContextStore } from "./store.js";
|
|
4
|
+
export const ComposerContainer = ({ className, render, style, ...elementProps }) => {
|
|
5
|
+
const store = useComposerContextStore();
|
|
6
|
+
const handleMouseDown = (event) => {
|
|
7
|
+
const target = event.target;
|
|
8
|
+
// The editor and interactive controls own their mouse behavior. Calling
|
|
9
|
+
// preventDefault on a mousedown inside the editable area would cancel the
|
|
10
|
+
// browser's text-selection drag, so only hijack focus when the click lands
|
|
11
|
+
// on the surrounding chrome.
|
|
12
|
+
if (target.closest("button, a, input, [data-composer-editor]")) {
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
event.preventDefault();
|
|
16
|
+
store.controller.ensureFocus();
|
|
17
|
+
};
|
|
18
|
+
return useRenderElement("div", { className, render, style }, {
|
|
19
|
+
// Register the element so Composer.Popover can observe it for repositioning.
|
|
20
|
+
ref: store.containerRef,
|
|
21
|
+
// No role/tabIndex: the chrome is a mouse-only focus passthrough, not a
|
|
22
|
+
// control — keyboard users tab straight to the editor, and an unnamed
|
|
23
|
+
// focusable "button" would only add a broken tab stop.
|
|
24
|
+
props: [
|
|
25
|
+
{
|
|
26
|
+
"data-composer-container": "",
|
|
27
|
+
onMouseDown: handleMouseDown,
|
|
28
|
+
},
|
|
29
|
+
elementProps,
|
|
30
|
+
],
|
|
31
|
+
});
|
|
32
|
+
};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
// Imperative composer access from inside the tree. From outside it, the
|
|
3
|
+
// Composer.createStore() handle carries the same surface directly:
|
|
4
|
+
// store.controller.focus() — no hook, no context.
|
|
5
|
+
import { useComposerContextStore } from "./store.js";
|
|
6
|
+
export const useComposerController = () => useComposerContextStore().controller;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { ChipData } from "../chip-markdown.js";
|
|
2
|
+
import { type SegmentDoc } from "./segments.js";
|
|
3
|
+
export type ReadableNode = {
|
|
4
|
+
nodeType: number;
|
|
5
|
+
nodeName: string;
|
|
6
|
+
/** Text node content (nodeType 3). */
|
|
7
|
+
data?: string;
|
|
8
|
+
childNodes: ArrayLike<ReadableNode>;
|
|
9
|
+
getAttribute?: (name: string) => string | null;
|
|
10
|
+
};
|
|
11
|
+
export declare const toReadable: (node: Node) => ReadableNode;
|
|
12
|
+
/** The writer marks its padding <br>; the reader recognizes it structurally. */
|
|
13
|
+
export declare const PADDING_BREAK_ATTRIBUTE = "data-padding-break";
|
|
14
|
+
export declare const readDocumentFromDom: (root: ReadableNode, resolveChip: (id: string) => ChipData | null, makeId?: () => string) => {
|
|
15
|
+
doc: SegmentDoc;
|
|
16
|
+
dirty: boolean;
|
|
17
|
+
};
|
|
18
|
+
/** Logical position of (node, offset), or null when the point isn't inside root. */
|
|
19
|
+
export declare const logicalRangeFromDom: (root: ReadableNode, targetNode: ReadableNode, targetOffset: number) => number | null;
|
|
20
|
+
/** DOM point for a logical position, preferring text-node points. */
|
|
21
|
+
export declare const domPointFromLogical: (root: HTMLElement, position: number) => {
|
|
22
|
+
node: Node;
|
|
23
|
+
offset: number;
|
|
24
|
+
};
|
|
25
|
+
export type DomNodeSpec = {
|
|
26
|
+
kind: "text";
|
|
27
|
+
text: string;
|
|
28
|
+
} | {
|
|
29
|
+
kind: "br";
|
|
30
|
+
padding: boolean;
|
|
31
|
+
} | {
|
|
32
|
+
kind: "chip";
|
|
33
|
+
id: string;
|
|
34
|
+
};
|
|
35
|
+
export declare const documentToDomSpec: (doc: SegmentDoc) => DomNodeSpec[];
|
|
36
|
+
/**
|
|
37
|
+
* Render the canonical child list. Existing chip spans are reused by id —
|
|
38
|
+
* moving a node preserves its React portal; recreating it would remount the
|
|
39
|
+
* chip content. Returns the live id → element map for the portal layer.
|
|
40
|
+
*/
|
|
41
|
+
export declare const renderDocumentToDom: (root: HTMLElement, doc: SegmentDoc) => Map<string, HTMLElement>;
|
|
42
|
+
export declare const readSelectionRange: (root: HTMLElement) => {
|
|
43
|
+
start: number;
|
|
44
|
+
end: number;
|
|
45
|
+
} | null;
|
|
46
|
+
export declare const writeCaretToDom: (root: HTMLElement, start: number, end?: number) => void;
|
|
47
|
+
export declare const scrollCaretIntoView: (root: HTMLElement) => void;
|
|
48
|
+
export type BadgeToken = {
|
|
49
|
+
start: number;
|
|
50
|
+
end: number;
|
|
51
|
+
showPlaceholder: boolean;
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* Make the DOM match the token (or its absence). Returns true when the DOM
|
|
55
|
+
* changed — the caller must restore the caret afterwards.
|
|
56
|
+
*/
|
|
57
|
+
export declare const syncBadge: (root: HTMLElement, token: BadgeToken | null) => boolean;
|
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
// Editor DOM — the adapters between the contenteditable element and the flat
|
|
2
|
+
// segment model. The reader and position mapping are pure (they walk a
|
|
3
|
+
// minimal structural node view, so tests need no DOM); the materializers at
|
|
4
|
+
// the bottom are thin real-DOM wrappers over the pure spec builder.
|
|
5
|
+
//
|
|
6
|
+
// One taxonomy, three walkers: classifyNode below is the single answer to
|
|
7
|
+
// "what is this node?", and leafWidth the single answer to "how many logical
|
|
8
|
+
// positions does it occupy?". The reader, logicalRangeFromDom and
|
|
9
|
+
// domPointFromLogical all defer to them — when they disagree the caret and the
|
|
10
|
+
// model desync, so the rule lives in exactly one place.
|
|
11
|
+
//
|
|
12
|
+
// Writer/reader pact for line breaks: every "\n" renders as <br>, plus one
|
|
13
|
+
// padding <br> when the document is empty or ends with "\n" (an unpadded
|
|
14
|
+
// trailing line is unreachable/zero-height). The padding carries
|
|
15
|
+
// data-padding-break, so the reader identifies it structurally rather than
|
|
16
|
+
// inferring it from position — a native edit that strands it mid-document then
|
|
17
|
+
// reads as scaffolding (dropped, dirty) instead of a phantom newline. Unmarked
|
|
18
|
+
// trailing <br>s are the browser's own (Chrome keeps an emptied line box
|
|
19
|
+
// alive); the trailing trim covers those too.
|
|
20
|
+
// Round-trips: "" ↔ <br·pad>, "a\n" ↔ a<br><br·pad>, "a\n\n" ↔ a<br><br><br·pad>.
|
|
21
|
+
import { scrollIntoViewWithin } from "../internal/scroll-into-view-within.js";
|
|
22
|
+
import { nextChipId } from "./segments.js";
|
|
23
|
+
const TEXT_NODE = 3;
|
|
24
|
+
const ELEMENT_NODE = 1;
|
|
25
|
+
export const toReadable = (node) => node;
|
|
26
|
+
const childrenOf = (node) => Array.from(node.childNodes);
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Node taxonomy — the shared vocabulary. Every walker in this file switches on
|
|
29
|
+
// a NodeClass rather than re-deriving node kinds from attributes, so they
|
|
30
|
+
// cannot drift apart.
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
/** The writer marks its padding <br>; the reader recognizes it structurally. */
|
|
33
|
+
export const PADDING_BREAK_ATTRIBUTE = "data-padding-break";
|
|
34
|
+
// Elements whose appearance inside the editable means something rewrote our
|
|
35
|
+
// flat structure (paste leftovers, extensions) — content still reads, but the
|
|
36
|
+
// document gets renormalized from the model afterwards.
|
|
37
|
+
const BLOCK_NODE_NAMES = new Set([
|
|
38
|
+
"DIV",
|
|
39
|
+
"P",
|
|
40
|
+
"PRE",
|
|
41
|
+
"BLOCKQUOTE",
|
|
42
|
+
"UL",
|
|
43
|
+
"OL",
|
|
44
|
+
"LI",
|
|
45
|
+
"H1",
|
|
46
|
+
"H2",
|
|
47
|
+
"H3",
|
|
48
|
+
"H4",
|
|
49
|
+
"H5",
|
|
50
|
+
"H6",
|
|
51
|
+
"TABLE",
|
|
52
|
+
]);
|
|
53
|
+
// Precedence is load-bearing: a chip span is also contenteditable="false", so
|
|
54
|
+
// the chip test must come before the presentation test.
|
|
55
|
+
const classifyNode = (node) => {
|
|
56
|
+
if (node.nodeType === TEXT_NODE)
|
|
57
|
+
return { kind: "text", text: node.data ?? "" };
|
|
58
|
+
if (node.nodeType !== ELEMENT_NODE)
|
|
59
|
+
return { kind: "ignored" };
|
|
60
|
+
if (node.nodeName === "BR") {
|
|
61
|
+
const marked = (node.getAttribute?.(PADDING_BREAK_ATTRIBUTE) ?? null) !== null;
|
|
62
|
+
return marked ? { kind: "padding" } : { kind: "break" };
|
|
63
|
+
}
|
|
64
|
+
const chipId = node.getAttribute?.("data-chip-id") ?? null;
|
|
65
|
+
if (chipId !== null)
|
|
66
|
+
return { kind: "chip", id: chipId };
|
|
67
|
+
if (node.getAttribute?.("contenteditable") === "false")
|
|
68
|
+
return { kind: "presentation" };
|
|
69
|
+
if (BLOCK_NODE_NAMES.has(node.nodeName))
|
|
70
|
+
return { kind: "block" };
|
|
71
|
+
return { kind: "wrapper" };
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* Logical positions a leaf occupies. Containers are the sum of their children,
|
|
75
|
+
* so they are not leaves and answer 0 here — the walkers descend instead.
|
|
76
|
+
*/
|
|
77
|
+
const leafWidth = (classified) => {
|
|
78
|
+
switch (classified.kind) {
|
|
79
|
+
case "text":
|
|
80
|
+
return classified.text.length;
|
|
81
|
+
case "break":
|
|
82
|
+
case "chip":
|
|
83
|
+
return 1;
|
|
84
|
+
default:
|
|
85
|
+
return 0;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
const tokenizeDom = (root) => {
|
|
89
|
+
const tokens = [];
|
|
90
|
+
let dirty = false;
|
|
91
|
+
const descend = (node) => {
|
|
92
|
+
for (const child of childrenOf(node))
|
|
93
|
+
visit(child);
|
|
94
|
+
};
|
|
95
|
+
const visit = (node) => {
|
|
96
|
+
const classified = classifyNode(node);
|
|
97
|
+
switch (classified.kind) {
|
|
98
|
+
case "text":
|
|
99
|
+
// Empty text nodes are editing residue — dropping them keeps the
|
|
100
|
+
// padding rules below looking at real neighbours.
|
|
101
|
+
if (classified.text.length > 0)
|
|
102
|
+
tokens.push(classified);
|
|
103
|
+
return;
|
|
104
|
+
case "break":
|
|
105
|
+
case "padding":
|
|
106
|
+
tokens.push({ kind: classified.kind });
|
|
107
|
+
return;
|
|
108
|
+
case "chip":
|
|
109
|
+
tokens.push(classified);
|
|
110
|
+
return;
|
|
111
|
+
case "presentation":
|
|
112
|
+
case "ignored":
|
|
113
|
+
return;
|
|
114
|
+
case "block":
|
|
115
|
+
dirty = true;
|
|
116
|
+
descend(node);
|
|
117
|
+
return;
|
|
118
|
+
case "wrapper":
|
|
119
|
+
descend(node);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
descend(root);
|
|
124
|
+
return { tokens, dirty };
|
|
125
|
+
};
|
|
126
|
+
/**
|
|
127
|
+
* Drop the writer's trailing line-box scaffolding: our own marked padding, or
|
|
128
|
+
* an unmarked <br> the browser added to keep an emptied last line alive. Any
|
|
129
|
+
* padding token that survives this was stranded mid-document.
|
|
130
|
+
*/
|
|
131
|
+
const trimTrailingPadding = (tokens) => {
|
|
132
|
+
const last = tokens.at(-1);
|
|
133
|
+
if (last?.kind === "padding" || last?.kind === "break")
|
|
134
|
+
return tokens.slice(0, -1);
|
|
135
|
+
return tokens;
|
|
136
|
+
};
|
|
137
|
+
const foldTokens = (tokens, resolveChip, makeId) => {
|
|
138
|
+
const doc = [];
|
|
139
|
+
let dirty = false;
|
|
140
|
+
let pendingText = "";
|
|
141
|
+
const seenChipIds = new Set();
|
|
142
|
+
const flushText = () => {
|
|
143
|
+
if (pendingText.length === 0)
|
|
144
|
+
return;
|
|
145
|
+
const previous = doc.at(-1);
|
|
146
|
+
if (previous?.type === "text") {
|
|
147
|
+
doc[doc.length - 1] = { type: "text", text: previous.text + pendingText };
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
doc.push({ type: "text", text: pendingText });
|
|
151
|
+
}
|
|
152
|
+
pendingText = "";
|
|
153
|
+
};
|
|
154
|
+
for (const token of trimTrailingPadding(tokens)) {
|
|
155
|
+
switch (token.kind) {
|
|
156
|
+
case "text":
|
|
157
|
+
// Adjacent text concatenates logically, never via root.normalize() —
|
|
158
|
+
// WebKit collapses the caret when the selection's node gets merged.
|
|
159
|
+
pendingText += token.text.replace(/\u00A0/g, " ");
|
|
160
|
+
break;
|
|
161
|
+
case "break":
|
|
162
|
+
pendingText += "\n";
|
|
163
|
+
break;
|
|
164
|
+
case "padding":
|
|
165
|
+
// Survived the trim, so it is not the trailing scaffolding: a native
|
|
166
|
+
// edit stranded it mid-document. Not content — repaint from the model.
|
|
167
|
+
dirty = true;
|
|
168
|
+
break;
|
|
169
|
+
case "chip": {
|
|
170
|
+
// Unknown ids are leftovers from DOM the model doesn't know (mark
|
|
171
|
+
// dirty, skip); duplicated ids (clone paths) get a fresh identity.
|
|
172
|
+
const chip = resolveChip(token.id);
|
|
173
|
+
if (!chip) {
|
|
174
|
+
dirty = true;
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
flushText();
|
|
178
|
+
const id = seenChipIds.has(token.id) ? makeId() : token.id;
|
|
179
|
+
seenChipIds.add(token.id);
|
|
180
|
+
doc.push({ type: "chip", id, chip });
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
flushText();
|
|
186
|
+
return { doc, dirty };
|
|
187
|
+
};
|
|
188
|
+
export const readDocumentFromDom = (root, resolveChip, makeId = nextChipId) => {
|
|
189
|
+
const { tokens, dirty: structureDirty } = tokenizeDom(root);
|
|
190
|
+
const { doc, dirty: contentDirty } = foldTokens(tokens, resolveChip, makeId);
|
|
191
|
+
return { doc, dirty: structureDirty || contentDirty };
|
|
192
|
+
};
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
// Position mapping — DOM points ↔ logical positions, inverses of each other
|
|
195
|
+
// over leafWidth. Chips count 1, a real <br> counts 1 (it renders a "\n"),
|
|
196
|
+
// padding and presentation count 0, wrappers are transparent.
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
/** Logical position of (node, offset), or null when the point isn't inside root. */
|
|
199
|
+
export const logicalRangeFromDom = (root, targetNode, targetOffset) => {
|
|
200
|
+
let position = 0;
|
|
201
|
+
let found = null;
|
|
202
|
+
const contains = (parent, target) => childrenOf(parent).some((child) => child === target || contains(child, target));
|
|
203
|
+
// Element point: the offset is a child index — advance past that many
|
|
204
|
+
// children, then record where we landed.
|
|
205
|
+
const measureUpTo = (node, childIndex) => {
|
|
206
|
+
for (const child of childrenOf(node).slice(0, childIndex)) {
|
|
207
|
+
if (visit(child))
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
return false;
|
|
211
|
+
};
|
|
212
|
+
const visit = (node) => {
|
|
213
|
+
const classified = classifyNode(node);
|
|
214
|
+
if (node === targetNode && classified.kind !== "text" && classified.kind !== "chip") {
|
|
215
|
+
if (measureUpTo(node, targetOffset))
|
|
216
|
+
return true;
|
|
217
|
+
found = position;
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
switch (classified.kind) {
|
|
221
|
+
case "text":
|
|
222
|
+
if (node === targetNode) {
|
|
223
|
+
found = position + Math.min(targetOffset, classified.text.length);
|
|
224
|
+
return true;
|
|
225
|
+
}
|
|
226
|
+
position += leafWidth(classified);
|
|
227
|
+
return false;
|
|
228
|
+
case "break":
|
|
229
|
+
case "padding":
|
|
230
|
+
position += leafWidth(classified);
|
|
231
|
+
return false;
|
|
232
|
+
// A point inside a chip or a hint span clamps to that node's start; they
|
|
233
|
+
// differ only in width.
|
|
234
|
+
case "chip":
|
|
235
|
+
case "presentation":
|
|
236
|
+
if (node === targetNode || contains(node, targetNode)) {
|
|
237
|
+
found = position;
|
|
238
|
+
return true;
|
|
239
|
+
}
|
|
240
|
+
position += leafWidth(classified);
|
|
241
|
+
return false;
|
|
242
|
+
case "wrapper":
|
|
243
|
+
case "block":
|
|
244
|
+
for (const child of childrenOf(node)) {
|
|
245
|
+
if (visit(child))
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
248
|
+
return false;
|
|
249
|
+
case "ignored":
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
if (root === targetNode) {
|
|
254
|
+
return measureUpTo(root, targetOffset) ? found : position;
|
|
255
|
+
}
|
|
256
|
+
for (const child of childrenOf(root)) {
|
|
257
|
+
if (visit(child))
|
|
258
|
+
return found;
|
|
259
|
+
}
|
|
260
|
+
return found;
|
|
261
|
+
};
|
|
262
|
+
/** DOM point for a logical position, preferring text-node points. */
|
|
263
|
+
export const domPointFromLogical = (root, position) => {
|
|
264
|
+
let remaining = position;
|
|
265
|
+
const walk = (parent) => {
|
|
266
|
+
for (let index = 0; index < parent.childNodes.length; index++) {
|
|
267
|
+
const child = parent.childNodes[index];
|
|
268
|
+
if (!child)
|
|
269
|
+
continue;
|
|
270
|
+
const classified = classifyNode(toReadable(child));
|
|
271
|
+
switch (classified.kind) {
|
|
272
|
+
case "text": {
|
|
273
|
+
const width = leafWidth(classified);
|
|
274
|
+
if (remaining <= width)
|
|
275
|
+
return { node: child, offset: remaining };
|
|
276
|
+
remaining -= width;
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
// Atomic children: the point is the gap before them. Padding is
|
|
280
|
+
// zero-width, so the caret can never be written past the model's end.
|
|
281
|
+
case "break":
|
|
282
|
+
case "padding":
|
|
283
|
+
case "chip":
|
|
284
|
+
if (remaining === 0)
|
|
285
|
+
return { node: parent, offset: index };
|
|
286
|
+
remaining -= leafWidth(classified);
|
|
287
|
+
continue;
|
|
288
|
+
case "presentation":
|
|
289
|
+
case "ignored":
|
|
290
|
+
continue;
|
|
291
|
+
case "wrapper":
|
|
292
|
+
case "block": {
|
|
293
|
+
const inner = walk(child);
|
|
294
|
+
if (inner)
|
|
295
|
+
return inner;
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return remaining === 0 ? { node: parent, offset: parent.childNodes.length } : null;
|
|
301
|
+
};
|
|
302
|
+
return walk(root) ?? { node: root, offset: root.childNodes.length };
|
|
303
|
+
};
|
|
304
|
+
export const documentToDomSpec = (doc) => {
|
|
305
|
+
const specs = [];
|
|
306
|
+
for (const segment of doc) {
|
|
307
|
+
if (segment.type === "chip") {
|
|
308
|
+
specs.push({ kind: "chip", id: segment.id });
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
const lines = segment.text.split("\n");
|
|
312
|
+
lines.forEach((line, lineIndex) => {
|
|
313
|
+
if (lineIndex > 0)
|
|
314
|
+
specs.push({ kind: "br", padding: false });
|
|
315
|
+
if (line.length > 0)
|
|
316
|
+
specs.push({ kind: "text", text: line });
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
// Padding <br>: an empty document or one ending in a line break needs a
|
|
320
|
+
// trailing <br> for the last line to be reachable and have height.
|
|
321
|
+
const last = specs.at(-1);
|
|
322
|
+
if (!last || last.kind === "br")
|
|
323
|
+
specs.push({ kind: "br", padding: true });
|
|
324
|
+
return specs;
|
|
325
|
+
};
|
|
326
|
+
// ---------------------------------------------------------------------------
|
|
327
|
+
// Real-DOM materializers — thin, untested by design; all decisions live in
|
|
328
|
+
// the pure layers above.
|
|
329
|
+
// ---------------------------------------------------------------------------
|
|
330
|
+
const createChipSpan = (id) => {
|
|
331
|
+
const span = document.createElement("span");
|
|
332
|
+
span.contentEditable = "false";
|
|
333
|
+
span.setAttribute("data-mention-chip", "");
|
|
334
|
+
span.setAttribute("data-chip-id", id);
|
|
335
|
+
// A chip is atomic inline content: it must sit on the text baseline and must
|
|
336
|
+
// not inherit the editor's break-spaces whitespace mode, or its label wraps
|
|
337
|
+
// mid-chip.
|
|
338
|
+
span.style.display = "inline";
|
|
339
|
+
span.style.verticalAlign = "baseline";
|
|
340
|
+
span.style.whiteSpace = "normal";
|
|
341
|
+
return span;
|
|
342
|
+
};
|
|
343
|
+
const createBreak = (padding) => {
|
|
344
|
+
const br = document.createElement("br");
|
|
345
|
+
if (padding)
|
|
346
|
+
br.setAttribute(PADDING_BREAK_ATTRIBUTE, "");
|
|
347
|
+
return br;
|
|
348
|
+
};
|
|
349
|
+
/**
|
|
350
|
+
* Render the canonical child list. Existing chip spans are reused by id —
|
|
351
|
+
* moving a node preserves its React portal; recreating it would remount the
|
|
352
|
+
* chip content. Returns the live id → element map for the portal layer.
|
|
353
|
+
*/
|
|
354
|
+
export const renderDocumentToDom = (root, doc) => {
|
|
355
|
+
const existingSpans = new Map();
|
|
356
|
+
for (const span of root.querySelectorAll("[data-chip-id]")) {
|
|
357
|
+
const id = span.getAttribute("data-chip-id");
|
|
358
|
+
if (id && !existingSpans.has(id))
|
|
359
|
+
existingSpans.set(id, span);
|
|
360
|
+
}
|
|
361
|
+
const chipsById = new Map(doc.flatMap((segment) => segment.type === "chip" ? [[segment.id, segment.chip]] : []));
|
|
362
|
+
const chipElements = new Map();
|
|
363
|
+
const children = documentToDomSpec(doc).map((spec) => {
|
|
364
|
+
if (spec.kind === "text")
|
|
365
|
+
return document.createTextNode(spec.text);
|
|
366
|
+
if (spec.kind === "br")
|
|
367
|
+
return createBreak(spec.padding);
|
|
368
|
+
const span = existingSpans.get(spec.id) ?? createChipSpan(spec.id);
|
|
369
|
+
// Without an accessible boundary the chip reads as bare prose inside the
|
|
370
|
+
// textbox — label + type gives AT an atomic token ("Rasmus, @ mention").
|
|
371
|
+
const chip = chipsById.get(spec.id);
|
|
372
|
+
if (chip)
|
|
373
|
+
span.setAttribute("aria-label", `${chip.label}, ${chip.prefix} mention`);
|
|
374
|
+
chipElements.set(spec.id, span);
|
|
375
|
+
return span;
|
|
376
|
+
});
|
|
377
|
+
root.replaceChildren(...children);
|
|
378
|
+
return chipElements;
|
|
379
|
+
};
|
|
380
|
+
// ---------------------------------------------------------------------------
|
|
381
|
+
// Selection — reading the caret out of the document and writing it back, plus
|
|
382
|
+
// keeping it visible after a programmatic write.
|
|
383
|
+
// ---------------------------------------------------------------------------
|
|
384
|
+
export const readSelectionRange = (root) => {
|
|
385
|
+
const selection = root.ownerDocument.getSelection();
|
|
386
|
+
if (!selection || selection.rangeCount === 0)
|
|
387
|
+
return null;
|
|
388
|
+
const range = selection.getRangeAt(0);
|
|
389
|
+
if (!root.contains(range.startContainer) || !root.contains(range.endContainer))
|
|
390
|
+
return null;
|
|
391
|
+
const start = logicalRangeFromDom(toReadable(root), toReadable(range.startContainer), range.startOffset);
|
|
392
|
+
const end = logicalRangeFromDom(toReadable(root), toReadable(range.endContainer), range.endOffset);
|
|
393
|
+
if (start === null || end === null)
|
|
394
|
+
return null;
|
|
395
|
+
return start <= end ? { start, end } : { start: end, end: start };
|
|
396
|
+
};
|
|
397
|
+
export const writeCaretToDom = (root, start, end = start) => {
|
|
398
|
+
const selection = root.ownerDocument.getSelection();
|
|
399
|
+
if (!selection)
|
|
400
|
+
return;
|
|
401
|
+
const anchor = domPointFromLogical(root, start);
|
|
402
|
+
const focus = end === start ? anchor : domPointFromLogical(root, end);
|
|
403
|
+
selection.setBaseAndExtent(anchor.node, anchor.offset, focus.node, focus.offset);
|
|
404
|
+
};
|
|
405
|
+
// The caret's box. A collapsed caret on an empty line has none of its own —
|
|
406
|
+
// browsers return an all-zero rect, which would read as "above the view" — so
|
|
407
|
+
// measure the line instead: the <br> that follows it (a line break, or the
|
|
408
|
+
// padding break that gives an empty document its line). Null if neither has
|
|
409
|
+
// a box.
|
|
410
|
+
const measureCaret = (range) => {
|
|
411
|
+
const rect = range.getBoundingClientRect();
|
|
412
|
+
if (rect.height > 0)
|
|
413
|
+
return rect;
|
|
414
|
+
const next = range.startContainer.childNodes[range.startOffset];
|
|
415
|
+
if (next?.nodeName !== "BR")
|
|
416
|
+
return null;
|
|
417
|
+
const line = next.ownerDocument?.createRange();
|
|
418
|
+
if (!line)
|
|
419
|
+
return null;
|
|
420
|
+
line.selectNode(next);
|
|
421
|
+
const lineRect = line.getBoundingClientRect();
|
|
422
|
+
return lineRect.height > 0 ? lineRect : null;
|
|
423
|
+
};
|
|
424
|
+
// Native typing auto-scrolls; programmatic writes don't. Scroll the caret into
|
|
425
|
+
// view within the composer's own scroll areas, never the page around it. The
|
|
426
|
+
// caret is the selection's focus end: a restored selection can be taller than
|
|
427
|
+
// the view, and its top edge would win.
|
|
428
|
+
export const scrollCaretIntoView = (root) => {
|
|
429
|
+
const selection = root.ownerDocument.getSelection();
|
|
430
|
+
if (!selection?.focusNode)
|
|
431
|
+
return;
|
|
432
|
+
const caret = root.ownerDocument.createRange();
|
|
433
|
+
caret.setStart(selection.focusNode, selection.focusOffset);
|
|
434
|
+
caret.collapse(true);
|
|
435
|
+
const rect = measureCaret(caret);
|
|
436
|
+
if (!rect)
|
|
437
|
+
return;
|
|
438
|
+
scrollIntoViewWithin(rect, root, root.closest("[data-composer-root]") ?? root);
|
|
439
|
+
};
|
|
440
|
+
const badgeSpansOf = (root) => [
|
|
441
|
+
...root.querySelectorAll("[data-command-badge]"),
|
|
442
|
+
];
|
|
443
|
+
const unwrapBadgeSpan = (span) => {
|
|
444
|
+
const parent = span.parentNode;
|
|
445
|
+
if (!parent)
|
|
446
|
+
return;
|
|
447
|
+
// Presentation children (the hint span) are the badge's own chrome — they
|
|
448
|
+
// die with it rather than spilling into content.
|
|
449
|
+
for (const child of [...span.children]) {
|
|
450
|
+
if (classifyNode(toReadable(child)).kind === "presentation")
|
|
451
|
+
child.remove();
|
|
452
|
+
}
|
|
453
|
+
while (span.firstChild)
|
|
454
|
+
parent.insertBefore(span.firstChild, span);
|
|
455
|
+
parent.removeChild(span);
|
|
456
|
+
// No parent.normalize() — the reader concatenates fragmented text nodes,
|
|
457
|
+
// and merging the selection's node collapses the caret in WebKit.
|
|
458
|
+
};
|
|
459
|
+
// The badge's logical text length: direct text nodes only — the hint span is
|
|
460
|
+
// zero-width presentation and must not count toward the token range.
|
|
461
|
+
const badgeTextLength = (span) => {
|
|
462
|
+
let length = 0;
|
|
463
|
+
for (const child of [...span.childNodes]) {
|
|
464
|
+
if (child.nodeType === TEXT_NODE)
|
|
465
|
+
length += child.data.length;
|
|
466
|
+
}
|
|
467
|
+
return length;
|
|
468
|
+
};
|
|
469
|
+
/**
|
|
470
|
+
* Make the DOM match the token (or its absence). Returns true when the DOM
|
|
471
|
+
* changed — the caller must restore the caret afterwards.
|
|
472
|
+
*/
|
|
473
|
+
export const syncBadge = (root, token) => {
|
|
474
|
+
const spans = badgeSpansOf(root);
|
|
475
|
+
if (!token) {
|
|
476
|
+
if (spans.length === 0)
|
|
477
|
+
return false;
|
|
478
|
+
for (const span of spans)
|
|
479
|
+
unwrapBadgeSpan(span);
|
|
480
|
+
return true;
|
|
481
|
+
}
|
|
482
|
+
// Fast path: one span already covering exactly [start, end] with the right
|
|
483
|
+
// placeholder marker.
|
|
484
|
+
const [onlySpan] = spans;
|
|
485
|
+
if (spans.length === 1 && onlySpan) {
|
|
486
|
+
const span = onlySpan;
|
|
487
|
+
const parent = span.parentNode;
|
|
488
|
+
if (parent) {
|
|
489
|
+
const index = [...parent.childNodes].indexOf(span);
|
|
490
|
+
const spanStart = logicalRangeFromDom(toReadable(root), toReadable(parent), index);
|
|
491
|
+
const spanLength = badgeTextLength(span);
|
|
492
|
+
const hasPlaceholder = span.hasAttribute("data-command-placeholder");
|
|
493
|
+
if (spanStart === token.start &&
|
|
494
|
+
spanStart + spanLength === token.end &&
|
|
495
|
+
hasPlaceholder === token.showPlaceholder) {
|
|
496
|
+
return false;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
for (const span of spans)
|
|
501
|
+
unwrapBadgeSpan(span);
|
|
502
|
+
// Wrap [start, end]: a Range handles boundary text-node splitting for us.
|
|
503
|
+
// The token is text-only by construction (chips scan as whitespace), so the
|
|
504
|
+
// extracted fragment never partially covers a non-text node.
|
|
505
|
+
const startPoint = domPointFromLogical(root, token.start);
|
|
506
|
+
const endPoint = domPointFromLogical(root, token.end);
|
|
507
|
+
const range = root.ownerDocument.createRange();
|
|
508
|
+
range.setStart(startPoint.node, startPoint.offset);
|
|
509
|
+
range.setEnd(endPoint.node, endPoint.offset);
|
|
510
|
+
const span = root.ownerDocument.createElement("span");
|
|
511
|
+
span.setAttribute("data-command-badge", "");
|
|
512
|
+
if (token.showPlaceholder)
|
|
513
|
+
span.setAttribute("data-command-placeholder", "");
|
|
514
|
+
span.appendChild(range.extractContents());
|
|
515
|
+
range.insertNode(span);
|
|
516
|
+
return true;
|
|
517
|
+
};
|