@react-x11/components 0.12.0 → 0.13.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.
Files changed (80) hide show
  1. package/dist/html/controls.d.ts +12 -3
  2. package/dist/html/controls.d.ts.map +1 -1
  3. package/dist/html/controls.js +15 -55
  4. package/dist/html/controls.js.map +1 -1
  5. package/dist/html/css/cascade.d.ts +134 -20
  6. package/dist/html/css/cascade.d.ts.map +1 -1
  7. package/dist/html/css/cascade.js +519 -122
  8. package/dist/html/css/cascade.js.map +1 -1
  9. package/dist/html/css/parse.d.ts +29 -2
  10. package/dist/html/css/parse.d.ts.map +1 -1
  11. package/dist/html/css/parse.js +277 -9
  12. package/dist/html/css/parse.js.map +1 -1
  13. package/dist/html/css/style.d.ts +14 -0
  14. package/dist/html/css/style.d.ts.map +1 -1
  15. package/dist/html/css/style.js +2 -3
  16. package/dist/html/css/style.js.map +1 -1
  17. package/dist/html/css/ua.d.ts +11 -1
  18. package/dist/html/css/ua.d.ts.map +1 -1
  19. package/dist/html/css/ua.js +59 -7
  20. package/dist/html/css/ua.js.map +1 -1
  21. package/dist/html/dom.d.ts.map +1 -1
  22. package/dist/html/dom.js +3 -1
  23. package/dist/html/dom.js.map +1 -1
  24. package/dist/html/fonts.d.ts +25 -1
  25. package/dist/html/fonts.d.ts.map +1 -1
  26. package/dist/html/fonts.js +99 -6
  27. package/dist/html/fonts.js.map +1 -1
  28. package/dist/html/form.d.ts +200 -0
  29. package/dist/html/form.d.ts.map +1 -0
  30. package/dist/html/form.js +801 -0
  31. package/dist/html/form.js.map +1 -0
  32. package/dist/html/index.d.ts +12 -0
  33. package/dist/html/index.d.ts.map +1 -1
  34. package/dist/html/index.js +30 -301
  35. package/dist/html/index.js.map +1 -1
  36. package/dist/html/layout/block.d.ts.map +1 -1
  37. package/dist/html/layout/block.js +5 -1
  38. package/dist/html/layout/block.js.map +1 -1
  39. package/dist/html/layout/boxes.d.ts +31 -1
  40. package/dist/html/layout/boxes.d.ts.map +1 -1
  41. package/dist/html/layout/boxes.js +52 -8
  42. package/dist/html/layout/boxes.js.map +1 -1
  43. package/dist/html/layout/flex.js +4 -1
  44. package/dist/html/layout/flex.js.map +1 -1
  45. package/dist/html/layout/inline.d.ts.map +1 -1
  46. package/dist/html/layout/inline.js +10 -0
  47. package/dist/html/layout/inline.js.map +1 -1
  48. package/dist/html/node.d.ts +87 -15
  49. package/dist/html/node.d.ts.map +1 -1
  50. package/dist/html/node.js +510 -152
  51. package/dist/html/node.js.map +1 -1
  52. package/dist/html/paint.d.ts +22 -1
  53. package/dist/html/paint.d.ts.map +1 -1
  54. package/dist/html/paint.js +290 -20
  55. package/dist/html/paint.js.map +1 -1
  56. package/dist/html/widgets.d.ts +29 -0
  57. package/dist/html/widgets.d.ts.map +1 -0
  58. package/dist/html/widgets.js +620 -0
  59. package/dist/html/widgets.js.map +1 -0
  60. package/dist/index.d.ts +1 -1
  61. package/dist/index.d.ts.map +1 -1
  62. package/dist/index.js.map +1 -1
  63. package/package.json +2 -2
  64. package/src/html/controls.ts +29 -49
  65. package/src/html/css/cascade.ts +591 -140
  66. package/src/html/css/parse.ts +324 -9
  67. package/src/html/css/style.ts +24 -3
  68. package/src/html/css/ua.ts +62 -8
  69. package/src/html/dom.ts +3 -1
  70. package/src/html/fonts.ts +116 -6
  71. package/src/html/form.ts +962 -0
  72. package/src/html/index.ts +49 -338
  73. package/src/html/layout/block.ts +3 -0
  74. package/src/html/layout/boxes.ts +78 -11
  75. package/src/html/layout/flex.ts +4 -1
  76. package/src/html/layout/inline.ts +10 -0
  77. package/src/html/node.ts +498 -135
  78. package/src/html/paint.ts +330 -29
  79. package/src/html/widgets.ts +735 -0
  80. package/src/index.ts +1 -0
package/src/html/index.ts CHANGED
@@ -14,14 +14,7 @@
14
14
  // is a property of the design rather than a setting.
15
15
  import React from 'react';
16
16
  import type { ReactElement, ReactNode } from 'react';
17
- import {
18
- Button,
19
- Checkbox,
20
- Radio,
21
- RadioGroup,
22
- Select,
23
- useTheme,
24
- } from 'react-x11';
17
+ import { useTheme } from 'react-x11';
25
18
  import type { DrawnNode, MouseEvent as X11MouseEvent, Rect } from 'react-x11';
26
19
  import { tint } from 'react-x11/style';
27
20
  import type { Style } from 'react-x11/style';
@@ -31,18 +24,13 @@ import type {} from 'react-x11/jsx-runtime';
31
24
  import { useLinkClicks, useSelectionMenu } from '../richtext/index.js';
32
25
  import { useFontPrewarm } from '../internal/prewarm.js';
33
26
  import { hx } from './hx.js';
34
- import { attr, tagOf } from './dom.js';
35
27
  import type { Document, Element } from './dom.js';
36
28
  import { ELEMENT, HtmlViewNode, registerHtmlView } from './node.js';
37
29
  import type { HtmlViewProps, ScriptRequest } from './node.js';
38
30
  import type { RootLook } from './css/style.js';
39
- import {
40
- buttonLabel,
41
- optionsOf,
42
- selectedOption,
43
- textareaValue,
44
- } from './controls.js';
45
- import type { BareField, ControlRect } from './controls.js';
31
+ import type { ControlRect } from './controls.js';
32
+ import type { FormSubmission } from './form.js';
33
+ import { useForms } from './widgets.js';
46
34
  import type { ResourceRequest, ResourceResult } from './resources.js';
47
35
 
48
36
  export {
@@ -53,6 +41,13 @@ export {
53
41
  export type { HtmlViewProps, ScriptRequest } from './node.js';
54
42
  export type { ResourceRequest, ResourceResult } from './resources.js';
55
43
  export type { BareField, ControlRect } from './controls.js';
44
+ export type {
45
+ FormEnctype,
46
+ FormMethod,
47
+ FormSubmission,
48
+ SubmitContext,
49
+ } from './form.js';
50
+ export { formSubmission } from './form.js';
56
51
  export type { ComputedStyle, RootLook } from './css/style.js';
57
52
  export type {
58
53
  AnyNode,
@@ -154,6 +149,15 @@ export interface HtmlProps {
154
149
  * calls the handle's `refresh()`.
155
150
  */
156
151
  onControlChange?: (element: Element, value: string | boolean) => void;
152
+ /**
153
+ * A form was submitted — a submit button pressed, or Enter in one of its
154
+ * text fields. Handed over as the request it makes: the method, the URL
155
+ * (resolved as `onLink`'s `href` is, and for a GET with the form's entries
156
+ * as its query), a POST's encoded body and its content type, and the
157
+ * entries themselves. Absent means submitting does nothing: this
158
+ * component never sends anything by itself, any more than it navigates.
159
+ */
160
+ onSubmit?: (submission: FormSubmission) => void;
157
161
  /** Base text style. Defaults: theme `fontSize` (14), `sans-serif`. */
158
162
  fontSize?: number;
159
163
  fontFamily?: string;
@@ -230,6 +234,10 @@ function deriveLook(
230
234
  controlPadY: Number(theme.paddingY ?? 6),
231
235
  controlBorder: Number(theme.borderWidth ?? 1),
232
236
  controlRadius: Number(theme.radius ?? 4),
237
+ // not `fontSize` and `fontFamily`, which a host sets to the web's
238
+ // `medium` and the face it reads pages in
239
+ controlFontSize: Number(theme.fontSize ?? 14),
240
+ controlFontFamily: String(theme.fontFamily ?? 'sans-serif'),
233
241
  };
234
242
  }
235
243
 
@@ -285,14 +293,27 @@ export function Html(props: HtmlProps): ReactElement {
285
293
  onScript,
286
294
  onDocument,
287
295
  onControlChange,
296
+ onSubmit,
288
297
  style,
289
298
  } = props;
290
299
 
291
300
  const theme = useTheme() as unknown as Record<string, unknown>;
292
301
  const links = useLinkClicks(onLink);
293
- const buttons = useButtonPresses(onControlChange);
294
302
  const menu = useSelectionMenu(selectable);
295
303
 
304
+ // The element, for the document's base when a form is submitted, as well
305
+ // as wherever the application's `ref` wants it.
306
+ const viewNode = React.useRef<HtmlViewNode | null>(null);
307
+ const outerRef = props.ref;
308
+ const viewRef = React.useCallback(
309
+ (node: HtmlViewNode | null) => {
310
+ viewNode.current = node;
311
+ if (typeof outerRef === 'function') outerRef(node);
312
+ else if (outerRef) (outerRef as React.RefObject<unknown>).current = node;
313
+ },
314
+ [outerRef],
315
+ );
316
+
296
317
  const look = React.useMemo(
297
318
  () => deriveLook(theme, props),
298
319
  [theme, props.fontSize, props.fontFamily, props.monoFamily],
@@ -305,6 +326,13 @@ export function Html(props: HtmlProps): ReactElement {
305
326
  const documentRef = React.useRef<Document | null>(null);
306
327
  const [controls, setControls] = React.useState<ControlRect[]>([]);
307
328
  const [domRevision, setDomRevision] = React.useState(0);
329
+ const forms = useForms({
330
+ view: viewNode,
331
+ baseUrl,
332
+ onControlChange,
333
+ onSubmit,
334
+ touch: () => setDomRevision((n) => n + 1),
335
+ });
308
336
 
309
337
  const handleDocument = React.useCallback(
310
338
  (doc: Document) => {
@@ -338,7 +366,7 @@ export function Html(props: HtmlProps): ReactElement {
338
366
  onDocument: handleDocument,
339
367
  onControls: handleControls,
340
368
  domRevision,
341
- ref: props.ref as React.Ref<unknown>,
369
+ ref: viewRef as React.Ref<unknown>,
342
370
  // grown with the component, where an application grows it: the root's
343
371
  // background covers the whole of it, as a page's covers the window
344
372
  style: { alignSelf: 'stretch', flexGrow: 1 },
@@ -347,16 +375,7 @@ export function Html(props: HtmlProps): ReactElement {
347
375
  const children: ReactNode[] = [
348
376
  h(ELEMENT, { key: 'view', ...viewProps } as Record<string, unknown>),
349
377
  ];
350
- for (const rect of controls) {
351
- children.push(
352
- renderControl(
353
- rect,
354
- look,
355
- () => setDomRevision((n) => n + 1),
356
- onControlChange,
357
- ),
358
- );
359
- }
378
+ children.push(...forms.render(controls, look));
360
379
 
361
380
  const rootStyle: Style = { flexDirection: 'column', position: 'relative' };
362
381
  return hx(
@@ -374,11 +393,11 @@ export function Html(props: HtmlProps): ReactElement {
374
393
  ...links,
375
394
  onMouseDown: (ev: X11MouseEvent<DrawnNode>) => {
376
395
  links.onMouseDown(ev);
377
- buttons.onMouseDown(ev);
396
+ forms.onMouseDown(ev);
378
397
  },
379
398
  onMouseUp: (ev: X11MouseEvent<DrawnNode>) => {
380
399
  links.onMouseUp(ev);
381
- buttons.onMouseUp(ev);
400
+ forms.onMouseUp(ev);
382
401
  },
383
402
  ...menu,
384
403
  'data-testname': props['data-testname'],
@@ -428,314 +447,6 @@ export function useHtmlHandle(): HtmlHandle & { ref: React.Ref<unknown> } {
428
447
  );
429
448
  }
430
449
 
431
- /**
432
- * A press and a release on the same `<button>` the document draws, reported
433
- * as a pressed widget reports one: `onControlChange` with the button and its
434
- * `value`. A `<button>` is laid out and drawn like any box (the UA sheet's
435
- * `button` rule) — its content is the page's, and most of the buttons on
436
- * the web are an icon or a pill of a page's own design — so it is the
437
- * element that finds out it was pressed, not a widget. A disabled one, or
438
- * a press that lands in a link inside one, is not a press of it.
439
- */
440
- function useButtonPresses(onControlChange: HtmlProps['onControlChange']): {
441
- onMouseDown: (ev: X11MouseEvent<DrawnNode>) => void;
442
- onMouseUp: (ev: X11MouseEvent<DrawnNode>) => void;
443
- } {
444
- const press = React.useRef<Element | null>(null);
445
- const buttonAt = (ev: X11MouseEvent<DrawnNode>): Element | null => {
446
- const target = ev.target as {
447
- elementAtPoint?: (x: number, y: number) => Element | null;
448
- } | null;
449
- let node =
450
- typeof target?.elementAtPoint === 'function'
451
- ? target.elementAtPoint(ev.x, ev.y)
452
- : null;
453
- for (
454
- ;
455
- node;
456
- node = node.parent?.type === 'tag' ? (node.parent as Element) : null
457
- ) {
458
- if (tagOf(node) === 'a' && attr(node, 'href') !== undefined) return null;
459
- if (tagOf(node) === 'button') {
460
- return attr(node, 'disabled') === undefined ? node : null;
461
- }
462
- }
463
- return null;
464
- };
465
- return {
466
- onMouseDown: (ev) => {
467
- press.current = onControlChange && ev.button === 1 ? buttonAt(ev) : null;
468
- },
469
- onMouseUp: (ev) => {
470
- const pressed = press.current;
471
- press.current = null;
472
- if (!pressed || !onControlChange || ev.button !== 1) return;
473
- if (buttonAt(ev) !== pressed) return;
474
- onControlChange(pressed, attr(pressed, 'value') ?? '');
475
- },
476
- };
477
- }
478
-
479
- // --- the widgets ------------------------------------------------------------
480
-
481
- /**
482
- * One form control, as a real widget at the rectangle layout reserved for it.
483
- *
484
- * Every one of these is a **core** widget rather than something drawn here:
485
- * a `<select>` in a document drops the same menu as a `<Select>` in the
486
- * window around it, a `<textinput>` gets the same caret, the same IME and the
487
- * same edit menu, and all of them join the window's focus order. The
488
- * alternative — drawing them in the paint pass — would be a picture of a form.
489
- */
490
- function renderControl(
491
- rect: ControlRect,
492
- look: RootLook,
493
- touch: () => void,
494
- onChange: HtmlProps['onControlChange'],
495
- ): ReactNode {
496
- const el = rect.element;
497
- const key = `${rect.kind}:${rect.x},${rect.y}`;
498
- const disabled = attr(el, 'disabled') !== undefined;
499
- const readOnly = attr(el, 'readonly') !== undefined;
500
- // a field whose box the document draws takes its content box
501
- const at = rect.bare ?? rect;
502
- const frame: Style = {
503
- position: 'absolute',
504
- left: Math.round(at.x),
505
- top: Math.round(at.y),
506
- width: Math.round(at.width),
507
- height: Math.round(at.height),
508
- // core's `opacity` is CSS's: the widget faded as a group, and at 0 not
509
- // drawn and still hit
510
- ...(rect.opacity !== undefined && { opacity: rect.opacity }),
511
- };
512
- const field = rect.bare ? bareField(rect.bare) : fieldChrome(look);
513
- const report = (value: string | boolean): void => {
514
- onChange?.(el, value);
515
- touch();
516
- };
517
- // A text edit does NOT touch: the value lives in the widget and is echoed
518
- // into the DOM attribute, and neither changes any box — while `touch()`
519
- // would re-run the cascade and relayout the whole document *per
520
- // keystroke*. This also matches HTML's own semantics: typing updates the
521
- // value property, not the attribute selectors match against. The
522
- // checkables keep the full touch, because `[checked]` is a selector
523
- // documents really use.
524
- const reportText = (value: string): void => {
525
- onChange?.(el, value);
526
- };
527
-
528
- let widget: ReactNode;
529
- switch (rect.kind) {
530
- case 'checkbox':
531
- widget = h(Checkbox, {
532
- checked: attr(el, 'checked') !== undefined,
533
- disabled,
534
- // the element's whole box takes the press, as it does in a browser:
535
- // a page that sizes one over its label, invisible, means the label
536
- style: { width: '100%', height: '100%' },
537
- onChange: (ev) => {
538
- const next = ev.value;
539
- if (next) el.attribs.checked = '';
540
- else delete el.attribs.checked;
541
- report(next);
542
- },
543
- });
544
- break;
545
- case 'radio': {
546
- // Core's radio is a group member and HTML's is a free-standing input
547
- // that happens to share a `name`. Each one is therefore its own
548
- // one-member `RadioGroup`, and the exclusivity that makes it a group
549
- // is done where HTML actually keeps it: in the DOM.
550
- const value = attr(el, 'value') ?? 'on';
551
- widget = h(
552
- RadioGroup,
553
- {
554
- value: attr(el, 'checked') !== undefined ? value : undefined,
555
- onChange: () => {
556
- clearRadioGroup(el);
557
- el.attribs.checked = '';
558
- report(value);
559
- },
560
- },
561
- h(Radio, { key: 'r', value, disabled }),
562
- );
563
- break;
564
- }
565
- case 'button':
566
- widget = h(Button, {
567
- label: buttonLabel(el),
568
- disabled,
569
- style: { width: '100%', height: '100%' },
570
- onPress: () => report(attr(el, 'value') ?? ''),
571
- });
572
- break;
573
- case 'select': {
574
- const options = optionsOf(el);
575
- widget = h(Select, {
576
- options: options.map((o) => ({ value: o.value, label: o.label })),
577
- value: selectedOption(el) ?? undefined,
578
- disabled,
579
- style: rect.bare
580
- ? [BARE_TRIGGER, { width: '100%', height: '100%' }]
581
- : { width: '100%', height: '100%' },
582
- // the slots choose the drawn trigger on every backend, and put the
583
- // caption and the arrow in the page's ink
584
- ...(rect.bare && {
585
- labelStyle: {
586
- color: rect.bare.color,
587
- fontFamily: rect.bare.fontFamily,
588
- fontSize: rect.bare.fontSize,
589
- },
590
- chevronStyle: rect.bare.chevron
591
- ? { color: rect.bare.color }
592
- : { display: 'none' },
593
- }),
594
- onChange: (ev) => {
595
- const next = String(ev.value ?? '');
596
- setSelectedOption(el, next);
597
- report(next);
598
- },
599
- });
600
- break;
601
- }
602
- case 'textarea':
603
- // Uncontrolled on purpose: the widget owns the live text the way a
604
- // browser's does, and a re-render from any other cause remounts it
605
- // with whatever was last echoed into the DOM.
606
- widget = hx('textarea', {
607
- defaultValue: textareaValue(el),
608
- style: [field, { width: '100%', height: '100%' }],
609
- onChange: readOnly ? undefined : (ev) => reportText(ev.value),
610
- });
611
- break;
612
- case 'input': {
613
- const type = (attr(el, 'type') ?? 'text').toLowerCase();
614
- widget = hx('textinput', {
615
- defaultValue: attr(el, 'value') ?? '',
616
- placeholder: attr(el, 'placeholder'),
617
- // Core's word for a password field: nothing in it reaches a
618
- // selection, PRIMARY included.
619
- sensitive: type === 'password',
620
- style: [field, { width: '100%', height: '100%' }],
621
- onChange: readOnly
622
- ? undefined
623
- : (ev) => {
624
- el.attribs.value = ev.value;
625
- reportText(ev.value);
626
- },
627
- });
628
- break;
629
- }
630
- default:
631
- return null;
632
- }
633
- return hx('box', { key, style: frame, selectable: false }, widget);
634
- }
635
-
636
- /**
637
- * The chrome a text field needs.
638
- *
639
- * `<textinput>` and `<textarea>` are core *elements* rather than components,
640
- * so they draw no frame of their own — an application supplies one, which is
641
- * why core's own `<Button>` and `<Select>` are components and these are not.
642
- * The values are the palette's, so a field in a document and a `<Select>`
643
- * beside it are the same height with the same corner and the same edge.
644
- */
645
- function fieldChrome(look: RootLook): Style {
646
- return {
647
- backgroundColor: look.surface,
648
- borderWidth: look.controlBorder,
649
- borderColor: look.borderColor,
650
- borderRadius: look.controlRadius,
651
- paddingLeft: 6,
652
- paddingRight: 6,
653
- color: look.color,
654
- fontFamily: look.fontFamily,
655
- fontSize: look.fontSize,
656
- };
657
- }
658
-
659
- /**
660
- * A text field whose box the author styled: the document draws the border
661
- * and the background, so the widget draws neither, and its text is the
662
- * element's colour and font, which the author chose to go on that
663
- * background, rather than the theme's.
664
- */
665
- function bareField(bare: BareField): Style {
666
- return {
667
- backgroundColor: 'transparent',
668
- borderWidth: 0,
669
- borderRadius: 0,
670
- paddingLeft: 0,
671
- paddingRight: 0,
672
- color: bare.color,
673
- fontFamily: bare.fontFamily,
674
- fontSize: bare.fontSize,
675
- };
676
- }
677
-
678
- /**
679
- * The trigger of a `<select>` whose box the page styled: no frame, no fill
680
- * and none of its own insets, and no wash under the pointer, since the box
681
- * it would tint is the document's. Core's focus ring still marks it for the
682
- * keyboard.
683
- */
684
- const BARE_TRIGGER: Style = {
685
- paddingTop: 0,
686
- paddingBottom: 0,
687
- paddingLeft: 0,
688
- paddingRight: 0,
689
- borderWidth: 0,
690
- borderRadius: 0,
691
- backgroundColor: 'transparent',
692
- ':hover': { backgroundColor: 'transparent' },
693
- ':active': { backgroundColor: 'transparent' },
694
- };
695
-
696
- function clearRadioGroup(el: Element): void {
697
- const name = attr(el, 'name');
698
- if (!name) return;
699
- let root: Element | null = el;
700
- while (root?.parent && root.parent.type === 'tag')
701
- root = root.parent as Element;
702
- if (!root) return;
703
- const walk = (node: Element): void => {
704
- for (const child of node.children) {
705
- if (child.type !== 'tag') continue;
706
- const tag = child.name.toLowerCase();
707
- if (
708
- tag === 'input' &&
709
- (child.attribs.type ?? '').toLowerCase() === 'radio' &&
710
- child.attribs.name === name
711
- ) {
712
- delete child.attribs.checked;
713
- }
714
- walk(child);
715
- }
716
- };
717
- walk(root);
718
- }
719
-
720
- function setSelectedOption(el: Element, value: string): void {
721
- const walk = (node: Element): void => {
722
- for (const child of node.children) {
723
- if (child.type !== 'tag') continue;
724
- if (child.name.toLowerCase() === 'option') {
725
- let label = '';
726
- for (const kid of child.children) {
727
- if (kid.type === 'text') label += kid.data;
728
- }
729
- const own = child.attribs.value ?? label.trim();
730
- if (own === value) child.attribs.selected = '';
731
- else delete child.attribs.selected;
732
- }
733
- walk(child);
734
- }
735
- };
736
- walk(el);
737
- }
738
-
739
450
  declare module 'react-x11/jsx-runtime' {
740
451
  namespace JSX {
741
452
  interface IntrinsicElements {
@@ -38,6 +38,7 @@ import {
38
38
  BOX_RAISES,
39
39
  Box,
40
40
  CLAMPED,
41
+ CLEARED_FROM,
41
42
  CUT_BLOCKS,
42
43
  GRID_TRACKS,
43
44
  FIRST_LINE,
@@ -927,6 +928,8 @@ function layoutChildren(
927
928
  }
928
929
  first = false;
929
930
  const moved = childY !== at;
931
+ if (moved) CLEARED_FROM.set(child, at);
932
+ else CLEARED_FROM.delete(child);
930
933
  if (!moved && collapsesThrough(child)) {
931
934
  // nothing in it parts its margins: they and the ones either side of
932
935
  // it are one (CSS 2.1 8.3.1), still hanging for what comes next —
@@ -28,7 +28,12 @@ import {
28
28
  NON_RENDERED,
29
29
  tagOf,
30
30
  } from '../dom.js';
31
- import type { Cascade, FirstLetterRules } from '../css/cascade.js';
31
+ import type {
32
+ Cascade,
33
+ FirstLetterRules,
34
+ KeptStyles,
35
+ SelectionStyle,
36
+ } from '../css/cascade.js';
32
37
  import type { CollapsedTable } from './collapse.js';
33
38
  import { quoteAt } from '../css/content.js';
34
39
  import type { CounterStyles } from '../css/counter-styles.js';
@@ -556,6 +561,10 @@ export type FirstLineStyler = (
556
561
  parent: ComputedStyle,
557
562
  ) => ComputedStyle;
558
563
 
564
+ /** The `::selection` of the element a box's text is in, or null for the
565
+ * palette's highlight (`Cascade.selectionStyle`). */
566
+ export type SelectionStyler = (box: Box | null) => SelectionStyle | null;
567
+
559
568
  /** The url of an image generated content put in a pseudo-element: it has
560
569
  * no element of its own to name it (`imageUrlOf`). */
561
570
  export const CONTENT_IMAGES = new WeakMap<Box, string>();
@@ -641,6 +650,12 @@ export const PAINT_ORDER = new WeakMap<Box, Box[]>();
641
650
  * a browser's does (`elementRect`). */
642
651
  export const CUT_BLOCKS = new WeakMap<Box, Box[]>();
643
652
 
653
+ /** Where a block that clearance moved below the floats would have stood
654
+ * without it, its border edge's top (CSS 2.1 9.5.2): a browser's
655
+ * fragment of an inline box around such a block starts there, and takes
656
+ * the clearance in (`elementRect`). Only the blocks that had clearance. */
657
+ export const CLEARED_FROM = new WeakMap<Box, number>();
658
+
644
659
  /** The element a pseudo-element's box is generated from, which has no
645
660
  * element of its own (`Box.pseudo`): whose the images its styles name are,
646
661
  * when the host is asked for them. */
@@ -673,6 +688,10 @@ export interface BoxTree {
673
688
  controls: Box[];
674
689
  /** Every box carrying an `href`, for click and hover. */
675
690
  links: Box[];
691
+ /** The style of an anonymous box under a parent's (`anonymousStyles`):
692
+ * the one the fix-up gave each it made, and what such a box takes when
693
+ * its parent's style changes where it is (`HtmlViewNode._hoverInPlace`). */
694
+ anonymous: AnonymousStyle;
676
695
  /** Every box with a `background-image`, a border image or a mask image,
677
696
  * an element's or a pseudo-element's, for the host to be asked for: a
678
697
  * document has a handful, and finding them was a walk over every box
@@ -694,6 +713,10 @@ export interface BoxTree {
694
713
  /** Each box's style on a first line (`FirstLineStyler`), where any
695
714
  * block has a `::first-line`. */
696
715
  firstLineStyler: FirstLineStyler | null;
716
+ /** Each box's `::selection` (`SelectionStyler`), where any rule styles
717
+ * one: worked out as a selection is first painted over it, not as the
718
+ * tree is built, since most builds are never selected in. */
719
+ selectionStyler: SelectionStyler | null;
697
720
  /** Whether a float or an out-of-flow box sits in an inline box: where
698
721
  * none does, layout looks for them among a block's own children and
699
722
  * goes through no inline box to find them. */
@@ -714,6 +737,9 @@ export interface BoxTree {
714
737
 
715
738
  export interface BuildOptions {
716
739
  cascade: Cascade;
740
+ /** The styles of the tree this one replaces, where only some elements'
741
+ * can have changed since (`Cascade.beginSharing`). */
742
+ kept?: KeptStyles | null;
717
743
  /** Device pixels per CSS pixel. An image's pixels and a `width="600"`
718
744
  * attribute are CSS pixels; every box is device, so both are multiplied
719
745
  * on the way in. Default 1. */
@@ -813,7 +839,7 @@ class Builder {
813
839
 
814
840
  run(root: Element): BoxTree {
815
841
  const cascade = this._options.cascade;
816
- cascade.beginSharing();
842
+ cascade.beginSharing(this._options.kept ?? null);
817
843
  const { style: rootStyle, html: impliedHtml } = cascade.rootStyle(
818
844
  hasBody(root),
819
845
  hasHtml(root),
@@ -832,10 +858,13 @@ class Builder {
832
858
  // it carries no margins of its own and cannot collapse with anything.
833
859
  this._children(root, rootBox, rootStyle, false, null, ROOT_SHARE_KEY);
834
860
  this._endLine();
835
- fixUp(rootBox, anonymousStyles(cascade.initial));
861
+ cascade.endSharing();
862
+ const anonymous = anonymousStyles(cascade.initial);
863
+ fixUp(rootBox, anonymous);
836
864
  assignSubtreeRanges(rootBox);
837
865
  return {
838
866
  root: rootBox,
867
+ anonymous,
839
868
  text: this._chunks.join(''),
840
869
  textBoxes: this._textBoxes,
841
870
  textStyles: this._textStyles,
@@ -848,6 +877,7 @@ class Builder {
848
877
  negative: this._negative,
849
878
  firstLine: this._firstLine,
850
879
  firstLineStyler: this._firstLine ? this._firstLineStyler() : null,
880
+ selectionStyler: cascade.hasSelection ? this._selectionStyler() : null,
851
881
  nestedOutOfLine: this._nestedOutOfLine,
852
882
  movedInline: this._movedInline,
853
883
  clipText: this._clipText,
@@ -855,6 +885,30 @@ class Builder {
855
885
  };
856
886
  }
857
887
 
888
+ /** `BoxTree.selectionStyler`, over this build's cascade: an element's
889
+ * over its parent's, each once. A box with no element of its own — text,
890
+ * an anonymous box — is its nearest element's. */
891
+ private _selectionStyler(): SelectionStyler {
892
+ const cascade = this._options.cascade;
893
+ const styles = this._styles;
894
+ const known = new Map<Element, SelectionStyle | null>();
895
+ const of = (el: Element): SelectionStyle | null => {
896
+ let found = known.get(el);
897
+ if (found !== undefined) return found;
898
+ // the parser keeps a document to 256 elements deep (`dom.ts`)
899
+ const parent = isElement(el.parent) ? of(el.parent) : null;
900
+ const own = styles.get(el)?.style;
901
+ found = own ? cascade.selectionStyle(el, own, parent) : parent;
902
+ known.set(el, found);
903
+ return found;
904
+ };
905
+ return (box) => {
906
+ let at = box;
907
+ while (at && !at.el) at = at.parent;
908
+ return at?.el ? of(at.el) : null;
909
+ };
910
+ }
911
+
858
912
  /** `BoxTree.firstLineStyler`, over this build's cascade. */
859
913
  private _firstLineStyler(): FirstLineStyler {
860
914
  const cascade = this._options.cascade;
@@ -1024,7 +1078,9 @@ class Builder {
1024
1078
  }
1025
1079
 
1026
1080
  // an `<object>` is its image once it has one, and its content until;
1027
- // an `<embed>` its image, and a `<video>` its poster, or a frame
1081
+ // an `<embed>` its image, and a `<video>` its poster, or a frame; an
1082
+ // image button its image, and a button saying what it is for until it
1083
+ // has one, so it can be pressed either way
1028
1084
  const replaced =
1029
1085
  tag === 'object'
1030
1086
  ? this._options.imageSize(el)
@@ -1032,7 +1088,11 @@ class Builder {
1032
1088
  : 'none'
1033
1089
  : (tag === 'embed' || tag === 'video') && this._options.imageSize(el)
1034
1090
  ? 'image'
1035
- : replacedKind(el, tag);
1091
+ : isImageButton(el, tag)
1092
+ ? this._options.imageSize(el)
1093
+ ? 'image'
1094
+ : 'button'
1095
+ : replacedKind(el, tag);
1036
1096
  if (replaced !== 'none') {
1037
1097
  this._replaced(el, tag, replaced, style, into);
1038
1098
  return;
@@ -2359,6 +2419,13 @@ function setIntrinsics(box: Box, size: IntrinsicSize, scale: number): void {
2359
2419
  };
2360
2420
  }
2361
2421
 
2422
+ /** An `<input type=image>`: a picture that submits its form. */
2423
+ function isImageButton(el: Element, tag: string): boolean {
2424
+ return (
2425
+ tag === 'input' && (attr(el, 'type') ?? '').trim().toLowerCase() === 'image'
2426
+ );
2427
+ }
2428
+
2362
2429
  function replacedKind(el: Element, tag: string): ReplacedKind {
2363
2430
  switch (tag) {
2364
2431
  case 'img':
@@ -2662,24 +2729,24 @@ function namesImages(style: ComputedStyle): boolean {
2662
2729
  * paragraph after it the height of the div further down. One per parent
2663
2730
  * style, which the cascade already shares between elements.
2664
2731
  */
2665
- type AnonymousStyle = (
2666
- parent: Box,
2732
+ export type AnonymousStyle = (
2733
+ parent: ComputedStyle,
2667
2734
  display: ComputedStyle['display'],
2668
2735
  ) => ComputedStyle;
2669
2736
 
2670
2737
  function anonymousStyles(initial: ComputedStyle): AnonymousStyle {
2671
2738
  const made = new WeakMap<ComputedStyle, Map<string, ComputedStyle>>();
2672
2739
  return (parent, display) => {
2673
- let byDisplay = made.get(parent.style);
2740
+ let byDisplay = made.get(parent);
2674
2741
  if (!byDisplay) {
2675
2742
  byDisplay = new Map();
2676
- made.set(parent.style, byDisplay);
2743
+ made.set(parent, byDisplay);
2677
2744
  }
2678
2745
  let style = byDisplay.get(display);
2679
2746
  if (!style) {
2680
2747
  // `display` is the one property it does not start from: it is the
2681
2748
  // box the fix-up made, and layout asks the style what a box is
2682
- style = inherit(parent.style, initial);
2749
+ style = inherit(parent, initial);
2683
2750
  style.display = display;
2684
2751
  byDisplay.set(display, style);
2685
2752
  }
@@ -2787,7 +2854,7 @@ function anonymousOf(
2787
2854
  anonymous: AnonymousStyle,
2788
2855
  display: ComputedStyle['display'] = kind as ComputedStyle['display'],
2789
2856
  ): Box {
2790
- const box = new Box(kind, null, anonymous(parent, display));
2857
+ const box = new Box(kind, null, anonymous(parent.style, display));
2791
2858
  box.parent = parent;
2792
2859
  for (const child of run) {
2793
2860
  child.parent = box;