@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.
- package/package.json +1 -1
- package/src/api/anchor-conversion.ts +2 -2
- package/src/api/public-types.ts +40 -6
- package/src/api/v3/_runtime-handle.ts +15 -0
- package/src/api/v3/runtime/workflow.ts +130 -1
- package/src/api/v3/ui/_types.ts +21 -0
- package/src/api/v3/ui/overlays.ts +276 -2
- package/src/api/v3/ui/scope.ts +113 -1
- package/src/compare/diff-engine.ts +1 -2
- package/src/core/commands/index.ts +14 -15
- package/src/core/selection/anchor-conversion.ts +2 -2
- package/src/core/selection/mapping.ts +10 -8
- package/src/core/selection/review-anchors.ts +3 -3
- package/src/io/export/export-session.ts +53 -0
- package/src/io/export/serialize-comments.ts +4 -4
- package/src/io/export/serialize-runtime-revisions.ts +10 -10
- package/src/io/export/split-review-boundaries.ts +4 -4
- package/src/io/export/split-story-blocks-for-runtime-revisions.ts +2 -2
- package/src/io/ooxml/parse-comments.ts +2 -2
- package/src/io/ooxml/parse-headers-footers.ts +7 -13
- package/src/io/ooxml/parse-main-document.ts +7 -31
- package/src/io/ooxml/table-opaque-preservation.ts +171 -0
- package/src/model/anchor.ts +9 -1
- package/src/model/canonical-document.ts +76 -3
- package/src/preservation/store.ts +24 -0
- package/src/review/store/comment-anchors.ts +1 -1
- package/src/review/store/comment-remapping.ts +1 -1
- package/src/review/store/revision-actions.ts +4 -4
- package/src/review/store/revision-types.ts +1 -1
- package/src/review/store/scope-tag-diff.ts +1 -1
- package/src/runtime/collab/map-local-selection-on-remote-replay.ts +7 -7
- package/src/runtime/document-runtime.ts +233 -38
- package/src/runtime/formatting/formatting-context.ts +1 -1
- package/src/runtime/layout/inert-layout-facet.ts +1 -0
- package/src/runtime/layout/layout-engine-version.ts +9 -1
- package/src/runtime/layout/public-facet.ts +27 -0
- package/src/runtime/scopes/evidence.ts +1 -1
- package/src/runtime/scopes/review-bundle.ts +1 -1
- package/src/runtime/scopes/scope-range.ts +1 -1
- package/src/runtime/selection/post-edit-validator.ts +4 -4
- package/src/runtime/surface-projection.ts +48 -4
- package/src/runtime/workflow/scope-writer.ts +212 -10
- package/src/session/import/review-import.ts +12 -12
- package/src/session/import/workflow-scope-import.ts +9 -8
- package/src/shell/session-bootstrap.ts +4 -0
- package/src/ui-tailwind/editor-surface/pm-schema.ts +22 -2
- package/src/ui-tailwind/editor-surface/surface-build-keys.ts +5 -2
- package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +22 -3
- package/src/ui-tailwind/page-stack/floating-image-overlay-model.ts +7 -3
- package/src/ui-tailwind/page-stack/tw-endnote-area.tsx +5 -1
- package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +5 -1
- package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +7 -5
- package/src/ui-tailwind/page-stack/use-visible-block-range.ts +99 -43
- package/src/ui-tailwind/review-workspace/use-page-markers.ts +48 -7
- package/src/ui-tailwind/review-workspace/use-workspace-side-effects.ts +0 -13
- package/src/ui-tailwind/tw-review-workspace.tsx +13 -41
- package/src/validation/compatibility-engine.ts +1 -1
- package/src/ui-tailwind/chrome/tw-layout-panel.tsx +0 -114
- package/src/ui-tailwind/review-workspace/tw-review-workspace-page-toolbar.tsx +0 -240
|
@@ -1,21 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Layer-06
|
|
2
|
+
* Layer-06 — marker-backed workflow-scope creators.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
269
|
-
(anchor.
|
|
270
|
-
anchor.
|
|
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.
|
|
280
|
-
anchor.
|
|
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
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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:
|
|
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:
|
|
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.
|
|
244
|
-
to: anchor.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
258
|
-
//
|
|
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
|
-
|
|
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
|
|
149
|
-
//
|
|
150
|
-
// story
|
|
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
|
|
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
|
|
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
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
|
|
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+):
|