@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.
- package/package.json +41 -31
- package/src/api/public-types.ts +305 -6
- package/src/core/commands/table-structure-commands.ts +31 -2
- package/src/core/commands/text-commands.ts +122 -2
- package/src/index.ts +9 -0
- package/src/io/docx-session.ts +1 -0
- package/src/io/export/serialize-numbering.ts +42 -8
- package/src/io/export/serialize-paragraph-formatting.ts +152 -0
- package/src/io/export/serialize-run-formatting.ts +90 -0
- package/src/io/export/serialize-styles.ts +212 -0
- package/src/io/ooxml/parse-fields.ts +10 -3
- package/src/io/ooxml/parse-numbering.ts +41 -1
- package/src/io/ooxml/parse-paragraph-formatting.ts +188 -0
- package/src/io/ooxml/parse-run-formatting.ts +129 -0
- package/src/io/ooxml/parse-styles.ts +31 -0
- package/src/io/ooxml/xml-attr-helpers.ts +60 -0
- package/src/io/ooxml/xml-element.ts +19 -0
- package/src/model/canonical-document.ts +83 -3
- package/src/runtime/collab/event-types.ts +165 -0
- package/src/runtime/collab/index.ts +22 -0
- package/src/runtime/collab/remote-cursor-awareness.ts +93 -0
- package/src/runtime/collab/runtime-collab-sync.ts +273 -0
- package/src/runtime/document-runtime.ts +141 -18
- package/src/runtime/layout/docx-font-loader.ts +30 -11
- package/src/runtime/layout/index.ts +2 -0
- package/src/runtime/layout/inert-layout-facet.ts +3 -0
- package/src/runtime/layout/layout-engine-instance.ts +69 -2
- package/src/runtime/layout/layout-invalidation.ts +14 -5
- package/src/runtime/layout/page-graph.ts +36 -0
- package/src/runtime/layout/paginate-paragraph-lines.ts +128 -0
- package/src/runtime/layout/paginated-layout-engine.ts +342 -28
- package/src/runtime/layout/project-block-fragments.ts +154 -20
- package/src/runtime/layout/public-facet.ts +81 -1
- package/src/runtime/layout/resolve-page-fields.ts +70 -0
- package/src/runtime/layout/resolve-page-previews.ts +185 -0
- package/src/runtime/layout/resolved-formatting-state.ts +30 -26
- package/src/runtime/layout/table-render-plan.ts +21 -1
- package/src/runtime/numbering-prefix.ts +5 -0
- package/src/runtime/paragraph-style-resolver.ts +194 -0
- package/src/runtime/render/render-kernel.ts +5 -1
- package/src/runtime/resolved-numbering-geometry.ts +9 -1
- package/src/runtime/surface-projection.ts +129 -9
- package/src/runtime/table-schema.ts +11 -0
- package/src/runtime/workflow-rail-segments.ts +149 -1
- package/src/ui/WordReviewEditor.tsx +302 -5
- package/src/ui/editor-command-bag.ts +4 -0
- package/src/ui/editor-runtime-boundary.ts +16 -0
- package/src/ui/editor-shell-view.tsx +22 -0
- package/src/ui/editor-surface-controller.tsx +9 -1
- package/src/ui/headless/chrome-registry.ts +34 -5
- package/src/ui/headless/scoped-chrome-policy.ts +29 -0
- package/src/ui-tailwind/chrome/review-queue-bar.tsx +2 -14
- package/src/ui-tailwind/chrome/role-action-sets.ts +14 -8
- package/src/ui-tailwind/chrome/tw-mode-dock.tsx +80 -0
- package/src/ui-tailwind/chrome/tw-selection-anchor-resolver.ts +7 -10
- package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +11 -0
- package/src/ui-tailwind/chrome/tw-selection-tool-structure.tsx +11 -0
- package/src/ui-tailwind/chrome/tw-table-border-picker.tsx +245 -0
- package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +101 -21
- package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +353 -0
- package/src/ui-tailwind/chrome-overlay/chrome-overlay-projector.ts +5 -1
- package/src/ui-tailwind/chrome-overlay/index.ts +2 -6
- package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +82 -18
- package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +133 -0
- package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +386 -0
- package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +140 -69
- package/src/ui-tailwind/editor-surface/page-slice-util.ts +15 -0
- package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +28 -3
- package/src/ui-tailwind/editor-surface/pm-decorations.ts +7 -2
- package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +389 -0
- package/src/ui-tailwind/editor-surface/pm-schema.ts +40 -2
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +170 -63
- package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +179 -0
- package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +559 -0
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +224 -78
- package/src/ui-tailwind/editor-surface/tw-table-bands.css +61 -0
- package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +19 -0
- package/src/ui-tailwind/index.ts +6 -5
- package/src/ui-tailwind/theme/editor-theme.css +108 -15
- package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +122 -1
- package/src/ui-tailwind/toolbar/tw-toolbar.tsx +36 -3
- package/src/ui-tailwind/tw-review-workspace.tsx +207 -54
- package/src/runtime/collab-review-sync.ts +0 -254
- package/src/ui-tailwind/chrome-overlay/tw-workspace-view-switcher.tsx +0 -95
- 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:
|
|
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
|
-
|
|
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 {
|
|
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
|
-
//
|
|
30
|
-
|
|
31
|
-
//
|
|
32
|
-
|
|
33
|
-
|
|
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) → (
|
|
46
|
-
*
|
|
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
|
-
//
|
|
156
|
-
//
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
+
}
|