@beyondwork/docx-react-component 1.0.38 → 1.0.40

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 (85) hide show
  1. package/package.json +41 -31
  2. package/src/api/public-types.ts +305 -6
  3. package/src/core/commands/table-structure-commands.ts +31 -2
  4. package/src/core/commands/text-commands.ts +122 -2
  5. package/src/index.ts +9 -0
  6. package/src/io/docx-session.ts +1 -0
  7. package/src/io/export/serialize-numbering.ts +42 -8
  8. package/src/io/export/serialize-paragraph-formatting.ts +152 -0
  9. package/src/io/export/serialize-run-formatting.ts +90 -0
  10. package/src/io/export/serialize-styles.ts +212 -0
  11. package/src/io/ooxml/parse-fields.ts +10 -3
  12. package/src/io/ooxml/parse-numbering.ts +41 -1
  13. package/src/io/ooxml/parse-paragraph-formatting.ts +188 -0
  14. package/src/io/ooxml/parse-run-formatting.ts +129 -0
  15. package/src/io/ooxml/parse-styles.ts +31 -0
  16. package/src/io/ooxml/xml-attr-helpers.ts +60 -0
  17. package/src/io/ooxml/xml-element.ts +19 -0
  18. package/src/model/canonical-document.ts +83 -3
  19. package/src/runtime/collab/event-types.ts +165 -0
  20. package/src/runtime/collab/index.ts +22 -0
  21. package/src/runtime/collab/remote-cursor-awareness.ts +93 -0
  22. package/src/runtime/collab/runtime-collab-sync.ts +273 -0
  23. package/src/runtime/document-runtime.ts +141 -18
  24. package/src/runtime/layout/docx-font-loader.ts +30 -11
  25. package/src/runtime/layout/index.ts +2 -0
  26. package/src/runtime/layout/inert-layout-facet.ts +3 -0
  27. package/src/runtime/layout/layout-engine-instance.ts +69 -2
  28. package/src/runtime/layout/layout-invalidation.ts +14 -5
  29. package/src/runtime/layout/page-graph.ts +36 -0
  30. package/src/runtime/layout/paginate-paragraph-lines.ts +128 -0
  31. package/src/runtime/layout/paginated-layout-engine.ts +342 -28
  32. package/src/runtime/layout/project-block-fragments.ts +154 -20
  33. package/src/runtime/layout/public-facet.ts +81 -1
  34. package/src/runtime/layout/resolve-page-fields.ts +70 -0
  35. package/src/runtime/layout/resolve-page-previews.ts +185 -0
  36. package/src/runtime/layout/resolved-formatting-state.ts +30 -26
  37. package/src/runtime/layout/table-render-plan.ts +21 -1
  38. package/src/runtime/numbering-prefix.ts +5 -0
  39. package/src/runtime/paragraph-style-resolver.ts +194 -0
  40. package/src/runtime/render/render-kernel.ts +5 -1
  41. package/src/runtime/resolved-numbering-geometry.ts +9 -1
  42. package/src/runtime/surface-projection.ts +129 -9
  43. package/src/runtime/table-schema.ts +11 -0
  44. package/src/runtime/workflow-rail-segments.ts +149 -1
  45. package/src/ui/WordReviewEditor.tsx +302 -5
  46. package/src/ui/editor-command-bag.ts +4 -0
  47. package/src/ui/editor-runtime-boundary.ts +16 -0
  48. package/src/ui/editor-shell-view.tsx +22 -0
  49. package/src/ui/editor-surface-controller.tsx +9 -1
  50. package/src/ui/headless/chrome-registry.ts +34 -5
  51. package/src/ui/headless/scoped-chrome-policy.ts +29 -0
  52. package/src/ui-tailwind/chrome/review-queue-bar.tsx +2 -14
  53. package/src/ui-tailwind/chrome/role-action-sets.ts +14 -8
  54. package/src/ui-tailwind/chrome/tw-mode-dock.tsx +80 -0
  55. package/src/ui-tailwind/chrome/tw-selection-anchor-resolver.ts +7 -10
  56. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +11 -0
  57. package/src/ui-tailwind/chrome/tw-selection-tool-structure.tsx +11 -0
  58. package/src/ui-tailwind/chrome/tw-table-border-picker.tsx +245 -0
  59. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +101 -21
  60. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +353 -0
  61. package/src/ui-tailwind/chrome-overlay/chrome-overlay-projector.ts +5 -1
  62. package/src/ui-tailwind/chrome-overlay/index.ts +2 -6
  63. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +82 -18
  64. package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +133 -0
  65. package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +386 -0
  66. package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +140 -69
  67. package/src/ui-tailwind/editor-surface/page-slice-util.ts +15 -0
  68. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +28 -3
  69. package/src/ui-tailwind/editor-surface/pm-decorations.ts +7 -2
  70. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +389 -0
  71. package/src/ui-tailwind/editor-surface/pm-schema.ts +40 -2
  72. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +170 -63
  73. package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +179 -0
  74. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +559 -0
  75. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +224 -78
  76. package/src/ui-tailwind/editor-surface/tw-table-bands.css +61 -0
  77. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +19 -0
  78. package/src/ui-tailwind/index.ts +6 -5
  79. package/src/ui-tailwind/theme/editor-theme.css +108 -15
  80. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +122 -1
  81. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +36 -3
  82. package/src/ui-tailwind/tw-review-workspace.tsx +207 -54
  83. package/src/runtime/collab-review-sync.ts +0 -254
  84. package/src/ui-tailwind/chrome-overlay/tw-workspace-view-switcher.tsx +0 -95
  85. package/src/ui-tailwind/editor-surface/pm-collab-plugins.ts +0 -40
@@ -23,7 +23,6 @@ import type { MediaPreviewDescriptor } from "../ui-tailwind/editor-surface/pm-st
23
23
 
24
24
  export interface EditorSurfaceControllerProps {
25
25
  currentUser: EditorUser;
26
- ydoc?: import('yjs').Doc;
27
26
  awareness?: import("y-protocols/awareness").Awareness;
28
27
  snapshot: RuntimeRenderSnapshot;
29
28
  canonicalDocument: CanonicalDocumentEnvelope;
@@ -46,6 +45,8 @@ export interface EditorSurfaceControllerProps {
46
45
  onDeleteForward?: () => void;
47
46
  onInsertTab?: () => void;
48
47
  onOutdentTab?: () => void;
48
+ onListIndent?: () => void;
49
+ onListOutdent?: () => void;
49
50
  onInsertHardBreak?: () => void;
50
51
  onSplitParagraph?: () => void;
51
52
  onUndo?: () => void;
@@ -63,6 +64,13 @@ export interface EditorSurfaceControllerProps {
63
64
  dispatchRuntimeCommand?: (
64
65
  command: import("../ui-tailwind/editor-surface/fast-text-edit-lane.ts").LaneRuntimeCommand,
65
66
  ) => import("../api/public-types.ts").TextCommandAck;
67
+ layoutFacet?: import("../runtime/layout/index.ts").WordReviewEditorLayoutFacet;
68
+ pageChromeHeaderBandPx?: number;
69
+ pageChromeFooterBandPx?: number;
70
+ pageChromeInterGapPx?: number;
71
+ pageBreakRevision?: number;
72
+ onOpenHeaderStoryForPage?: (pageIndex: number) => void;
73
+ onOpenFooterStoryForPage?: (pageIndex: number) => void;
66
74
  }
67
75
 
68
76
  export const EditorSurfaceController = forwardRef<
@@ -31,6 +31,8 @@ export type ToolbarChromeItemId =
31
31
  | "export"
32
32
  // R1: role-scoped action region entries
33
33
  | "editor-scope-posture-menu"
34
+ | "review-sidebar-tracked-changes"
35
+ | "review-sidebar-comments"
34
36
  | "review-queue-prev"
35
37
  | "review-queue-next"
36
38
  | "review-queue-counts"
@@ -72,7 +74,7 @@ export interface ToolbarChromeRegistryEntry extends ChromeRegistryEntryBase {
72
74
  roles: ReadonlyArray<EditorRole>;
73
75
  fullPlacement: Exclude<ToolbarChromePlacement, "hidden">;
74
76
  compactPlacement: ToolbarChromePlacement;
75
- runtimeBehavior: "always" | "formatting" | "structure" | "comment";
77
+ runtimeBehavior: "always" | "formatting" | "structure" | "comment" | "sidebar-panel";
76
78
  scopeBehavior?: "default" | "scoped-only" | "hidden-when-scoped";
77
79
  /**
78
80
  * Optional per-role placement override. When a role overrides the
@@ -265,6 +267,9 @@ export const TOOLBAR_CHROME_REGISTRY: ReadonlyArray<ToolbarChromeRegistryEntry>
265
267
  surfaces: ["top-toolbar"],
266
268
  group: "review",
267
269
  presets: ["simple", "advanced", "review", "workflow"],
270
+ // Visible in every role, but editor/review roles render it inside the
271
+ // role action region (see ROLE_ACTION_SETS) and the right cluster
272
+ // suppresses it via `isChromeItemOwnedByRoleRegion` to avoid duplication.
268
273
  roles: ALL_ROLES,
269
274
  fullPlacement: "inline",
270
275
  compactPlacement: "inline",
@@ -274,9 +279,8 @@ export const TOOLBAR_CHROME_REGISTRY: ReadonlyArray<ToolbarChromeRegistryEntry>
274
279
  id: "tracked-changes-toggle",
275
280
  surfaces: ["top-toolbar"],
276
281
  group: "review",
277
- // R1 promotes this toggle to every preset that shows a toolbar so the
278
- // editor role can see inline tracked changes without switching preset.
279
282
  presets: ["simple", "advanced", "review", "workflow"],
283
+ // Same ownership rule as `comment` — editor/review role regions own it.
280
284
  roles: ALL_ROLES,
281
285
  fullPlacement: "inline",
282
286
  compactPlacement: "inline",
@@ -323,18 +327,43 @@ export const TOOLBAR_CHROME_REGISTRY: ReadonlyArray<ToolbarChromeRegistryEntry>
323
327
  runtimeBehavior: "always",
324
328
  },
325
329
 
326
- // ───── R1: Editor-role primaries ──────────────────────────────────────
330
+ // ───── R1: Workflow-role scope posture menu ───────────────────────────
327
331
  {
328
332
  id: "editor-scope-posture-menu",
329
333
  surfaces: ["top-toolbar"],
330
334
  group: "scope",
331
335
  presets: ["advanced", "review", "workflow"],
332
- roles: EDITOR_ONLY,
336
+ // Scoping/tagging is a workflow-primary action; moved from editor role.
337
+ roles: WORKFLOW_ONLY,
333
338
  fullPlacement: "inline",
334
339
  compactPlacement: "overflow",
335
340
  runtimeBehavior: "always",
336
341
  },
337
342
 
343
+ // ───── R1: Review-role sidebar panel toggles (optional, host-provided) ─
344
+ {
345
+ id: "review-sidebar-tracked-changes",
346
+ surfaces: ["top-toolbar"],
347
+ group: "review-sidebar",
348
+ presets: ["review"],
349
+ roles: REVIEW_ONLY,
350
+ fullPlacement: "inline",
351
+ compactPlacement: "inline",
352
+ // Hidden unless the host provides the sidebar panel callback via
353
+ // hasSidebarPanelAccess in ResolveScopedChromePolicyInput.
354
+ runtimeBehavior: "sidebar-panel",
355
+ },
356
+ {
357
+ id: "review-sidebar-comments",
358
+ surfaces: ["top-toolbar"],
359
+ group: "review-sidebar",
360
+ presets: ["review"],
361
+ roles: REVIEW_ONLY,
362
+ fullPlacement: "inline",
363
+ compactPlacement: "inline",
364
+ runtimeBehavior: "sidebar-panel",
365
+ },
366
+
338
367
  // ───── R1: Review-role primaries ──────────────────────────────────────
339
368
  {
340
369
  id: "review-queue-prev",
@@ -11,6 +11,7 @@ import {
11
11
  type ToolbarChromeItemId,
12
12
  type ToolbarChromePlacement,
13
13
  } from "./chrome-registry";
14
+ import { ROLE_ACTION_SETS } from "../../ui-tailwind/chrome/role-action-sets";
14
15
  import type { SelectionToolKind } from "./selection-tool-types";
15
16
 
16
17
  export interface ToolbarChromeItemPolicy {
@@ -44,6 +45,13 @@ export interface ResolveScopedChromePolicyInput {
44
45
  * callers that haven't adopted the role model yet).
45
46
  */
46
47
  role?: EditorRole;
48
+ /**
49
+ * Whether the host has wired sidebar-panel callbacks
50
+ * (`onReviewSidebarTrackedChanges` / `onReviewSidebarComments`).
51
+ * Defaults to `false` — sidebar panel buttons are hidden unless the host
52
+ * explicitly opts in (typically the harness).
53
+ */
54
+ hasSidebarPanelAccess?: boolean;
47
55
  }
48
56
 
49
57
  export function resolveScopedChromePolicy(
@@ -88,6 +96,9 @@ export function resolveScopedChromePolicy(
88
96
  case "comment":
89
97
  visible = canAddComment;
90
98
  break;
99
+ case "sidebar-panel":
100
+ visible = Boolean(input.hasSidebarPanelAccess);
101
+ break;
91
102
  default:
92
103
  visible = true;
93
104
  break;
@@ -152,6 +163,24 @@ export function isToolbarChromeItemVisible(
152
163
  return policy.toolbar[itemId]?.visible ?? false;
153
164
  }
154
165
 
166
+ /**
167
+ * Returns true when the given chrome item is owned by the active role's
168
+ * inline role-action region (i.e. it appears in `ROLE_ACTION_SETS[role]`).
169
+ * The right-cluster render path uses this to suppress its own copy so we
170
+ * don't render the same button twice.
171
+ *
172
+ * When `role` is undefined (legacy host, no role model adopted) this
173
+ * returns `false` — the right cluster keeps ownership per the pre-R1
174
+ * layout.
175
+ */
176
+ export function isChromeItemOwnedByRoleRegion(
177
+ itemId: ToolbarChromeItemId,
178
+ role: EditorRole | undefined,
179
+ ): boolean {
180
+ if (!role) return false;
181
+ return ROLE_ACTION_SETS[role].includes(itemId);
182
+ }
183
+
155
184
  export function getToolbarChromePlacement(
156
185
  policy: ScopedChromePolicy,
157
186
  itemId: ToolbarChromeItemId,
@@ -1,15 +1,15 @@
1
1
  import React from "react";
2
2
 
3
- import { BookmarkPlus, ChevronLeft, ChevronRight, MessageSquareText, Rows3 } from "lucide-react";
3
+ import { ChevronLeft, ChevronRight, MessageSquareText, Rows3 } from "lucide-react";
4
4
 
5
5
  import type { ReviewQueueSnapshot } from "../../api/public-types";
6
6
  import { preserveEditorSelectionMouseDown } from "../../ui/headless/preserve-editor-selection";
7
7
 
8
+ /** @deprecated Use the role action region (TwRoleActionRegion) for queue navigation. */
8
9
  export interface TwReviewQueueBarProps {
9
10
  queue: ReviewQueueSnapshot;
10
11
  onPrevious?: () => void;
11
12
  onNext?: () => void;
12
- onMarkSection?: () => void;
13
13
  }
14
14
 
15
15
  const buttonClass =
@@ -47,18 +47,6 @@ export function TwReviewQueueBar(props: TwReviewQueueBarProps) {
47
47
  Next
48
48
  <ChevronRight className="h-3.5 w-3.5" />
49
49
  </button>
50
- {props.onMarkSection ? (
51
- <button
52
- type="button"
53
- aria-label="Mark section for review"
54
- className={buttonClass}
55
- onMouseDown={preserveEditorSelectionMouseDown}
56
- onClick={props.onMarkSection}
57
- >
58
- <BookmarkPlus className="h-3.5 w-3.5" />
59
- Mark section
60
- </button>
61
- ) : null}
62
50
  <div className="ml-auto flex flex-wrap items-center gap-2 text-xs text-secondary">
63
51
  <span className="inline-flex items-center gap-1 rounded-full bg-canvas px-2.5 py-1 font-medium text-primary">
64
52
  <MessageSquareText className="h-3.5 w-3.5 text-comment" />
@@ -26,16 +26,20 @@ export const ROLE_ACTION_SETS: Record<
26
26
  ReadonlyArray<ToolbarChromeItemId>
27
27
  > = {
28
28
  editor: [
29
- // Posture menu replaces the flat "Mark section" button per spec §6.4.
30
- "editor-scope-posture-menu",
31
- // Insert menu (tables / images / page breaks / section breaks) — only
32
- // meaningful for authoring, so it lives in the editor role's action
33
- // region rather than the base toolbar.
34
- "insert-actions",
35
- "update-actions",
36
- "list-continuation",
29
+ // Comment + inline tracked-changes toggle are the two review-layer
30
+ // actions relevant to authoring. They live in the role region rather
31
+ // than the right cluster so the right cluster stays view-focused.
32
+ "comment",
33
+ "tracked-changes-toggle",
37
34
  ],
38
35
  review: [
36
+ // Optional sidebar panel shortcuts — visible only when the host provides
37
+ // hasSidebarPanelAccess (e.g. the harness). Hidden in base runtime.
38
+ "review-sidebar-tracked-changes",
39
+ "review-sidebar-comments",
40
+ // Inline review actions shared with editor role.
41
+ "comment",
42
+ "tracked-changes-toggle",
39
43
  // Queue navigation + counts, collapsed from the old TwReviewQueueBar.
40
44
  "review-queue-prev",
41
45
  "review-queue-next",
@@ -50,6 +54,8 @@ export const ROLE_ACTION_SETS: Record<
50
54
  "review-markup-mode",
51
55
  ],
52
56
  workflow: [
57
+ // Scoping/posture menu is the primary workflow action (tagging sections).
58
+ "editor-scope-posture-menu",
53
59
  // Work-item navigation (distinct from review-queue nav).
54
60
  "workflow-prev",
55
61
  "workflow-next",
@@ -0,0 +1,80 @@
1
+ import React, { type ReactNode } from "react";
2
+
3
+ export interface TwModeDockAction {
4
+ id: string;
5
+ label: string;
6
+ icon: ReactNode;
7
+ onClick: () => void;
8
+ isActive?: boolean;
9
+ }
10
+
11
+ export interface TwModeDockProps {
12
+ label: string;
13
+ icon?: ReactNode;
14
+ actions?: readonly TwModeDockAction[];
15
+ className?: string;
16
+ }
17
+
18
+ const focusRingClass =
19
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-canvas";
20
+
21
+ export function TwModeDock(props: TwModeDockProps) {
22
+ const actions = (props.actions ?? []).slice(0, 3);
23
+
24
+ const className = [
25
+ "pointer-events-auto fixed bottom-4 left-1/2 z-40 -translate-x-1/2",
26
+ "flex h-9 items-center gap-2 rounded-[var(--radius-pill)] border border-border bg-canvas px-2 py-1",
27
+ "shadow-[var(--shadow-float)] backdrop-blur-sm",
28
+ "transition-opacity duration-[var(--motion-fast)]",
29
+ props.className,
30
+ ]
31
+ .filter(Boolean)
32
+ .join(" ");
33
+
34
+ return (
35
+ <div
36
+ role="toolbar"
37
+ aria-label="Mode dock"
38
+ className={className}
39
+ data-testid="tw-mode-dock"
40
+ >
41
+ <div className="flex items-center gap-1.5 pl-1 pr-1.5">
42
+ {props.icon ? (
43
+ <span aria-hidden="true" className="flex h-3.5 w-3.5 items-center text-tertiary">
44
+ {props.icon}
45
+ </span>
46
+ ) : null}
47
+ <span
48
+ className="text-[10px] font-semibold uppercase tracking-[0.14em] text-secondary"
49
+ data-testid="tw-mode-dock__label"
50
+ >
51
+ {props.label}
52
+ </span>
53
+ </div>
54
+ {actions.length > 0 ? (
55
+ <div className="flex items-center gap-0.5 border-l border-border/70 pl-1.5">
56
+ {actions.map((action) => (
57
+ <button
58
+ key={action.id}
59
+ type="button"
60
+ onClick={action.onClick}
61
+ aria-label={action.label}
62
+ aria-pressed={action.isActive ?? false}
63
+ title={action.label}
64
+ className={[
65
+ "inline-flex h-7 w-7 items-center justify-center rounded-[var(--radius-control)] transition-colors",
66
+ action.isActive
67
+ ? "bg-accent-soft text-accent"
68
+ : "text-tertiary hover:bg-surface-hover hover:text-primary",
69
+ focusRingClass,
70
+ ].join(" ")}
71
+ data-testid={`tw-mode-dock__action-${action.id}`}
72
+ >
73
+ <span aria-hidden="true">{action.icon}</span>
74
+ </button>
75
+ ))}
76
+ </div>
77
+ ) : null}
78
+ </div>
79
+ );
80
+ }
@@ -42,8 +42,8 @@ export interface ResolveSelectionAnchorInput {
42
42
  * - structure(image) → byBlockId when the image has a mediaId that the
43
43
  * engine can map back to a block; else falls through to selection.
44
44
  * - structure(object) → same as image.
45
- * - structure(table) → (byTableCell from sibling plan P4 when shipped;
46
- * today we fall back to bySelection against the selected cells' range).
45
+ * - structure(table) → byTableCell(tableBlockId, currentCell.row, col);
46
+ * blockId from deterministic "table-{tableBlockIndex}" scheme (P4).
47
47
  * - structure(list) → bySelection.
48
48
  *
49
49
  * Returns `null` when the facet has no render kernel installed or the
@@ -152,12 +152,9 @@ function resolveTableAnchor(
152
152
  table: TableStructureContextSnapshot | undefined,
153
153
  ): RenderFrameRect | null {
154
154
  if (!table) return null;
155
- // Sibling plan P4 will expose `byTableCell(tableBlockId, row, col)`
156
- // and `byTableColumnEdge` / `byTableRowEdge` on the anchor index.
157
- // Today we have only `tableBlockIndex` (ordinal), so the best-effort
158
- // fallback is the selection path — callers chain to `anchorForSelection`
159
- // when this returns null.
160
- void frame;
161
- void table;
162
- return null;
155
+ // BlockIds follow the deterministic prefix scheme from surface-projection.ts:
156
+ // table blocks are keyed as "table-{tableBlockIndex}".
157
+ const tableBlockId = `table-${table.tableBlockIndex}`;
158
+ const { rowIndex, columnIndex } = table.currentCell;
159
+ return frame.anchorIndex.byTableCell(tableBlockId, rowIndex, columnIndex);
163
160
  }
@@ -65,6 +65,12 @@ export interface TwSelectionToolHostProps {
65
65
  ) => void;
66
66
  onRestartNumbering?: () => void;
67
67
  onContinueNumbering?: () => void;
68
+ // P6: new table ops
69
+ onToggleRowHeader?: () => void;
70
+ onToggleRowCantSplit?: () => void;
71
+ onDistributeColumnsEvenly?: () => void;
72
+ onSetTableAlignment?: (alignment: "left" | "center" | "right") => void;
73
+ onSetCellVerticalAlign?: (align: "top" | "center" | "bottom") => void;
68
74
  }
69
75
 
70
76
  export function TwSelectionToolHost(props: TwSelectionToolHostProps) {
@@ -216,6 +222,11 @@ function renderTool(
216
222
  onSetImageFrame={props.onSetImageFrame}
217
223
  onRestartNumbering={props.onRestartNumbering}
218
224
  onContinueNumbering={props.onContinueNumbering}
225
+ onToggleRowHeader={props.onToggleRowHeader}
226
+ onToggleRowCantSplit={props.onToggleRowCantSplit}
227
+ onDistributeColumnsEvenly={props.onDistributeColumnsEvenly}
228
+ onSetTableAlignment={props.onSetTableAlignment}
229
+ onSetCellVerticalAlign={props.onSetCellVerticalAlign}
219
230
  />
220
231
  );
221
232
  case "comment-thread":
@@ -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":
@@ -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
+ }