@llui/markdown-editor 0.2.11 → 0.2.13

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.
@@ -0,0 +1,116 @@
1
+ // Math plugin — a block math node ($$…$$) rendered via the decorator bridge. The
2
+ // TeX source is editable inline (persists on blur); an optional `render` callback
3
+ // (e.g. KaTeX) turns it into a typeset preview. Round-trips to `$$tex$$`.
4
+
5
+ import { type ElementNode, type LexicalNode } from 'lexical'
6
+ import { $insertNodeToNearestRoot } from '@lexical/utils'
7
+ import type { ElementTransformer } from '@lexical/markdown'
8
+ import {
9
+ $createLLuiDecoratorNode,
10
+ $isLLuiDecoratorNode,
11
+ LLuiDecoratorNode,
12
+ decoratorBridge,
13
+ } from '@llui/lexical'
14
+ import { component, div, span, text, type Mountable, type Signal } from '@llui/dom'
15
+ import type { MarkdownPlugin } from './types.js'
16
+ import { renderedPreview, type PreviewRender } from './_preview.js'
17
+
18
+ const BRIDGE_TYPE = 'math'
19
+
20
+ interface MathData {
21
+ tex: string
22
+ }
23
+
24
+ function isMathData(value: unknown): value is MathData {
25
+ return typeof value === 'object' && value !== null && typeof (value as MathData).tex === 'string'
26
+ }
27
+
28
+ type MathMsg = { type: 'commit'; tex: string }
29
+
30
+ const stop = (e: Event): void => e.stopPropagation()
31
+
32
+ export interface MathPluginOptions {
33
+ /** Typeset TeX to an HTML string (e.g. via KaTeX). When omitted, the raw TeX is
34
+ * shown in a styled box. */
35
+ /** Render the TeX source to a preview. Return a DOM `Node` (mounted
36
+ * directly, no sanitization) or a **trusted HTML string** (injected as-is
37
+ * — sanitize it yourself, e.g. via DOMPurify, since it carries document
38
+ * content). See `renderedPreview`. */
39
+ render?: PreviewRender
40
+ }
41
+
42
+ export function mathPlugin(opts: MathPluginOptions = {}): MarkdownPlugin {
43
+ const bridge = decoratorBridge<MathData, MathData, MathMsg, never>(BRIDGE_TYPE, (data, api) =>
44
+ component<MathData, MathMsg, never>({
45
+ name: 'Math',
46
+ init: () => ({ tex: data.tex }),
47
+ update: (state, msg) => {
48
+ if (msg.type === 'commit') {
49
+ if (msg.tex === state.tex) return state
50
+ api.update({ tex: msg.tex })
51
+ return { tex: msg.tex }
52
+ }
53
+ return state
54
+ },
55
+ view: ({ state, send }) => {
56
+ const children: Mountable[] = [
57
+ span(
58
+ {
59
+ 'data-part': 'source',
60
+ contenteditable: 'true',
61
+ role: 'textbox',
62
+ 'aria-label': 'TeX source',
63
+ onKeyDown: stop,
64
+ onBeforeInput: stop,
65
+ onPaste: stop,
66
+ onBlur: (e: FocusEvent) =>
67
+ send({ type: 'commit', tex: (e.target as HTMLElement).textContent ?? '' }),
68
+ },
69
+ [text(state.at('tex') as Signal<string>)],
70
+ ),
71
+ ]
72
+ if (opts.render) {
73
+ children.push(renderedPreview(state.at('tex') as Signal<string>, opts.render, 'span'))
74
+ }
75
+ return [
76
+ div({ 'data-scope': 'md-math', 'data-part': 'root', contenteditable: 'false' }, children),
77
+ ]
78
+ },
79
+ }),
80
+ )
81
+
82
+ const transformer: ElementTransformer = {
83
+ dependencies: [LLuiDecoratorNode],
84
+ export: (node: LexicalNode): string | null => {
85
+ if (!$isLLuiDecoratorNode(node) || node.getBridgeType() !== BRIDGE_TYPE) return null
86
+ const data = node.getData()
87
+ return isMathData(data) ? `$$${data.tex}$$` : null
88
+ },
89
+ regExp: /^\$\$(.+)\$\$$/,
90
+ replace: (parentNode: ElementNode, _children, match): void => {
91
+ parentNode.replace($createLLuiDecoratorNode(BRIDGE_TYPE, { tex: match[1] ?? '' }))
92
+ },
93
+ type: 'element',
94
+ }
95
+
96
+ return {
97
+ name: 'math',
98
+ nodes: [LLuiDecoratorNode],
99
+ decorators: [bridge],
100
+ transformers: [transformer],
101
+ items: [
102
+ {
103
+ id: 'math',
104
+ label: 'Math block',
105
+ icon: 'math',
106
+ group: 'insert',
107
+ keywords: ['latex', 'tex', 'equation', 'formula'],
108
+ run: (editor) =>
109
+ editor.update(() =>
110
+ $insertNodeToNearestRoot($createLLuiDecoratorNode(BRIDGE_TYPE, { tex: 'e = mc^2' })),
111
+ ),
112
+ surfaces: ['slash', 'context'],
113
+ },
114
+ ],
115
+ }
116
+ }
@@ -0,0 +1,224 @@
1
+ // Mention plugin — an `@`-triggered typeahead. Same shape as the slash menu, but
2
+ // the candidates come from a configurable `source`, and choosing inserts the
3
+ // mention text (`@label`) rather than running a command. Demonstrates a second
4
+ // typeahead built on the plugin-UI seam with no new machinery.
5
+
6
+ import {
7
+ $getSelection,
8
+ $isRangeSelection,
9
+ $isTextNode,
10
+ COMMAND_PRIORITY_HIGH,
11
+ KEY_ARROW_DOWN_COMMAND,
12
+ KEY_ARROW_UP_COMMAND,
13
+ KEY_ENTER_COMMAND,
14
+ KEY_ESCAPE_COMMAND,
15
+ } from 'lexical'
16
+ import { mergeRegister } from '@lexical/utils'
17
+ import { div, each, text, type Signal } from '@llui/dom'
18
+ import { definePluginUI } from './ui.js'
19
+ import { OVERLAY_Z, hideOverlay, overlayRoot } from './overlay.js'
20
+ import type { MarkdownPlugin } from './types.js'
21
+
22
+ export interface Mention {
23
+ id: string
24
+ label: string
25
+ }
26
+
27
+ interface Row {
28
+ id: string
29
+ label: string
30
+ active: boolean
31
+ }
32
+
33
+ interface MentionState {
34
+ open: boolean
35
+ query: string
36
+ items: Row[]
37
+ index: number
38
+ x: number
39
+ y: number
40
+ }
41
+
42
+ type MentionMsg =
43
+ | { type: 'show'; query: string; items: Mention[]; x: number; y: number }
44
+ | { type: 'hide' }
45
+ | { type: 'move'; delta: number }
46
+ | { type: 'choose' }
47
+ | { type: 'click'; index: number }
48
+
49
+ type MentionEffect = { type: 'insert'; label: string; query: string }
50
+
51
+ const TRIGGER = /(?:^|\s)@(\w*)$/
52
+
53
+ const DEFAULT_MENTIONS: readonly Mention[] = [
54
+ { id: 'franco', label: 'Franco' },
55
+ { id: 'ada', label: 'Ada' },
56
+ { id: 'grace', label: 'Grace' },
57
+ { id: 'linus', label: 'Linus' },
58
+ { id: 'margaret', label: 'Margaret' },
59
+ ]
60
+
61
+ function withActive(items: readonly Mention[], index: number): Row[] {
62
+ return items.map((it, i) => ({ id: it.id, label: it.label, active: i === index }))
63
+ }
64
+
65
+ function $readQuery(): string | null {
66
+ const selection = $getSelection()
67
+ if (!$isRangeSelection(selection) || !selection.isCollapsed()) return null
68
+ const node = selection.anchor.getNode()
69
+ if (!$isTextNode(node)) return null
70
+ const before = node.getTextContent().slice(0, selection.anchor.offset)
71
+ const match = before.match(TRIGGER)
72
+ return match ? (match[1] ?? '') : null
73
+ }
74
+
75
+ function caretXY(): { x: number; y: number } {
76
+ if (typeof window === 'undefined') return { x: 0, y: 0 }
77
+ const sel = window.getSelection()
78
+ if (sel && sel.rangeCount > 0) {
79
+ const rect = sel.getRangeAt(0).getBoundingClientRect()
80
+ return { x: rect.left, y: rect.bottom + 4 }
81
+ }
82
+ return { x: 0, y: 0 }
83
+ }
84
+
85
+ export interface MentionPluginOptions {
86
+ /** Resolve candidates for a query (default: a small sample list). */
87
+ source?: (query: string) => readonly Mention[]
88
+ }
89
+
90
+ export function mentionPlugin(opts: MentionPluginOptions = {}): MarkdownPlugin {
91
+ const source =
92
+ opts.source ??
93
+ ((query: string) =>
94
+ DEFAULT_MENTIONS.filter((m) => m.label.toLowerCase().includes(query.toLowerCase())))
95
+
96
+ return {
97
+ name: 'mention',
98
+ register: (editor, ctx) => {
99
+ const isActive = (): boolean => editor.getEditorState().read(() => $readQuery() !== null)
100
+
101
+ const refresh = (): void => {
102
+ const query = editor.getEditorState().read(() => $readQuery())
103
+ if (query === null) {
104
+ ctx.emit({ type: 'plugin', name: 'mention', msg: { type: 'hide' } })
105
+ return
106
+ }
107
+ const items = [...source(query)].slice(0, 8)
108
+ const { x, y } = caretXY()
109
+ ctx.emit({ type: 'plugin', name: 'mention', msg: { type: 'show', query, items, x, y } })
110
+ }
111
+
112
+ const nav = (delta: number, e: KeyboardEvent | null): boolean => {
113
+ if (!isActive()) return false
114
+ e?.preventDefault()
115
+ ctx.emit({ type: 'plugin', name: 'mention', msg: { type: 'move', delta } })
116
+ return true
117
+ }
118
+
119
+ return mergeRegister(
120
+ editor.registerUpdateListener(() => refresh()),
121
+ editor.registerCommand(KEY_ARROW_DOWN_COMMAND, (e) => nav(1, e), COMMAND_PRIORITY_HIGH),
122
+ editor.registerCommand(KEY_ARROW_UP_COMMAND, (e) => nav(-1, e), COMMAND_PRIORITY_HIGH),
123
+ editor.registerCommand(
124
+ KEY_ENTER_COMMAND,
125
+ (e) => {
126
+ if (!isActive()) return false
127
+ e?.preventDefault()
128
+ ctx.emit({ type: 'plugin', name: 'mention', msg: { type: 'choose' } })
129
+ return true
130
+ },
131
+ COMMAND_PRIORITY_HIGH,
132
+ ),
133
+ editor.registerCommand(
134
+ KEY_ESCAPE_COMMAND,
135
+ () => {
136
+ if (!isActive()) return false
137
+ ctx.emit({ type: 'plugin', name: 'mention', msg: { type: 'hide' } })
138
+ return true
139
+ },
140
+ COMMAND_PRIORITY_HIGH,
141
+ ),
142
+ )
143
+ },
144
+ ui: definePluginUI<MentionState, MentionMsg, MentionEffect>({
145
+ init: () => ({ open: false, query: '', items: [], index: 0, x: 0, y: 0 }),
146
+ update: (state, msg) => {
147
+ switch (msg.type) {
148
+ case 'show':
149
+ return {
150
+ open: msg.items.length > 0,
151
+ query: msg.query,
152
+ items: withActive(msg.items, 0),
153
+ index: 0,
154
+ x: msg.x,
155
+ y: msg.y,
156
+ }
157
+ case 'hide':
158
+ return hideOverlay(state)
159
+ case 'move': {
160
+ if (!state.open || state.items.length === 0) return state
161
+ const index = (state.index + msg.delta + state.items.length) % state.items.length
162
+ return { ...state, index, items: withActive(state.items, index) }
163
+ }
164
+ case 'choose': {
165
+ const item = state.items[state.index]
166
+ if (!state.open || !item) return hideOverlay(state)
167
+ return [
168
+ { ...state, open: false },
169
+ [{ type: 'insert', label: item.label, query: state.query }],
170
+ ]
171
+ }
172
+ case 'click': {
173
+ const item = state.items[msg.index]
174
+ if (!item) return state
175
+ return [
176
+ { ...state, open: false },
177
+ [{ type: 'insert', label: item.label, query: state.query }],
178
+ ]
179
+ }
180
+ }
181
+ },
182
+ onEffect: (effect, ctx) => {
183
+ const editor = ctx.editor()
184
+ if (!editor) return
185
+ editor.update(() => {
186
+ const selection = $getSelection()
187
+ if (!$isRangeSelection(selection) || !selection.isCollapsed()) return
188
+ const node = selection.anchor.getNode()
189
+ if (!$isTextNode(node)) return
190
+ const offset = selection.anchor.offset
191
+ const start = offset - effect.query.length - 1
192
+ if (start >= 0) node.spliceText(start, effect.query.length + 1, `@${effect.label} `, true)
193
+ })
194
+ },
195
+ view: ({ state, send }) =>
196
+ overlayRoot({
197
+ open: state.at('open'),
198
+ x: state.at('x'),
199
+ y: state.at('y'),
200
+ zIndex: OVERLAY_Z.typeahead,
201
+ attrs: { 'data-scope': 'md-slash', 'data-part': 'root' },
202
+ children: () => [
203
+ each(state.at('items') as Signal<Row[]>, {
204
+ key: (it) => it.id,
205
+ render: (item, index) => [
206
+ div(
207
+ {
208
+ 'data-scope': 'md-slash',
209
+ 'data-part': 'option',
210
+ 'data-active': item.map((it) => (it.active ? '' : undefined)),
211
+ onMouseDown: (e: MouseEvent) => {
212
+ e.preventDefault()
213
+ send({ type: 'click', index: index.peek() })
214
+ },
215
+ },
216
+ [text(item.map((it) => `@${it.label}`))],
217
+ ),
218
+ ],
219
+ }),
220
+ ],
221
+ }),
222
+ }),
223
+ }
224
+ }
@@ -0,0 +1,134 @@
1
+ // Mermaid plugin — a fenced ```mermaid block rendered via the decorator bridge.
2
+ // The diagram source is editable (persists on blur); an optional `render` callback
3
+ // (e.g. the mermaid library) produces the diagram. Round-trips to a ```mermaid
4
+ // fence.
5
+ //
6
+ // NOTE: place `mermaidPlugin()` BEFORE `corePlugin()` in the plugins array so its
7
+ // multiline transformer matches ```mermaid before the generic code-block one.
8
+
9
+ import { type LexicalNode } from 'lexical'
10
+ import { $insertNodeToNearestRoot } from '@lexical/utils'
11
+ import type { MultilineElementTransformer } from '@lexical/markdown'
12
+ import {
13
+ $createLLuiDecoratorNode,
14
+ $isLLuiDecoratorNode,
15
+ LLuiDecoratorNode,
16
+ decoratorBridge,
17
+ } from '@llui/lexical'
18
+ import { component, div, text, type Mountable, type Signal } from '@llui/dom'
19
+ import type { MarkdownPlugin } from './types.js'
20
+ import { renderedPreview, type PreviewRender } from './_preview.js'
21
+
22
+ const BRIDGE_TYPE = 'mermaid'
23
+
24
+ interface MermaidData {
25
+ code: string
26
+ }
27
+
28
+ function isMermaidData(value: unknown): value is MermaidData {
29
+ return (
30
+ typeof value === 'object' && value !== null && typeof (value as MermaidData).code === 'string'
31
+ )
32
+ }
33
+
34
+ type MermaidMsg = { type: 'commit'; code: string }
35
+
36
+ const stop = (e: Event): void => e.stopPropagation()
37
+
38
+ export interface MermaidPluginOptions {
39
+ /** Render the diagram source to an HTML string (e.g. mermaid). When omitted,
40
+ * the raw source is shown in a styled box. */
41
+ /** Render the mermaid source to a preview. Return a DOM `Node` (mounted
42
+ * directly, no sanitization) or a **trusted HTML string** (injected as-is
43
+ * — sanitize it yourself, e.g. via DOMPurify, since it carries document
44
+ * content). See `renderedPreview`. */
45
+ render?: PreviewRender
46
+ }
47
+
48
+ export function mermaidPlugin(opts: MermaidPluginOptions = {}): MarkdownPlugin {
49
+ const bridge = decoratorBridge<MermaidData, MermaidData, MermaidMsg, never>(
50
+ BRIDGE_TYPE,
51
+ (data, api) =>
52
+ component<MermaidData, MermaidMsg, never>({
53
+ name: 'Mermaid',
54
+ init: () => ({ code: data.code }),
55
+ update: (state, msg) => {
56
+ if (msg.type === 'commit') {
57
+ if (msg.code === state.code) return state
58
+ api.update({ code: msg.code })
59
+ return { code: msg.code }
60
+ }
61
+ return state
62
+ },
63
+ view: ({ state, send }) => {
64
+ const children: Mountable[] = [
65
+ div(
66
+ {
67
+ 'data-part': 'source',
68
+ contenteditable: 'true',
69
+ role: 'textbox',
70
+ 'aria-label': 'Mermaid source',
71
+ onKeyDown: stop,
72
+ onBeforeInput: stop,
73
+ onPaste: stop,
74
+ onBlur: (e: FocusEvent) =>
75
+ send({ type: 'commit', code: (e.target as HTMLElement).textContent ?? '' }),
76
+ },
77
+ [text(state.at('code') as Signal<string>)],
78
+ ),
79
+ ]
80
+ if (opts.render) {
81
+ children.push(renderedPreview(state.at('code') as Signal<string>, opts.render))
82
+ }
83
+ return [
84
+ div(
85
+ { 'data-scope': 'md-mermaid', 'data-part': 'root', contenteditable: 'false' },
86
+ children,
87
+ ),
88
+ ]
89
+ },
90
+ }),
91
+ )
92
+
93
+ const transformer: MultilineElementTransformer = {
94
+ dependencies: [LLuiDecoratorNode],
95
+ export: (node: LexicalNode): string | null => {
96
+ if (!$isLLuiDecoratorNode(node) || node.getBridgeType() !== BRIDGE_TYPE) return null
97
+ const data = node.getData()
98
+ return isMermaidData(data) ? '```mermaid\n' + data.code + '\n```' : null
99
+ },
100
+ regExpStart: /^```mermaid$/,
101
+ regExpEnd: /^```$/,
102
+ replace: (rootNode, _children, _startMatch, _endMatch, linesInBetween): boolean => {
103
+ const lines = [...(linesInBetween ?? [])]
104
+ while (lines.length > 0 && lines[0] === '') lines.shift()
105
+ while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop()
106
+ rootNode.append($createLLuiDecoratorNode(BRIDGE_TYPE, { code: lines.join('\n') }))
107
+ return true
108
+ },
109
+ type: 'multiline-element',
110
+ }
111
+
112
+ return {
113
+ name: 'mermaid',
114
+ nodes: [LLuiDecoratorNode],
115
+ decorators: [bridge],
116
+ transformers: [transformer],
117
+ items: [
118
+ {
119
+ id: 'mermaid',
120
+ label: 'Diagram',
121
+ icon: 'mermaid',
122
+ group: 'insert',
123
+ keywords: ['mermaid', 'diagram', 'flowchart', 'graph'],
124
+ run: (editor) =>
125
+ editor.update(() =>
126
+ $insertNodeToNearestRoot(
127
+ $createLLuiDecoratorNode(BRIDGE_TYPE, { code: 'graph TD\n A --> B' }),
128
+ ),
129
+ ),
130
+ surfaces: ['slash', 'context'],
131
+ },
132
+ ],
133
+ }
134
+ }
@@ -0,0 +1,138 @@
1
+ // Shared overlay primitive for plugin-UI surfaces (context menu, slash/mention
2
+ // typeaheads, floating toolbar, table tools). Every one of these is a portaled,
3
+ // viewport-positioned panel whose `{ open, x, y }` lives in pure, JSON state and
4
+ // is rendered with `show → portal → fixed-positioned div`. Factoring that single
5
+ // shape here keeps positioning testable (a plain style string, not imperative
6
+ // floating-ui calls) and removes the copy-paste that otherwise accumulates one
7
+ // near-identical state machine per overlay plugin.
8
+ //
9
+ // Anchored positioning via `@llui/components`' `attachFloating` (floating-ui) was
10
+ // considered: it gives flip/shift/auto-update for free but needs a live element
11
+ // ref and async, jsdom-untestable DOM writes — a poor fit for overlays whose
12
+ // position is derived from a transient caret/pointer/element rect each `register`
13
+ // tick. The pure-state idiom below covers scroll/resize via {@link onViewportChange}.
14
+ //
15
+ // ## Stacking order (z-index)
16
+ // A single documented scale so overlays never tie and obscure each other:
17
+ // 60 — typeaheads (slash, mention) — caret-anchored, lowest
18
+ // 61 — context menu — pointer-anchored
19
+ // 62 — floating selection toolbar — selection-anchored bubble
20
+ // 63 — table tools — element-anchored, sits over a table
21
+ // The values live with each plugin's view; this comment is the source of truth.
22
+
23
+ import {
24
+ div,
25
+ derived,
26
+ onMount,
27
+ portal,
28
+ show,
29
+ type Mountable,
30
+ type Renderable,
31
+ type Signal,
32
+ } from '@llui/dom'
33
+ import { registerNestedLayer } from '@llui/components/utils'
34
+
35
+ export const OVERLAY_Z = {
36
+ typeahead: 60,
37
+ contextMenu: 61,
38
+ floatingToolbar: 62,
39
+ tableTools: 63,
40
+ } as const
41
+
42
+ const toMountables = (r: Renderable): Mountable[] => [...r]
43
+
44
+ // Unique marker per overlay instance so its portal root can be located while
45
+ // open. Every overlayRoot is a body-level sibling portal; without declaring it
46
+ // as a nested layer, a host `dialog.overlay()` mis-reads an interaction inside
47
+ // it as "outside" and dismisses (see @llui/components registerNestedLayer).
48
+ let layerSeq = 0
49
+ const NESTED_LAYER_ATTR = 'data-llui-nested-layer'
50
+
51
+ export interface OverlayRootConfig {
52
+ /** Whether the overlay is shown. */
53
+ open: Signal<boolean>
54
+ /** Viewport x of the anchor point (px). */
55
+ x: Signal<number>
56
+ /** Viewport y of the anchor point (px). */
57
+ y: Signal<number>
58
+ /** Stacking level — use {@link OVERLAY_Z}. */
59
+ zIndex: number
60
+ /** Extra CSS appended after positioning (e.g. a centering/lift transform). */
61
+ transform?: string
62
+ /** Attributes for the positioned element (`data-scope`/`data-part`, handlers). */
63
+ attrs: Record<string, unknown>
64
+ /** Optional siblings rendered inside the portal before the root (e.g. a backdrop). */
65
+ before?: () => Renderable
66
+ /** The positioned element's children. */
67
+ children: () => Renderable
68
+ }
69
+
70
+ /**
71
+ * A portaled, `position:fixed` overlay placed at `(x, y)` and shown while `open`.
72
+ * Returns the `Renderable` a plugin's `view` yields directly. The emitted style is
73
+ * a deterministic string — `position:fixed;left:${x}px;top:${y}px;z-index:${z}` plus
74
+ * an optional `transform` — so positioning is unit-testable.
75
+ */
76
+ export function overlayRoot(cfg: OverlayRootConfig): Renderable {
77
+ const style = derived(
78
+ cfg.x,
79
+ cfg.y,
80
+ (x, y) =>
81
+ `position:fixed;left:${x}px;top:${y}px;z-index:${cfg.zIndex}` +
82
+ (cfg.transform ? `;${cfg.transform}` : ''),
83
+ ) as Signal<string>
84
+ const layerId = `mdo-${++layerSeq}`
85
+ return [
86
+ // Register once for the overlay's lifetime; the resolver returns the live
87
+ // portal root only while open (and nothing when closed/unmounted), so the
88
+ // single registration tracks open/closed without per-toggle churn.
89
+ onMount(() =>
90
+ registerNestedLayer(() => {
91
+ if (typeof document === 'undefined') return []
92
+ const el = document.querySelector(`[${NESTED_LAYER_ATTR}="${layerId}"]`)
93
+ return el ? [el] : []
94
+ }),
95
+ ),
96
+ show(cfg.open, () => [
97
+ portal(() => [
98
+ ...(cfg.before ? toMountables(cfg.before()) : []),
99
+ div({ ...cfg.attrs, [NESTED_LAYER_ATTR]: layerId, style }, toMountables(cfg.children())),
100
+ ]),
101
+ ]),
102
+ ]
103
+ }
104
+
105
+ /** Guarded close: collapse `open` to false, preserving the reference when already
106
+ * closed so the host doesn't reconcile a no-op. */
107
+ export function hideOverlay<S extends { open: boolean }>(state: S): S {
108
+ return state.open ? { ...state, open: false } : state
109
+ }
110
+
111
+ /**
112
+ * Run `run` (debounced to one call per animation frame) whenever the viewport
113
+ * changes — any ancestor scroll or a window resize. Overlays anchored to a
114
+ * persistent element (a table) or a live selection must reposition on scroll;
115
+ * editor update listeners alone never fire for a plain scroll. Returns a cleanup
116
+ * that removes the listeners. No-op (and cleanup is a no-op) outside the browser.
117
+ */
118
+ export function onViewportChange(run: () => void): () => void {
119
+ if (typeof window === 'undefined' || typeof requestAnimationFrame !== 'function') {
120
+ return () => {}
121
+ }
122
+ let pending = 0
123
+ const handler = (): void => {
124
+ if (pending) return
125
+ pending = requestAnimationFrame(() => {
126
+ pending = 0
127
+ run()
128
+ })
129
+ }
130
+ // Capture phase so scrolls in any nested scroll container are caught.
131
+ window.addEventListener('scroll', handler, true)
132
+ window.addEventListener('resize', handler)
133
+ return () => {
134
+ window.removeEventListener('scroll', handler, true)
135
+ window.removeEventListener('resize', handler)
136
+ if (pending) cancelAnimationFrame(pending)
137
+ }
138
+ }