@weasel-js/text-dom 0.0.0-stage → 1.9.4

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 orochi235
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,27 @@
1
- # Temporary Holding Version
1
+ # @weasel-js/text-dom
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ In-place text editing over a canvas: a `contenteditable` overlay that reads and
4
+ writes styled runs.
5
+
6
+ Part of [weasel](https://github.com/orochi235/weasel), a domain-agnostic 2D
7
+ scene-graph canvas kit for React. See the
8
+ [API reference](https://orochi235.github.io/weasel/api/).
9
+
10
+ ```sh
11
+ npm install @weasel-js/text-dom
12
+ ```
13
+
14
+ `useTextEdit` mounts an editable element over a box on screen, set in the same
15
+ face, size, and alignment the canvas draws the text in, and hands back the
16
+ edited text as `StyledRun`s from `@weasel-js/text` when the edit commits. It
17
+ takes a screen pose and a `TextStyle`; it does not know about a scene graph or
18
+ a renderer. `useSceneTextEdit` in `@weasel-js/core` is this hook wired to a
19
+ scene node.
20
+
21
+ While an edit is open the hook reports the selection's shared style
22
+ (`MIXED` where the selected runs disagree) and applies a style or a flag such
23
+ as bold to the selected range.
24
+
25
+ `runsToDom` and `domToRuns` are the conversion between runs and the overlay's
26
+ DOM, and `charOffsetToDomPosition` / `domPositionToCharOffset` map a character
27
+ offset in the text to a DOM position and back.
@@ -0,0 +1,345 @@
1
+ import { FlagKey, StyledRun, TextStyle, TextVerticalAlign, TextPaint, RangeStyle, RunStylePatch, ResolvedTextStyle } from '@weasel-js/text';
2
+
3
+ /**
4
+ * In-place text editing via a contenteditable overlay positioned over the
5
+ * text node's screen-space pose. Enter / blur commit; Shift+Enter inserts a
6
+ * newline; Escape cancels. The hook owns the DOM lifecycle of the overlay
7
+ * element — caller just provides a container, the lookup hooks, and a
8
+ * `setText` callback (which is responsible for op-batching/undo).
9
+ *
10
+ * The text layer should hide the node it's currently editing
11
+ * (`isHidden: (n) => n.id === editingId`) so the overlay isn't drawn twice.
12
+ */
13
+
14
+ /**
15
+ * A styling a shortcut or a control toggles. The additive booleans, plus the
16
+ * two `script` values — which are exclusive rather than additive, so they
17
+ * toggle against one enum rather than each owning a boolean.
18
+ */
19
+ type StyleToggle = FlagKey | 'super' | 'sub';
20
+ /**
21
+ * The caret's character range within the text being edited. Half-open
22
+ * `[start, end)` over the concatenated run text, normalized so `start <= end`
23
+ * regardless of which way the user dragged. `start === end` is a collapsed
24
+ * caret — a real position, not the absence of one, which is why the hook
25
+ * reports `null` rather than a zero-width range when there is no caret.
26
+ */
27
+ interface TextEditSelection {
28
+ start: number;
29
+ end: number;
30
+ }
31
+ /** Screen-space pose passed to `useTextEdit` so the overlay can be placed and sized in CSS pixels. */
32
+ interface TextEditScreenPose {
33
+ /** Top-left in CSS pixels relative to `container`. Always screen pixels,
34
+ * including when `zoom` is set — the scale is anchored at this point, not
35
+ * translated by it. */
36
+ x: number;
37
+ y: number;
38
+ /** Pre-scale — CSS pixels, or world units when `zoom` is set. */
39
+ width: number;
40
+ height: number;
41
+ /** Pre-scale font size: `style.fontSize * zoom` when `zoom` is omitted, the
42
+ * world-unit `style.fontSize` when it is set. */
43
+ fontSize: number;
44
+ /** Effective on-screen line height multiplier (defaults to style.lineHeight). */
45
+ lineHeight?: number;
46
+ /**
47
+ * Where the text sits within `height`, as the canvas places it. `'center'`
48
+ * and `'bottom'` size the overlay to its own content and offset it by the
49
+ * slack, re-measured as typing changes the line count. Default `'top'`.
50
+ */
51
+ verticalAlign?: TextVerticalAlign;
52
+ /**
53
+ * CSS scale applied to the overlay (`transform: scale(zoom)`, anchored at
54
+ * its top-left). Every other size on this pose, and every typographic
55
+ * metric the hook writes, is then **pre-scale** — pass world units and the
56
+ * transform does the world→screen conversion.
57
+ *
58
+ * This is the only way run-level typography can be correct at a zoom other
59
+ * than 1. `runsToDom` emits run `fontSize` / `letterSpacing` in world units
60
+ * and `domToRuns` reads them straight back; threading a scale through the
61
+ * writer and its inverse through the reader would go lossy on fractional
62
+ * zooms. Scaling the whole overlay instead leaves that serializer pure and
63
+ * scales node-level and run-level values by the same factor for free.
64
+ *
65
+ * Omit it (the default, `1`) and the pose is plain screen pixels — the hook
66
+ * then infers the world→screen factor from `fontSize / style.fontSize` to
67
+ * scale node-level `letterSpacing`, and run-level overrides are left
68
+ * unscaled. Correct only at zoom 1.
69
+ */
70
+ zoom?: number;
71
+ }
72
+ /** A box in CSS pixels relative to `useTextEdit`'s `container`. */
73
+ interface TextEditClipRect {
74
+ x: number;
75
+ y: number;
76
+ width: number;
77
+ height: number;
78
+ }
79
+ /** What one edit session changed, as `UseTextEditOptions.commit` receives it. */
80
+ interface TextEditCommit {
81
+ text: string;
82
+ runs?: StyledRun[];
83
+ style?: TextStyle;
84
+ }
85
+ /** Options for `useTextEdit`. */
86
+ interface UseTextEditOptions {
87
+ /** Element the overlay is appended to. Must be `position: relative`/absolute. */
88
+ container: HTMLElement | null;
89
+ /** Read the current text for `id`. */
90
+ getText: (id: string) => string;
91
+ /** Read style for `id` (used for font setup on the overlay). */
92
+ getStyle: (id: string) => TextStyle | undefined;
93
+ /**
94
+ * Read the node's paint for `id` — its `data.fill` / `data.stroke`. The
95
+ * overlay paints its text and caret from the fill, so omitting this
96
+ * renders every node's editor in the default black however the node
97
+ * itself is painted.
98
+ */
99
+ getPaint?: (id: string) => TextPaint | undefined;
100
+ /**
101
+ * Write style back for `id`. Optional; needed only to turn a flag **off**
102
+ * inside a node whose own `TextStyle` sets it. Run flags are additive, so
103
+ * that edit is expressed by lowering the node flag and raising it on the
104
+ * runs outside the selection — which needs somewhere to put the new node
105
+ * style. Without this or `commit`, such a toggle is declined and nothing
106
+ * changes. See `flagRange` in `@weasel-js/text`.
107
+ *
108
+ * Called when the edit commits, never during it: the session holds the
109
+ * rewritten style until then (see `UseTextEditReturn.nodeStyle`), so a
110
+ * canceled edit writes nothing.
111
+ */
112
+ setStyle?: (id: string, style: TextStyle) => void;
113
+ /** Read screen-space pose for `id`. Called per frame while editing. */
114
+ getScreenPose: (id: string) => TextEditScreenPose | null;
115
+ /**
116
+ * The box, in CSS pixels relative to `container`, the overlay is visible
117
+ * within — where the canvas clips the glyphs the overlay stands in for.
118
+ * Re-read every frame. Omit it, or return `null`, to clip nothing.
119
+ *
120
+ * The clip cannot scroll, so a caret moving past its edge leaves the page
121
+ * where it is; a scrollable container would drag the canvas after the caret.
122
+ */
123
+ getClipRect?: () => TextEditClipRect | null;
124
+ /** Commit text. Caller wraps in op/undo. Required unless `commit` is given. */
125
+ setText?: (id: string, text: string) => void;
126
+ /**
127
+ * Take the whole edit in one call, so the caller can make it one write and
128
+ * one undo step. When given, it replaces `setText`, `setRuns`, and
129
+ * `setStyle`, none of which is called. `runs` is present when runs are in
130
+ * play (see `setRuns`), `style` when the edit rewrote the node's own style
131
+ * (see `setStyle`).
132
+ */
133
+ commit?: (id: string, edit: TextEditCommit) => void;
134
+ /**
135
+ * Optional: read the current rich-text runs for `id`. When provided AND
136
+ * returns a non-empty array, the overlay renders the runs as styled
137
+ * `<span data-run>` elements; otherwise the overlay is seeded with plain
138
+ * text via `getText`.
139
+ */
140
+ getRuns?: (id: string) => readonly StyledRun[] | undefined;
141
+ /**
142
+ * Optional: commit rich-text runs back to the node. When omitted, only
143
+ * `setText` is called with the plain-text form on commit. When provided,
144
+ * commit calls both `setText` (with `runsToPlainText(runs)`) and
145
+ * `setRuns(id, runs)` — but only when runs are actually in play: the node
146
+ * already had some, or the edit produced styling. A plain-text edit of a
147
+ * plain-text node still calls `setText` alone, so a node that has never
148
+ * been styled doesn't grow a single-run `runs` array just for being edited.
149
+ */
150
+ setRuns?: (id: string, runs: StyledRun[]) => void;
151
+ /**
152
+ * Is `el` part of the editor's own chrome — a character-options bar, a
153
+ * color popover, anything whose whole purpose is to style the text being
154
+ * edited? Focus moving into one does not end the edit.
155
+ *
156
+ * Without this, the controls the feature exists for are exactly what
157
+ * destroys it: clicking a size field blurs the overlay, blur commits, and
158
+ * the caret the control was about to act on is gone. Toggle buttons can
159
+ * dodge it by `preventDefault()`-ing their own mousedown, but a field the
160
+ * user has to type into cannot.
161
+ *
162
+ * Chrome focus does not disturb the reported `selection` either — see
163
+ * `UseTextEditReturn.selection`.
164
+ */
165
+ isEditorChrome?: (el: Element) => boolean;
166
+ /** What Escape does to the edit: `'cancel'` (the default) drops it,
167
+ * `'commit'` keeps the text as Enter would. */
168
+ escape?: 'cancel' | 'commit';
169
+ /**
170
+ * How long, in ms, an edit opening waits for the overlay's face before
171
+ * showing the text in the fallback. Defaults to {@link TEXT_EDIT_FONT_HOLD}.
172
+ *
173
+ * The overlay stands in for canvas text the host has hidden, so the wait
174
+ * shows nothing at all; past it, the fallback shows and reflows to the real
175
+ * face when that lands. A face already loaded — the usual case, since
176
+ * `@weasel-js/font` builds it as soon as its bytes arrive — shows at once.
177
+ * `0` never waits.
178
+ */
179
+ fontHold?: number;
180
+ }
181
+ /**
182
+ * The default {@link UseTextEditOptions.fontHold}: at 100ms a blank edit
183
+ * still reads as an instant response, and it is the block period CSS gives
184
+ * `font-display: fallback` for the same trade.
185
+ */
186
+ declare const TEXT_EDIT_FONT_HOLD = 100;
187
+ /** Options for `useTextEdit().startEdit`. */
188
+ interface StartEditOptions {
189
+ /**
190
+ * Where to place the caret on edit start. Number = caret offset (0..text.length);
191
+ * `'all'` selects the whole text (default — preserves the prior behavior).
192
+ */
193
+ caret?: number | 'all';
194
+ }
195
+ /** Return shape of `useTextEdit`. */
196
+ interface UseTextEditReturn {
197
+ editingId: string | null;
198
+ startEdit: (id: string, opts?: StartEditOptions) => void;
199
+ cancelEdit: () => void;
200
+ /** End the edit and write it. An edit that leaves the text, the runs, and
201
+ * the node's style as it found them writes nothing. */
202
+ commit: () => void;
203
+ isEditing: (id: string) => boolean;
204
+ /**
205
+ * The node-wide style this edit has rewritten and not yet written back, or
206
+ * `null` when it has rewritten none (the node's own style stands) or
207
+ * nothing is being edited. Lowering a flag the node sets rewrites it; the
208
+ * write waits for the commit. A control showing the node's style during an
209
+ * edit reads this first.
210
+ */
211
+ nodeStyle: TextStyle | null;
212
+ /**
213
+ * The caret's character range, or `null` when nothing is being edited. A
214
+ * collapsed caret reports `{ start: n, end: n }`, so `null` and "caret at
215
+ * n" stay distinguishable — a styling written at a collapsed caret arms
216
+ * {@link UseTextEditReturn.pendingStyle} instead of restyling a range.
217
+ *
218
+ * Follows the DOM selection, which browsers (and jsdom) report from a task
219
+ * rather than synchronously; anything this hook writes itself updates it
220
+ * synchronously. A DOM selection that leaves the overlay does **not** clear
221
+ * it: that is what happens when the user clicks a styling control, and
222
+ * reporting `null` there would read as "collapsed caret" and send the
223
+ * control's patch to the node instead of to the range. It is cleared on
224
+ * `startEdit` and when the edit ends.
225
+ */
226
+ selection: TextEditSelection | null;
227
+ /**
228
+ * The styling shared by every run in `selection` — a concrete value where
229
+ * the range agrees, `MIXED` where it doesn't. `null` exactly when
230
+ * `selection` is `null`. A collapsed caret reports the styling *at* the
231
+ * caret (the run to its left, or the run to its right at offset 0), which
232
+ * is what the next typed character inherits.
233
+ *
234
+ * This does not include {@link UseTextEditReturn.pendingStyle}. A control
235
+ * showing what the next character will look like wants both, merged in
236
+ * that order; one showing what is already written wants only this.
237
+ */
238
+ rangeStyle: RangeStyle | null;
239
+ /**
240
+ * Styling armed at a collapsed caret, applied to the next character typed
241
+ * and then dropped. `{}` when nothing is armed — which is always the case
242
+ * while `selection` covers a real range, since a range is styled directly.
243
+ *
244
+ * Moving the caret abandons it, as in any other editor.
245
+ */
246
+ pendingStyle: RunStylePatch;
247
+ /**
248
+ * Write `patch` over `selection`. A no-op with no active edit or an empty
249
+ * patch. Over a real range this restyles the runs under it; at a collapsed
250
+ * caret it merges into `pendingStyle` instead, so the styling lands on
251
+ * what gets typed next rather than on text the user didn't select.
252
+ *
253
+ * Lowering a flag the *node* sets is neither of those — a run cannot say
254
+ * "not italic" — so it rewrites instead: the node flag is cleared and raised
255
+ * on every run outside the range. Bold is spared that: a run's own
256
+ * `fontWeight` can say "regular", so it is written over the range.
257
+ *
258
+ * The caret survives, so a second style can be applied without
259
+ * re-selecting, and `rangeStyle` reflects the write before this returns.
260
+ */
261
+ applyStyleToSelection: (patch: RunStylePatch) => void;
262
+ /**
263
+ * Toggle one styling over `selection` — set it if the range doesn't
264
+ * uniformly carry it, clear it if it does. The shape a B / I / x² control
265
+ * or a keyboard shortcut wants, and what this hook's own shortcuts call.
266
+ */
267
+ toggleStyle: (toggle: StyleToggle) => void;
268
+ }
269
+ /** In-place text editing via a contenteditable overlay positioned over the text node's screen-space pose. */
270
+ declare function useTextEdit(opts: UseTextEditOptions): UseTextEditReturn;
271
+ /**
272
+ * The overlay's `top`, in container pixels, for content `contentHeight` tall
273
+ * (pre-scale, like the rest of `pose`): the pose's top, shifted by the same
274
+ * `verticalAlign` slack the canvas applies, plus `drop` — how far below the
275
+ * CSS line's baseline the canvas puts its own, from {@link baselineDrop}.
276
+ * @internal
277
+ */
278
+ declare function overlayTop(pose: TextEditScreenPose, contentHeight: number, drop?: number): number;
279
+ /**
280
+ * The canvas and CSS both hang a line's baseline half the leading plus one
281
+ * ascent below the line top, from the same ascent rule, but each engine
282
+ * rounds the font's metrics and that leading its own way, and a face that
283
+ * states no ascent and descent hangs from its ascent alone. So the difference
284
+ * is read from both sides rather than predicted: the canvas's from its own layout, the
285
+ * overlay's from a zero-height inline box on a hidden line set exactly as the
286
+ * overlay is. Pre-scale; `0` when either side has nothing to measure.
287
+ * @internal
288
+ */
289
+ declare function baselineDrop(el: HTMLElement, style: ResolvedTextStyle, pose: TextEditScreenPose, lineHeight: number): number;
290
+
291
+ /**
292
+ * The DOM position of plain-text character position `offset`: a text node
293
+ * and an offset into it wherever one holds the position, otherwise an element
294
+ * and a child index (just past a `<br>` that ends the text). Offsets beyond
295
+ * the text clamp to its end. Returns `{ node: parent, offset: 0 }` for an
296
+ * overlay with no text.
297
+ */
298
+ declare function charOffsetToDomPosition(parent: HTMLElement, offset: number): {
299
+ node: Node;
300
+ offset: number;
301
+ } | null;
302
+ /**
303
+ * Inverse of `charOffsetToDomPosition`: the characters that precede the DOM
304
+ * position `(node, offset)`.
305
+ *
306
+ * When `node` is an element the DOM offset indexes *child nodes*, not
307
+ * characters — `(overlay, 0)` is the start of the text and `(overlay,
308
+ * childNodes.length)` its end. That shape is not exotic: `Range.
309
+ * selectNodeContents`, which `useTextEdit` uses for its select-all caret,
310
+ * produces exactly it. So an element position is resolved by counting the
311
+ * pieces that end at or before the boundary point.
312
+ */
313
+ declare function domPositionToCharOffset(parent: HTMLElement, node: Node, offset: number): number;
314
+
315
+ /**
316
+ * Pure DOM ↔ `StyledRun[]` serializers for the contenteditable overlay
317
+ * used by `useTextEdit`. The overlay's children are a flat sequence of
318
+ * `<span data-run>` elements, each carrying one run's text and inline
319
+ * styles. Newlines inside a run are literal `\n` characters; the overlay
320
+ * has `white-space: pre-wrap` so they render as line breaks. The browser
321
+ * breaks at no other UAX #14 hard break, so each of those is written as a
322
+ * `<span data-break>` holding a `\n` in its place — one character for one,
323
+ * which keeps DOM offsets equal to source offsets.
324
+ *
325
+ * A small-caps run is the one exception to one text node per span: its
326
+ * lowercase letters sit in `<span data-small-caps>` pieces, uppercased and
327
+ * sized at the scale the canvas draws them with, since a browser's own
328
+ * synthesis uses a fixed factor of its own. The pieces are presentation
329
+ * only — `domToRuns` reads straight through them.
330
+ */
331
+
332
+ /** Walk an overlay tree and emit a coalesced `StyledRun[]`. */
333
+ declare function domToRuns(parent: HTMLElement): StyledRun[];
334
+ /** What a run inherits from its node, for the overlay's small caps: which
335
+ * runs have it, and the face and transform that decide their pieces. */
336
+ type OverlayRunBase = Pick<ResolvedTextStyle, 'fontFamily' | 'fontWeight' | 'fontStyle' | 'textTransform' | 'fontVariantCaps'>;
337
+ /**
338
+ * Build a flat sequence of `<span data-run>` children from `runs`, replacing
339
+ * any existing children of `parent`. `base` is the node's style, which a run
340
+ * inheriting small caps needs to be split by; without it the kit defaults
341
+ * stand in.
342
+ */
343
+ declare function runsToDom(runs: readonly StyledRun[], parent: HTMLElement, base?: OverlayRunBase): void;
344
+
345
+ export { type StartEditOptions, type StyleToggle, TEXT_EDIT_FONT_HOLD, type TextEditClipRect, type TextEditCommit, type TextEditScreenPose, type TextEditSelection, type UseTextEditOptions, type UseTextEditReturn, baselineDrop, charOffsetToDomPosition, domPositionToCharOffset, domToRuns, overlayTop, runsToDom, useTextEdit };