@atolljs/islands 0.1.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.
@@ -0,0 +1,656 @@
1
+ import {
2
+ allocId,
3
+ bumpOpsVersion,
4
+ getActiveInstance,
5
+ getLastActiveInstance,
6
+ getInstanceSize,
7
+ instances,
8
+ markInstanceActive,
9
+ pushOp,
10
+ registerHandler,
11
+ unregisterHandler,
12
+ } from '../instance';
13
+ import type { ElementInstance, HostInstance, TextInstance } from '../instance';
14
+ import type { EventPayload, Op } from '../../ops';
15
+
16
+ import {
17
+ ProxyNode,
18
+ ProxyFragment,
19
+ listenerOptsForWire,
20
+ normalizeListenerOptions,
21
+ type ProxyEventHandler,
22
+ } from './node';
23
+ import { parseChildren, serializeNode } from './html';
24
+ import { parseSelector, matchCompound, matchesChain } from './selectors';
25
+ import { classListFor, styleProxyFor, datasetProxyFor, type ProxyClassList } from './css';
26
+ import type { InternalDocument } from './document';
27
+
28
+ /* ── ProxyElement ──────────────────────────────────────────────────────── */
29
+
30
+ /** Positions `insertAdjacentHTML`/`insertAdjacentElement` accept. */
31
+ export type AdjacentPosition = 'beforebegin' | 'afterbegin' | 'beforeend' | 'afterend';
32
+
33
+ /** `<template>` element → its cached `.content` fragment view (shares the
34
+ * element's shadow child array). */
35
+ const templateContents = new WeakMap<ProxyElement, ProxyFragment>();
36
+
37
+ export class ProxyElement extends ProxyNode {
38
+ declare readonly instance: ElementInstance;
39
+ readonly _attrs = new Map<string, string>();
40
+ _classes = new Set<string>();
41
+ readonly _styleProps: Record<string, string> = {};
42
+ private readonly _listeners: Array<{
43
+ type: string;
44
+ fn: ProxyEventHandler;
45
+ hid: number;
46
+ capture: boolean;
47
+ }> = [];
48
+
49
+ override get nodeType(): number {
50
+ return 1;
51
+ }
52
+ get tagName(): string {
53
+ return this.instance.type.toUpperCase();
54
+ }
55
+ /** The element's namespace URI — HTML for ordinary elements, the `create`
56
+ * op's `ns` for namespaced (svg/mathml) ones. */
57
+ get namespaceURI(): string {
58
+ return this.instance.ns ?? 'http://www.w3.org/1999/xhtml';
59
+ }
60
+ /** Element children only — the shadow tree's HTMLCollection equivalent. */
61
+ get children(): ProxyElement[] {
62
+ return this._children.filter((c): c is ProxyElement => c instanceof ProxyElement);
63
+ }
64
+
65
+ get id(): string {
66
+ return this._attrs.get('id') ?? '';
67
+ }
68
+ set id(v: string) {
69
+ this._setAttr('id', v);
70
+ }
71
+ get className(): string {
72
+ return this._attrs.get('class') ?? '';
73
+ }
74
+ set className(v: string) {
75
+ this._setAttr('class', v);
76
+ }
77
+
78
+ getAttribute(name: string): string | null {
79
+ return this._attrs.get(name) ?? null;
80
+ }
81
+ hasAttribute(name: string): boolean {
82
+ return this._attrs.has(name);
83
+ }
84
+ setAttribute(name: string, value: string): void {
85
+ this._setAttr(name, value);
86
+ }
87
+ removeAttribute(name: string): void {
88
+ this.doc._assertAlive();
89
+ if (!this._attrs.has(name)) return;
90
+ this._attrs.delete(name);
91
+ this._afterAttrChange(name);
92
+ this._op({ t: 'attr', id: this.instance.id, name, value: null });
93
+ }
94
+
95
+ /* Namespaced attribute surface — the wire `attr` op has no namespace
96
+ * field, so the qualified name ('xlink:href') is the whole shadow key
97
+ * and the driver's setAttribute covers prefixed names. Limitation: two
98
+ * same-local-name attributes in different namespaces can't be told
99
+ * apart. get/remove follow the DOM convention of receiving the LOCAL
100
+ * name — resolve it to the stored qualified key (or accept a qualified
101
+ * name directly, since some callers pass both ways). */
102
+ setAttributeNS(_ns: string | null, qualifiedName: string, value: string): void {
103
+ this.setAttribute(qualifiedName, value);
104
+ }
105
+ getAttributeNS(_ns: string | null, name: string): string | null {
106
+ const direct = this.getAttribute(name);
107
+ if (direct !== null) return direct;
108
+ for (const key of this._attrs.keys()) {
109
+ if (key.endsWith(`:${name}`)) return this.getAttribute(key);
110
+ }
111
+ return null;
112
+ }
113
+ removeAttributeNS(_ns: string | null, name: string): void {
114
+ if (this.getAttribute(name) !== null) {
115
+ this.removeAttribute(name);
116
+ return;
117
+ }
118
+ for (const key of [...this._attrs.keys()]) {
119
+ if (key.endsWith(`:${name}`)) this.removeAttribute(key);
120
+ }
121
+ }
122
+
123
+ /* classList — a live view over _classes synced to the `class` attr. */
124
+ get classList(): ProxyClassList {
125
+ return classListFor(this);
126
+ }
127
+
128
+ /**
129
+ * Inline style — a Proxy over a plain prop record. Writes re-send only
130
+ * the changed key; `delete style.x`/`style.removeProperty('x')` send ''
131
+ * (the protocol's "clear this key"). `setProperty`/`getPropertyValue`
132
+ * accept kebab-case and camelize it, like the real CSSStyleDeclaration.
133
+ */
134
+ get style(): CSSStyleDeclaration {
135
+ return styleProxyFor(this);
136
+ }
137
+
138
+ /**
139
+ * dataset — a Proxy mapping camelCase keys to `data-*` attribute ops.
140
+ * `swatch.dataset.color = c` emits attr {name:'data-color', value:c};
141
+ * `delete el.dataset.x` emits the removal form.
142
+ */
143
+ get dataset(): DOMStringMap {
144
+ return datasetProxyFor(this);
145
+ }
146
+
147
+ /**
148
+ * Focus/blur — NO-OPS. Real focus can't be delivered over async postMessage
149
+ * (the driver's dispatch fires after the event; there is no focus op).
150
+ * They exist because gesture libraries call `el.focus()` unconditionally
151
+ * during mousedown paths (Leaflet's Keyboard handler does).
152
+ */
153
+ focus(): void {}
154
+ blur(): void {}
155
+
156
+ /* Events — registers a worker handler-table entry and emits listen.
157
+ * The driver's listenerFor dispatches EventPayloads back into the worker;
158
+ * the payload is enriched with a synthesized `target`/`composedPath` and
159
+ * the handler runs with `this` = this element, like the real DOM's
160
+ * currentTarget. `{once, passive, capture}` ride the op's `opts` field;
161
+ * `once` additionally auto-detaches worker-side after the first dispatch
162
+ * (matching the real listener's own auto-removal driver-side). */
163
+ addEventListener(
164
+ type: string,
165
+ fn: ProxyEventHandler,
166
+ options?: boolean | AddEventListenerOptions,
167
+ ): void {
168
+ this.doc._assertAlive();
169
+ const opts = normalizeListenerOptions(options);
170
+ // The real DOM dedupes identical (type, listener, capture) triples.
171
+ if (
172
+ this._listeners.some((l) => l.type === type && l.fn === fn && l.capture === opts.capture)
173
+ ) {
174
+ return;
175
+ }
176
+ const hid = registerHandler(
177
+ (p) => {
178
+ try {
179
+ fn.call(this, this.doc._enrichEvent(p as EventPayload, this));
180
+ } finally {
181
+ if (opts.once) this.removeEventListener(type, fn, { capture: opts.capture });
182
+ }
183
+ },
184
+ this.instance.instance,
185
+ this.instance.id,
186
+ );
187
+ this._listeners.push({ type, fn, hid, capture: opts.capture });
188
+ this.doc._handlerIds.add(hid);
189
+ this._op({
190
+ t: 'listen',
191
+ id: this.instance.id,
192
+ type,
193
+ handler: hid,
194
+ opts: listenerOptsForWire(options),
195
+ });
196
+ }
197
+ removeEventListener(
198
+ type: string,
199
+ fn: ProxyEventHandler,
200
+ options?: boolean | EventListenerOptions,
201
+ ): void {
202
+ this.doc._assertAlive();
203
+ const capture = normalizeListenerOptions(options).capture;
204
+ const index = this._listeners.findIndex(
205
+ (l) => l.type === type && l.fn === fn && l.capture === capture,
206
+ );
207
+ if (index === -1) return;
208
+ const [entry] = this._listeners.splice(index, 1);
209
+ unregisterHandler(entry.hid);
210
+ this.doc._handlerIds.delete(entry.hid);
211
+ this._op({
212
+ t: 'unlisten',
213
+ id: this.instance.id,
214
+ type,
215
+ handler: entry.hid,
216
+ opts: entry.capture ? { capture: true } : undefined,
217
+ });
218
+ }
219
+
220
+ /** Live-descendant search over the shadow tree. Nodes mounted by the
221
+ * reconciler aren't reachable (the op stream doesn't mirror React's
222
+ * structure into _children) — adopted subtrees match only their own
223
+ * proxy-built descendants. Class matching reads _classes AND the
224
+ * instance's serialized className prop, so adopted React elements with
225
+ * className set still match. */
226
+ getElementsByClassName(name: string): ProxyElement[] {
227
+ this.doc._assertAlive();
228
+ const out: ProxyElement[] = [];
229
+ const visit = (el: ProxyElement): void => {
230
+ for (const child of el._children) {
231
+ if (!(child instanceof ProxyElement)) continue;
232
+ const viaProps = ((child.instance.props?.className as string | undefined) ?? '')
233
+ .split(/\s+/)
234
+ .includes(name);
235
+ if (child._classes.has(name) || viaProps) out.push(child);
236
+ visit(child);
237
+ }
238
+ };
239
+ visit(this);
240
+ return out;
241
+ }
242
+
243
+ getElementsByTagName(tag: string): ProxyElement[] {
244
+ this.doc._assertAlive();
245
+ const want = tag.toLowerCase();
246
+ const out: ProxyElement[] = [];
247
+ const visit = (el: ProxyElement): void => {
248
+ for (const child of el._children) {
249
+ if (!(child instanceof ProxyElement)) continue;
250
+ if (want === '*' || child.instance.type.toLowerCase() === want) out.push(child);
251
+ visit(child);
252
+ }
253
+ };
254
+ visit(this);
255
+ return out;
256
+ }
257
+
258
+ /* SVG measurement APIs — no layout/text engine exists worker-side, so
259
+ * these report honest zeros (warned once), exactly like the rest of the
260
+ * geometry surface. Libraries doing animation sizing (recharts'
261
+ * getTotalLength paths) degrade to instant/no animation. */
262
+ getBBox(): {
263
+ x: number;
264
+ y: number;
265
+ width: number;
266
+ height: number;
267
+ top: number;
268
+ right: number;
269
+ bottom: number;
270
+ left: number;
271
+ } {
272
+ this.doc._assertAlive();
273
+ this.doc._warn('getBBox');
274
+ return { x: 0, y: 0, width: 0, height: 0, top: 0, right: 0, bottom: 0, left: 0 };
275
+ }
276
+
277
+ getTotalLength(): number {
278
+ this.doc._assertAlive();
279
+ this.doc._warn('getTotalLength');
280
+ return 0;
281
+ }
282
+
283
+ getComputedTextLength(): number {
284
+ this.doc._assertAlive();
285
+ this.doc._warn('getComputedTextLength');
286
+ return 0;
287
+ }
288
+
289
+ /* Selector matching — compound selectors only (see parseSelector). */
290
+ matches(selector: string): boolean {
291
+ return matchesChain(this, parseSelector(selector));
292
+ }
293
+
294
+ /** Nearest element (self inclusive) matching the selector — walks the
295
+ * shadow tree upward through ancestors. */
296
+ closest(selector: string): ProxyElement | null {
297
+ const chain = parseSelector(selector);
298
+ let n: ProxyNode | null = this;
299
+ while (n !== null) {
300
+ if (n instanceof ProxyElement && matchesChain(n, chain)) return n;
301
+ n = n._parent;
302
+ }
303
+ return null;
304
+ }
305
+
306
+ /** Scoped local-tree query — descendants only, same engine and the same
307
+ * supported-selector subset as document.querySelectorAll. */
308
+ querySelector(selector: string): ProxyElement | null {
309
+ return this.querySelectorAll(selector)[0] ?? null;
310
+ }
311
+
312
+ querySelectorAll(selector: string): ProxyElement[] {
313
+ const chain = parseSelector(selector);
314
+ const out: ProxyElement[] = [];
315
+ const walk = (node: ProxyNode): void => {
316
+ for (const child of node._children) {
317
+ if (child instanceof ProxyElement && matchesChain(child, chain)) out.push(child);
318
+ walk(child);
319
+ }
320
+ };
321
+ walk(this);
322
+ return out;
323
+ }
324
+
325
+ /* ── innerHTML + friends: real HTML parsing worker-side ──────────────── */
326
+
327
+ /** Serialized shadow-tree children — the only innerHTML there CAN be over
328
+ * an async op channel (it reflects writes made through this facade, not
329
+ * the real DOM's current markup). */
330
+ get innerHTML(): string {
331
+ return this._children.map((child) => serializeNode(child)).join('');
332
+ }
333
+ get outerHTML(): string {
334
+ return serializeNode(this);
335
+ }
336
+ /**
337
+ * Parse `html` worker-side (htmlparser2 — pure JS, worker-safe) and
338
+ * rebuild this element's children as proxy nodes: each parsed element is
339
+ * createElement + setAttribute + recursive children, each text run a
340
+ * createTextNode — all emitting ordinary create/attr/append ops. Existing
341
+ * children are removed first (one remove op each). Comments and
342
+ * directives are skipped.
343
+ */
344
+ set innerHTML(html: string) {
345
+ this.doc._assertAlive();
346
+ for (const child of this._children.slice()) this.removeChild(child);
347
+ for (const node of parseChildren(this.doc, html)) this.appendChild(node);
348
+ }
349
+
350
+ /**
351
+ * Parse `html` and insert the resulting nodes relative to this element —
352
+ * 'beforebegin'/'afterend' require a parent and no-op without one (like
353
+ * the DOM on detached elements).
354
+ */
355
+ insertAdjacentHTML(position: AdjacentPosition, html: string): void {
356
+ this.doc._assertAlive();
357
+ this._insertAt(position, parseChildren(this.doc, html));
358
+ }
359
+
360
+ /** insertAdjacentElement — returns the inserted element like the DOM. */
361
+ insertAdjacentElement(position: AdjacentPosition, el: ProxyElement): ProxyElement | null {
362
+ this.doc._assertAlive();
363
+ if (!(el instanceof ProxyElement)) return null;
364
+ this._insertAt(position, [el]);
365
+ return el;
366
+ }
367
+
368
+ private _insertAt(position: AdjacentPosition, nodes: ProxyNode[]): void {
369
+ switch (position) {
370
+ case 'beforebegin': {
371
+ if (this._parent === null) return;
372
+ for (const n of nodes) this._parent.insertBefore(n, this);
373
+ return;
374
+ }
375
+ case 'afterbegin': {
376
+ const ref = this.firstChild;
377
+ for (const n of nodes) this.insertBefore(n, ref);
378
+ return;
379
+ }
380
+ case 'beforeend': {
381
+ for (const n of nodes) this.appendChild(n);
382
+ return;
383
+ }
384
+ case 'afterend': {
385
+ if (this._parent === null) return;
386
+ const ref = this.nextSibling;
387
+ for (const n of nodes) this._parent.insertBefore(n, ref);
388
+ return;
389
+ }
390
+ default:
391
+ throw new Error(`proxyDom.insertAdjacent*: unknown position "${String(position)}"`);
392
+ }
393
+ }
394
+
395
+ /**
396
+ * `<template>.content` — a fragment VIEW sharing the element's shadow
397
+ * child array (same backing store) AND the element's instance id, so
398
+ * `content.appendChild` emits `append` ops targeted at the template. The
399
+ * driver routes template-parented ops into `el.content` — the DOM spec's
400
+ * home for template children anyway. Non-template elements read
401
+ * undefined.
402
+ */
403
+ get content(): ProxyFragment | undefined {
404
+ if (this.instance.type !== 'template') return undefined;
405
+ let frag = templateContents.get(this);
406
+ if (frag === undefined) {
407
+ frag = this.doc.createDocumentFragment();
408
+ frag._children = this._children;
409
+ // A fresh fragment's phantom (negative) id would swallow every op —
410
+ // adopt the element's instance so parent id, instance, and op targeting
411
+ // all read as the template.
412
+ (frag as { instance: unknown }).instance = this.instance;
413
+ templateContents.set(this, frag);
414
+ }
415
+ return frag;
416
+ }
417
+
418
+ /**
419
+ * Clone as new ops — the copy gets its own instance id (it's a real new
420
+ * element on the main thread, not a shared record). Attributes, the
421
+ * namespace and the style-proxy properties are copied; listeners are NOT
422
+ * (matches the DOM). `deep` clones children polymorphically — comment
423
+ * children keep nodeType 8, texts and nested elements/fragments their
424
+ * own kind.
425
+ */
426
+ cloneNode(deep?: boolean): ProxyElement {
427
+ this.doc._assertAlive();
428
+ const copy =
429
+ this.instance.ns === undefined
430
+ ? this.doc.createElement(this.instance.type)
431
+ : this.doc.createElementNS(this.instance.ns, this.instance.type);
432
+ for (const [name, value] of this._attrs) copy.setAttribute(name, value);
433
+ for (const [k, v] of Object.entries(this._styleProps)) copy._writeStyle(k, v);
434
+ if (deep === true) {
435
+ for (const child of this._children) copy.appendChild(child.cloneNode(true));
436
+ }
437
+ return copy;
438
+ }
439
+
440
+ /* ── Pushed-size geometry. The ONLY measured box is the island container:
441
+ * `doc.body`/`documentElement` and markContainer()ed elements report the
442
+ * last setSize push; everything else keeps the honest 0 and warns once
443
+ * per document per API. A marked element reports the CONTAINER's box —
444
+ * the driver can only measure the island's root, so "marked" means
445
+ * "pretend my box is the container's", which is only honest for elements
446
+ * that genuinely fill it. ── */
447
+ getBoundingClientRect(): DOMRect {
448
+ const size = this.doc._sizeFor(this);
449
+ if (size === undefined) {
450
+ this.doc._warn('getBoundingClientRect');
451
+ return {
452
+ x: 0, y: 0, top: 0, left: 0, right: 0, bottom: 0, width: 0, height: 0,
453
+ toJSON: () => ({}),
454
+ } as DOMRect;
455
+ }
456
+ return {
457
+ x: 0, y: 0, top: 0, left: 0, right: size.w, bottom: size.h,
458
+ width: size.w, height: size.h,
459
+ toJSON: () => ({}),
460
+ } as DOMRect;
461
+ }
462
+ get clientWidth(): number {
463
+ return this._geom('clientWidth', (s) => s.w);
464
+ }
465
+ get clientHeight(): number {
466
+ return this._geom('clientHeight', (s) => s.h);
467
+ }
468
+ get offsetWidth(): number {
469
+ return this._geom('offsetWidth', (s) => s.w);
470
+ }
471
+ get offsetHeight(): number {
472
+ return this._geom('offsetHeight', (s) => s.h);
473
+ }
474
+ /** Border edge widths — honestly 0 (the pushed box doesn't measure borders). */
475
+ get clientLeft(): number {
476
+ return 0;
477
+ }
478
+ get clientTop(): number {
479
+ return 0;
480
+ }
481
+ /** Position within offsetParent — not measured; 0 is honest for root-level boxes. */
482
+ get offsetLeft(): number {
483
+ return 0;
484
+ }
485
+ get offsetTop(): number {
486
+ return 0;
487
+ }
488
+ get offsetParent(): null {
489
+ return null;
490
+ }
491
+ get scrollTop(): number {
492
+ this.doc._warn('scrollTop');
493
+ return 0;
494
+ }
495
+ set scrollTop(_v: number) {
496
+ this.doc._warn('scrollTop');
497
+ }
498
+ get scrollLeft(): number {
499
+ this.doc._warn('scrollLeft');
500
+ return 0;
501
+ }
502
+ set scrollLeft(_v: number) {
503
+ this.doc._warn('scrollLeft');
504
+ }
505
+ get scrollHeight(): number {
506
+ this.doc._warn('scrollHeight');
507
+ return 0;
508
+ }
509
+ get scrollWidth(): number {
510
+ this.doc._warn('scrollWidth');
511
+ return 0;
512
+ }
513
+
514
+ /**
515
+ * Reflected properties. Library code assigns `img.src = url`,
516
+ * `el.tabIndex = 0`, `link.href = '#'` — on a bare class instance those
517
+ * become silent expandos and nothing crosses the wire. The attribute-backed
518
+ * accessors below (defined for the properties real libraries poke) make
519
+ * those assignments emit ordinary `attr` ops. Everything else still falls
520
+ * through to a plain own-property — which is also exactly what library
521
+ * expandos like `_leaflet_pos` need, so proxy elements are deliberately
522
+ * NOT frozen/sealed.
523
+ */
524
+ get src(): string {
525
+ return this._attrs.get('src') ?? '';
526
+ }
527
+ set src(v: string) {
528
+ this._setAttr('src', v);
529
+ }
530
+ get srcset(): string {
531
+ return this._attrs.get('srcset') ?? '';
532
+ }
533
+ set srcset(v: string) {
534
+ this._setAttr('srcset', v);
535
+ }
536
+ get href(): string {
537
+ return this._attrs.get('href') ?? '';
538
+ }
539
+ set href(v: string) {
540
+ this._setAttr('href', v);
541
+ }
542
+ get alt(): string {
543
+ return this._attrs.get('alt') ?? '';
544
+ }
545
+ set alt(v: string) {
546
+ this._setAttr('alt', v);
547
+ }
548
+ get title(): string {
549
+ return this._attrs.get('title') ?? '';
550
+ }
551
+ set title(v: string) {
552
+ this._setAttr('title', v);
553
+ }
554
+ get tabIndex(): number {
555
+ return Number(this._attrs.get('tabindex') ?? -1);
556
+ }
557
+ set tabIndex(v: number) {
558
+ this._setAttr('tabindex', String(v));
559
+ }
560
+ get draggable(): boolean {
561
+ return this._attrs.get('draggable') === 'true';
562
+ }
563
+ set draggable(v: boolean) {
564
+ this._setAttr('draggable', String(v));
565
+ }
566
+ get crossOrigin(): string | null {
567
+ return this._attrs.get('crossorigin') ?? null;
568
+ }
569
+ set crossOrigin(v: string | null) {
570
+ if (v === null) this.removeAttribute('crossorigin');
571
+ else this._setAttr('crossorigin', v);
572
+ }
573
+ get width(): number {
574
+ return Number(this._attrs.get('width') ?? 0);
575
+ }
576
+ set width(v: number) {
577
+ this._setAttr('width', String(v));
578
+ }
579
+ get height(): number {
580
+ return Number(this._attrs.get('height') ?? 0);
581
+ }
582
+ set height(v: number) {
583
+ this._setAttr('height', String(v));
584
+ }
585
+ get type(): string {
586
+ return this._attrs.get('type') ?? '';
587
+ }
588
+ set type(v: string) {
589
+ this._setAttr('type', v);
590
+ }
591
+ get loading(): string {
592
+ return this._attrs.get('loading') ?? '';
593
+ }
594
+ set loading(v: string) {
595
+ this._setAttr('loading', v);
596
+ }
597
+ get decoding(): string {
598
+ return this._attrs.get('decoding') ?? '';
599
+ }
600
+ set decoding(v: string) {
601
+ this._setAttr('decoding', v);
602
+ }
603
+ get value(): string {
604
+ return this._attrs.get('value') ?? '';
605
+ }
606
+ set value(v: string) {
607
+ this._setAttr('value', v);
608
+ }
609
+ get checked(): boolean {
610
+ return this._attrs.has('checked');
611
+ }
612
+ set checked(v: boolean) {
613
+ if (v) this._setAttr('checked', '');
614
+ else this.removeAttribute('checked');
615
+ }
616
+ get disabled(): boolean {
617
+ return this._attrs.has('disabled');
618
+ }
619
+ set disabled(v: boolean) {
620
+ if (v) this._setAttr('disabled', '');
621
+ else this.removeAttribute('disabled');
622
+ }
623
+
624
+ /* internals */
625
+ /** Geometry read shared by the pushed-size getters above. */
626
+ _geom(feature: string, pick: (size: { w: number; h: number }) => number): number {
627
+ const size = this.doc._sizeFor(this);
628
+ if (size === undefined) {
629
+ this.doc._warn(feature);
630
+ return 0;
631
+ }
632
+ return pick(size);
633
+ }
634
+ _writeStyle(key: string, value: string): void {
635
+ this.doc._assertAlive();
636
+ if (this._styleProps[key] === value) return;
637
+ this._styleProps[key] = value;
638
+ this._op({ t: 'style', id: this.instance.id, props: { [key]: value } });
639
+ }
640
+ private _setAttr(name: string, value: string): void {
641
+ this.doc._assertAlive();
642
+ const v = String(value);
643
+ if (this._attrs.get(name) === v) return; // unchanged → no op
644
+ this._attrs.set(name, v);
645
+ this._afterAttrChange(name);
646
+ this._op({ t: 'attr', id: this.instance.id, name, value: v });
647
+ }
648
+ private _afterAttrChange(name: string): void {
649
+ if (name === 'class') {
650
+ this._classes = new Set((this._attrs.get('class') ?? '').split(/\s+/).filter(Boolean));
651
+ } else if (name === 'id') {
652
+ this.doc._trackId(this);
653
+ }
654
+ }
655
+ }
656
+