@beyondwork/docx-react-component 1.0.73 → 1.0.75

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 (59) hide show
  1. package/package.json +1 -1
  2. package/src/api/anchor-conversion.ts +2 -2
  3. package/src/api/public-types.ts +40 -6
  4. package/src/api/v3/_runtime-handle.ts +15 -0
  5. package/src/api/v3/runtime/workflow.ts +130 -1
  6. package/src/api/v3/ui/_types.ts +21 -0
  7. package/src/api/v3/ui/overlays.ts +276 -2
  8. package/src/api/v3/ui/scope.ts +113 -1
  9. package/src/compare/diff-engine.ts +1 -2
  10. package/src/core/commands/index.ts +14 -15
  11. package/src/core/selection/anchor-conversion.ts +2 -2
  12. package/src/core/selection/mapping.ts +10 -8
  13. package/src/core/selection/review-anchors.ts +3 -3
  14. package/src/io/export/export-session.ts +53 -0
  15. package/src/io/export/serialize-comments.ts +4 -4
  16. package/src/io/export/serialize-runtime-revisions.ts +10 -10
  17. package/src/io/export/split-review-boundaries.ts +4 -4
  18. package/src/io/export/split-story-blocks-for-runtime-revisions.ts +2 -2
  19. package/src/io/ooxml/parse-comments.ts +2 -2
  20. package/src/io/ooxml/parse-headers-footers.ts +7 -13
  21. package/src/io/ooxml/parse-main-document.ts +7 -31
  22. package/src/io/ooxml/table-opaque-preservation.ts +171 -0
  23. package/src/model/anchor.ts +9 -1
  24. package/src/model/canonical-document.ts +76 -3
  25. package/src/preservation/store.ts +24 -0
  26. package/src/review/store/comment-anchors.ts +1 -1
  27. package/src/review/store/comment-remapping.ts +1 -1
  28. package/src/review/store/revision-actions.ts +4 -4
  29. package/src/review/store/revision-types.ts +1 -1
  30. package/src/review/store/scope-tag-diff.ts +1 -1
  31. package/src/runtime/collab/map-local-selection-on-remote-replay.ts +7 -7
  32. package/src/runtime/document-runtime.ts +233 -38
  33. package/src/runtime/formatting/formatting-context.ts +1 -1
  34. package/src/runtime/layout/inert-layout-facet.ts +1 -0
  35. package/src/runtime/layout/layout-engine-version.ts +9 -1
  36. package/src/runtime/layout/public-facet.ts +27 -0
  37. package/src/runtime/scopes/evidence.ts +1 -1
  38. package/src/runtime/scopes/review-bundle.ts +1 -1
  39. package/src/runtime/scopes/scope-range.ts +1 -1
  40. package/src/runtime/selection/post-edit-validator.ts +4 -4
  41. package/src/runtime/surface-projection.ts +48 -4
  42. package/src/runtime/workflow/scope-writer.ts +212 -10
  43. package/src/session/import/review-import.ts +12 -12
  44. package/src/session/import/workflow-scope-import.ts +9 -8
  45. package/src/shell/session-bootstrap.ts +4 -0
  46. package/src/ui-tailwind/editor-surface/pm-schema.ts +22 -2
  47. package/src/ui-tailwind/editor-surface/surface-build-keys.ts +5 -2
  48. package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +22 -3
  49. package/src/ui-tailwind/page-stack/floating-image-overlay-model.ts +7 -3
  50. package/src/ui-tailwind/page-stack/tw-endnote-area.tsx +5 -1
  51. package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +5 -1
  52. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +7 -5
  53. package/src/ui-tailwind/page-stack/use-visible-block-range.ts +99 -43
  54. package/src/ui-tailwind/review-workspace/use-page-markers.ts +48 -7
  55. package/src/ui-tailwind/review-workspace/use-workspace-side-effects.ts +0 -13
  56. package/src/ui-tailwind/tw-review-workspace.tsx +13 -41
  57. package/src/validation/compatibility-engine.ts +1 -1
  58. package/src/ui-tailwind/chrome/tw-layout-panel.tsx +0 -114
  59. package/src/ui-tailwind/review-workspace/tw-review-workspace-page-toolbar.tsx +0 -240
@@ -1,21 +1,26 @@
1
1
  /**
2
- * Layer-06 Slice 3 `createScopeFromBlockId` adapter.
2
+ * Layer-06 marker-backed workflow-scope creators.
3
3
  *
4
- * Resolves a `blockId` (paragraph/table/sdt-index identifier minted by
5
- * `src/runtime/surface-projection.ts`) into the `EditorAnchorProjection`
6
- * that `runtime.addScope` expects, then delegates to the existing
7
- * marker-backed scope creator.
4
+ * Two adapters share `runtime.addScope` as the underlying creator:
5
+ *
6
+ * 1. `createScopeFromBlockId` resolves a block identifier
7
+ * (`paragraph-N`, `table-N`, `sdt-N`) minted by
8
+ * `src/runtime/surface-projection.ts` into the whole-block range.
9
+ * Use when the scope should cover an entire paragraph / table / SDT.
10
+ *
11
+ * 2. `createScopeFromAnchor` — takes a sub-block `{from, to}` range
12
+ * directly. Use when the scope should bracket a sub-span — a phrase
13
+ * inside a paragraph, a cross-paragraph selection, a span from a
14
+ * click + drag. Resolves the KI-P9 "positions-as-references" trap:
15
+ * positions are consumed **once** at creation to plant the markers;
16
+ * the returned `scopeId` is durable and survives subsequent editing.
17
+ * Callers must stop carrying the positions after the call returns.
8
18
  *
9
19
  * Resolution walks the canonical document directly, not the surface
10
20
  * snapshot. Going through `runtime.getRenderSnapshot().surface.blocks`
11
21
  * would miss blocks the surface replaces with `placeholder-culled-*`
12
22
  * entries under viewport culling — a real reproducibility failure on
13
23
  * CCEP-size documents.
14
- *
15
- * This adapter unblocks `v3 runtime.workflow.createScope` graduation
16
- * from `mock` → `live-with-adapter`. Consumers that already carry a
17
- * full `EditorAnchorProjection` should continue calling
18
- * `runtime.addScope` directly.
19
24
  */
20
25
 
21
26
  import type {
@@ -220,3 +225,200 @@ export function createScopeFromBlockId(
220
225
 
221
226
  // Exported for tests that need to verify the resolver independently.
222
227
  export { resolveBlockAnchorFromCanonical };
228
+
229
+ /**
230
+ * Input for `createScopeFromAnchor`. The `anchor` fields are a one-shot
231
+ * position query — the runtime plants inline `scope_marker_start` /
232
+ * `scope_marker_end` at these offsets and returns a marker-backed
233
+ * `scopeId`. From that moment the markers travel with their bracketed
234
+ * content through any number of edits; callers must re-resolve by
235
+ * `scopeId` afterward and MUST NOT store or reuse `from`/`to`.
236
+ */
237
+ export interface CreateScopeFromAnchorInput {
238
+ /**
239
+ * Range in the current document state. `from` and `to` are measured
240
+ * in the editor's surface-projection offset space (the same space
241
+ * `findText`, `replaceText`, and `addScope` ranges use). Must satisfy
242
+ * `0 <= from <= to <= storyLength`.
243
+ */
244
+ readonly anchor: {
245
+ readonly from: number;
246
+ readonly to: number;
247
+ readonly storyTarget?: EditorStoryTarget;
248
+ };
249
+ readonly mode?: WorkflowScopeMode;
250
+ readonly label?: string;
251
+ readonly scopeId?: string;
252
+ readonly persistence?: WorkflowMetadataPersistence;
253
+ readonly metadata?: Partial<WorkflowMetadataEntry>;
254
+ /**
255
+ * Per-scope edge stickiness for the range anchor. Defaults to
256
+ * `{ start: 1, end: -1 }` (greedy — absorbs boundary inserts). See
257
+ * `CreateScopeFromBlockIdInput.assoc` for the full matrix.
258
+ */
259
+ readonly assoc?: { readonly start: -1 | 1; readonly end: -1 | 1 };
260
+ /** Caller-steerable identity strategy; same semantics as on `CreateScopeFromBlockIdInput`. */
261
+ readonly stableRefHint?:
262
+ | "scope-id"
263
+ | "bookmark"
264
+ | "semantic-path"
265
+ | "runtime-handle";
266
+ }
267
+
268
+ export type CreateScopeFromAnchorResult =
269
+ | {
270
+ readonly status: "created";
271
+ readonly scopeId: string;
272
+ readonly anchor: EditorAnchorProjection;
273
+ }
274
+ | {
275
+ readonly status: "range-invalid";
276
+ readonly reason:
277
+ | "from-negative"
278
+ | "to-less-than-from"
279
+ | "range-exceeds-story-length";
280
+ readonly from: number;
281
+ readonly to: number;
282
+ readonly storyLength: number;
283
+ /**
284
+ * Single-sentence, agent-actionable explanation. Tells the caller
285
+ * what the failure was and the concrete next step — no guesswork
286
+ * required. Safe to surface directly to an LLM tool-use loop as
287
+ * the `error` content of the tool reply.
288
+ */
289
+ readonly message: string;
290
+ /**
291
+ * Short machine-routable next-step hint for thin consumers that
292
+ * don't want to pattern-match on `reason`. Examples:
293
+ * "clamp-from-to-zero", "swap-from-and-to",
294
+ * "clamp-to-to-storyLength-or-pick-a-different-range".
295
+ */
296
+ readonly nextStep: string;
297
+ };
298
+
299
+ /**
300
+ * Sum the total length of the main story by walking blocks with
301
+ * inter-block gap semantics matched to `resolveBlockAnchorFromCanonical`.
302
+ * Used as the bounds check for `createScopeFromAnchor`.
303
+ */
304
+ function computeMainStoryLength(document: CanonicalDocumentEnvelope): number {
305
+ const root = document.content as DocumentRootNode;
306
+ const blocks = root.children;
307
+ let total = 0;
308
+ for (let i = 0; i < blocks.length; i += 1) {
309
+ const block: BlockNode = blocks[i]!;
310
+ if (block.type === "paragraph") {
311
+ total += paragraphLength(block);
312
+ } else {
313
+ total += 1;
314
+ }
315
+ if (i < blocks.length - 1) total += 1;
316
+ }
317
+ return total;
318
+ }
319
+
320
+ /**
321
+ * Create a marker-backed workflow scope over a sub-block anchor range.
322
+ *
323
+ * The returned `scopeId` is the durable reference — after this call
324
+ * returns, `from`/`to` are not part of the scope's identity and must
325
+ * not be stored or reused. All later lookups use
326
+ * `resolveReference({kind:"scope-id", value: scopeId})` or `get_scope`,
327
+ * which walk to the markers' current positions regardless of
328
+ * intervening edits.
329
+ *
330
+ * Bounds: `0 <= from <= to <= storyLength`. Out-of-bounds or inverted
331
+ * ranges return `{status: "range-invalid", reason}` without mutating
332
+ * the document. `storyTarget` defaults to main; footnote / header /
333
+ * endnote stories are supported by passing the target through.
334
+ */
335
+ export function createScopeFromAnchor(
336
+ runtime: ScopeWriterRuntime,
337
+ input: CreateScopeFromAnchorInput,
338
+ ): CreateScopeFromAnchorResult {
339
+ const doc = runtime.getCanonicalDocument();
340
+ const storyTarget: EditorStoryTarget =
341
+ input.anchor.storyTarget ?? { kind: "main" };
342
+ // Bounds check only covers the main story today. Non-main stories
343
+ // (footnote / header / endnote) skip the numeric bound check —
344
+ // `runtime.addScope` is the authoritative bound enforcer for those
345
+ // secondary stories and returns an error anchor when the range is
346
+ // invalid. This matches the pre-existing `createScopeFromBlockId`
347
+ // contract where block resolution handles story-aware bounds.
348
+ const storyLength =
349
+ storyTarget.kind === "main" ? computeMainStoryLength(doc) : Number.MAX_SAFE_INTEGER;
350
+
351
+ const { from, to } = input.anchor;
352
+ if (from < 0) {
353
+ return {
354
+ status: "range-invalid",
355
+ reason: "from-negative",
356
+ from,
357
+ to,
358
+ storyLength,
359
+ message:
360
+ `createScopeFromAnchor requires from >= 0 (received from=${from}). ` +
361
+ `Offsets are zero-based absolute positions in the current document; ` +
362
+ `clamp negative values to 0 before calling.`,
363
+ nextStep: "clamp-from-to-zero",
364
+ };
365
+ }
366
+ if (to < from) {
367
+ return {
368
+ status: "range-invalid",
369
+ reason: "to-less-than-from",
370
+ from,
371
+ to,
372
+ storyLength,
373
+ message:
374
+ `createScopeFromAnchor requires to >= from (received from=${from}, to=${to}). ` +
375
+ `The range is inverted — swap the two values before calling.`,
376
+ nextStep: "swap-from-and-to",
377
+ };
378
+ }
379
+ if (to > storyLength) {
380
+ return {
381
+ status: "range-invalid",
382
+ reason: "range-exceeds-story-length",
383
+ from,
384
+ to,
385
+ storyLength,
386
+ message:
387
+ `createScopeFromAnchor requires to <= storyLength (received to=${to}, ` +
388
+ `storyLength=${storyLength}). This typically means the offset was ` +
389
+ `captured from a stale document state (see KI-P9) — do not carry ` +
390
+ `offsets across mutations. Re-derive from the current document, or ` +
391
+ `clamp to <= storyLength and verify the result targets the intended content.`,
392
+ nextStep: "clamp-to-to-storyLength-or-pick-a-different-range",
393
+ };
394
+ }
395
+
396
+ const anchor: EditorAnchorProjection = {
397
+ kind: "range",
398
+ from,
399
+ to,
400
+ assoc: input.assoc ?? { start: 1, end: -1 },
401
+ };
402
+
403
+ const scopeMetadataFields: WorkflowScopeMetadataField[] = [];
404
+ if (input.stableRefHint !== undefined) {
405
+ scopeMetadataFields.push({
406
+ key: "stableRefHint",
407
+ valueType: "string",
408
+ value: input.stableRefHint,
409
+ });
410
+ }
411
+
412
+ const result: AddScopeResult = runtime.addScope({
413
+ anchor,
414
+ mode: input.mode,
415
+ scopeId: input.scopeId,
416
+ persistence: input.persistence,
417
+ metadata: input.metadata,
418
+ storyTarget,
419
+ label: input.label,
420
+ ...(scopeMetadataFields.length > 0 ? { scopeMetadataFields } : {}),
421
+ });
422
+
423
+ return { status: "created", scopeId: result.scopeId, anchor: result.anchor };
424
+ }
@@ -82,7 +82,7 @@ export function normalizeImportedRevisionRecords(
82
82
 
83
83
  const preserveOnlyReason =
84
84
  getStructuralPreserveOnlyReason(revision, paragraphRanges) ??
85
- (opaqueRanges.some((range) => rangesIntersect(range, anchor.range))
85
+ (opaqueRanges.some((range) => rangesIntersect(range, { from: anchor.from, to: anchor.to }))
86
86
  ? "Imported revision overlaps preserve-only OOXML and remains preserve-only."
87
87
  : undefined);
88
88
 
@@ -118,7 +118,7 @@ export function normalizeImportedCommentThreads(
118
118
  ) {
119
119
  return [];
120
120
  }
121
- return [revision.anchor.range];
121
+ return [{ from: revision.anchor.from, to: revision.anchor.to }];
122
122
  });
123
123
  const preserveOnlyCommentIds = new Set(parsed.diagnostics.map((diagnostic) => diagnostic.commentId));
124
124
  const additionalDiagnostics: CommentImportDiagnostic[] = [];
@@ -129,7 +129,7 @@ export function normalizeImportedCommentThreads(
129
129
  return thread;
130
130
  }
131
131
 
132
- const opaqueOverlap = opaqueRanges.some((range) => rangesIntersect(range, anchor.range));
132
+ const opaqueOverlap = opaqueRanges.some((range) => rangesIntersect(range, { from: anchor.from, to: anchor.to }));
133
133
  if (opaqueOverlap) {
134
134
  preserveOnlyCommentIds.add(thread.commentId);
135
135
  additionalDiagnostics.push({
@@ -143,7 +143,7 @@ export function normalizeImportedCommentThreads(
143
143
  });
144
144
  return {
145
145
  ...thread,
146
- anchor: createDetachedAnchor(anchor.range, "importAmbiguity"),
146
+ anchor: createDetachedAnchor({ from: anchor.from, to: anchor.to }, "importAmbiguity"),
147
147
  status: "detached",
148
148
  metadata: {
149
149
  ...thread.metadata,
@@ -155,7 +155,7 @@ export function normalizeImportedCommentThreads(
155
155
  }
156
156
 
157
157
  const preserveOnlyRevisionOverlap = preserveOnlyRevisionRanges.some((range) =>
158
- rangesIntersect(range, anchor.range),
158
+ rangesIntersect(range, { from: anchor.from, to: anchor.to }),
159
159
  );
160
160
  if (preserveOnlyRevisionOverlap) {
161
161
  preserveOnlyCommentIds.add(thread.commentId);
@@ -170,7 +170,7 @@ export function normalizeImportedCommentThreads(
170
170
  });
171
171
  return {
172
172
  ...thread,
173
- anchor: createDetachedAnchor(anchor.range, "importAmbiguity"),
173
+ anchor: createDetachedAnchor({ from: anchor.from, to: anchor.to }, "importAmbiguity"),
174
174
  status: "detached",
175
175
  metadata: {
176
176
  ...thread.metadata,
@@ -257,7 +257,7 @@ export function getStructuralPreserveOnlyReason(
257
257
 
258
258
  if (
259
259
  (form === "run-insertion" || form === "run-deletion") &&
260
- anchor.range.from === anchor.range.to
260
+ anchor.from === anchor.to
261
261
  ) {
262
262
  return "Imported zero-width run revision remains preserve-only.";
263
263
  }
@@ -265,9 +265,9 @@ export function getStructuralPreserveOnlyReason(
265
265
  if (form === "paragraph-insertion" || form === "paragraph-deletion") {
266
266
  const paragraphBoundary = paragraphRanges.find(
267
267
  (boundary) =>
268
- boundary.end === anchor.range.from ||
269
- (anchor.range.from >= boundary.start &&
270
- anchor.range.from <= boundary.end),
268
+ boundary.end === anchor.from ||
269
+ (anchor.from >= boundary.start &&
270
+ anchor.from <= boundary.end),
271
271
  );
272
272
  return paragraphBoundary
273
273
  ? undefined
@@ -276,8 +276,8 @@ export function getStructuralPreserveOnlyReason(
276
276
 
277
277
  const paragraphBoundary = paragraphRanges.find(
278
278
  (boundary) =>
279
- anchor.range.from >= boundary.start &&
280
- anchor.range.to <= boundary.end,
279
+ anchor.from >= boundary.start &&
280
+ anchor.to <= boundary.end,
281
281
  );
282
282
  return paragraphBoundary
283
283
  ? undefined
@@ -6,12 +6,13 @@
6
6
  *
7
7
  * P6-clean: `CommentThread` reaches through `src/model/review/`; every
8
8
  * workflow type comes from `src/api/public-types.ts` (an allowed surface
9
- * for the session layer). Anchor-shape conversion reaches through the
10
- * shared boundary helper at `src/api/anchor-conversion.ts` (relocated in
11
- * slice 5e-6g).
9
+ * for the session layer). Anchor-shape conversion is no longer needed —
10
+ * L02's FLAT WINS collapse (2026-04-24, `5b2f6f56`) made `CanonicalAnchor`,
11
+ * `InternalEditorAnchorProjection`, and the public `EditorAnchorProjection`
12
+ * structurally identical, so `thread.anchor` flows directly into the
13
+ * `WorkflowScope.anchor` slot without a helper call.
12
14
  */
13
15
 
14
- import { toPublicAnchorProjection } from "../../api/anchor-conversion.ts";
15
16
  import type {
16
17
  WorkflowMetadataSnapshot,
17
18
  WorkflowOverlay,
@@ -155,7 +156,7 @@ export function createClmWorkflowScope(
155
156
  scopeId,
156
157
  version,
157
158
  mode: directive.mode,
158
- anchor: toPublicAnchorProjection(thread.anchor),
159
+ anchor: thread.anchor,
159
160
  storyTarget: { kind: "main" },
160
161
  workItemId,
161
162
  label: directive.description,
@@ -166,7 +167,7 @@ export function createClmWorkflowScope(
166
167
  scopeId,
167
168
  version,
168
169
  mode: directive.mode,
169
- anchor: toPublicAnchorProjection(thread.anchor),
170
+ anchor: thread.anchor,
170
171
  storyTarget: { kind: "main" },
171
172
  label: directive.description,
172
173
  metadata: createClmScopeMetadata(directive),
@@ -240,8 +241,8 @@ export function getNextClmScopeVersion(
240
241
  anchor: Extract<CommentThread["anchor"], { kind: "range" }>,
241
242
  ): number {
242
243
  const anchorRange = {
243
- from: anchor.range.from,
244
- to: anchor.range.to,
244
+ from: anchor.from,
245
+ to: anchor.to,
245
246
  };
246
247
  const overlappingVersions = scopes.flatMap((scope) => {
247
248
  if (scope.anchor.kind !== "range") {
@@ -1139,6 +1139,9 @@ function createLoadingRuntimeBridge(input: {
1139
1139
  },
1140
1140
  getScope: () => null,
1141
1141
  compileScopeBundleById: () => null,
1142
+ compileScopeList: () => [],
1143
+ compileScopeCardById: () => null,
1144
+ compileScopeRailSnapshot: () => ({ segments: [] }),
1142
1145
  getMarkerBackedScopeIds: () => new Set<string>(),
1143
1146
  debug: createLoadingDebugFacet(),
1144
1147
  removeScope: () => undefined,
@@ -1281,6 +1284,7 @@ function createLoadingRuntimeBridge(input: {
1281
1284
  getPerfCountersSnapshot: () => ({}),
1282
1285
  resetPerfCounters: () => undefined,
1283
1286
  setVisibleBlockRange: () => undefined,
1287
+ setVisibleBlockRanges: () => undefined,
1284
1288
  requestViewportRefresh: () => undefined,
1285
1289
  addInvisibleScope: () => ({ scopeId: "", anchor: { kind: "range", from: 0, to: 0, assoc: { start: -1, end: 1 } } }),
1286
1290
  setScopeVisibility: () => undefined,
@@ -289,7 +289,15 @@ export const editorSchema = new Schema({
289
289
  if (pageBreak) styles.push("border-top: 2px dashed rgba(0,0,0,0.1); padding-top: 8px; margin-top: 16px");
290
290
  // `<w:framePr>` out-of-flow frame — mirror the static-path branch in
291
291
  // tw-page-block-view.helpers.ts so PM-rendered page 1 absolutely
292
- // positions the frame identically to pages 2+.
292
+ // positions the frame identically to pages 2+. Gating rules
293
+ // match the static path: drop-cap stays in-flow; a framePr with
294
+ // zero positional fields stays in-flow (no out-of-flow without
295
+ // a declared position — M3 review finding).
296
+ //
297
+ // N1: `position: absolute` is pushed BEFORE `hiddenTextOnly`'s
298
+ // `display: none` by code order; `display: none` wins visually
299
+ // regardless of source order, so a hidden framed paragraph
300
+ // collapses as expected. Do not "fix" the order — it's correct.
293
301
  const framePr = node.attrs.frameProperties as
294
302
  | {
295
303
  xTwips?: number;
@@ -298,9 +306,21 @@ export const editorSchema = new Schema({
298
306
  heightTwips?: number;
299
307
  hRule?: "auto" | "atLeast" | "exact";
300
308
  dropCap?: "none" | "drop" | "margin";
309
+ xAlign?: string;
310
+ yAlign?: string;
301
311
  }
302
312
  | null;
303
- if (framePr && framePr.dropCap !== "drop" && framePr.dropCap !== "margin") {
313
+ const hasPosition =
314
+ typeof framePr?.xTwips === "number" ||
315
+ typeof framePr?.yTwips === "number" ||
316
+ typeof framePr?.xAlign === "string" ||
317
+ typeof framePr?.yAlign === "string";
318
+ if (
319
+ framePr &&
320
+ framePr.dropCap !== "drop" &&
321
+ framePr.dropCap !== "margin" &&
322
+ hasPosition
323
+ ) {
304
324
  styles.push("position: absolute");
305
325
  if (typeof framePr.xTwips === "number") styles.push(`left: ${framePr.xTwips / 20}pt`);
306
326
  if (typeof framePr.yTwips === "number") styles.push(`top: ${framePr.yTwips / 20}pt`);
@@ -24,7 +24,7 @@ export function createSurfaceDocumentBuildKey(input: {
24
24
  showUnsupportedObjectPreviews?: boolean;
25
25
  isPageWorkspace?: boolean;
26
26
  }): string {
27
- const vp = input.surface?.viewportBlockRange ?? null;
27
+ const ranges = input.surface?.viewportBlockRanges ?? null;
28
28
  return JSON.stringify({
29
29
  surfaceIdentity:
30
30
  input.surface === undefined || input.surface === null
@@ -34,7 +34,10 @@ export function createSurfaceDocumentBuildKey(input: {
34
34
  mediaPreviewKey: input.mediaPreviewKey,
35
35
  showUnsupportedObjectPreviews: input.showUnsupportedObjectPreviews ?? false,
36
36
  isPageWorkspace: input.isPageWorkspace ?? false,
37
- viewport: vp ? `${vp.start}:${vp.end}` : "full",
37
+ // Serialize all intervals (sorted by start) disjoint viewport+caret
38
+ // ranges must key distinctly from a single merged range so PM rebuilds
39
+ // when the caret's page comes into/out of the realized set.
40
+ viewport: ranges ? ranges.map((r) => `${r.start}:${r.end}`).join("|") : "full",
38
41
  });
39
42
  }
40
43
 
@@ -124,6 +124,9 @@ export function computeTabWidthsInPoints(
124
124
  if (stop) {
125
125
  const prevPos = tabIndex > 0 ? readPos(rawStops[tabIndex - 1]) : 0;
126
126
  const widthTwips = readPos(stop) - prevPos;
127
+ // `> 0` (not `>= 0`) is intentional — a zero-width zone between
128
+ // two identical stops is meaningless; skip so the caller falls
129
+ // back to the 32 px default.
127
130
  if (widthTwips > 0) {
128
131
  widths.set(seg.segmentId, widthTwips / 20);
129
132
  }
@@ -254,10 +257,26 @@ export function buildParagraphStyle(
254
257
  // `<w:framePr>` out-of-flow frame (ECMA-376 §17.3.1.11). L04 returns 0
255
258
  // from measureBlockHeight for these paragraphs (a298391e) so the inline
256
259
  // flow doesn't double-count; L11 renders them absolutely positioned.
257
- // Drop-cap (dropCap="drop"|"margin") is in-flow — only the initial
258
- // letter is framed so skip the absolute switch there.
260
+ //
261
+ // Gating rules (M3 review finding):
262
+ // - Drop-cap (`dropCap` "drop" | "margin") is in-flow — only the
263
+ // initial letter is framed; paragraph body stays with its text.
264
+ // - A `<w:framePr/>` with ZERO positional fields (no xTwips / yTwips
265
+ // / xAlign / yAlign) is ambiguous; Word treats it as in-flow. If
266
+ // we emitted `position: absolute` we'd pin the paragraph at (0,0)
267
+ // of the nearest positioned ancestor — dramatically wrong.
259
268
  const framePr = block.frameProperties;
260
- if (framePr && framePr.dropCap !== "drop" && framePr.dropCap !== "margin") {
269
+ const hasPosition =
270
+ typeof framePr?.xTwips === "number" ||
271
+ typeof framePr?.yTwips === "number" ||
272
+ typeof framePr?.xAlign === "string" ||
273
+ typeof framePr?.yAlign === "string";
274
+ if (
275
+ framePr &&
276
+ framePr.dropCap !== "drop" &&
277
+ framePr.dropCap !== "margin" &&
278
+ hasPosition
279
+ ) {
261
280
  style.position = "absolute";
262
281
  if (typeof framePr.xTwips === "number") {
263
282
  style.left = `${framePr.xTwips / 20}pt`;
@@ -145,11 +145,15 @@ export function collectFloatingImageOverlayItems(input: {
145
145
  };
146
146
 
147
147
  // coord-01 §9 / §5.1 — CCEP logos live in header stories; collect from
148
- // the main story for the active-story case AND from every secondary
149
- // story so header/footer images reach the overlay regardless of which
150
- // story is active in the editor.
148
+ // the active story's surface.blocks (always) + every secondary story
149
+ // EXCEPT the one whose target matches activeStory (runtime fills
150
+ // surface.blocks with that same story's blocks, so walking secondary
151
+ // stories unconditionally would double-emit header/footer segments
152
+ // the moment the user enters a header for editing — M1).
151
153
  collectFromStory(surface.blocks, activeStory);
154
+ const activeKey = storyTargetKey(activeStory);
152
155
  for (const secondary of surface.secondaryStories ?? []) {
156
+ if (storyTargetKey(secondary.target) === activeKey) continue;
153
157
  collectFromStory(secondary.blocks, secondary.target);
154
158
  }
155
159
 
@@ -52,7 +52,11 @@ export const TwEndnoteArea: React.FC<TwEndnoteAreaProps> = ({
52
52
  marginBottom: "8pt",
53
53
  }}
54
54
  />
55
- <TwRegionBlockRenderer blocks={blocks} mediaPreviews={mediaPreviews} />
55
+ <TwRegionBlockRenderer
56
+ blocks={blocks}
57
+ mediaPreviews={mediaPreviews}
58
+ fallbackDisplay="hidden"
59
+ />
56
60
  </div>
57
61
  );
58
62
  };
@@ -66,7 +66,11 @@ export const TwFootnoteArea: React.FC<TwFootnoteAreaProps> = React.memo(({
66
66
  marginBottom: "4pt",
67
67
  }}
68
68
  />
69
- <TwRegionBlockRenderer blocks={blocks} mediaPreviews={mediaPreviews} />
69
+ <TwRegionBlockRenderer
70
+ blocks={blocks}
71
+ mediaPreviews={mediaPreviews}
72
+ fallbackDisplay="hidden"
73
+ />
70
74
  </div>
71
75
  );
72
76
  });
@@ -14,6 +14,7 @@ import {
14
14
  headingClassList,
15
15
  resolveHeadingLevel,
16
16
  } from "../editor-surface/tw-page-block-view.helpers.ts";
17
+ import { shouldRenderAbsoluteFloatingImageInPageOverlay } from "./floating-image-overlay-model.ts";
17
18
 
18
19
  const EMU_PER_PX = 9525;
19
20
 
@@ -94,11 +95,12 @@ function renderSegment(
94
95
  case "hard_break":
95
96
  return <br key={seg.segmentId} />;
96
97
  case "image": {
97
- // §5.1 gap 3 — floating-anchor images are owned by the absolute
98
- // floating-image overlay (`TwFloatingImageLayer`). Emitting them
99
- // inline here would double-paint the CCEP header logo on every
100
- // page. Skip entirely so only the overlay renders them.
101
- if (seg.anchor?.display === "floating") {
98
+ // §5.1 gap 3 — floating-anchor images the overlay can render
99
+ // (`TwFloatingImageLayer`) are owned by the overlay. Skip inline
100
+ // emission ONLY for anchors the overlay predicate accepts
101
+ // otherwise wrap-mode=square / column-relative / tight-wrapped
102
+ // floats get dropped from inline AND from the overlay (M2).
103
+ if (shouldRenderAbsoluteFloatingImageInPageOverlay(seg.anchor)) {
102
104
  return null;
103
105
  }
104
106
  // Mirror body-renderer behavior (`pm-state-from-snapshot.ts` :500+):