@weasel-js/text-dom 0.0.0-stage → 1.10.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 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,32 @@
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
+ A paste or a drop goes in as runs, never as the clipboard's markup: its blocks
26
+ become lines, its table cells are set apart by tabs, and it keeps its bold,
27
+ italic, underline, strikethrough, overline, and super- and subscript. Face,
28
+ size, and color come from the text it lands in.
29
+
30
+ `runsToDom` and `domToRuns` are the conversion between runs and the overlay's
31
+ DOM, and `charOffsetToDomPosition` / `domPositionToCharOffset` map a character
32
+ offset in the text to a DOM position and back.
@@ -0,0 +1,315 @@
1
+ import { FlagKey, StyledRun, TextStyle, TextVerticalAlign, TextPaint, RangeStyle, RunStylePatch, ResolvedTextStyle } from '@weasel-js/text';
2
+
3
+ /** The option, return, and pose types of `useTextEdit`. */
4
+
5
+ /**
6
+ * A styling a shortcut or a control toggles. The additive booleans, plus the
7
+ * two `script` values — which are exclusive rather than additive, so they
8
+ * toggle against one enum rather than each owning a boolean.
9
+ */
10
+ type StyleToggle = FlagKey | 'super' | 'sub';
11
+ /**
12
+ * The caret's character range within the text being edited. Half-open
13
+ * `[start, end)` over the concatenated run text, normalized so `start <= end`
14
+ * regardless of which way the user dragged. `start === end` is a collapsed
15
+ * caret — a real position, not the absence of one, which is why the hook
16
+ * reports `null` rather than a zero-width range when there is no caret.
17
+ */
18
+ interface TextEditSelection {
19
+ start: number;
20
+ end: number;
21
+ }
22
+ /** Screen-space pose passed to `useTextEdit` so the overlay can be placed and sized in CSS pixels. */
23
+ interface TextEditScreenPose {
24
+ /** Top-left in CSS pixels relative to `container`. Always screen pixels,
25
+ * including when `zoom` is set — the scale is anchored at this point, not
26
+ * translated by it. */
27
+ x: number;
28
+ y: number;
29
+ /** Pre-scale — CSS pixels, or world units when `zoom` is set. */
30
+ width: number;
31
+ height: number;
32
+ /** Pre-scale font size: `style.fontSize * zoom` when `zoom` is omitted, the
33
+ * world-unit `style.fontSize` when it is set. */
34
+ fontSize: number;
35
+ /** Effective on-screen line height multiplier (defaults to style.lineHeight). */
36
+ lineHeight?: number;
37
+ /**
38
+ * Where the text sits within `height`, as the canvas places it. `'center'`
39
+ * and `'bottom'` size the overlay to its own content and offset it by the
40
+ * slack, re-measured as typing changes the line count. Default `'top'`.
41
+ */
42
+ verticalAlign?: TextVerticalAlign;
43
+ /**
44
+ * CSS scale applied to the overlay (`transform: scale(zoom)`, anchored at
45
+ * its top-left). Every other size on this pose, and every typographic
46
+ * metric the hook writes, is then **pre-scale** — pass world units and the
47
+ * transform does the world→screen conversion.
48
+ *
49
+ * This is the only way run-level typography can be correct at a zoom other
50
+ * than 1. Omit it (the default, `1`) and the pose is plain screen pixels —
51
+ * the hook then infers the world→screen factor from
52
+ * `fontSize / style.fontSize` to scale node-level `letterSpacing`, and
53
+ * run-level overrides are left unscaled.
54
+ */
55
+ zoom?: number;
56
+ /**
57
+ * The frame the text is drawn in, when its box is turned or skewed: the
58
+ * linear part `[a, b, c, d]` (`x' = a·x + c·y`, `y' = b·x + d·y`) applied
59
+ * about the box's center. `x`, `y`, `width`, and `height` stay those of
60
+ * the box before it. Absent is the identity.
61
+ */
62
+ frame?: readonly [a: number, b: number, c: number, d: number];
63
+ }
64
+ /** A box in CSS pixels relative to `useTextEdit`'s `container`. */
65
+ interface TextEditClipRect {
66
+ x: number;
67
+ y: number;
68
+ width: number;
69
+ height: number;
70
+ }
71
+ /** What one edit session changed, as `UseTextEditOptions.commit` receives it. */
72
+ interface TextEditCommit {
73
+ text: string;
74
+ runs?: StyledRun[];
75
+ style?: TextStyle;
76
+ }
77
+ /** Options for `useTextEdit`. */
78
+ interface UseTextEditOptions {
79
+ /** Element the overlay is appended to. Must be `position: relative`/absolute. */
80
+ container: HTMLElement | null;
81
+ /** Read the current text for `id`. */
82
+ getText: (id: string) => string;
83
+ /** Read style for `id` (used for font setup on the overlay). */
84
+ getStyle: (id: string) => TextStyle | undefined;
85
+ /**
86
+ * Read the node's paint for `id` — its `data.fill` / `data.stroke`. The
87
+ * overlay paints its text and caret from the fill, so omitting this
88
+ * renders every node's editor in the default black however the node
89
+ * itself is painted.
90
+ */
91
+ getPaint?: (id: string) => TextPaint | undefined;
92
+ /**
93
+ * Write style back for `id`. Optional; needed only to turn a flag **off**
94
+ * inside a node whose own `TextStyle` sets it. Run flags are additive, so
95
+ * that edit is expressed by lowering the node flag and raising it on the
96
+ * runs outside the selection — which needs somewhere to put the new node
97
+ * style. Without this or `commit`, such a toggle is declined and nothing
98
+ * changes. See `flagRange` in `@weasel-js/text`.
99
+ *
100
+ * Called when the edit commits, never during it: the session holds the
101
+ * rewritten style until then (see `UseTextEditReturn.nodeStyle`), so a
102
+ * canceled edit writes nothing.
103
+ */
104
+ setStyle?: (id: string, style: TextStyle) => void;
105
+ /** Read screen-space pose for `id`. Called per frame while editing. */
106
+ getScreenPose: (id: string) => TextEditScreenPose | null;
107
+ /**
108
+ * The box, in CSS pixels relative to `container`, the overlay is visible
109
+ * within — where the canvas clips the glyphs the overlay stands in for.
110
+ * Re-read every frame. Omit it, or return `null`, to clip nothing.
111
+ *
112
+ * The clip cannot scroll, so a caret moving past its edge leaves the page
113
+ * where it is; a scrollable container would drag the canvas after the caret.
114
+ */
115
+ getClipRect?: () => TextEditClipRect | null;
116
+ /** Commit text. Caller wraps in op/undo. Required unless `commit` is given. */
117
+ setText?: (id: string, text: string) => void;
118
+ /**
119
+ * Take the whole edit in one call, so the caller can make it one write and
120
+ * one undo step. When given, it replaces `setText`, `setRuns`, and
121
+ * `setStyle`, none of which is called. `runs` is present when runs are in
122
+ * play (see `setRuns`), `style` when the edit rewrote the node's own style
123
+ * (see `setStyle`).
124
+ */
125
+ commit?: (id: string, edit: TextEditCommit) => void;
126
+ /**
127
+ * Optional: read the current rich-text runs for `id`. When provided AND
128
+ * returns a non-empty array, the overlay renders the runs as styled
129
+ * `<span data-run>` elements; otherwise the overlay is seeded with plain
130
+ * text via `getText`.
131
+ */
132
+ getRuns?: (id: string) => readonly StyledRun[] | undefined;
133
+ /**
134
+ * Optional: commit rich-text runs back to the node. When omitted, only
135
+ * `setText` is called with the plain-text form on commit. When provided,
136
+ * commit calls both `setText` (with `runsToPlainText(runs)`) and
137
+ * `setRuns(id, runs)` — but only when runs are actually in play: the node
138
+ * already had some, or the edit produced styling. A plain-text edit of a
139
+ * plain-text node still calls `setText` alone.
140
+ */
141
+ setRuns?: (id: string, runs: StyledRun[]) => void;
142
+ /**
143
+ * Is `el` part of the editor's own chrome — a character-options bar, a
144
+ * color popover, anything whose whole purpose is to style the text being
145
+ * edited? Focus moving into one does not end the edit.
146
+ *
147
+ * Without this, clicking a size field blurs the overlay, blur commits, and
148
+ * the caret the control was about to act on is gone. Toggle buttons can
149
+ * dodge it by `preventDefault()`-ing their own mousedown, but a field the
150
+ * user has to type into cannot.
151
+ *
152
+ * Chrome focus does not disturb the reported `selection` either — see
153
+ * `UseTextEditReturn.selection`.
154
+ */
155
+ isEditorChrome?: (el: Element) => boolean;
156
+ /** What Escape does to the edit: `'cancel'` (the default) drops it,
157
+ * `'commit'` keeps the text as Enter would. */
158
+ escape?: 'cancel' | 'commit';
159
+ /**
160
+ * How long, in ms, an edit opening waits for the overlay's face before
161
+ * showing the text in the fallback. Defaults to {@link TEXT_EDIT_FONT_HOLD}.
162
+ *
163
+ * The overlay stands in for canvas text the host has hidden, so the wait
164
+ * shows nothing at all; past it, the fallback shows and reflows to the real
165
+ * face when that lands. A face already loaded shows at once. `0` never waits.
166
+ */
167
+ fontHold?: number;
168
+ }
169
+ /**
170
+ * The default {@link UseTextEditOptions.fontHold}: at 100ms a blank edit
171
+ * still reads as an instant response, and it is the block period CSS gives
172
+ * `font-display: fallback` for the same trade.
173
+ */
174
+ declare const TEXT_EDIT_FONT_HOLD = 100;
175
+ /** Options for `useTextEdit().startEdit`. */
176
+ interface StartEditOptions {
177
+ /** Where to place the caret on edit start. Number = caret offset (0..text.length);
178
+ * `'all'` selects the whole text (default). */
179
+ caret?: number | 'all';
180
+ }
181
+ /** Return shape of `useTextEdit`. */
182
+ interface UseTextEditReturn {
183
+ editingId: string | null;
184
+ startEdit: (id: string, opts?: StartEditOptions) => void;
185
+ cancelEdit: () => void;
186
+ /** End the edit and write it. An edit that leaves the text, the runs, and
187
+ * the node's style as it found them writes nothing. */
188
+ commit: () => void;
189
+ isEditing: (id: string) => boolean;
190
+ /**
191
+ * The node-wide style this edit has rewritten and not yet written back, or
192
+ * `null` when it has rewritten none (the node's own style stands) or
193
+ * nothing is being edited. Lowering a flag the node sets rewrites it; the
194
+ * write waits for the commit. A control showing the node's style during an
195
+ * edit reads this first.
196
+ */
197
+ nodeStyle: TextStyle | null;
198
+ /**
199
+ * The caret's character range, or `null` when nothing is being edited. A
200
+ * collapsed caret reports `{ start: n, end: n }`, so `null` and "caret at
201
+ * n" stay distinguishable — a styling written at a collapsed caret arms
202
+ * {@link UseTextEditReturn.pendingStyle} instead of restyling a range.
203
+ *
204
+ * Follows the DOM selection, which browsers report from a task
205
+ * rather than synchronously; anything this hook writes itself updates it
206
+ * synchronously. A DOM selection that leaves the overlay does **not** clear
207
+ * it: that is what happens when the user clicks a styling control, and
208
+ * reporting `null` there would read as "collapsed caret" and send the
209
+ * control's patch to the node instead of to the range. It is cleared on
210
+ * `startEdit` and when the edit ends.
211
+ */
212
+ selection: TextEditSelection | null;
213
+ /**
214
+ * The styling shared by every run in `selection` — a concrete value where
215
+ * the range agrees, `MIXED` where it doesn't. `null` exactly when
216
+ * `selection` is `null`. A collapsed caret reports the styling *at* the
217
+ * caret (the run to its left, or the run to its right at offset 0), which
218
+ * is what the next typed character inherits.
219
+ *
220
+ * This does not include {@link UseTextEditReturn.pendingStyle}. A control
221
+ * showing what the next character will look like wants both, merged in
222
+ * that order; one showing what is already written wants only this.
223
+ */
224
+ rangeStyle: RangeStyle | null;
225
+ /**
226
+ * Styling armed at a collapsed caret, applied to the next character typed
227
+ * and then dropped. `{}` when nothing is armed — which is always the case
228
+ * while `selection` covers a real range, since a range is styled directly.
229
+ * Moving the caret abandons it.
230
+ */
231
+ pendingStyle: RunStylePatch;
232
+ /**
233
+ * What a styling control shows during an edit: the node's own style and
234
+ * paint, `rangeStyle` over it, and `pendingStyle` last — what the user
235
+ * will see themselves type. `null` when nothing is being edited.
236
+ */
237
+ effectiveStyle: RangeStyle | null;
238
+ /**
239
+ * Write `patch` over `selection`. A no-op with no active edit or an empty
240
+ * patch. Over a real range this restyles the runs under it; at a collapsed
241
+ * caret it merges into `pendingStyle` instead, so the styling lands on
242
+ * what gets typed next rather than on text the user didn't select.
243
+ *
244
+ * Lowering a flag the *node* sets is neither of those — a run cannot say
245
+ * "not italic" — so it rewrites instead: the node flag is cleared and raised
246
+ * on every run outside the range. Bold is spared that: a run's own
247
+ * `fontWeight` can say "regular", so it is written over the range.
248
+ *
249
+ * The caret survives, so a second style can be applied without
250
+ * re-selecting, and `rangeStyle` reflects the write before this returns.
251
+ */
252
+ applyStyleToSelection: (patch: RunStylePatch) => void;
253
+ /** Toggle one styling over `selection` — set it if the range doesn't
254
+ * uniformly carry it, clear it if it does. */
255
+ toggleStyle: (toggle: StyleToggle) => void;
256
+ }
257
+
258
+ /**
259
+ * In-place text editing via a contenteditable overlay positioned over the
260
+ * text node's screen-space pose. Enter / blur commit; Shift+Enter inserts a
261
+ * newline. The text layer should hide the node it's currently editing
262
+ * (`isHidden: (n) => n.id === editingId`) so the overlay isn't drawn twice.
263
+ */
264
+
265
+ /** In-place text editing via a contenteditable overlay positioned over the text node's screen-space pose. */
266
+ declare function useTextEdit(opts: UseTextEditOptions): UseTextEditReturn;
267
+
268
+ /**
269
+ * The DOM position of plain-text character position `offset`: a text node
270
+ * and an offset into it wherever one holds the position, otherwise an element
271
+ * and a child index (just past a `<br>` that ends the text). Offsets beyond
272
+ * the text clamp to its end. Returns `{ node: parent, offset: 0 }` for an
273
+ * overlay with no text.
274
+ */
275
+ declare function charOffsetToDomPosition(parent: HTMLElement, offset: number): {
276
+ node: Node;
277
+ offset: number;
278
+ } | null;
279
+ /**
280
+ * Inverse of `charOffsetToDomPosition`: the characters that precede the DOM
281
+ * position `(node, offset)`.
282
+ *
283
+ * When `node` is an element the DOM offset indexes *child nodes*, not
284
+ * characters — `(overlay, 0)` is the start of the text and `(overlay,
285
+ * childNodes.length)` its end. That shape is not exotic: `Range.
286
+ * selectNodeContents`, which `useTextEdit` uses for its select-all caret,
287
+ * produces exactly it. So an element position is resolved by counting the
288
+ * pieces that end at or before the boundary point.
289
+ */
290
+ declare function domPositionToCharOffset(parent: HTMLElement, node: Node, offset: number): number;
291
+
292
+ /**
293
+ * DOM ↔ `StyledRun[]` serializers for the contenteditable overlay: a flat
294
+ * sequence of `<span data-run>` elements, one per run. The browser breaks at
295
+ * `\n` and at no other UAX #14 hard break, so each of those is written as a
296
+ * `<span data-break>` holding a `\n` in its place — one character for one,
297
+ * which keeps DOM offsets equal to source offsets.
298
+ *
299
+ * A small-caps run's lowercase letters sit in `<span data-small-caps>` pieces,
300
+ * uppercased and sized at the scale the canvas draws them with, since a
301
+ * browser's own synthesis uses a fixed factor of its own.
302
+ */
303
+
304
+ /** Walk an overlay tree and emit a coalesced `StyledRun[]`. */
305
+ declare function domToRuns(parent: HTMLElement): StyledRun[];
306
+ /** What a run inherits from its node, for the overlay's small caps: which
307
+ * runs have it, and the face and transform that decide their pieces. */
308
+ type OverlayRunBase = Pick<ResolvedTextStyle, 'fontFamily' | 'fontWeight' | 'fontStyle' | 'textTransform' | 'fontVariantCaps'>;
309
+ /**
310
+ * Replace `parent`'s children with one `<span data-run>` per run. `base` is
311
+ * the node's style, which a run inheriting small caps is split by.
312
+ */
313
+ declare function runsToDom(runs: readonly StyledRun[], parent: HTMLElement, base?: OverlayRunBase): void;
314
+
315
+ export { type OverlayRunBase, type StartEditOptions, type StyleToggle, TEXT_EDIT_FONT_HOLD, type TextEditClipRect, type TextEditCommit, type TextEditScreenPose, type TextEditSelection, type UseTextEditOptions, type UseTextEditReturn, charOffsetToDomPosition, domPositionToCharOffset, domToRuns, runsToDom, useTextEdit };