@beyondwork/docx-react-component 1.0.37 → 1.0.39

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 (116) hide show
  1. package/package.json +41 -31
  2. package/src/api/public-types.ts +496 -1
  3. package/src/core/commands/section-layout-commands.ts +58 -0
  4. package/src/core/commands/table-grid.ts +431 -0
  5. package/src/core/commands/table-structure-commands.ts +845 -56
  6. package/src/core/commands/text-commands.ts +122 -2
  7. package/src/io/docx-session.ts +1 -0
  8. package/src/io/export/serialize-main-document.ts +2 -11
  9. package/src/io/export/serialize-numbering.ts +43 -10
  10. package/src/io/export/serialize-paragraph-formatting.ts +152 -0
  11. package/src/io/export/serialize-run-formatting.ts +90 -0
  12. package/src/io/export/serialize-styles.ts +212 -0
  13. package/src/io/export/serialize-tables.ts +74 -0
  14. package/src/io/export/table-properties-xml.ts +139 -4
  15. package/src/io/normalize/normalize-text.ts +15 -0
  16. package/src/io/ooxml/parse-fields.ts +10 -3
  17. package/src/io/ooxml/parse-footnotes.ts +60 -0
  18. package/src/io/ooxml/parse-headers-footers.ts +60 -0
  19. package/src/io/ooxml/parse-main-document.ts +137 -0
  20. package/src/io/ooxml/parse-numbering.ts +41 -1
  21. package/src/io/ooxml/parse-paragraph-formatting.ts +188 -0
  22. package/src/io/ooxml/parse-run-formatting.ts +129 -0
  23. package/src/io/ooxml/parse-styles.ts +31 -0
  24. package/src/io/ooxml/parse-tables.ts +249 -0
  25. package/src/io/ooxml/xml-attr-helpers.ts +60 -0
  26. package/src/io/ooxml/xml-element.ts +19 -0
  27. package/src/model/canonical-document.ts +117 -3
  28. package/src/runtime/collab/event-types.ts +165 -0
  29. package/src/runtime/collab/index.ts +22 -0
  30. package/src/runtime/collab/remote-cursor-awareness.ts +93 -0
  31. package/src/runtime/collab/runtime-collab-sync.ts +273 -0
  32. package/src/runtime/document-layout.ts +4 -2
  33. package/src/runtime/document-navigation.ts +1 -1
  34. package/src/runtime/document-runtime.ts +248 -18
  35. package/src/runtime/layout/default-page-format.ts +96 -0
  36. package/src/runtime/layout/index.ts +47 -0
  37. package/src/runtime/layout/inert-layout-facet.ts +16 -0
  38. package/src/runtime/layout/layout-engine-instance.ts +100 -23
  39. package/src/runtime/layout/layout-invalidation.ts +14 -5
  40. package/src/runtime/layout/margin-preset-catalog.ts +178 -0
  41. package/src/runtime/layout/page-format-catalog.ts +233 -0
  42. package/src/runtime/layout/page-graph.ts +55 -0
  43. package/src/runtime/layout/paginate-paragraph-lines.ts +128 -0
  44. package/src/runtime/layout/paginated-layout-engine.ts +484 -37
  45. package/src/runtime/layout/project-block-fragments.ts +225 -0
  46. package/src/runtime/layout/public-facet.ts +748 -16
  47. package/src/runtime/layout/resolve-page-fields.ts +70 -0
  48. package/src/runtime/layout/resolve-page-previews.ts +185 -0
  49. package/src/runtime/layout/resolved-formatting-state.ts +30 -26
  50. package/src/runtime/layout/table-render-plan.ts +249 -0
  51. package/src/runtime/numbering-prefix.ts +5 -0
  52. package/src/runtime/paragraph-style-resolver.ts +194 -0
  53. package/src/runtime/render/block-fragment-projection.ts +35 -0
  54. package/src/runtime/render/decoration-resolver.ts +189 -0
  55. package/src/runtime/render/index.ts +57 -0
  56. package/src/runtime/render/pending-op-delta-reader.ts +129 -0
  57. package/src/runtime/render/render-frame-types.ts +317 -0
  58. package/src/runtime/render/render-kernel.ts +759 -0
  59. package/src/runtime/resolved-numbering-geometry.ts +9 -1
  60. package/src/runtime/surface-projection.ts +129 -9
  61. package/src/runtime/table-schema.ts +11 -0
  62. package/src/runtime/view-state.ts +67 -0
  63. package/src/runtime/workflow-markup.ts +1 -5
  64. package/src/runtime/workflow-rail-segments.ts +280 -0
  65. package/src/ui/WordReviewEditor.tsx +368 -19
  66. package/src/ui/editor-command-bag.ts +4 -0
  67. package/src/ui/editor-runtime-boundary.ts +16 -0
  68. package/src/ui/editor-shell-view.tsx +10 -0
  69. package/src/ui/editor-surface-controller.tsx +9 -1
  70. package/src/ui/headless/chrome-registry.ts +310 -15
  71. package/src/ui/headless/scoped-chrome-policy.ts +49 -1
  72. package/src/ui/headless/selection-tool-types.ts +10 -0
  73. package/src/ui-tailwind/chrome/chrome-preset-model.ts +23 -2
  74. package/src/ui-tailwind/chrome/review-queue-bar.tsx +2 -14
  75. package/src/ui-tailwind/chrome/role-action-sets.ts +80 -0
  76. package/src/ui-tailwind/chrome/tw-detach-handle.tsx +147 -0
  77. package/src/ui-tailwind/chrome/tw-selection-anchor-resolver.ts +160 -0
  78. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +68 -92
  79. package/src/ui-tailwind/chrome/tw-selection-tool-placement.ts +149 -0
  80. package/src/ui-tailwind/chrome/tw-selection-tool-structure.tsx +11 -0
  81. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +15 -4
  82. package/src/ui-tailwind/chrome/tw-table-border-picker.tsx +245 -0
  83. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +356 -140
  84. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +284 -0
  85. package/src/ui-tailwind/chrome-overlay/chrome-overlay-projector.ts +94 -0
  86. package/src/ui-tailwind/chrome-overlay/index.ts +16 -0
  87. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +96 -0
  88. package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +178 -0
  89. package/src/ui-tailwind/editor-surface/fast-text-edit-lane.ts +4 -0
  90. package/src/ui-tailwind/editor-surface/local-edit-session-state.ts +11 -0
  91. package/src/ui-tailwind/editor-surface/page-slice-util.ts +15 -0
  92. package/src/ui-tailwind/editor-surface/perf-probe.ts +7 -1
  93. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +40 -4
  94. package/src/ui-tailwind/editor-surface/pm-decorations.ts +22 -12
  95. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +389 -0
  96. package/src/ui-tailwind/editor-surface/pm-schema.ts +40 -2
  97. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +144 -62
  98. package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +179 -0
  99. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +559 -0
  100. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +224 -75
  101. package/src/ui-tailwind/editor-surface/tw-table-bands.css +61 -0
  102. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +19 -0
  103. package/src/ui-tailwind/index.ts +29 -0
  104. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +2 -2
  105. package/src/ui-tailwind/review/tw-rail-card.tsx +150 -0
  106. package/src/ui-tailwind/review/tw-review-rail-footer.tsx +52 -0
  107. package/src/ui-tailwind/review/tw-review-rail.tsx +166 -11
  108. package/src/ui-tailwind/review/tw-workflow-tab.tsx +108 -0
  109. package/src/ui-tailwind/theme/editor-theme.css +498 -163
  110. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +680 -0
  111. package/src/ui-tailwind/toolbar/tw-scope-posture-menu.tsx +182 -0
  112. package/src/ui-tailwind/toolbar/tw-shell-header.tsx +162 -0
  113. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +104 -2
  114. package/src/ui-tailwind/tw-review-workspace.tsx +234 -21
  115. package/src/runtime/collab-review-sync.ts +0 -254
  116. package/src/ui-tailwind/editor-surface/pm-collab-plugins.ts +0 -40
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Selection-tool placement helper — pure function that decides where a
3
+ * selection-anchored floating chrome panel renders relative to its
4
+ * anchor rect.
5
+ *
6
+ * Extracts the right→left→above/below decision that lived in
7
+ * `tw-review-workspace.tsx::resolveSelectionToolbarPlacement` so
8
+ * consumers can feed it render-frame rects instead of DOM rects (R2.1
9
+ * owns the rect production; this helper is zoom-agnostic).
10
+ *
11
+ * The output is a `SelectionToolPlacement` compatible with
12
+ * `tw-selection-tool-host`'s existing contract so the rewire is
13
+ * additive — callers that already use `SelectionToolPlacement` keep
14
+ * working.
15
+ */
16
+
17
+ import type { CSSProperties } from "react";
18
+
19
+ import type { RenderFrameRect } from "../../api/public-types";
20
+
21
+ export interface SelectionToolPlacementDecision {
22
+ placement: "right" | "left" | "above" | "below";
23
+ style: CSSProperties;
24
+ }
25
+
26
+ export interface ResolveSelectionToolPlacementInput {
27
+ /** Anchor rect in overlay coordinate space. */
28
+ anchor: RenderFrameRect;
29
+ /** Container size in overlay coordinate space. */
30
+ container: { widthPx: number; heightPx: number };
31
+ /**
32
+ * Estimated toolbar width in px. Defaults to a reasonable bounded
33
+ * window (`clamp(168, 32% of container, 260)`) so the helper can run
34
+ * before the toolbar measures its own width.
35
+ */
36
+ estimatedToolbarWidthPx?: number;
37
+ /**
38
+ * Estimated toolbar height in px. Defaults to 44 (matches the
39
+ * legacy DOM-based math).
40
+ */
41
+ estimatedToolbarHeightPx?: number;
42
+ /** Edge padding in px. Defaults to 16. */
43
+ edgePaddingPx?: number;
44
+ /** Gap between anchor and toolbar in px. Defaults to 12. */
45
+ gapPx?: number;
46
+ }
47
+
48
+ const DEFAULT_EDGE_PADDING = 16;
49
+ const DEFAULT_GAP = 12;
50
+ const DEFAULT_TOOLBAR_HEIGHT = 44;
51
+ const TOOLBAR_WIDTH_MIN = 168;
52
+ const TOOLBAR_WIDTH_MAX = 260;
53
+ const TOOLBAR_WIDTH_CONTAINER_FRACTION = 0.32;
54
+
55
+ /**
56
+ * Decide where to render the selection-tool panel relative to its
57
+ * anchor. Prefers `right` when there is clearance, falls back to
58
+ * `left`, then `above` / `below` based on vertical room.
59
+ *
60
+ * Returns `null` when the container is zero-sized (the caller should
61
+ * then skip rendering entirely).
62
+ */
63
+ export function resolveSelectionToolPlacement(
64
+ input: ResolveSelectionToolPlacementInput,
65
+ ): SelectionToolPlacementDecision | null {
66
+ const { anchor, container } = input;
67
+ if (container.widthPx <= 0 || container.heightPx <= 0) return null;
68
+
69
+ const edgePadding = input.edgePaddingPx ?? DEFAULT_EDGE_PADDING;
70
+ const gapPx = input.gapPx ?? DEFAULT_GAP;
71
+ const toolbarHeight = input.estimatedToolbarHeightPx ?? DEFAULT_TOOLBAR_HEIGHT;
72
+ const toolbarWidth =
73
+ input.estimatedToolbarWidthPx ??
74
+ Math.min(
75
+ TOOLBAR_WIDTH_MAX,
76
+ Math.max(
77
+ TOOLBAR_WIDTH_MIN,
78
+ container.widthPx * TOOLBAR_WIDTH_CONTAINER_FRACTION,
79
+ ),
80
+ );
81
+
82
+ const anchorLeft = anchor.leftPx;
83
+ const anchorRight = anchor.leftPx + anchor.widthPx;
84
+ const anchorTop = anchor.topPx;
85
+ const anchorBottom = anchor.topPx + anchor.heightPx;
86
+ const centerX = anchorLeft + anchor.widthPx / 2;
87
+ const centerY = anchorTop + anchor.heightPx / 2;
88
+
89
+ const rightClearance = container.widthPx - anchorRight - gapPx - edgePadding;
90
+ const leftClearance = anchorLeft - gapPx - edgePadding;
91
+
92
+ const clampedCenterX = Math.max(
93
+ edgePadding,
94
+ Math.min(
95
+ centerX,
96
+ Math.max(edgePadding, container.widthPx - edgePadding),
97
+ ),
98
+ );
99
+ const clampedCenterY = Math.max(
100
+ edgePadding + toolbarHeight / 2,
101
+ Math.min(
102
+ centerY,
103
+ Math.max(
104
+ edgePadding + toolbarHeight / 2,
105
+ container.heightPx - edgePadding - toolbarHeight / 2,
106
+ ),
107
+ ),
108
+ );
109
+ const maxWidthPx = Math.max(220, container.widthPx - edgePadding * 2);
110
+
111
+ if (rightClearance >= toolbarWidth) {
112
+ return {
113
+ placement: "right",
114
+ style: {
115
+ left: `${anchorRight}px`,
116
+ top: `${clampedCenterY}px`,
117
+ maxWidth: `${maxWidthPx}px`,
118
+ transform: `translate(${gapPx}px, -50%)`,
119
+ },
120
+ };
121
+ }
122
+
123
+ if (leftClearance >= toolbarWidth) {
124
+ return {
125
+ placement: "left",
126
+ style: {
127
+ left: `${anchorLeft}px`,
128
+ top: `${clampedCenterY}px`,
129
+ maxWidth: `${maxWidthPx}px`,
130
+ transform: `translate(calc(-100% - ${gapPx}px), -50%)`,
131
+ },
132
+ };
133
+ }
134
+
135
+ const placement: "above" | "below" =
136
+ anchorTop < toolbarHeight + gapPx + edgePadding ? "below" : "above";
137
+ return {
138
+ placement,
139
+ style: {
140
+ left: `${clampedCenterX}px`,
141
+ top: `${placement === "above" ? anchorTop : anchorBottom}px`,
142
+ maxWidth: `${maxWidthPx}px`,
143
+ transform:
144
+ placement === "above"
145
+ ? `translate(-50%, calc(-100% - ${gapPx}px))`
146
+ : `translate(-50%, ${gapPx}px)`,
147
+ },
148
+ };
149
+ }
@@ -29,6 +29,12 @@ export interface TwSelectionToolStructureProps {
29
29
  ) => void;
30
30
  onRestartNumbering?: () => void;
31
31
  onContinueNumbering?: () => void;
32
+ // P6: new table ops
33
+ onToggleRowHeader?: () => void;
34
+ onToggleRowCantSplit?: () => void;
35
+ onDistributeColumnsEvenly?: () => void;
36
+ onSetTableAlignment?: (alignment: "left" | "center" | "right") => void;
37
+ onSetCellVerticalAlign?: (align: "top" | "center" | "bottom") => void;
32
38
  }
33
39
 
34
40
  export function TwSelectionToolStructure(props: TwSelectionToolStructureProps) {
@@ -64,6 +70,11 @@ export function TwSelectionToolStructure(props: TwSelectionToolStructureProps) {
64
70
  onMergeCells={props.onMergeCells}
65
71
  onSplitCell={props.onSplitCell}
66
72
  onSetCellBackground={props.onSetCellBackground}
73
+ onToggleRowHeader={props.onToggleRowHeader}
74
+ onToggleRowCantSplit={props.onToggleRowCantSplit}
75
+ onDistributeColumnsEvenly={props.onDistributeColumnsEvenly}
76
+ onSetTableAlignment={props.onSetTableAlignment}
77
+ onSetCellVerticalAlign={props.onSetCellVerticalAlign}
67
78
  />
68
79
  );
69
80
  case "list":
@@ -22,6 +22,15 @@ export interface TwSelectionToolbarProps {
22
22
  const focusRingClass =
23
23
  "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-canvas";
24
24
 
25
+ /**
26
+ * Fallback colors for the selection-toolbar one-click apply buttons.
27
+ * The model can override via `textColorDefault` / `highlightColorDefault`
28
+ * which R2.5 plumbs through from `formattingState` so the apply button
29
+ * reflects the user's recent color pick.
30
+ */
31
+ const DEFAULT_TEXT_COLOR = "#1660a8";
32
+ const DEFAULT_HIGHLIGHT_COLOR = "#ffff00";
33
+
25
34
  export const TwSelectionToolbar = forwardRef<HTMLDivElement, TwSelectionToolbarProps>(function TwSelectionToolbar(props, ref) {
26
35
  const { model } = props;
27
36
  const addCommentDisabled = !model.canAddComment;
@@ -64,17 +73,19 @@ export const TwSelectionToolbar = forwardRef<HTMLDivElement, TwSelectionToolbarP
64
73
  />
65
74
  <ToolbarActionButton
66
75
  icon={<Baseline className="h-3.5 w-3.5" />}
67
- label="Text color blue"
76
+ label={`Apply ${model.textColorDefault ?? DEFAULT_TEXT_COLOR}`}
68
77
  pressed={false}
69
78
  disabled={formattingDisabled}
70
- onClick={() => props.onSetTextColor?.("#1660a8")}
79
+ onClick={() => props.onSetTextColor?.(model.textColorDefault ?? DEFAULT_TEXT_COLOR)}
71
80
  />
72
81
  <ToolbarActionButton
73
82
  icon={<Highlighter className="h-3.5 w-3.5" />}
74
- label="Highlight yellow"
83
+ label={`Apply ${model.highlightColorDefault ?? DEFAULT_HIGHLIGHT_COLOR} highlight`}
75
84
  pressed={false}
76
85
  disabled={formattingDisabled}
77
- onClick={() => props.onSetHighlightColor?.("#ffff00")}
86
+ onClick={() =>
87
+ props.onSetHighlightColor?.(model.highlightColorDefault ?? DEFAULT_HIGHLIGHT_COLOR)
88
+ }
78
89
  />
79
90
 
80
91
  <div className="mx-0.5 h-4 w-px bg-border" />
@@ -0,0 +1,245 @@
1
+ /**
2
+ * R4b: floating border picker for the table context toolbar.
3
+ *
4
+ * Opens from a "Borders" button in the toolbar's Cells cluster. Reads the
5
+ * current border state via callbacks into `ref.tables.apply()` (set-cell-borders
6
+ * for selection-scoped edits, set-table-borders for whole-table edits).
7
+ *
8
+ * UI shape — two top-level choices the user makes in sequence:
9
+ * 1. Target family: Outside / Inside / All / None
10
+ * (where "All" applies to every side of every cell in the selection,
11
+ * "Outside" limits to the exterior edges, "Inside" to the interior
12
+ * edges, and "None" clears the selected sides)
13
+ * 2. Style: single / double / dashed / none
14
+ * 3. Size: 0.5pt / 1pt / 1.5pt / 2pt (8ths of a point — OOXML uses `sz` in 8ths)
15
+ * 4. Color: black / primary / secondary / custom
16
+ *
17
+ * The picker itself is a pure UI component — it knows nothing about the
18
+ * canonical document. It emits `onApply(config)` with a structured payload
19
+ * the toolbar hands to `ref.tables.apply({ kind: "set-cell-borders"|"set-table-borders", ... })`.
20
+ */
21
+
22
+ import React, { useState } from "react";
23
+
24
+ import { preserveEditorSelectionMouseDown } from "../../ui/headless/preserve-editor-selection";
25
+
26
+ export type BorderFamily = "outside" | "inside" | "all" | "none";
27
+ export type BorderStyle = "single" | "double" | "dashed" | "none";
28
+ export type BorderSizeEighths = 4 | 8 | 12 | 16; // 0.5pt, 1pt, 1.5pt, 2pt
29
+
30
+ export interface BorderPickerConfig {
31
+ family: BorderFamily;
32
+ style: BorderStyle;
33
+ sizeEighths: BorderSizeEighths;
34
+ colorHex: string;
35
+ }
36
+
37
+ export interface TwTableBorderPickerProps {
38
+ /** When true, the picker is visible. */
39
+ open: boolean;
40
+ /** Disable interaction; applies grey styling. */
41
+ disabled?: boolean;
42
+ /**
43
+ * Called when the user commits a configuration. The toolbar maps this to
44
+ * either `set-table-borders` (T5 selection) or `set-cell-borders` (T2/T3/T4).
45
+ */
46
+ onApply: (config: BorderPickerConfig) => void;
47
+ /** Called when the user dismisses without applying. */
48
+ onDismiss?: () => void;
49
+ }
50
+
51
+ const FAMILIES: ReadonlyArray<{ id: BorderFamily; label: string }> = [
52
+ { id: "outside", label: "Outside" },
53
+ { id: "inside", label: "Inside" },
54
+ { id: "all", label: "All" },
55
+ { id: "none", label: "None" },
56
+ ];
57
+
58
+ const STYLES: ReadonlyArray<{ id: BorderStyle; label: string }> = [
59
+ { id: "single", label: "Single" },
60
+ { id: "double", label: "Double" },
61
+ { id: "dashed", label: "Dashed" },
62
+ { id: "none", label: "None" },
63
+ ];
64
+
65
+ const SIZES: ReadonlyArray<{ id: BorderSizeEighths; label: string }> = [
66
+ { id: 4, label: "0.5 pt" },
67
+ { id: 8, label: "1 pt" },
68
+ { id: 12, label: "1.5 pt" },
69
+ { id: 16, label: "2 pt" },
70
+ ];
71
+
72
+ const COLORS: ReadonlyArray<{ id: string; label: string; hex: string }> = [
73
+ { id: "black", label: "Black", hex: "#000000" },
74
+ { id: "muted", label: "Muted", hex: "#8b8b88" },
75
+ { id: "accent", label: "Accent", hex: "#2563eb" },
76
+ { id: "danger", label: "Danger", hex: "#dc2626" },
77
+ ];
78
+
79
+ /**
80
+ * The picker itself. Lives inside a parent that controls `open` / `onDismiss`;
81
+ * a simple popover today, destined to graduate into a tooltip-positioned
82
+ * overlay once the chrome-overlay floating-tool protocol is stable.
83
+ */
84
+ export function TwTableBorderPicker(props: TwTableBorderPickerProps) {
85
+ const [family, setFamily] = useState<BorderFamily>("all");
86
+ const [style, setStyle] = useState<BorderStyle>("single");
87
+ const [sizeEighths, setSizeEighths] = useState<BorderSizeEighths>(8);
88
+ const [colorHex, setColorHex] = useState<string>("#000000");
89
+
90
+ if (!props.open) return null;
91
+
92
+ const handleApply = () => {
93
+ props.onApply({ family, style, sizeEighths, colorHex });
94
+ };
95
+
96
+ const baseButton =
97
+ "inline-flex h-6 items-center rounded border px-2 text-[11px] font-medium transition-colors";
98
+ const activeButton = "border-accent bg-accent/10 text-accent";
99
+ const idleButton = "border-border text-secondary hover:bg-surface";
100
+
101
+ return (
102
+ <div
103
+ data-testid="table-border-picker"
104
+ role="dialog"
105
+ aria-label="Border picker"
106
+ className="flex flex-col gap-2 rounded-lg border border-border bg-canvas px-3 py-2 shadow-md"
107
+ onMouseDown={preserveEditorSelectionMouseDown}
108
+ >
109
+ {/* Family */}
110
+ <div className="flex items-center gap-1.5">
111
+ <span className="w-14 text-[9px] font-semibold uppercase tracking-[0.12em] text-tertiary">
112
+ Target
113
+ </span>
114
+ {FAMILIES.map((f) => (
115
+ <button
116
+ key={f.id}
117
+ type="button"
118
+ aria-label={`Border target ${f.label}`}
119
+ aria-pressed={family === f.id}
120
+ disabled={props.disabled}
121
+ onClick={() => setFamily(f.id)}
122
+ className={`${baseButton} ${family === f.id ? activeButton : idleButton}`}
123
+ >
124
+ {f.label}
125
+ </button>
126
+ ))}
127
+ </div>
128
+
129
+ {/* Style */}
130
+ <div className="flex items-center gap-1.5">
131
+ <span className="w-14 text-[9px] font-semibold uppercase tracking-[0.12em] text-tertiary">
132
+ Style
133
+ </span>
134
+ {STYLES.map((s) => (
135
+ <button
136
+ key={s.id}
137
+ type="button"
138
+ aria-label={`Border style ${s.label}`}
139
+ aria-pressed={style === s.id}
140
+ disabled={props.disabled}
141
+ onClick={() => setStyle(s.id)}
142
+ className={`${baseButton} ${style === s.id ? activeButton : idleButton}`}
143
+ >
144
+ {s.label}
145
+ </button>
146
+ ))}
147
+ </div>
148
+
149
+ {/* Size */}
150
+ <div className="flex items-center gap-1.5">
151
+ <span className="w-14 text-[9px] font-semibold uppercase tracking-[0.12em] text-tertiary">
152
+ Size
153
+ </span>
154
+ {SIZES.map((s) => (
155
+ <button
156
+ key={s.id}
157
+ type="button"
158
+ aria-label={`Border size ${s.label}`}
159
+ aria-pressed={sizeEighths === s.id}
160
+ disabled={props.disabled}
161
+ onClick={() => setSizeEighths(s.id)}
162
+ className={`${baseButton} ${sizeEighths === s.id ? activeButton : idleButton}`}
163
+ >
164
+ {s.label}
165
+ </button>
166
+ ))}
167
+ </div>
168
+
169
+ {/* Color */}
170
+ <div className="flex items-center gap-1.5">
171
+ <span className="w-14 text-[9px] font-semibold uppercase tracking-[0.12em] text-tertiary">
172
+ Color
173
+ </span>
174
+ {COLORS.map((c) => (
175
+ <button
176
+ key={c.id}
177
+ type="button"
178
+ aria-label={`Border color ${c.label}`}
179
+ aria-pressed={colorHex === c.hex}
180
+ disabled={props.disabled}
181
+ onClick={() => setColorHex(c.hex)}
182
+ className={`h-6 w-6 rounded-full border ${
183
+ colorHex === c.hex ? "border-accent ring-2 ring-accent/40" : "border-border"
184
+ }`}
185
+ style={{ backgroundColor: c.hex }}
186
+ />
187
+ ))}
188
+ </div>
189
+
190
+ {/* Actions */}
191
+ <div className="flex items-center justify-end gap-1.5 pt-1">
192
+ {props.onDismiss ? (
193
+ <button
194
+ type="button"
195
+ aria-label="Dismiss border picker"
196
+ onClick={props.onDismiss}
197
+ disabled={props.disabled}
198
+ className="h-6 rounded border border-border px-2 text-[11px] font-medium text-secondary hover:bg-surface"
199
+ >
200
+ Cancel
201
+ </button>
202
+ ) : null}
203
+ <button
204
+ type="button"
205
+ aria-label="Apply borders"
206
+ data-testid="border-picker-apply"
207
+ onClick={handleApply}
208
+ disabled={props.disabled}
209
+ className="h-6 rounded border border-accent bg-accent px-2 text-[11px] font-medium text-white hover:bg-accent/90 disabled:opacity-40"
210
+ >
211
+ Apply
212
+ </button>
213
+ </div>
214
+ </div>
215
+ );
216
+ }
217
+
218
+ /**
219
+ * Translate the picker's family + style + size + color into the per-side
220
+ * partial that the canonical `set-cell-borders` / `set-table-borders` op
221
+ * expects. Toolbar uses this to build the op payload.
222
+ *
223
+ * The emitted object uses runtime-side border keys:
224
+ * single → "single", double → "double", dashed → "dashed"
225
+ * and a size encoded in 8ths of a point.
226
+ */
227
+ export function configToBordersPatch(
228
+ config: BorderPickerConfig,
229
+ ): {
230
+ applyToFamily: BorderFamily;
231
+ sides: {
232
+ style: BorderStyle;
233
+ sizeEighths: BorderSizeEighths;
234
+ colorHex: string;
235
+ };
236
+ } {
237
+ return {
238
+ applyToFamily: config.family,
239
+ sides: {
240
+ style: config.style,
241
+ sizeEighths: config.sizeEighths,
242
+ colorHex: config.colorHex,
243
+ },
244
+ };
245
+ }