@beyondwork/docx-react-component 1.0.42 → 1.0.43
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 +30 -41
- package/src/api/editor-state-types.ts +110 -0
- package/src/api/public-types.ts +194 -1
- package/src/core/commands/index.ts +33 -8
- package/src/core/search/search-text.ts +15 -2
- package/src/index.ts +13 -0
- package/src/io/docx-session.ts +672 -2
- package/src/io/load-scheduler.ts +230 -0
- package/src/io/normalize/normalize-text.ts +83 -0
- package/src/io/ooxml/workflow-payload-validator.ts +97 -1
- package/src/io/ooxml/workflow-payload.ts +172 -1
- package/src/runtime/collab-session.ts +1 -1
- package/src/runtime/document-runtime.ts +364 -36
- package/src/runtime/editor-state-channel.ts +544 -0
- package/src/runtime/editor-state-integration.ts +217 -0
- package/src/runtime/layout/index.ts +2 -0
- package/src/runtime/layout/inert-layout-facet.ts +2 -0
- package/src/runtime/layout/layout-engine-instance.ts +17 -2
- package/src/runtime/layout/paginated-layout-engine.ts +211 -14
- package/src/runtime/layout/public-facet.ts +400 -1
- package/src/runtime/perf-counters.ts +28 -0
- package/src/runtime/render/render-frame-types.ts +17 -0
- package/src/runtime/render/render-kernel.ts +172 -29
- package/src/runtime/surface-projection.ts +10 -5
- package/src/runtime/workflow-markup.ts +71 -16
- package/src/ui/WordReviewEditor.tsx +67 -45
- package/src/ui/editor-command-bag.ts +14 -0
- package/src/ui/editor-runtime-boundary.ts +110 -11
- package/src/ui/editor-shell-view.tsx +10 -0
- package/src/ui/editor-surface-controller.tsx +5 -0
- package/src/ui/headless/selection-helpers.ts +10 -0
- package/src/ui-tailwind/chrome/chrome-preset-toolbar.tsx +62 -2
- package/src/ui-tailwind/chrome/collab-top-nav-container.tsx +281 -0
- package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +48 -0
- package/src/ui-tailwind/editor-surface/paste-plain-text.ts +72 -0
- package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +118 -8
- package/src/ui-tailwind/editor-surface/pm-schema.ts +152 -4
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +35 -7
- package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +265 -0
- package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +8 -255
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +9 -0
- package/src/ui-tailwind/index.ts +5 -1
- package/src/ui-tailwind/page-stack/tw-endnote-area.tsx +57 -0
- package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +71 -0
- package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +73 -0
- package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +74 -0
- package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +477 -0
- package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +374 -0
- package/src/ui-tailwind/review/comment-markdown-renderer.tsx +155 -0
- package/src/ui-tailwind/review/tw-comment-sidebar.tsx +77 -16
- package/src/ui-tailwind/tw-review-workspace.tsx +172 -94
|
@@ -34,12 +34,20 @@ import {
|
|
|
34
34
|
type DocumentRuntimeEvent,
|
|
35
35
|
type DocumentRuntime,
|
|
36
36
|
} from "../runtime/document-runtime.ts";
|
|
37
|
+
import { createEditorStateChannel } from "../runtime/editor-state-channel.ts";
|
|
37
38
|
import {
|
|
38
39
|
createRuntimeCommandAppliedBridge,
|
|
39
40
|
type RuntimeCommandAppliedBridge,
|
|
40
41
|
} from "../runtime/collab/runtime-collab-sync.ts";
|
|
41
42
|
import { createInertLayoutFacet } from "../runtime/layout/index.ts";
|
|
42
|
-
import {
|
|
43
|
+
import {
|
|
44
|
+
loadDocxEditorSession,
|
|
45
|
+
loadDocxEditorSessionAsync,
|
|
46
|
+
} from "../io/docx-session.ts";
|
|
47
|
+
import {
|
|
48
|
+
createLoadScheduler,
|
|
49
|
+
type LoadScheduler,
|
|
50
|
+
} from "../io/load-scheduler.ts";
|
|
43
51
|
import {
|
|
44
52
|
decodePersistedSourcePackageBytes,
|
|
45
53
|
hasValidPersistedSourcePackageDigest,
|
|
@@ -48,6 +56,7 @@ import {
|
|
|
48
56
|
createEditorViewStateSnapshot,
|
|
49
57
|
createViewState,
|
|
50
58
|
} from "../runtime/view-state.ts";
|
|
59
|
+
import { hydrateEditorStateFromEnvelope } from "../runtime/editor-state-integration.ts";
|
|
51
60
|
import {
|
|
52
61
|
recordPerfSample,
|
|
53
62
|
} from "../ui-tailwind/editor-surface/perf-probe.ts";
|
|
@@ -62,6 +71,14 @@ export interface ResolvedSource {
|
|
|
62
71
|
initialDocx?: Uint8Array | ArrayBuffer;
|
|
63
72
|
initialSessionState?: EditorSessionState;
|
|
64
73
|
initialSnapshot?: PersistedEditorSnapshot;
|
|
74
|
+
/**
|
|
75
|
+
* Fastload P6: when the boundary hook pre-loads the docx via the
|
|
76
|
+
* async loader (DOM environments only), it stashes the session here
|
|
77
|
+
* so `createRuntime` reuses it instead of running another synchronous
|
|
78
|
+
* load. Undefined in SSR / Node test fallback — `createRuntime` then
|
|
79
|
+
* does the classic synchronous load.
|
|
80
|
+
*/
|
|
81
|
+
preloadedDocxSession?: ReturnType<typeof loadDocxEditorSession>;
|
|
65
82
|
}
|
|
66
83
|
|
|
67
84
|
export interface CreateRuntimeArgs {
|
|
@@ -280,6 +297,9 @@ export function useEditorRuntimeBoundary(
|
|
|
280
297
|
const pendingReadySourceRef = useRef<"docx" | "session" | "snapshot" | null>(null);
|
|
281
298
|
const autosaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
282
299
|
const lastSavedRevisionTokenRef = useRef<string | null>(null);
|
|
300
|
+
// Fastload P6: scheduler used by the DOM-side async docx loader. Held
|
|
301
|
+
// here so it can be disposed on unmount / source change.
|
|
302
|
+
const loadSchedulerRef = useRef<LoadScheduler | null>(null);
|
|
283
303
|
const hostAdapterRef = useRef(hostAdapter);
|
|
284
304
|
const datastoreRef = useRef(datastore);
|
|
285
305
|
const onEventRef = useRef(onEvent);
|
|
@@ -355,6 +375,10 @@ export function useEditorRuntimeBoundary(
|
|
|
355
375
|
lastSavedRevisionTokenRef.current = null;
|
|
356
376
|
runtimeRef.current?.dispose?.();
|
|
357
377
|
runtimeRef.current = null;
|
|
378
|
+
// Fastload P6: dispose any scheduler held from a previous mount /
|
|
379
|
+
// source cycle before allocating a fresh one for this load.
|
|
380
|
+
loadSchedulerRef.current?.dispose();
|
|
381
|
+
loadSchedulerRef.current = null;
|
|
358
382
|
setRuntime(null);
|
|
359
383
|
|
|
360
384
|
try {
|
|
@@ -375,6 +399,33 @@ export function useEditorRuntimeBoundary(
|
|
|
375
399
|
return;
|
|
376
400
|
}
|
|
377
401
|
|
|
402
|
+
// Fastload P6: in a DOM environment, preload the docx via the
|
|
403
|
+
// async loader so normalize-body yields mid-walk and the browser
|
|
404
|
+
// can paint the skeleton while the rest of the parse finishes.
|
|
405
|
+
// SSR / Node tests fall through to the synchronous load inside
|
|
406
|
+
// `createRuntime`.
|
|
407
|
+
if (
|
|
408
|
+
source.initialDocx !== undefined &&
|
|
409
|
+
source.preloadedDocxSession === undefined &&
|
|
410
|
+
typeof document !== "undefined"
|
|
411
|
+
) {
|
|
412
|
+
const scheduler = createLoadScheduler();
|
|
413
|
+
loadSchedulerRef.current = scheduler;
|
|
414
|
+
const preloaded = await loadDocxEditorSessionAsync({
|
|
415
|
+
documentId,
|
|
416
|
+
sourceLabel: source.sourceLabel,
|
|
417
|
+
bytes: source.initialDocx,
|
|
418
|
+
editorBuild: "dev",
|
|
419
|
+
scheduler,
|
|
420
|
+
});
|
|
421
|
+
if (cancelled) {
|
|
422
|
+
scheduler.dispose();
|
|
423
|
+
loadSchedulerRef.current = null;
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
source.preloadedDocxSession = preloaded;
|
|
427
|
+
}
|
|
428
|
+
|
|
378
429
|
const nextRuntime = createRuntime(
|
|
379
430
|
{
|
|
380
431
|
documentId,
|
|
@@ -480,6 +531,11 @@ export function useEditorRuntimeBoundary(
|
|
|
480
531
|
}
|
|
481
532
|
runtimeRef.current?.dispose?.();
|
|
482
533
|
runtimeRef.current = null;
|
|
534
|
+
// Fastload P6: release any pending idle callbacks the load scheduler
|
|
535
|
+
// registered so React unmount doesn't leak setTimeout/IdleCallback
|
|
536
|
+
// handles.
|
|
537
|
+
loadSchedulerRef.current?.dispose();
|
|
538
|
+
loadSchedulerRef.current = null;
|
|
483
539
|
};
|
|
484
540
|
}, []);
|
|
485
541
|
|
|
@@ -568,14 +624,16 @@ function createRuntime(
|
|
|
568
624
|
handlers: RuntimeLifecycleHandlers = {},
|
|
569
625
|
): WordReviewEditorRuntime {
|
|
570
626
|
const bootstrapEvents: DocumentRuntimeEvent[] = [];
|
|
571
|
-
const docxSession =
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
627
|
+
const docxSession =
|
|
628
|
+
args.source.preloadedDocxSession ??
|
|
629
|
+
(args.source.initialDocx
|
|
630
|
+
? loadDocxEditorSession({
|
|
631
|
+
documentId: args.documentId,
|
|
632
|
+
sourceLabel: args.source.sourceLabel,
|
|
633
|
+
bytes: args.source.initialDocx,
|
|
634
|
+
editorBuild: "dev",
|
|
635
|
+
})
|
|
636
|
+
: undefined);
|
|
579
637
|
const snapshotExportResolution = !args.source.initialDocx
|
|
580
638
|
? resolvePackageBackedExportSession(args)
|
|
581
639
|
: undefined;
|
|
@@ -594,7 +652,7 @@ function createRuntime(
|
|
|
594
652
|
? applySessionExportBarrier(initialSessionState, snapshotExportResolution.barrier)
|
|
595
653
|
: initialSessionState;
|
|
596
654
|
|
|
597
|
-
const
|
|
655
|
+
const baseRuntime = createDocumentRuntime({
|
|
598
656
|
documentId: args.documentId,
|
|
599
657
|
initialSessionState: runtimeSessionState,
|
|
600
658
|
sourceKind: args.source.source,
|
|
@@ -629,7 +687,39 @@ function createRuntime(
|
|
|
629
687
|
},
|
|
630
688
|
defaultAuthorId: args.currentUserId,
|
|
631
689
|
onCommandApplied: args.commandAppliedBridge?.onCommandApplied,
|
|
632
|
-
})
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
// Schema 1.2: drive load-path hydration from the parsed envelope.
|
|
693
|
+
if (docxSession?.initialEditorStatePayload) {
|
|
694
|
+
void hydrateEditorStateFromEnvelope({
|
|
695
|
+
editorState: docxSession.initialEditorStatePayload,
|
|
696
|
+
channel: baseRuntime.editorStateChannel,
|
|
697
|
+
applyBlob: (ns, data) => {
|
|
698
|
+
switch (ns) {
|
|
699
|
+
case "hostAnnotations":
|
|
700
|
+
baseRuntime.setHostAnnotationOverlay(data as import("../api/public-types.ts").HostAnnotationOverlay);
|
|
701
|
+
break;
|
|
702
|
+
case "workflowOverlay":
|
|
703
|
+
baseRuntime.setWorkflowOverlay(data as import("../api/public-types.ts").WorkflowOverlay);
|
|
704
|
+
break;
|
|
705
|
+
case "workflowMetadata":
|
|
706
|
+
// Metadata is split across definitions + entries in the runtime;
|
|
707
|
+
// inline payload carries the full snapshot so we apply entries.
|
|
708
|
+
if (data && typeof data === "object" && "entries" in (data as object)) {
|
|
709
|
+
baseRuntime.setWorkflowMetadataEntries(
|
|
710
|
+
(data as { entries: import("../api/public-types.ts").WorkflowMetadataEntry[] }).entries,
|
|
711
|
+
);
|
|
712
|
+
}
|
|
713
|
+
break;
|
|
714
|
+
case "workItems":
|
|
715
|
+
// workItems namespace is reserved; no runtime setter yet.
|
|
716
|
+
break;
|
|
717
|
+
}
|
|
718
|
+
},
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
const runtime: WordReviewEditorRuntime = Object.assign(baseRuntime, {
|
|
633
723
|
drainBootstrapEvents: () => bootstrapEvents.splice(0, bootstrapEvents.length),
|
|
634
724
|
emitBlockedCommand: (
|
|
635
725
|
command: string,
|
|
@@ -908,6 +998,15 @@ function createLoadingRuntimeBridge(input: {
|
|
|
908
998
|
items: [],
|
|
909
999
|
}),
|
|
910
1000
|
getRuntimeContextAnalytics: () => null,
|
|
1001
|
+
// Schema 1.2 — no-op stubs for loading boundary (SSR / headless path).
|
|
1002
|
+
configureEditorStatePolicy: () => undefined,
|
|
1003
|
+
registerEditorStateResolver: () => undefined,
|
|
1004
|
+
registerEditorStatePersister: () => undefined,
|
|
1005
|
+
getEditorStateKey: () => undefined,
|
|
1006
|
+
retryPendingPersist: async () => undefined,
|
|
1007
|
+
editorStateChannel: createEditorStateChannel(),
|
|
1008
|
+
getPerfCountersSnapshot: () => ({}),
|
|
1009
|
+
resetPerfCounters: () => undefined,
|
|
911
1010
|
};
|
|
912
1011
|
}
|
|
913
1012
|
|
|
@@ -64,6 +64,16 @@ export interface EditorShellViewProps {
|
|
|
64
64
|
interactionGuardSnapshot?: InteractionGuardSnapshot;
|
|
65
65
|
chromePreset?: WordReviewEditorChromePreset;
|
|
66
66
|
chromeOptions?: Partial<WordReviewEditorChromeOptions>;
|
|
67
|
+
/** P9g — live collab session for the `"collab"` chrome preset's top nav. */
|
|
68
|
+
collabSession?: import("../runtime/collab-session.ts").CollabSession;
|
|
69
|
+
collabTransportStatus?: import("../api/awareness-identity-types.ts").TransportStatus;
|
|
70
|
+
collabActorId?: string;
|
|
71
|
+
collabSendBaseline?: {
|
|
72
|
+
originDocumentId: string;
|
|
73
|
+
originPayloadId: string;
|
|
74
|
+
originContentHash: string;
|
|
75
|
+
payloadXml: string;
|
|
76
|
+
};
|
|
67
77
|
reviewQueue?: ReviewQueueSnapshot;
|
|
68
78
|
documentContextAnalytics?: RuntimeContextAnalyticsSnapshot | null;
|
|
69
79
|
selectionContextAnalytics?: RuntimeContextAnalyticsSnapshot | null;
|
|
@@ -52,6 +52,11 @@ export interface EditorSurfaceControllerProps {
|
|
|
52
52
|
onUndo?: () => void;
|
|
53
53
|
onRedo?: () => void;
|
|
54
54
|
onBlockedInput?: (command: "paste" | "drop", message: string) => void;
|
|
55
|
+
onPasteApplied?: (meta: {
|
|
56
|
+
segmentCount: number;
|
|
57
|
+
charCount: number;
|
|
58
|
+
source: "paste" | "drop";
|
|
59
|
+
}) => void;
|
|
55
60
|
onCommentActivated?: (commentId: string) => void;
|
|
56
61
|
onRevisionActivated?: (revisionId: string) => void;
|
|
57
62
|
workflowScopes?: readonly WorkflowScope[];
|
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
import type { SelectionSnapshot } from "../../api/public-types";
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* Headless-UI-side `createSelectionSnapshot` that produces the **public**
|
|
5
|
+
* `EditorAnchorProjection` shape (top-level `from`/`to`). The runtime-facing
|
|
6
|
+
* twin at `src/core/state/editor-state.ts` produces the internal
|
|
7
|
+
* `RangeAnchor` shape (`range: { from, to }`). The two are *not*
|
|
8
|
+
* interchangeable — they serve different type contracts. See the
|
|
9
|
+
* `EditorAnchorProjection` definitions in `src/api/public-types.ts` vs
|
|
10
|
+
* `src/core/selection/mapping.ts`. Do not merge without first unifying
|
|
11
|
+
* those two definitions.
|
|
12
|
+
*/
|
|
3
13
|
export function createSelectionSnapshot(anchor: number, head = anchor): SelectionSnapshot {
|
|
4
14
|
const from = Math.min(anchor, head);
|
|
5
15
|
const to = Math.max(anchor, head);
|
|
@@ -1,15 +1,75 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
|
|
3
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
WordReviewEditorChromeOptions,
|
|
5
|
+
WordReviewEditorChromePreset,
|
|
6
|
+
} from "../../api/public-types";
|
|
7
|
+
import type { CollabSession } from "../../runtime/collab-session.ts";
|
|
8
|
+
import type { TransportStatus } from "../../api/awareness-identity-types.ts";
|
|
4
9
|
import {
|
|
5
10
|
TwToolbar,
|
|
6
11
|
type TwToolbarProps,
|
|
7
12
|
} from "../toolbar/tw-toolbar";
|
|
13
|
+
import { CollabTopNavContainer } from "./collab-top-nav-container";
|
|
8
14
|
|
|
9
15
|
export interface ChromePresetToolbarProps extends TwToolbarProps {
|
|
10
16
|
chromePreset: WordReviewEditorChromePreset;
|
|
17
|
+
/** P9g — optional collab session. Rendered above the toolbar when the `"collab"` preset is active. */
|
|
18
|
+
collabSession?: CollabSession;
|
|
19
|
+
collabTransportStatus?: TransportStatus;
|
|
20
|
+
activeCommentId?: string;
|
|
21
|
+
collabActorId?: string;
|
|
22
|
+
collabSendBaseline?: {
|
|
23
|
+
originDocumentId: string;
|
|
24
|
+
originPayloadId: string;
|
|
25
|
+
originContentHash: string;
|
|
26
|
+
payloadXml: string;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Sub-surface visibility toggles from `resolveChromePresetOptions`.
|
|
30
|
+
* When omitted the container defaults match the `"collab"` preset.
|
|
31
|
+
*/
|
|
32
|
+
chromeOptionsResolved?: Pick<
|
|
33
|
+
WordReviewEditorChromeOptions,
|
|
34
|
+
| "showCollabTopNav"
|
|
35
|
+
| "showCollabPresenceStrip"
|
|
36
|
+
| "showCollabRoleChip"
|
|
37
|
+
| "showCollabAudienceChip"
|
|
38
|
+
| "showCollabTamperBanner"
|
|
39
|
+
| "showCollabNegotiationActionBar"
|
|
40
|
+
| "showCollabSendToSupplier"
|
|
41
|
+
>;
|
|
11
42
|
}
|
|
12
43
|
|
|
13
44
|
export function ChromePresetToolbar(props: ChromePresetToolbarProps) {
|
|
14
|
-
|
|
45
|
+
const {
|
|
46
|
+
collabSession,
|
|
47
|
+
collabTransportStatus,
|
|
48
|
+
activeCommentId,
|
|
49
|
+
collabActorId,
|
|
50
|
+
collabSendBaseline,
|
|
51
|
+
chromeOptionsResolved,
|
|
52
|
+
...toolbarProps
|
|
53
|
+
} = props;
|
|
54
|
+
|
|
55
|
+
const collabActive =
|
|
56
|
+
props.chromePreset === "collab" && collabSession !== undefined;
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<>
|
|
60
|
+
{collabActive ? (
|
|
61
|
+
<CollabTopNavContainer
|
|
62
|
+
session={collabSession}
|
|
63
|
+
{...(activeCommentId !== undefined ? { activeCommentId } : {})}
|
|
64
|
+
actorId={collabActorId ?? "local"}
|
|
65
|
+
{...(collabTransportStatus
|
|
66
|
+
? { transportStatus: collabTransportStatus }
|
|
67
|
+
: {})}
|
|
68
|
+
{...(chromeOptionsResolved ? { visibility: chromeOptionsResolved } : {})}
|
|
69
|
+
{...(collabSendBaseline ? { sendBaseline: collabSendBaseline } : {})}
|
|
70
|
+
/>
|
|
71
|
+
) : null}
|
|
72
|
+
<TwToolbar {...toolbarProps} preset={props.chromePreset} />
|
|
73
|
+
</>
|
|
74
|
+
);
|
|
15
75
|
}
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
|
|
3
|
+
import type { CollabSession } from "../../runtime/collab-session.ts";
|
|
4
|
+
import type {
|
|
5
|
+
CommentAudience,
|
|
6
|
+
} from "../../api/comment-presentation-types.ts";
|
|
7
|
+
import type { WordReviewEditorChromeOptions } from "../../api/public-types.ts";
|
|
8
|
+
import type { TransportStatus } from "../../api/awareness-identity-types.ts";
|
|
9
|
+
import { CollabPresenceStrip } from "./collab-presence-strip.tsx";
|
|
10
|
+
import { CollabRoleChip } from "./collab-role-chip.tsx";
|
|
11
|
+
import { CollabAudienceChip } from "./collab-audience-chip.tsx";
|
|
12
|
+
import { CollabTamperBanner } from "./collab-tamper-banner.tsx";
|
|
13
|
+
import { CollabNegotiationActionBar } from "./collab-negotiation-action-bar.tsx";
|
|
14
|
+
import { CollabSendToSupplierButton } from "./collab-send-to-supplier-button.tsx";
|
|
15
|
+
import {
|
|
16
|
+
CollabSendToSupplierModal,
|
|
17
|
+
type CollabSendToSupplierSubmitArgs,
|
|
18
|
+
} from "./collab-send-to-supplier-modal.tsx";
|
|
19
|
+
|
|
20
|
+
export type CollabSubSurfaceVisibility = Pick<
|
|
21
|
+
WordReviewEditorChromeOptions,
|
|
22
|
+
| "showCollabTopNav"
|
|
23
|
+
| "showCollabPresenceStrip"
|
|
24
|
+
| "showCollabRoleChip"
|
|
25
|
+
| "showCollabAudienceChip"
|
|
26
|
+
| "showCollabTamperBanner"
|
|
27
|
+
| "showCollabNegotiationActionBar"
|
|
28
|
+
| "showCollabSendToSupplier"
|
|
29
|
+
>;
|
|
30
|
+
|
|
31
|
+
export interface CollabTopNavContainerProps {
|
|
32
|
+
/** Wired collab session. When `undefined` / detached, the container renders nothing. */
|
|
33
|
+
session: CollabSession | undefined;
|
|
34
|
+
/** The currently-focused comment id (if any). Host supplies this from its own selection signal. */
|
|
35
|
+
activeCommentId?: string;
|
|
36
|
+
/**
|
|
37
|
+
* Fresh identity metadata about the local user. The session falls
|
|
38
|
+
* back to `author` if no awareness is wired, which matches the
|
|
39
|
+
* `CollabPosture` contract. This prop is passed through so tests /
|
|
40
|
+
* hosts can override the actor id used by the action bar without
|
|
41
|
+
* also mutating awareness.
|
|
42
|
+
*/
|
|
43
|
+
actorId: string;
|
|
44
|
+
/** Current transport status signal (from the host's provider). */
|
|
45
|
+
transportStatus?: TransportStatus;
|
|
46
|
+
/** Visibility toggles from the chrome preset options. */
|
|
47
|
+
visibility?: CollabSubSurfaceVisibility;
|
|
48
|
+
/**
|
|
49
|
+
* Minimum send-to-supplier args a host wants to commit to before the
|
|
50
|
+
* confirmation modal even appears. The modal collects the rest.
|
|
51
|
+
*/
|
|
52
|
+
sendBaseline?: {
|
|
53
|
+
originDocumentId: string;
|
|
54
|
+
originPayloadId: string;
|
|
55
|
+
originContentHash: string;
|
|
56
|
+
payloadXml: string;
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* Optional override for the activeStory filter passed into
|
|
60
|
+
* `getPresenceSnapshot({ activeStoryFilter })`.
|
|
61
|
+
*/
|
|
62
|
+
activeStoryFilter?: string;
|
|
63
|
+
className?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const DEFAULT_VISIBILITY: Required<CollabSubSurfaceVisibility> = {
|
|
67
|
+
showCollabTopNav: true,
|
|
68
|
+
showCollabPresenceStrip: true,
|
|
69
|
+
showCollabRoleChip: true,
|
|
70
|
+
showCollabAudienceChip: true,
|
|
71
|
+
showCollabTamperBanner: true,
|
|
72
|
+
showCollabNegotiationActionBar: true,
|
|
73
|
+
showCollabSendToSupplier: true,
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* P9g — chrome-toolbar container. Subscribes to the `CollabSession`
|
|
78
|
+
* event stream and renders the six P9a–f components from live
|
|
79
|
+
* snapshots. Pure React, no Yjs / awareness coupling — the session
|
|
80
|
+
* abstracts those away.
|
|
81
|
+
*
|
|
82
|
+
* The container intentionally renders `null` when no session is wired
|
|
83
|
+
* so the chrome toolbar can mount it unconditionally while the host
|
|
84
|
+
* decides whether to supply a session.
|
|
85
|
+
*
|
|
86
|
+
* Individual sub-surfaces can be opted out via `visibility`; the
|
|
87
|
+
* defaults mirror the `"collab"` chrome preset so hosts that
|
|
88
|
+
* instantiate directly get the full top nav.
|
|
89
|
+
*/
|
|
90
|
+
export function CollabTopNavContainer({
|
|
91
|
+
session,
|
|
92
|
+
activeCommentId,
|
|
93
|
+
actorId,
|
|
94
|
+
transportStatus,
|
|
95
|
+
visibility,
|
|
96
|
+
sendBaseline,
|
|
97
|
+
activeStoryFilter,
|
|
98
|
+
className,
|
|
99
|
+
}: CollabTopNavContainerProps): React.ReactElement | null {
|
|
100
|
+
const effective: Required<CollabSubSurfaceVisibility> = {
|
|
101
|
+
...DEFAULT_VISIBILITY,
|
|
102
|
+
...normalizeVisibility(visibility),
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const [tick, setTick] = React.useState(0);
|
|
106
|
+
const [sendModalOpen, setSendModalOpen] = React.useState(false);
|
|
107
|
+
|
|
108
|
+
React.useEffect(() => {
|
|
109
|
+
if (!session) return undefined;
|
|
110
|
+
const off = session.subscribe((event) => {
|
|
111
|
+
// Any event from the bridge OR the tamper gate triggers a
|
|
112
|
+
// re-read. The container is cheap to re-render; the underlying
|
|
113
|
+
// snapshots are already deep-cloned by the stores.
|
|
114
|
+
//
|
|
115
|
+
// We deliberately ignore the event type — the container is
|
|
116
|
+
// declarative about what it renders, so a single bump is enough.
|
|
117
|
+
void event;
|
|
118
|
+
setTick((n) => n + 1);
|
|
119
|
+
});
|
|
120
|
+
return off;
|
|
121
|
+
}, [session]);
|
|
122
|
+
|
|
123
|
+
if (!session || !effective.showCollabTopNav) return null;
|
|
124
|
+
|
|
125
|
+
const presence = session.getPresenceSnapshot({
|
|
126
|
+
...(transportStatus ? { transportStatus } : {}),
|
|
127
|
+
...(activeStoryFilter !== undefined ? { activeStoryFilter } : {}),
|
|
128
|
+
});
|
|
129
|
+
const posture = session.getCollabPosture({
|
|
130
|
+
...(transportStatus ? { transportStatus } : {}),
|
|
131
|
+
});
|
|
132
|
+
const integrity = session.getMetadataIntegrity();
|
|
133
|
+
const presentation = session.getCommentPresentationSnapshot();
|
|
134
|
+
const negotiation = session.getCommentNegotiationSnapshot();
|
|
135
|
+
|
|
136
|
+
const activePresentation = activeCommentId
|
|
137
|
+
? presentation.entries.find((e) => e.commentId === activeCommentId)
|
|
138
|
+
: undefined;
|
|
139
|
+
const activeNegotiation = activeCommentId
|
|
140
|
+
? negotiation.entries.find((e) => e.commentId === activeCommentId)
|
|
141
|
+
: undefined;
|
|
142
|
+
|
|
143
|
+
const internalCount = presentation.entries.filter(
|
|
144
|
+
(e) => e.audience === "internal",
|
|
145
|
+
).length;
|
|
146
|
+
const shareableCount = presentation.entries.filter(
|
|
147
|
+
(e) => e.audience !== "internal",
|
|
148
|
+
).length;
|
|
149
|
+
|
|
150
|
+
const canEditAudience = posture.role !== "observer" && activePresentation !== undefined;
|
|
151
|
+
const tampered = integrity === "tampered";
|
|
152
|
+
|
|
153
|
+
const rootClass = [
|
|
154
|
+
"tw-collab-top-nav",
|
|
155
|
+
`tw-collab-top-nav--${posture.role}`,
|
|
156
|
+
tampered ? "tw-collab-top-nav--tampered" : null,
|
|
157
|
+
className ?? null,
|
|
158
|
+
]
|
|
159
|
+
.filter((v): v is string => v !== null)
|
|
160
|
+
.join(" ");
|
|
161
|
+
|
|
162
|
+
const handleAudienceCycle = (next: CommentAudience): void => {
|
|
163
|
+
if (!activeCommentId) return;
|
|
164
|
+
void session.dispatchCommentPresentation({
|
|
165
|
+
type: "set-audience",
|
|
166
|
+
commentId: activeCommentId,
|
|
167
|
+
audience: next,
|
|
168
|
+
});
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const handleNegotiationDispatch = (
|
|
172
|
+
action: Parameters<CollabSession["dispatchCommentNegotiation"]>[0],
|
|
173
|
+
): void => {
|
|
174
|
+
session.dispatchCommentNegotiation(action, {
|
|
175
|
+
role: posture.role,
|
|
176
|
+
now: new Date().toISOString(),
|
|
177
|
+
});
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const handleSendSubmit = async (
|
|
181
|
+
args: CollabSendToSupplierSubmitArgs,
|
|
182
|
+
): Promise<void> => {
|
|
183
|
+
if (!sendBaseline) {
|
|
184
|
+
setSendModalOpen(false);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
await session.sendToExternal({
|
|
188
|
+
payloadXml: sendBaseline.payloadXml,
|
|
189
|
+
originDocumentId: sendBaseline.originDocumentId,
|
|
190
|
+
originPayloadId: sendBaseline.originPayloadId,
|
|
191
|
+
originContentHash: sendBaseline.originContentHash,
|
|
192
|
+
recipient: args.recipient,
|
|
193
|
+
sentBy: actorId,
|
|
194
|
+
archiveRef: args.archiveRef,
|
|
195
|
+
});
|
|
196
|
+
setSendModalOpen(false);
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
return (
|
|
200
|
+
<div
|
|
201
|
+
className={rootClass}
|
|
202
|
+
data-testid="collab-top-nav-container"
|
|
203
|
+
data-tick={tick.toString()}
|
|
204
|
+
data-role={posture.role}
|
|
205
|
+
data-integrity={integrity}
|
|
206
|
+
>
|
|
207
|
+
{effective.showCollabTamperBanner ? (
|
|
208
|
+
<CollabTamperBanner
|
|
209
|
+
integrity={integrity}
|
|
210
|
+
onAcknowledge={() => session.acknowledgeMetadataTampering()}
|
|
211
|
+
/>
|
|
212
|
+
) : null}
|
|
213
|
+
|
|
214
|
+
<div className="tw-collab-top-nav__row">
|
|
215
|
+
{effective.showCollabPresenceStrip ? (
|
|
216
|
+
<CollabPresenceStrip
|
|
217
|
+
presence={presence}
|
|
218
|
+
localRole={posture.role}
|
|
219
|
+
/>
|
|
220
|
+
) : null}
|
|
221
|
+
|
|
222
|
+
{effective.showCollabRoleChip ? (
|
|
223
|
+
<CollabRoleChip
|
|
224
|
+
posture={posture}
|
|
225
|
+
{...(transportStatus ? { transportStatus } : {})}
|
|
226
|
+
/>
|
|
227
|
+
) : null}
|
|
228
|
+
|
|
229
|
+
{effective.showCollabAudienceChip ? (
|
|
230
|
+
<CollabAudienceChip
|
|
231
|
+
audience={activePresentation?.audience}
|
|
232
|
+
canEdit={canEditAudience}
|
|
233
|
+
onCycle={handleAudienceCycle}
|
|
234
|
+
/>
|
|
235
|
+
) : null}
|
|
236
|
+
|
|
237
|
+
{effective.showCollabNegotiationActionBar ? (
|
|
238
|
+
<CollabNegotiationActionBar
|
|
239
|
+
entry={activeNegotiation}
|
|
240
|
+
actorId={actorId}
|
|
241
|
+
role={posture.role}
|
|
242
|
+
onDispatch={handleNegotiationDispatch}
|
|
243
|
+
disabled={tampered}
|
|
244
|
+
/>
|
|
245
|
+
) : null}
|
|
246
|
+
|
|
247
|
+
{effective.showCollabSendToSupplier ? (
|
|
248
|
+
<CollabSendToSupplierButton
|
|
249
|
+
role={posture.role}
|
|
250
|
+
integrity={integrity}
|
|
251
|
+
shareableCount={shareableCount}
|
|
252
|
+
internalCount={internalCount}
|
|
253
|
+
onOpenModal={() => setSendModalOpen(true)}
|
|
254
|
+
/>
|
|
255
|
+
) : null}
|
|
256
|
+
</div>
|
|
257
|
+
|
|
258
|
+
{effective.showCollabSendToSupplier ? (
|
|
259
|
+
<CollabSendToSupplierModal
|
|
260
|
+
open={sendModalOpen}
|
|
261
|
+
shareableCount={shareableCount}
|
|
262
|
+
internalCount={internalCount}
|
|
263
|
+
onClose={() => setSendModalOpen(false)}
|
|
264
|
+
onSubmit={handleSendSubmit}
|
|
265
|
+
/>
|
|
266
|
+
) : null}
|
|
267
|
+
</div>
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function normalizeVisibility(
|
|
272
|
+
visibility: CollabSubSurfaceVisibility | undefined,
|
|
273
|
+
): Partial<Required<CollabSubSurfaceVisibility>> {
|
|
274
|
+
if (!visibility) return {};
|
|
275
|
+
const out: Partial<Required<CollabSubSurfaceVisibility>> = {};
|
|
276
|
+
for (const key of Object.keys(DEFAULT_VISIBILITY) as (keyof CollabSubSurfaceVisibility)[]) {
|
|
277
|
+
const v = visibility[key];
|
|
278
|
+
if (typeof v === "boolean") out[key] = v;
|
|
279
|
+
}
|
|
280
|
+
return out;
|
|
281
|
+
}
|
|
@@ -17,6 +17,7 @@ import type { OverlayCoordinateSpace } from "./chrome-overlay-projector";
|
|
|
17
17
|
import type { ScopeRailSegment } from "../../runtime/layout";
|
|
18
18
|
import type {
|
|
19
19
|
EditorRole,
|
|
20
|
+
EditorStoryTarget,
|
|
20
21
|
ScopeIssueAction,
|
|
21
22
|
TableStructureContextSnapshot,
|
|
22
23
|
WordReviewEditorLayoutFacet,
|
|
@@ -25,6 +26,7 @@ import type {
|
|
|
25
26
|
import { TwScopeRailLayer } from "./tw-scope-rail-layer";
|
|
26
27
|
import { TwScopeCardLayer } from "./tw-scope-card-layer";
|
|
27
28
|
import { TwPageStackOverlayLayer } from "./tw-page-stack-overlay-layer";
|
|
29
|
+
import { TwPageStackChromeLayer, type PmPortalView } from "../page-stack/tw-page-stack-chrome-layer";
|
|
28
30
|
import { TwTableGripLayer } from "../chrome/tw-table-grip-layer";
|
|
29
31
|
|
|
30
32
|
export interface TwChromeOverlayProps {
|
|
@@ -119,6 +121,37 @@ export interface TwChromeOverlayProps {
|
|
|
119
121
|
* full workspace re-render.
|
|
120
122
|
*/
|
|
121
123
|
renderFrameRevision?: number;
|
|
124
|
+
|
|
125
|
+
// Page-stack chrome layer (P8.8) --------------------------------------
|
|
126
|
+
/**
|
|
127
|
+
* Current active story target — the page-stack chrome layer uses this
|
|
128
|
+
* to decide which per-page band (if any) should render in active-slot
|
|
129
|
+
* mode. When omitted the chrome layer treats `{ kind: "main" }` as
|
|
130
|
+
* the active story, so no band is promoted.
|
|
131
|
+
*/
|
|
132
|
+
activeStory?: EditorStoryTarget;
|
|
133
|
+
/**
|
|
134
|
+
* Fired when the user clicks a per-page header / footer band to
|
|
135
|
+
* promote it into the active editing surface. Task 10 will route PM
|
|
136
|
+
* into the matching band via React portals; today the handler is a
|
|
137
|
+
* pass-through to `runtime.openStory`.
|
|
138
|
+
*/
|
|
139
|
+
onOpenStory?: (target: EditorStoryTarget) => void;
|
|
140
|
+
/**
|
|
141
|
+
* P8.11 — PM surface DOM element (`.ProseMirror` div). The chrome
|
|
142
|
+
* layer's portal mechanism reparents this element across the per-page
|
|
143
|
+
* band portal slots as `activeStory` changes. When omitted the
|
|
144
|
+
* reparent step is skipped; the chrome layer still renders read-only
|
|
145
|
+
* bands but the active-slot promotion is inert.
|
|
146
|
+
*/
|
|
147
|
+
pmSurfaceElement?: HTMLElement | null;
|
|
148
|
+
/**
|
|
149
|
+
* P8.11 — optional PM view handle with `hasFocus()` + `focus()`. When
|
|
150
|
+
* supplied, the chrome layer re-focuses PM after a portal swap so
|
|
151
|
+
* mid-edit band clicks don't silently drop the caret. Omitting the
|
|
152
|
+
* handle leaves focus-restore as a no-op — DOM reparent still runs.
|
|
153
|
+
*/
|
|
154
|
+
pmView?: PmPortalView | null;
|
|
122
155
|
}
|
|
123
156
|
|
|
124
157
|
/**
|
|
@@ -151,6 +184,10 @@ export const TwChromeOverlay: React.FC<TwChromeOverlayProps> = ({
|
|
|
151
184
|
onSetRowHeight,
|
|
152
185
|
pageStackScrollRoot,
|
|
153
186
|
renderFrameRevision,
|
|
187
|
+
activeStory,
|
|
188
|
+
onOpenStory,
|
|
189
|
+
pmSurfaceElement,
|
|
190
|
+
pmView,
|
|
154
191
|
}) => {
|
|
155
192
|
return (
|
|
156
193
|
<div
|
|
@@ -165,6 +202,17 @@ export const TwChromeOverlay: React.FC<TwChromeOverlayProps> = ({
|
|
|
165
202
|
renderFrameRevision={renderFrameRevision ?? 0}
|
|
166
203
|
/>
|
|
167
204
|
) : null}
|
|
205
|
+
{pageStackScrollRoot !== undefined ? (
|
|
206
|
+
<TwPageStackChromeLayer
|
|
207
|
+
facet={facet}
|
|
208
|
+
scrollRoot={pageStackScrollRoot}
|
|
209
|
+
renderFrameRevision={renderFrameRevision ?? 0}
|
|
210
|
+
activeStory={activeStory ?? { kind: "main" }}
|
|
211
|
+
onOpenStory={onOpenStory}
|
|
212
|
+
pmSurfaceElement={pmSurfaceElement}
|
|
213
|
+
pmView={pmView}
|
|
214
|
+
/>
|
|
215
|
+
) : null}
|
|
168
216
|
<TwScopeRailLayer
|
|
169
217
|
facet={facet}
|
|
170
218
|
space={space}
|