@beyondwork/docx-react-component 1.0.57 → 1.0.58
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/README.md +1 -1
- package/package.json +1 -1
- package/src/api/public-types.ts +173 -0
- package/src/io/ooxml/parse-picture.ts +51 -0
- package/src/model/canonical-document.ts +6 -0
- package/src/runtime/document-runtime.ts +68 -1
- package/src/runtime/document-search.ts +61 -0
- package/src/runtime/query-scopes.ts +186 -0
- package/src/runtime/scope-resolver.ts +60 -0
- package/src/runtime/surface-projection.ts +7 -1
- package/src/ui/WordReviewEditor.tsx +71 -1
- package/src/ui/editor-runtime-boundary.ts +3 -0
- package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +23 -0
- package/src/ui-tailwind/chrome-overlay/tw-object-selection-overlay.tsx +157 -0
- package/src/ui-tailwind/editor-surface/pm-schema.ts +26 -0
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +4 -0
- package/src/ui-tailwind/tw-review-workspace.tsx +23 -0
package/README.md
CHANGED
|
@@ -238,7 +238,7 @@ Engineering work is organized into 9 lanes (5 active now + 2 later polish + 2 fi
|
|
|
238
238
|
| 6b | [**Shell & Workspace Chrome**](docs/plans/lane-6b-shell-workspace-chrome.md) | **~15%** | Gated on 6a.S1+S2 — shell header + toolbar + status + alert banner + unsaved modal + collab chrome restyle; mode-dock decommission; TwCommandPalette |
|
|
239
239
|
| 6c | [**Context & Review Surfaces**](docs/plans/lane-6c-context-review-surfaces.md) | **~15%** | Gated on 6a.S1+S2 — selection toolbar + suggestion card + rail + scope + context toolbars + health panel restyle; TwCommentPreview / TwEmptyState / TwShortcutHint |
|
|
240
240
|
| 6d | [**Visual Fidelity**](docs/plans/lane-6d-visual-fidelity.md) | **~20%** | Gated on 6a.S1+S2 + external-lane deps — L8 A/B/C shipped; L8 Phase D + P11 overlays + P7 + P12 + P14 next |
|
|
241
|
-
| 7a | [**Visual Fidelity Register**](docs/plans/lane-7a-visual-fidelity-register.md) |
|
|
241
|
+
| 7a | [**Visual Fidelity Register**](docs/plans/lane-7a-visual-fidelity-register.md) | **✅ 100%** | CLOSED — V5 covers (`6f8869b7`), V6 REF/PAGEREF baseline via CO3 + contract pin (`99b66a1f`), V7 × CO1 5-combo cascade audit (`df315488`) |
|
|
242
242
|
| 7b | [**Revision & Redline Completion**](docs/plans/lane-7b-revision-redline-completion.md) | **0%** | LATER (gated on 6a–6d) — X4.a/b structural table revisions, X5 ffData, move-pairing |
|
|
243
243
|
| 7c | [**Preservation & Format Coverage**](docs/plans/lane-7c-preservation-format-coverage.md) | **0%** | LATER (gated on 6a–6d) — O6 Strict, OLE preserve-only, picture-SDT, w14/kern/VML defer |
|
|
244
244
|
| 7d | [**Platform Hardening & Hygiene**](docs/plans/lane-7d-platform-hardening-hygiene.md) | **0%** | LATER (gated on 6a–6d) — harness-crash-hardening, fastload activation, worktree consolidation |
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@beyondwork/docx-react-component",
|
|
3
3
|
"publisher": "beyondwork",
|
|
4
|
-
"version": "1.0.
|
|
4
|
+
"version": "1.0.58",
|
|
5
5
|
"description": "Embeddable React Word (docx) editor with review, comments, tracked changes, and round-trip OOXML fidelity.",
|
|
6
6
|
"packageManager": "pnpm@10.30.3",
|
|
7
7
|
"type": "module",
|
package/src/api/public-types.ts
CHANGED
|
@@ -482,6 +482,12 @@ export interface SearchOptions {
|
|
|
482
482
|
matchCase?: boolean;
|
|
483
483
|
wholeWord?: boolean;
|
|
484
484
|
limit?: number;
|
|
485
|
+
/** Phase C §C3 — treat `query` as a JS regex pattern (compiled with `u` flag). */
|
|
486
|
+
regex?: boolean;
|
|
487
|
+
/** Phase C §C3 — restrict results to positions inside this scopeId's marker range. */
|
|
488
|
+
inScope?: string;
|
|
489
|
+
/** Phase C §C3 — restrict results to a specific story target (default: main). */
|
|
490
|
+
inStory?: EditorStoryTarget;
|
|
485
491
|
}
|
|
486
492
|
|
|
487
493
|
export interface SearchResultSnapshot {
|
|
@@ -841,6 +847,12 @@ export interface SurfacePictureEffects {
|
|
|
841
847
|
flipV?: boolean;
|
|
842
848
|
presetGeom?: string;
|
|
843
849
|
stretch?: boolean;
|
|
850
|
+
/** N11.b — a:softEdge feather radius in EMU. Maps to CSS `filter: blur(R)`. */
|
|
851
|
+
softEdgeRadius?: number;
|
|
852
|
+
/** N11.b — a:outerShdw drop shadow. `blurRad`/`dist` in EMU; `dir` in 60000ths of a degree. */
|
|
853
|
+
outerShadow?: { blurRad: number; dist: number; dir: number; color: string; colorType: "srgbClr" | "schemeClr" };
|
|
854
|
+
/** N11.b — a:glow ambient glow. `radius` in EMU. */
|
|
855
|
+
glow?: { radius: number; color: string; colorType: "srgbClr" | "schemeClr" };
|
|
844
856
|
}
|
|
845
857
|
|
|
846
858
|
export type SurfaceInlineSegment =
|
|
@@ -1812,6 +1824,8 @@ export interface RuntimeRenderSnapshot {
|
|
|
1812
1824
|
commandState: CommandStateSnapshot;
|
|
1813
1825
|
surface?: EditorSurfaceSnapshot;
|
|
1814
1826
|
protectionSnapshot: ProtectionSnapshot;
|
|
1827
|
+
/** R.3 — stable id of the currently grabbed image/shape, or null. Populated from grab state so chrome overlays re-render on selectObject/deselectObject. */
|
|
1828
|
+
grabbedObjectId?: string | null;
|
|
1815
1829
|
}
|
|
1816
1830
|
|
|
1817
1831
|
export interface EditorSessionState {
|
|
@@ -2042,6 +2056,67 @@ export interface WorkflowMetadataSnapshot {
|
|
|
2042
2056
|
entries: WorkflowMetadataEntry[];
|
|
2043
2057
|
}
|
|
2044
2058
|
|
|
2059
|
+
// ---------------------------------------------------------------------------
|
|
2060
|
+
// Phase C — host-side scope query surface (§C1, §C2)
|
|
2061
|
+
// ---------------------------------------------------------------------------
|
|
2062
|
+
|
|
2063
|
+
/**
|
|
2064
|
+
* §C1 — Filter passed to `queryScopes` / `findScopesAt` /
|
|
2065
|
+
* `findScopesIntersecting`. All fields are optional and AND'd together.
|
|
2066
|
+
* Snapshot-based; never triggers runtime mutation.
|
|
2067
|
+
*/
|
|
2068
|
+
export interface ScopeQueryFilter {
|
|
2069
|
+
/** Match only scopes attached to any of these work items. */
|
|
2070
|
+
workItemIds?: string[];
|
|
2071
|
+
/** Match only scopes in one of these modes. */
|
|
2072
|
+
modes?: WorkflowScopeMode[];
|
|
2073
|
+
/** Match only scopes whose `domain` is one of these. */
|
|
2074
|
+
domains?: Array<NonNullable<WorkflowScope["domain"]>>;
|
|
2075
|
+
/**
|
|
2076
|
+
* Match scopes that carry at least one entry with this `metadataId`.
|
|
2077
|
+
* Uses the runtime `WorkflowMetadataSnapshot` joined by `entry.scopeId`.
|
|
2078
|
+
*/
|
|
2079
|
+
metadataId?: string;
|
|
2080
|
+
/**
|
|
2081
|
+
* Match scopes that carry at least one entry whose `value` passes the
|
|
2082
|
+
* predicate. Entries with no `value` are skipped.
|
|
2083
|
+
*/
|
|
2084
|
+
hasValue?: (value: Record<string, unknown>, entry: WorkflowMetadataEntry) => boolean;
|
|
2085
|
+
/** Label prefix (case-insensitive). */
|
|
2086
|
+
labelPrefix?: string;
|
|
2087
|
+
/**
|
|
2088
|
+
* Story target filter. Defaults to `{ kind: "main" }` when omitted. Pass
|
|
2089
|
+
* `"*"` for any story.
|
|
2090
|
+
*/
|
|
2091
|
+
storyTarget?: EditorStoryTarget | "*";
|
|
2092
|
+
/** Max result count. Undefined = no cap. */
|
|
2093
|
+
limit?: number;
|
|
2094
|
+
/**
|
|
2095
|
+
* §C8 — include scopes with `visibility: "hidden"`. Default: false.
|
|
2096
|
+
* Accepted but has no effect until §C8 adds the `visibility` field.
|
|
2097
|
+
*/
|
|
2098
|
+
includeHidden?: boolean;
|
|
2099
|
+
/**
|
|
2100
|
+
* §C8 — include scopes with `visibility: "invisible"`. Default: false.
|
|
2101
|
+
* Accepted but has no effect until §C8 adds the `visibility` field.
|
|
2102
|
+
*/
|
|
2103
|
+
includeInvisible?: boolean;
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
/**
|
|
2107
|
+
* §C1 — One result from `queryScopes`. Joins the scope record with the
|
|
2108
|
+
* metadata entries that point at it and the resolved work item (when
|
|
2109
|
+
* `scope.workItemId` matches one in the overlay).
|
|
2110
|
+
*/
|
|
2111
|
+
export interface ScopeQueryResult {
|
|
2112
|
+
/** Scope record from the overlay. */
|
|
2113
|
+
scope: WorkflowScope;
|
|
2114
|
+
/** Metadata entries whose `entry.scopeId === scope.scopeId`. */
|
|
2115
|
+
entries: WorkflowMetadataEntry[];
|
|
2116
|
+
/** Resolved work item when `scope.workItemId` is set and present. */
|
|
2117
|
+
workItem: WorkflowWorkItem | null;
|
|
2118
|
+
}
|
|
2119
|
+
|
|
2045
2120
|
// ---------------------------------------------------------------------------
|
|
2046
2121
|
// R2 — issue metadata (scope-card-overlay P1)
|
|
2047
2122
|
// ---------------------------------------------------------------------------
|
|
@@ -3326,6 +3401,86 @@ export interface WordReviewEditorRef {
|
|
|
3326
3401
|
setWorkflowMetadataEntries(entries: WorkflowMetadataEntry[]): void;
|
|
3327
3402
|
clearWorkflowMetadataEntries(): void;
|
|
3328
3403
|
getWorkflowMetadataSnapshot(): WorkflowMetadataSnapshot;
|
|
3404
|
+
/**
|
|
3405
|
+
* Phase C §C1 — filter + project the current workflow overlay into a
|
|
3406
|
+
* scope-joined view. Reads a snapshot of the current overlay + metadata
|
|
3407
|
+
* entries + work items; no mutation. Results are sorted by start-marker
|
|
3408
|
+
* document position, tie-broken by `scopeId` ascending.
|
|
3409
|
+
*
|
|
3410
|
+
* Defaults: `storyTarget` = `{ kind: "main" }` (pass `"*"` for any
|
|
3411
|
+
* story); `includeHidden` / `includeInvisible` default to `false`
|
|
3412
|
+
* (§C8 additive — accepted today but no-ops until `visibility` lands).
|
|
3413
|
+
*
|
|
3414
|
+
* Returns `[]` when no workflow overlay has been set.
|
|
3415
|
+
*/
|
|
3416
|
+
queryScopes(filter?: ScopeQueryFilter): ScopeQueryResult[];
|
|
3417
|
+
/**
|
|
3418
|
+
* Phase C §C2 — every scope whose marker range contains `position.from`
|
|
3419
|
+
* (end-inclusive), ordered outermost → innermost. Pass a zero-length
|
|
3420
|
+
* range (`from === to`) for a point query. Returns the full
|
|
3421
|
+
* `ScopeQueryResult` (scope + entries + workItem) so results compose
|
|
3422
|
+
* directly with `setSelection`, `addScope`, `getLocationForAnchor`.
|
|
3423
|
+
*
|
|
3424
|
+
* Companion to `getInteractionGuardSnapshot().matchedScopeId` — that
|
|
3425
|
+
* API returns the innermost-only match; this one returns the whole
|
|
3426
|
+
* enclosing stack.
|
|
3427
|
+
*/
|
|
3428
|
+
findScopesAt(
|
|
3429
|
+
position: EditorAnchorProjection,
|
|
3430
|
+
options?: { includeHidden?: boolean; includeInvisible?: boolean },
|
|
3431
|
+
): ScopeQueryResult[];
|
|
3432
|
+
/**
|
|
3433
|
+
* Phase C §C2 — every scope whose marker range intersects `range`
|
|
3434
|
+
* (accepts a range anchor; non-range anchors yield `[]`). Default
|
|
3435
|
+
* `mode: "overlap"` matches any intersection including touching
|
|
3436
|
+
* endpoints; `mode: "contain"` requires the scope's entire range to
|
|
3437
|
+
* sit within `range`. Deterministic order by start-marker position.
|
|
3438
|
+
*/
|
|
3439
|
+
findScopesIntersecting(
|
|
3440
|
+
range: EditorAnchorProjection,
|
|
3441
|
+
options?: {
|
|
3442
|
+
includeHidden?: boolean;
|
|
3443
|
+
includeInvisible?: boolean;
|
|
3444
|
+
mode?: "overlap" | "contain";
|
|
3445
|
+
},
|
|
3446
|
+
): ScopeQueryResult[];
|
|
3447
|
+
/**
|
|
3448
|
+
* Phase C §C3 — find the first text match in the document.
|
|
3449
|
+
* Supports `regex`, `inScope`, `inStory` options.
|
|
3450
|
+
* Throws `EditorApiError({ code: "search_invalid_regex" })` synchronously
|
|
3451
|
+
* when `options.regex === true` and `query` is not a valid regex pattern.
|
|
3452
|
+
* Returns `null` when no match is found.
|
|
3453
|
+
*/
|
|
3454
|
+
findFirstText(
|
|
3455
|
+
query: string,
|
|
3456
|
+
options?: SearchOptions,
|
|
3457
|
+
): EditorAnchorProjection | null;
|
|
3458
|
+
/**
|
|
3459
|
+
* Phase C §C3 — find all text matches in the document.
|
|
3460
|
+
* Same options and error contract as `findFirstText`.
|
|
3461
|
+
* Returns `[]` when no matches are found.
|
|
3462
|
+
*/
|
|
3463
|
+
findAllText(
|
|
3464
|
+
query: string,
|
|
3465
|
+
options?: SearchOptions,
|
|
3466
|
+
): EditorAnchorProjection[];
|
|
3467
|
+
/**
|
|
3468
|
+
* Phase C §C3 — find first text match and select it. Returns `true` if a
|
|
3469
|
+
* match was found (and selection updated), `false` otherwise.
|
|
3470
|
+
*/
|
|
3471
|
+
selectFirstText(
|
|
3472
|
+
query: string,
|
|
3473
|
+
options?: SearchOptions,
|
|
3474
|
+
): boolean;
|
|
3475
|
+
/**
|
|
3476
|
+
* Phase C §C3 — select first text match and return the total match count.
|
|
3477
|
+
* Returns `0` when no matches are found. Multi-range selection deferred
|
|
3478
|
+
* to Lane 1 shipping `SelectionSnapshot.multi`.
|
|
3479
|
+
*/
|
|
3480
|
+
selectAllText(
|
|
3481
|
+
query: string,
|
|
3482
|
+
options?: SearchOptions,
|
|
3483
|
+
): number;
|
|
3329
3484
|
/**
|
|
3330
3485
|
* Schema 1.1 — set the overlay default for metadata persistence.
|
|
3331
3486
|
* Author-only per collab-master-plan §7 role-gating matrix;
|
|
@@ -3863,6 +4018,24 @@ export interface ResolveMetadataConflictInput {
|
|
|
3863
4018
|
}
|
|
3864
4019
|
|
|
3865
4020
|
// ---------------------------------------------------------------------------
|
|
4021
|
+
// Phase C §C3 — generic editor API error
|
|
4022
|
+
// ---------------------------------------------------------------------------
|
|
4023
|
+
|
|
4024
|
+
/**
|
|
4025
|
+
* Thrown synchronously by query methods when the caller passes an invalid
|
|
4026
|
+
* argument. The `code` field identifies the specific failure:
|
|
4027
|
+
* - `"search_invalid_regex"` — `options.regex === true` and `query` is not
|
|
4028
|
+
* a valid JavaScript regular expression pattern.
|
|
4029
|
+
*/
|
|
4030
|
+
export class EditorApiError extends Error {
|
|
4031
|
+
readonly code: string;
|
|
4032
|
+
constructor(params: { code: string; message?: string }) {
|
|
4033
|
+
super(params.message ?? `EditorApiError: ${params.code}`);
|
|
4034
|
+
this.name = "EditorApiError";
|
|
4035
|
+
this.code = params.code;
|
|
4036
|
+
}
|
|
4037
|
+
}
|
|
4038
|
+
|
|
3866
4039
|
// Schema 1.1 — metadata-persistence errors (P17)
|
|
3867
4040
|
// ---------------------------------------------------------------------------
|
|
3868
4041
|
|
|
@@ -60,6 +60,17 @@ export function parsePicture(graphicDataEl: XmlElementNode): PictureContent | nu
|
|
|
60
60
|
const prstGeom = spPr ? findFirstChild(spPr, "prstGeom") : undefined;
|
|
61
61
|
const presetGeom = prstGeom?.attributes.prst;
|
|
62
62
|
|
|
63
|
+
// N11.b — effectLst: softEdge, outerShdw, glow
|
|
64
|
+
const effectLst = spPr ? findFirstChild(spPr, "effectLst") : undefined;
|
|
65
|
+
const softEdgeEl = effectLst ? findFirstChild(effectLst, "softEdge") : undefined;
|
|
66
|
+
const softEdgeRadius = softEdgeEl ? readEmuAttr(softEdgeEl, "rad") : undefined;
|
|
67
|
+
const outerShdwEl = effectLst ? findFirstChild(effectLst, "outerShdw") : undefined;
|
|
68
|
+
const outerShadow = outerShdwEl
|
|
69
|
+
? parseOuterShadow(outerShdwEl)
|
|
70
|
+
: undefined;
|
|
71
|
+
const glowEl = effectLst ? findFirstChild(effectLst, "glow") : undefined;
|
|
72
|
+
const glow = glowEl ? parseGlow(glowEl) : undefined;
|
|
73
|
+
|
|
63
74
|
const result: PictureContent = { type: "picture", blipRef };
|
|
64
75
|
if (srcRect) result.srcRect = srcRect;
|
|
65
76
|
if (stretch !== undefined) result.stretch = stretch;
|
|
@@ -67,9 +78,49 @@ export function parsePicture(graphicDataEl: XmlElementNode): PictureContent | nu
|
|
|
67
78
|
if (flipH !== undefined) result.flipH = flipH;
|
|
68
79
|
if (flipV !== undefined) result.flipV = flipV;
|
|
69
80
|
if (presetGeom) result.presetGeom = presetGeom;
|
|
81
|
+
if (softEdgeRadius !== undefined) result.softEdgeRadius = softEdgeRadius;
|
|
82
|
+
if (outerShadow) result.outerShadow = outerShadow;
|
|
83
|
+
if (glow) result.glow = glow;
|
|
70
84
|
return result;
|
|
71
85
|
}
|
|
72
86
|
|
|
87
|
+
function readEmuAttr(el: XmlElementNode, name: string): number | undefined {
|
|
88
|
+
const v = el.attributes[name];
|
|
89
|
+
if (v === undefined) return undefined;
|
|
90
|
+
const n = parseInt(v, 10);
|
|
91
|
+
return Number.isFinite(n) ? n : undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function parseColorFromEl(el: XmlElementNode): { color: string; colorType: "srgbClr" | "schemeClr" } | null {
|
|
95
|
+
const srgb = findFirstChild(el, "srgbClr");
|
|
96
|
+
if (srgb) {
|
|
97
|
+
return { color: (srgb.attributes.val ?? "000000").toUpperCase(), colorType: "srgbClr" };
|
|
98
|
+
}
|
|
99
|
+
const scheme = findFirstChild(el, "schemeClr");
|
|
100
|
+
if (scheme) {
|
|
101
|
+
return { color: scheme.attributes.val ?? "dk1", colorType: "schemeClr" };
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function parseOuterShadow(
|
|
107
|
+
el: XmlElementNode,
|
|
108
|
+
): PictureContent["outerShadow"] {
|
|
109
|
+
const blurRad = readEmuAttr(el, "blurRad") ?? 0;
|
|
110
|
+
const dist = readEmuAttr(el, "dist") ?? 0;
|
|
111
|
+
const dir = parseInt(el.attributes.dir ?? "0", 10) || 0;
|
|
112
|
+
const colorInfo = parseColorFromEl(el);
|
|
113
|
+
if (!colorInfo) return undefined;
|
|
114
|
+
return { blurRad, dist, dir, color: colorInfo.color, colorType: colorInfo.colorType };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function parseGlow(el: XmlElementNode): PictureContent["glow"] {
|
|
118
|
+
const radius = readEmuAttr(el, "rad") ?? 0;
|
|
119
|
+
const colorInfo = parseColorFromEl(el);
|
|
120
|
+
if (!colorInfo) return undefined;
|
|
121
|
+
return { radius, color: colorInfo.color, colorType: colorInfo.colorType };
|
|
122
|
+
}
|
|
123
|
+
|
|
73
124
|
function readPercentAttr(el: XmlElementNode, name: string): number {
|
|
74
125
|
const v = el.attributes[name];
|
|
75
126
|
if (v === undefined) return 0;
|
|
@@ -1478,6 +1478,12 @@ export interface PictureContent {
|
|
|
1478
1478
|
flipH?: boolean;
|
|
1479
1479
|
flipV?: boolean;
|
|
1480
1480
|
presetGeom?: string;
|
|
1481
|
+
/** N11.b — DrawingML a:softEdge feather radius in EMU. */
|
|
1482
|
+
softEdgeRadius?: number;
|
|
1483
|
+
/** N11.b — DrawingML a:outerShdw attributes. `dir` is in 60000ths of a degree; `blurRad`/`dist` in EMU. */
|
|
1484
|
+
outerShadow?: { blurRad: number; dist: number; dir: number; color: string; colorType: "srgbClr" | "schemeClr" };
|
|
1485
|
+
/** N11.b — DrawingML a:glow radius in EMU + color. */
|
|
1486
|
+
glow?: { radius: number; color: string; colorType: "srgbClr" | "schemeClr" };
|
|
1481
1487
|
/** Original w:drawing XML slice, preserved for lossless round-trip serialization. */
|
|
1482
1488
|
rawXml?: string;
|
|
1483
1489
|
}
|
|
@@ -86,6 +86,8 @@ import type {
|
|
|
86
86
|
WorkflowOverlay,
|
|
87
87
|
WorkflowScope,
|
|
88
88
|
WorkflowScopeSnapshot,
|
|
89
|
+
ScopeQueryFilter,
|
|
90
|
+
ScopeQueryResult,
|
|
89
91
|
WorkspaceMode,
|
|
90
92
|
WordReviewEditorEvent,
|
|
91
93
|
ZoomLevel,
|
|
@@ -136,7 +138,16 @@ import {
|
|
|
136
138
|
} from "../review/store/revision-store.ts";
|
|
137
139
|
import { createSuggestionsSnapshot } from "./suggestions-snapshot.ts";
|
|
138
140
|
import { validateSelectionAgainstDocument } from "./selection/post-edit-validator.ts";
|
|
139
|
-
import {
|
|
141
|
+
import {
|
|
142
|
+
collectScopeLocations,
|
|
143
|
+
findAllScopesAt,
|
|
144
|
+
findScopesIntersecting,
|
|
145
|
+
resolveScope,
|
|
146
|
+
} from "./scope-resolver.ts";
|
|
147
|
+
import {
|
|
148
|
+
projectScopeQueryResults,
|
|
149
|
+
queryScopes as runQueryScopes,
|
|
150
|
+
} from "./query-scopes.ts";
|
|
140
151
|
import {
|
|
141
152
|
insertScopeMarkers,
|
|
142
153
|
removeScopeMarkers,
|
|
@@ -504,6 +515,27 @@ export interface DocumentRuntime {
|
|
|
504
515
|
setWorkflowMetadataEntries(entries: WorkflowMetadataEntry[]): void;
|
|
505
516
|
clearWorkflowMetadataEntries(): void;
|
|
506
517
|
getWorkflowMetadataSnapshot(): WorkflowMetadataSnapshot;
|
|
518
|
+
/**
|
|
519
|
+
* Phase C §C1 — snapshot-based filter + join projection. See
|
|
520
|
+
* `WordReviewEditorRef.queryScopes` for contract.
|
|
521
|
+
*/
|
|
522
|
+
queryScopes(filter?: ScopeQueryFilter): ScopeQueryResult[];
|
|
523
|
+
/**
|
|
524
|
+
* Phase C §C2 — geometric scope queries. See `WordReviewEditorRef`
|
|
525
|
+
* for contract. Non-range anchors yield `[]`.
|
|
526
|
+
*/
|
|
527
|
+
findScopesAt(
|
|
528
|
+
position: EditorAnchorProjection,
|
|
529
|
+
options?: { includeHidden?: boolean; includeInvisible?: boolean },
|
|
530
|
+
): ScopeQueryResult[];
|
|
531
|
+
findScopesIntersecting(
|
|
532
|
+
range: EditorAnchorProjection,
|
|
533
|
+
options?: {
|
|
534
|
+
includeHidden?: boolean;
|
|
535
|
+
includeInvisible?: boolean;
|
|
536
|
+
mode?: "overlap" | "contain";
|
|
537
|
+
},
|
|
538
|
+
): ScopeQueryResult[];
|
|
507
539
|
setHostAnnotationOverlay(overlay: HostAnnotationOverlay): void;
|
|
508
540
|
clearHostAnnotationOverlay(): void;
|
|
509
541
|
getHostAnnotationSnapshot(): HostAnnotationSnapshot;
|
|
@@ -2215,6 +2247,7 @@ export function createDocumentRuntime(
|
|
|
2215
2247
|
},
|
|
2216
2248
|
surface,
|
|
2217
2249
|
protectionSnapshot,
|
|
2250
|
+
grabbedObjectId: grabState.objectId,
|
|
2218
2251
|
};
|
|
2219
2252
|
}
|
|
2220
2253
|
|
|
@@ -3690,6 +3723,40 @@ export function createDocumentRuntime(
|
|
|
3690
3723
|
getWorkflowMetadataSnapshot() {
|
|
3691
3724
|
return deriveWorkflowMetadataSnapshot();
|
|
3692
3725
|
},
|
|
3726
|
+
queryScopes(filter) {
|
|
3727
|
+
return runQueryScopes(
|
|
3728
|
+
{
|
|
3729
|
+
overlay: workflowOverlay,
|
|
3730
|
+
entries: workflowMetadataEntries,
|
|
3731
|
+
document: state.document,
|
|
3732
|
+
},
|
|
3733
|
+
filter,
|
|
3734
|
+
);
|
|
3735
|
+
},
|
|
3736
|
+
findScopesAt(position, options) {
|
|
3737
|
+
const pos =
|
|
3738
|
+
position.kind === "range"
|
|
3739
|
+
? position.from
|
|
3740
|
+
: position.kind === "node"
|
|
3741
|
+
? position.at
|
|
3742
|
+
: null;
|
|
3743
|
+
if (pos === null) return [];
|
|
3744
|
+
const hits = findAllScopesAt(state.document, pos);
|
|
3745
|
+
return projectScopeQueryResults(
|
|
3746
|
+
{ overlay: workflowOverlay, entries: workflowMetadataEntries, document: state.document },
|
|
3747
|
+
hits.map((h) => h.scopeId),
|
|
3748
|
+
options,
|
|
3749
|
+
);
|
|
3750
|
+
},
|
|
3751
|
+
findScopesIntersecting(range, options) {
|
|
3752
|
+
if (range.kind !== "range") return [];
|
|
3753
|
+
const hits = findScopesIntersecting(state.document, range.from, range.to, options?.mode);
|
|
3754
|
+
return projectScopeQueryResults(
|
|
3755
|
+
{ overlay: workflowOverlay, entries: workflowMetadataEntries, document: state.document },
|
|
3756
|
+
hits.map((h) => h.scopeId),
|
|
3757
|
+
options,
|
|
3758
|
+
);
|
|
3759
|
+
},
|
|
3693
3760
|
setHostAnnotationOverlay(overlay) {
|
|
3694
3761
|
this.dispatch({
|
|
3695
3762
|
type: "host-annotation.set-overlay",
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
DocumentNavigationSnapshot,
|
|
3
|
+
EditorAnchorProjection,
|
|
3
4
|
EditorStoryTarget,
|
|
4
5
|
SearchOptions,
|
|
5
6
|
SearchResultSnapshot,
|
|
6
7
|
SelectionSnapshot,
|
|
7
8
|
} from "../api/public-types";
|
|
9
|
+
import { EditorApiError } from "../api/public-types.ts";
|
|
8
10
|
import {
|
|
9
11
|
MAIN_STORY_TARGET,
|
|
10
12
|
storyTargetsEqual,
|
|
@@ -23,6 +25,7 @@ import {
|
|
|
23
25
|
resolveSectionForStoryTarget,
|
|
24
26
|
} from "./document-layout.ts";
|
|
25
27
|
import { createEditorSurfaceSnapshot } from "./surface-projection.ts";
|
|
28
|
+
import { resolveScope } from "./scope-resolver.ts";
|
|
26
29
|
|
|
27
30
|
export function searchDocument(
|
|
28
31
|
document: CanonicalDocumentEnvelope,
|
|
@@ -143,3 +146,61 @@ function getActiveSearchResultIndex(
|
|
|
143
146
|
|
|
144
147
|
return activeIndex >= 0 ? activeIndex : 0;
|
|
145
148
|
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Phase C §C3 — find all text matches in the document, respecting the new
|
|
152
|
+
* `regex`, `inScope`, and `inStory` options. Throws `EditorApiError` with
|
|
153
|
+
* `code: "search_invalid_regex"` if `options.regex === true` and `query`
|
|
154
|
+
* is not a valid JavaScript regular expression pattern.
|
|
155
|
+
*
|
|
156
|
+
* Returns an array of `EditorAnchorProjection` values (range anchors) that
|
|
157
|
+
* compose directly with `setSelection`, `addScope`, `getLocationForAnchor`.
|
|
158
|
+
*/
|
|
159
|
+
export function findTextMatches(
|
|
160
|
+
document: CanonicalDocumentEnvelope,
|
|
161
|
+
selection: SelectionSnapshot,
|
|
162
|
+
query: string,
|
|
163
|
+
options: SearchOptions = {},
|
|
164
|
+
): EditorAnchorProjection[] {
|
|
165
|
+
const normalizedQuery = query.trim();
|
|
166
|
+
if (!normalizedQuery) return [];
|
|
167
|
+
|
|
168
|
+
if (options.regex) {
|
|
169
|
+
try {
|
|
170
|
+
const flags = options.matchCase ? "ug" : "uig";
|
|
171
|
+
new RegExp(normalizedQuery, flags);
|
|
172
|
+
} catch {
|
|
173
|
+
throw new EditorApiError({
|
|
174
|
+
code: "search_invalid_regex",
|
|
175
|
+
message: `Invalid regex pattern: ${normalizedQuery}`,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const storyTarget: EditorStoryTarget = options.inStory ?? MAIN_STORY_TARGET;
|
|
181
|
+
const surface = createEditorSurfaceSnapshot(
|
|
182
|
+
document,
|
|
183
|
+
createSelectionSnapshot(selection.anchor, selection.head),
|
|
184
|
+
storyTarget,
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
let results = searchSurfaceBlocks(surface.blocks, normalizedQuery, options);
|
|
188
|
+
|
|
189
|
+
if (options.inScope) {
|
|
190
|
+
const scopeAnchor = resolveScope(document, options.inScope);
|
|
191
|
+
if (!scopeAnchor || scopeAnchor.kind !== "range") {
|
|
192
|
+
return [];
|
|
193
|
+
}
|
|
194
|
+
const { from: scopeFrom, to: scopeTo } = scopeAnchor;
|
|
195
|
+
results = results.filter(
|
|
196
|
+
(r) => r.from >= scopeFrom && r.to <= scopeTo,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return results.map((r) => ({
|
|
201
|
+
kind: "range" as const,
|
|
202
|
+
from: r.from,
|
|
203
|
+
to: r.to,
|
|
204
|
+
assoc: { start: -1 as const, end: 1 as const },
|
|
205
|
+
}));
|
|
206
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
EditorStoryTarget,
|
|
3
|
+
ScopeQueryFilter,
|
|
4
|
+
ScopeQueryResult,
|
|
5
|
+
WorkflowMetadataEntry,
|
|
6
|
+
WorkflowOverlay,
|
|
7
|
+
WorkflowScope,
|
|
8
|
+
WorkflowWorkItem,
|
|
9
|
+
} from "../api/public-types.ts";
|
|
10
|
+
import type { CanonicalDocument } from "../model/canonical-document.ts";
|
|
11
|
+
import type { CanonicalDocumentEnvelope } from "../core/state/editor-state.ts";
|
|
12
|
+
import { collectScopeLocations } from "./scope-resolver.ts";
|
|
13
|
+
|
|
14
|
+
function storyTargetsEqual(a: EditorStoryTarget, b: EditorStoryTarget): boolean {
|
|
15
|
+
if (a.kind !== b.kind) return false;
|
|
16
|
+
switch (a.kind) {
|
|
17
|
+
case "main":
|
|
18
|
+
return true;
|
|
19
|
+
case "header":
|
|
20
|
+
case "footer":
|
|
21
|
+
return (
|
|
22
|
+
a.relationshipId === (b as typeof a).relationshipId &&
|
|
23
|
+
a.variant === (b as typeof a).variant &&
|
|
24
|
+
a.sectionIndex === (b as typeof a).sectionIndex
|
|
25
|
+
);
|
|
26
|
+
case "footnote":
|
|
27
|
+
case "endnote":
|
|
28
|
+
return a.noteId === (b as typeof a).noteId;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const MAIN_STORY: EditorStoryTarget = { kind: "main" };
|
|
33
|
+
|
|
34
|
+
export interface ScopeQueryInputs {
|
|
35
|
+
readonly overlay: WorkflowOverlay | null;
|
|
36
|
+
readonly entries: readonly WorkflowMetadataEntry[];
|
|
37
|
+
readonly document: Pick<CanonicalDocument, "content"> | CanonicalDocumentEnvelope;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Phase C §C2 helper — project an ordered list of scopeIds (already produced
|
|
42
|
+
* by a geometric walk like `findAllScopesAt` / `findScopesIntersecting`)
|
|
43
|
+
* into `ScopeQueryResult[]`. Preserves incoming order. Filters out scopeIds
|
|
44
|
+
* that are not in the overlay (e.g. orphan markers). Applies the §C8
|
|
45
|
+
* visibility filter via `includeHidden` / `includeInvisible`.
|
|
46
|
+
*/
|
|
47
|
+
export function projectScopeQueryResults(
|
|
48
|
+
inputs: ScopeQueryInputs,
|
|
49
|
+
scopeIds: readonly string[],
|
|
50
|
+
options: { includeHidden?: boolean; includeInvisible?: boolean } = {},
|
|
51
|
+
): ScopeQueryResult[] {
|
|
52
|
+
const overlay = inputs.overlay;
|
|
53
|
+
if (!overlay) return [];
|
|
54
|
+
const includeHidden = options.includeHidden === true;
|
|
55
|
+
const includeInvisible = options.includeInvisible === true;
|
|
56
|
+
|
|
57
|
+
const scopesById = new Map<string, WorkflowScope>();
|
|
58
|
+
for (const scope of overlay.scopes) scopesById.set(scope.scopeId, scope);
|
|
59
|
+
|
|
60
|
+
const entriesByScope = new Map<string, WorkflowMetadataEntry[]>();
|
|
61
|
+
for (const entry of inputs.entries) {
|
|
62
|
+
if (!entry.scopeId) continue;
|
|
63
|
+
let list = entriesByScope.get(entry.scopeId);
|
|
64
|
+
if (!list) {
|
|
65
|
+
list = [];
|
|
66
|
+
entriesByScope.set(entry.scopeId, list);
|
|
67
|
+
}
|
|
68
|
+
list.push(entry);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const workItemsById = new Map<string, WorkflowWorkItem>();
|
|
72
|
+
for (const item of overlay.workItems ?? []) {
|
|
73
|
+
workItemsById.set(item.workItemId, item);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const results: ScopeQueryResult[] = [];
|
|
77
|
+
for (const scopeId of scopeIds) {
|
|
78
|
+
const scope = scopesById.get(scopeId);
|
|
79
|
+
if (!scope) continue;
|
|
80
|
+
const visibility = (scope as WorkflowScope & { visibility?: string }).visibility;
|
|
81
|
+
if (visibility === "hidden" && !includeHidden) continue;
|
|
82
|
+
if (visibility === "invisible" && !includeInvisible) continue;
|
|
83
|
+
const entries = entriesByScope.get(scopeId) ?? [];
|
|
84
|
+
const workItem = scope.workItemId
|
|
85
|
+
? workItemsById.get(scope.workItemId) ?? null
|
|
86
|
+
: null;
|
|
87
|
+
results.push({ scope, entries, workItem });
|
|
88
|
+
}
|
|
89
|
+
return results;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Pure §C1 projector — filter + join scopes with entries + workItem, ordered
|
|
94
|
+
* by start-marker document position (scopeId ASC tiebreak). Snapshot-based;
|
|
95
|
+
* no runtime state mutation.
|
|
96
|
+
*/
|
|
97
|
+
export function queryScopes(
|
|
98
|
+
inputs: ScopeQueryInputs,
|
|
99
|
+
filter: ScopeQueryFilter | undefined,
|
|
100
|
+
): ScopeQueryResult[] {
|
|
101
|
+
const overlay = inputs.overlay;
|
|
102
|
+
if (!overlay) return [];
|
|
103
|
+
|
|
104
|
+
const normalizedStoryFilter = filter?.storyTarget ?? MAIN_STORY;
|
|
105
|
+
const workItemIdSet = filter?.workItemIds ? new Set(filter.workItemIds) : null;
|
|
106
|
+
const modeSet = filter?.modes ? new Set(filter.modes) : null;
|
|
107
|
+
const domainSet = filter?.domains ? new Set(filter.domains) : null;
|
|
108
|
+
const labelPrefix = filter?.labelPrefix?.toLowerCase();
|
|
109
|
+
const metadataId = filter?.metadataId;
|
|
110
|
+
const hasValue = filter?.hasValue;
|
|
111
|
+
const limit = filter?.limit;
|
|
112
|
+
const includeHidden = filter?.includeHidden === true;
|
|
113
|
+
const includeInvisible = filter?.includeInvisible === true;
|
|
114
|
+
|
|
115
|
+
const entriesByScope = new Map<string, WorkflowMetadataEntry[]>();
|
|
116
|
+
for (const entry of inputs.entries) {
|
|
117
|
+
if (!entry.scopeId) continue;
|
|
118
|
+
let list = entriesByScope.get(entry.scopeId);
|
|
119
|
+
if (!list) {
|
|
120
|
+
list = [];
|
|
121
|
+
entriesByScope.set(entry.scopeId, list);
|
|
122
|
+
}
|
|
123
|
+
list.push(entry);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const workItemsById = new Map<string, WorkflowWorkItem>();
|
|
127
|
+
for (const item of overlay.workItems ?? []) {
|
|
128
|
+
workItemsById.set(item.workItemId, item);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const locations = collectScopeLocations(inputs.document);
|
|
132
|
+
|
|
133
|
+
const candidates: Array<{ scope: WorkflowScope; startPos: number }> = [];
|
|
134
|
+
for (const scope of overlay.scopes) {
|
|
135
|
+
// §C8 pre-wire: respect visibility flags. Visibility field itself lands
|
|
136
|
+
// in §C8; treating missing as "visible" keeps this forward-compatible.
|
|
137
|
+
const visibility = (scope as WorkflowScope & { visibility?: string }).visibility;
|
|
138
|
+
if (visibility === "hidden" && !includeHidden) continue;
|
|
139
|
+
if (visibility === "invisible" && !includeInvisible) continue;
|
|
140
|
+
|
|
141
|
+
if (normalizedStoryFilter !== "*") {
|
|
142
|
+
const scopeStory = scope.storyTarget ?? MAIN_STORY;
|
|
143
|
+
if (!storyTargetsEqual(scopeStory, normalizedStoryFilter)) continue;
|
|
144
|
+
}
|
|
145
|
+
if (modeSet && !modeSet.has(scope.mode)) continue;
|
|
146
|
+
if (domainSet && !(scope.domain && domainSet.has(scope.domain))) continue;
|
|
147
|
+
if (workItemIdSet) {
|
|
148
|
+
if (!scope.workItemId || !workItemIdSet.has(scope.workItemId)) continue;
|
|
149
|
+
}
|
|
150
|
+
if (labelPrefix) {
|
|
151
|
+
const label = scope.label?.toLowerCase() ?? "";
|
|
152
|
+
if (!label.startsWith(labelPrefix)) continue;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const scopeEntries = entriesByScope.get(scope.scopeId) ?? [];
|
|
156
|
+
|
|
157
|
+
if (metadataId && !scopeEntries.some((e) => e.metadataId === metadataId)) continue;
|
|
158
|
+
if (hasValue) {
|
|
159
|
+
const anyMatch = scopeEntries.some(
|
|
160
|
+
(e) => e.value !== undefined && hasValue(e.value, e),
|
|
161
|
+
);
|
|
162
|
+
if (!anyMatch) continue;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const loc = locations.get(scope.scopeId);
|
|
166
|
+
const startPos =
|
|
167
|
+
loc?.startPos ?? loc?.endPos ?? Number.POSITIVE_INFINITY;
|
|
168
|
+
candidates.push({ scope, startPos });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
candidates.sort((a, b) => {
|
|
172
|
+
if (a.startPos !== b.startPos) return a.startPos - b.startPos;
|
|
173
|
+
return a.scope.scopeId < b.scope.scopeId ? -1 : a.scope.scopeId > b.scope.scopeId ? 1 : 0;
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const results: ScopeQueryResult[] = [];
|
|
177
|
+
for (const { scope } of candidates) {
|
|
178
|
+
if (limit !== undefined && results.length >= limit) break;
|
|
179
|
+
const entries = entriesByScope.get(scope.scopeId) ?? [];
|
|
180
|
+
const workItem = scope.workItemId
|
|
181
|
+
? workItemsById.get(scope.workItemId) ?? null
|
|
182
|
+
: null;
|
|
183
|
+
results.push({ scope, entries, workItem });
|
|
184
|
+
}
|
|
185
|
+
return results;
|
|
186
|
+
}
|
|
@@ -146,3 +146,63 @@ export function findScopeAt(
|
|
|
146
146
|
}
|
|
147
147
|
return best;
|
|
148
148
|
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Phase C §C2 — every enclosing scope at `position`, ordered outermost →
|
|
152
|
+
* innermost (lowest startPos first; ties broken on scopeId ASC). Includes
|
|
153
|
+
* scopes that touch the position exactly (`startPos <= position <= endPos`).
|
|
154
|
+
* Companion to `findScopeAt`, which keeps the innermost-only contract.
|
|
155
|
+
*/
|
|
156
|
+
export function findAllScopesAt(
|
|
157
|
+
document: Pick<CanonicalDocument, "content"> | CanonicalDocumentEnvelope,
|
|
158
|
+
position: number,
|
|
159
|
+
): ResolvedScopeLocation[] {
|
|
160
|
+
const locations = collectScopeLocations(document);
|
|
161
|
+
const hits: ResolvedScopeLocation[] = [];
|
|
162
|
+
for (const [scopeId, loc] of locations) {
|
|
163
|
+
if (loc.startPos === undefined || loc.endPos === undefined) continue;
|
|
164
|
+
if (position < loc.startPos || position > loc.endPos) continue;
|
|
165
|
+
hits.push({ scopeId, startPos: loc.startPos, endPos: loc.endPos });
|
|
166
|
+
}
|
|
167
|
+
hits.sort((a, b) => {
|
|
168
|
+
if (a.startPos !== b.startPos) return a.startPos - b.startPos;
|
|
169
|
+
return a.scopeId < b.scopeId ? -1 : a.scopeId > b.scopeId ? 1 : 0;
|
|
170
|
+
});
|
|
171
|
+
return hits;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Phase C §C2 — every scope whose marker range intersects `[rangeFrom,
|
|
176
|
+
* rangeTo]`. `mode: "overlap"` (default) accepts any intersection including
|
|
177
|
+
* touching endpoints; `mode: "contain"` requires the scope's entire marker
|
|
178
|
+
* range to lie within `[rangeFrom, rangeTo]`. Deterministic order: startPos
|
|
179
|
+
* ASC, scopeId ASC.
|
|
180
|
+
*/
|
|
181
|
+
export function findScopesIntersecting(
|
|
182
|
+
document: Pick<CanonicalDocument, "content"> | CanonicalDocumentEnvelope,
|
|
183
|
+
rangeFrom: number,
|
|
184
|
+
rangeTo: number,
|
|
185
|
+
mode: "overlap" | "contain" = "overlap",
|
|
186
|
+
): ResolvedScopeLocation[] {
|
|
187
|
+
const from = Math.min(rangeFrom, rangeTo);
|
|
188
|
+
const to = Math.max(rangeFrom, rangeTo);
|
|
189
|
+
const locations = collectScopeLocations(document);
|
|
190
|
+
const hits: ResolvedScopeLocation[] = [];
|
|
191
|
+
for (const [scopeId, loc] of locations) {
|
|
192
|
+
if (loc.startPos === undefined || loc.endPos === undefined) continue;
|
|
193
|
+
const sFrom = Math.min(loc.startPos, loc.endPos);
|
|
194
|
+
const sTo = Math.max(loc.startPos, loc.endPos);
|
|
195
|
+
if (mode === "contain") {
|
|
196
|
+
if (sFrom < from || sTo > to) continue;
|
|
197
|
+
} else {
|
|
198
|
+
// overlap — any intersection including touching endpoints
|
|
199
|
+
if (sTo < from || sFrom > to) continue;
|
|
200
|
+
}
|
|
201
|
+
hits.push({ scopeId, startPos: loc.startPos, endPos: loc.endPos });
|
|
202
|
+
}
|
|
203
|
+
hits.sort((a, b) => {
|
|
204
|
+
if (a.startPos !== b.startPos) return a.startPos - b.startPos;
|
|
205
|
+
return a.scopeId < b.scopeId ? -1 : a.scopeId > b.scopeId ? 1 : 0;
|
|
206
|
+
});
|
|
207
|
+
return hits;
|
|
208
|
+
}
|
|
@@ -1320,7 +1320,10 @@ function surfacePictureEffectsFromContent(
|
|
|
1320
1320
|
content.flipH !== undefined ||
|
|
1321
1321
|
content.flipV !== undefined ||
|
|
1322
1322
|
content.presetGeom !== undefined ||
|
|
1323
|
-
content.stretch !== undefined
|
|
1323
|
+
content.stretch !== undefined ||
|
|
1324
|
+
content.softEdgeRadius !== undefined ||
|
|
1325
|
+
content.outerShadow !== undefined ||
|
|
1326
|
+
content.glow !== undefined;
|
|
1324
1327
|
if (!has) return undefined;
|
|
1325
1328
|
return {
|
|
1326
1329
|
...(content.srcRect ? { srcRect: { ...content.srcRect } } : {}),
|
|
@@ -1329,6 +1332,9 @@ function surfacePictureEffectsFromContent(
|
|
|
1329
1332
|
...(content.flipV !== undefined ? { flipV: content.flipV } : {}),
|
|
1330
1333
|
...(content.presetGeom !== undefined ? { presetGeom: content.presetGeom } : {}),
|
|
1331
1334
|
...(content.stretch !== undefined ? { stretch: content.stretch } : {}),
|
|
1335
|
+
...(content.softEdgeRadius !== undefined ? { softEdgeRadius: content.softEdgeRadius } : {}),
|
|
1336
|
+
...(content.outerShadow !== undefined ? { outerShadow: { ...content.outerShadow } } : {}),
|
|
1337
|
+
...(content.glow !== undefined ? { glow: { ...content.glow } } : {}),
|
|
1332
1338
|
};
|
|
1333
1339
|
}
|
|
1334
1340
|
|
|
@@ -151,7 +151,7 @@ import {
|
|
|
151
151
|
} from "../io/source-package-provenance.ts";
|
|
152
152
|
import { readOpcPackage } from "../io/opc/package-reader.ts";
|
|
153
153
|
import { deriveCapabilities } from "../runtime/session-capabilities";
|
|
154
|
-
import { searchDocument } from "../runtime/document-search.ts";
|
|
154
|
+
import { findTextMatches, searchDocument } from "../runtime/document-search.ts";
|
|
155
155
|
import {
|
|
156
156
|
resolveCurrentContextAnalyticsQuery,
|
|
157
157
|
runtimeContextAnalyticsSnapshotsEqual,
|
|
@@ -909,6 +909,34 @@ export function __createWordReviewEditorRefBridge(
|
|
|
909
909
|
getWorkflowMetadataSnapshot: () => {
|
|
910
910
|
return clonePublicValue(runtime.getWorkflowMetadataSnapshot());
|
|
911
911
|
},
|
|
912
|
+
queryScopes: (filter) => {
|
|
913
|
+
return clonePublicValue(runtime.queryScopes(filter));
|
|
914
|
+
},
|
|
915
|
+
findScopesAt: (position, options) => {
|
|
916
|
+
return clonePublicValue(runtime.findScopesAt(position, options));
|
|
917
|
+
},
|
|
918
|
+
findScopesIntersecting: (range, options) => {
|
|
919
|
+
return clonePublicValue(runtime.findScopesIntersecting(range, options));
|
|
920
|
+
},
|
|
921
|
+
findFirstText: (query, opts) => {
|
|
922
|
+
const hits = findTextMatchesForRuntime(runtime, query, opts);
|
|
923
|
+
return hits.length > 0 ? (hits[0] ?? null) : null;
|
|
924
|
+
},
|
|
925
|
+
findAllText: (query, opts) => {
|
|
926
|
+
return findTextMatchesForRuntime(runtime, query, opts);
|
|
927
|
+
},
|
|
928
|
+
selectFirstText: (query, opts) => {
|
|
929
|
+
const hits = findTextMatchesForRuntime(runtime, query, opts);
|
|
930
|
+
if (hits.length === 0) return false;
|
|
931
|
+
applyRuntimeSelection(runtime, createSelectionFromAnchor(hits[0]!));
|
|
932
|
+
return true;
|
|
933
|
+
},
|
|
934
|
+
selectAllText: (query, opts) => {
|
|
935
|
+
const hits = findTextMatchesForRuntime(runtime, query, opts);
|
|
936
|
+
if (hits.length === 0) return 0;
|
|
937
|
+
applyRuntimeSelection(runtime, createSelectionFromAnchor(hits[0]!));
|
|
938
|
+
return hits.length;
|
|
939
|
+
},
|
|
912
940
|
// P17 — metadata persistence toggle + convert methods.
|
|
913
941
|
setMetadataPersistenceMode: (mode) => {
|
|
914
942
|
if (mode === "external" && !(options?.resolverRef?.current ?? null)) {
|
|
@@ -1976,6 +2004,34 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
|
|
|
1976
2004
|
getWorkflowMetadataSnapshot: () => {
|
|
1977
2005
|
return clonePublicValue(activeRuntime.getWorkflowMetadataSnapshot());
|
|
1978
2006
|
},
|
|
2007
|
+
queryScopes: (filter) => {
|
|
2008
|
+
return clonePublicValue(activeRuntime.queryScopes(filter));
|
|
2009
|
+
},
|
|
2010
|
+
findScopesAt: (position, options) => {
|
|
2011
|
+
return clonePublicValue(activeRuntime.findScopesAt(position, options));
|
|
2012
|
+
},
|
|
2013
|
+
findScopesIntersecting: (range, options) => {
|
|
2014
|
+
return clonePublicValue(activeRuntime.findScopesIntersecting(range, options));
|
|
2015
|
+
},
|
|
2016
|
+
findFirstText: (query, opts) => {
|
|
2017
|
+
const hits = findTextMatchesForRuntime(activeRuntime, query, opts);
|
|
2018
|
+
return hits.length > 0 ? (hits[0] ?? null) : null;
|
|
2019
|
+
},
|
|
2020
|
+
findAllText: (query, opts) => {
|
|
2021
|
+
return findTextMatchesForRuntime(activeRuntime, query, opts);
|
|
2022
|
+
},
|
|
2023
|
+
selectFirstText: (query, opts) => {
|
|
2024
|
+
const hits = findTextMatchesForRuntime(activeRuntime, query, opts);
|
|
2025
|
+
if (hits.length === 0) return false;
|
|
2026
|
+
applyRuntimeSelection(activeRuntime, createSelectionFromAnchor(hits[0]!));
|
|
2027
|
+
return true;
|
|
2028
|
+
},
|
|
2029
|
+
selectAllText: (query, opts) => {
|
|
2030
|
+
const hits = findTextMatchesForRuntime(activeRuntime, query, opts);
|
|
2031
|
+
if (hits.length === 0) return 0;
|
|
2032
|
+
applyRuntimeSelection(activeRuntime, createSelectionFromAnchor(hits[0]!));
|
|
2033
|
+
return hits.length;
|
|
2034
|
+
},
|
|
1979
2035
|
// P17 — metadata persistence toggle + convert methods.
|
|
1980
2036
|
setMetadataPersistenceMode: (mode) => {
|
|
1981
2037
|
if (mode === "external" && scopeMetadataResolverRef.current === null) {
|
|
@@ -5056,6 +5112,20 @@ function clonePublicValue<T>(value: T): T {
|
|
|
5056
5112
|
return structuredClone(value);
|
|
5057
5113
|
}
|
|
5058
5114
|
|
|
5115
|
+
function findTextMatchesForRuntime(
|
|
5116
|
+
runtime: WordReviewEditorRuntime,
|
|
5117
|
+
query: string,
|
|
5118
|
+
options: SearchOptions | undefined,
|
|
5119
|
+
): EditorAnchorProjection[] {
|
|
5120
|
+
const snapshot = runtime.getRenderSnapshot();
|
|
5121
|
+
return findTextMatches(
|
|
5122
|
+
runtime.getSessionState().canonicalDocument,
|
|
5123
|
+
snapshot.selection,
|
|
5124
|
+
query,
|
|
5125
|
+
options ?? {},
|
|
5126
|
+
);
|
|
5127
|
+
}
|
|
5128
|
+
|
|
5059
5129
|
/**
|
|
5060
5130
|
* Open the correct header/footer story for a specific page. The page's
|
|
5061
5131
|
* resolved `stories.header` / `stories.footer` already carries the
|
|
@@ -1154,6 +1154,9 @@ function createLoadingRuntimeBridge(input: {
|
|
|
1154
1154
|
definitions: [],
|
|
1155
1155
|
entries: [],
|
|
1156
1156
|
}),
|
|
1157
|
+
queryScopes: () => [],
|
|
1158
|
+
findScopesAt: () => [],
|
|
1159
|
+
findScopesIntersecting: () => [],
|
|
1157
1160
|
setHostAnnotationOverlay: () => undefined,
|
|
1158
1161
|
clearHostAnnotationOverlay: () => undefined,
|
|
1159
1162
|
getHostAnnotationSnapshot: () => ({
|
|
@@ -28,6 +28,7 @@ import { TwScopeCardLayer } from "./tw-scope-card-layer";
|
|
|
28
28
|
import { TwPageStackOverlayLayer } from "./tw-page-stack-overlay-layer";
|
|
29
29
|
import { TwPageStackChromeLayer, type PmPortalView } from "../page-stack/tw-page-stack-chrome-layer";
|
|
30
30
|
import { TwTableGripLayer } from "../chrome/tw-table-grip-layer";
|
|
31
|
+
import { TwObjectSelectionOverlay } from "./tw-object-selection-overlay";
|
|
31
32
|
|
|
32
33
|
export interface TwChromeOverlayProps {
|
|
33
34
|
/** Layout facet the overlay layers read from. */
|
|
@@ -93,6 +94,16 @@ export interface TwChromeOverlayProps {
|
|
|
93
94
|
/** Optional extra children (e.g., future comment balloon layer). */
|
|
94
95
|
children?: React.ReactNode;
|
|
95
96
|
|
|
97
|
+
// Object selection overlay (N6) ----------------------------------------
|
|
98
|
+
/** R.3 — grabbed image/shape id, or null. When set, the selection overlay renders. */
|
|
99
|
+
grabbedObjectId?: string | null;
|
|
100
|
+
/** Document `from` offset of the grabbed segment (for anchor-index lookup). */
|
|
101
|
+
grabbedObjectFromOffset?: number | null;
|
|
102
|
+
/** Document `to` offset of the grabbed segment. */
|
|
103
|
+
grabbedObjectToOffset?: number | null;
|
|
104
|
+
/** Called when the user clicks outside the selection box to deselect. */
|
|
105
|
+
onDeselectObject?: () => void;
|
|
106
|
+
|
|
96
107
|
// Table grip props (P6) -----------------------------------------------
|
|
97
108
|
/** Active table context — when present, column/row resize grips are shown. */
|
|
98
109
|
tableContext?: TableStructureContextSnapshot | null;
|
|
@@ -187,6 +198,10 @@ export const TwChromeOverlay: React.FC<TwChromeOverlayProps> = ({
|
|
|
187
198
|
scopeCardScopeTagEditor,
|
|
188
199
|
"data-testid": testId,
|
|
189
200
|
children,
|
|
201
|
+
grabbedObjectId,
|
|
202
|
+
grabbedObjectFromOffset,
|
|
203
|
+
grabbedObjectToOffset,
|
|
204
|
+
onDeselectObject,
|
|
190
205
|
tableContext,
|
|
191
206
|
onSetColumnWidth,
|
|
192
207
|
onSetRowHeight,
|
|
@@ -251,6 +266,14 @@ export const TwChromeOverlay: React.FC<TwChromeOverlayProps> = ({
|
|
|
251
266
|
onSetColumnWidth={onSetColumnWidth}
|
|
252
267
|
onSetRowHeight={onSetRowHeight}
|
|
253
268
|
/>
|
|
269
|
+
<TwObjectSelectionOverlay
|
|
270
|
+
grabbedObjectId={grabbedObjectId ?? null}
|
|
271
|
+
grabbedObjectFromOffset={grabbedObjectFromOffset ?? null}
|
|
272
|
+
grabbedObjectToOffset={grabbedObjectToOffset ?? null}
|
|
273
|
+
facet={facet}
|
|
274
|
+
space={space}
|
|
275
|
+
onDeselect={onDeselectObject}
|
|
276
|
+
/>
|
|
254
277
|
{children}
|
|
255
278
|
</div>
|
|
256
279
|
);
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lane 6d — N6 P11.1–P11.5: object selection chrome overlay.
|
|
3
|
+
*
|
|
4
|
+
* Renders a selection box with 8 resize handles and a rotate grip around the
|
|
5
|
+
* grabbed image or shape. Purely visual chrome — no resize/rotate mutations
|
|
6
|
+
* in this slice; handle interaction is deferred to the follow-up N6.b slice.
|
|
7
|
+
*
|
|
8
|
+
* Positioning: uses `RenderAnchorIndex.byRuntimeOffset(from)` + `bySelection`
|
|
9
|
+
* to compute the object rect in overlay-coordinate space, then paints over it.
|
|
10
|
+
*
|
|
11
|
+
* Dismissal: clicking outside the overlay calls `onDeselect()` which routes
|
|
12
|
+
* to `runtime.deselectObject()` in the workspace.
|
|
13
|
+
*
|
|
14
|
+
* v1 scope:
|
|
15
|
+
* - rect, ellipse, roundRect shapes and inline/floating images.
|
|
16
|
+
* - Resize handles are visual only (pointer-events-none on each handle).
|
|
17
|
+
* - Rotate grip is visual only.
|
|
18
|
+
* - Anchor drag indicator omitted until N6.b.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import * as React from "react";
|
|
22
|
+
import { useEffect, useRef } from "react";
|
|
23
|
+
import type { WordReviewEditorLayoutFacet } from "../../api/public-types";
|
|
24
|
+
import type { OverlayCoordinateSpace } from "./chrome-overlay-projector";
|
|
25
|
+
import { projectRectToOverlay } from "./chrome-overlay-projector";
|
|
26
|
+
|
|
27
|
+
/** The 8 corner/edge handle positions. */
|
|
28
|
+
const HANDLE_POSITIONS = [
|
|
29
|
+
"nw", "n", "ne",
|
|
30
|
+
"w", "e",
|
|
31
|
+
"sw", "s", "se",
|
|
32
|
+
] as const;
|
|
33
|
+
type HandlePosition = typeof HANDLE_POSITIONS[number];
|
|
34
|
+
|
|
35
|
+
const CURSOR_MAP: Record<HandlePosition, string> = {
|
|
36
|
+
nw: "nw-resize", n: "n-resize", ne: "ne-resize",
|
|
37
|
+
w: "w-resize", e: "e-resize",
|
|
38
|
+
sw: "sw-resize", s: "s-resize", se: "se-resize",
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export interface TwObjectSelectionOverlayProps {
|
|
42
|
+
/** Stable id of the grabbed image/shape (mediaId or shapeId), or null. */
|
|
43
|
+
grabbedObjectId: string | null;
|
|
44
|
+
/** Document offset (`from`) of the grabbed object's inline segment. Null when no object grabbed. */
|
|
45
|
+
grabbedObjectFromOffset: number | null;
|
|
46
|
+
/** Document offset (`to`) of the grabbed object's inline segment. Null when no object grabbed. */
|
|
47
|
+
grabbedObjectToOffset: number | null;
|
|
48
|
+
/** Layout facet for render-frame + anchor-index access. */
|
|
49
|
+
facet: WordReviewEditorLayoutFacet;
|
|
50
|
+
/** Optional overlay coordinate-space override. */
|
|
51
|
+
space?: OverlayCoordinateSpace;
|
|
52
|
+
/** Called when the user clicks outside the selection box. */
|
|
53
|
+
onDeselect?: () => void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function TwObjectSelectionOverlay({
|
|
57
|
+
grabbedObjectId,
|
|
58
|
+
grabbedObjectFromOffset,
|
|
59
|
+
grabbedObjectToOffset,
|
|
60
|
+
facet,
|
|
61
|
+
space,
|
|
62
|
+
onDeselect,
|
|
63
|
+
}: TwObjectSelectionOverlayProps) {
|
|
64
|
+
const overlayRef = useRef<HTMLDivElement>(null);
|
|
65
|
+
|
|
66
|
+
// Click-outside to deselect.
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
if (!grabbedObjectId || !onDeselect) return;
|
|
69
|
+
function handlePointerDown(e: PointerEvent) {
|
|
70
|
+
if (overlayRef.current && !overlayRef.current.contains(e.target as Node)) {
|
|
71
|
+
onDeselect!();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
document.addEventListener("pointerdown", handlePointerDown, { capture: true });
|
|
75
|
+
return () => document.removeEventListener("pointerdown", handlePointerDown, { capture: true });
|
|
76
|
+
}, [grabbedObjectId, onDeselect]);
|
|
77
|
+
|
|
78
|
+
if (!grabbedObjectId || grabbedObjectFromOffset == null) return null;
|
|
79
|
+
|
|
80
|
+
const frame = typeof facet.getRenderFrame === "function" ? facet.getRenderFrame() : null;
|
|
81
|
+
if (!frame) return null;
|
|
82
|
+
|
|
83
|
+
const rawRect = grabbedObjectToOffset != null
|
|
84
|
+
? frame.anchorIndex.bySelection(grabbedObjectFromOffset, grabbedObjectToOffset)
|
|
85
|
+
: frame.anchorIndex.byRuntimeOffset(grabbedObjectFromOffset);
|
|
86
|
+
if (!rawRect) return null;
|
|
87
|
+
|
|
88
|
+
const rect = projectRectToOverlay(rawRect, space);
|
|
89
|
+
|
|
90
|
+
const boxStyle: React.CSSProperties = {
|
|
91
|
+
position: "absolute",
|
|
92
|
+
left: rect.left,
|
|
93
|
+
top: rect.top,
|
|
94
|
+
width: rect.width,
|
|
95
|
+
height: rect.height,
|
|
96
|
+
outline: "2px solid var(--color-accent-primary)",
|
|
97
|
+
boxSizing: "border-box",
|
|
98
|
+
pointerEvents: "auto",
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<div
|
|
103
|
+
ref={overlayRef}
|
|
104
|
+
style={boxStyle}
|
|
105
|
+
data-object-selection=""
|
|
106
|
+
data-object-id={grabbedObjectId}
|
|
107
|
+
aria-label="Selected object"
|
|
108
|
+
>
|
|
109
|
+
{HANDLE_POSITIONS.map((pos) => (
|
|
110
|
+
<ObjectHandle key={pos} position={pos} />
|
|
111
|
+
))}
|
|
112
|
+
<RotateGrip />
|
|
113
|
+
</div>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function ObjectHandle({ position }: { position: HandlePosition }) {
|
|
118
|
+
const HANDLE_PX = 8;
|
|
119
|
+
const half = HANDLE_PX / 2;
|
|
120
|
+
const pos = position;
|
|
121
|
+
|
|
122
|
+
const style: React.CSSProperties = {
|
|
123
|
+
position: "absolute",
|
|
124
|
+
width: HANDLE_PX,
|
|
125
|
+
height: HANDLE_PX,
|
|
126
|
+
background: "white",
|
|
127
|
+
border: "1.5px solid var(--color-accent-primary)",
|
|
128
|
+
borderRadius: 1,
|
|
129
|
+
boxSizing: "border-box",
|
|
130
|
+
cursor: CURSOR_MAP[pos],
|
|
131
|
+
// Visual only in v1 — pointer events disabled so clicks fall through to
|
|
132
|
+
// the click-outside listener which deselectObjects.
|
|
133
|
+
pointerEvents: "none",
|
|
134
|
+
...(pos.includes("w") ? { left: -half } : pos.includes("e") ? { right: -half } : { left: "50%", transform: "translateX(-50%)" }),
|
|
135
|
+
...(pos.includes("n") ? { top: -half } : pos.includes("s") ? { bottom: -half } : { top: "50%", transform: `${pos === "w" || pos === "e" ? "translateY(-50%)" : "translateX(-50%) translateY(-50%)"}` }),
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
return <div style={style} data-handle={pos} aria-hidden="true" />;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function RotateGrip() {
|
|
142
|
+
const style: React.CSSProperties = {
|
|
143
|
+
position: "absolute",
|
|
144
|
+
width: 10,
|
|
145
|
+
height: 10,
|
|
146
|
+
borderRadius: "50%",
|
|
147
|
+
background: "white",
|
|
148
|
+
border: "1.5px solid var(--color-accent-primary)",
|
|
149
|
+
top: -24,
|
|
150
|
+
left: "50%",
|
|
151
|
+
transform: "translateX(-50%)",
|
|
152
|
+
cursor: "grab",
|
|
153
|
+
pointerEvents: "none",
|
|
154
|
+
boxSizing: "border-box",
|
|
155
|
+
};
|
|
156
|
+
return <div style={style} data-handle="rotate" aria-hidden="true" />;
|
|
157
|
+
}
|
|
@@ -465,6 +465,10 @@ export const editorSchema = new Schema({
|
|
|
465
465
|
wrapMode: { default: null },
|
|
466
466
|
distMargins: { default: null },
|
|
467
467
|
positionH: { default: null },
|
|
468
|
+
// Lane 6d N11.b — CSS filter effects (soft-edge, outer shadow, glow).
|
|
469
|
+
softEdgeRadius: { default: null },
|
|
470
|
+
outerShadow: { default: null },
|
|
471
|
+
glow: { default: null },
|
|
468
472
|
},
|
|
469
473
|
toDOM(node) {
|
|
470
474
|
const isMissing = node.attrs.state === "missing";
|
|
@@ -496,6 +500,27 @@ export const editorSchema = new Schema({
|
|
|
496
500
|
`inset(${(srcRect.top / SRCRECT_UNITS_PER_PERCENT).toFixed(3)}% ${(srcRect.right / SRCRECT_UNITS_PER_PERCENT).toFixed(3)}% ${(srcRect.bottom / SRCRECT_UNITS_PER_PERCENT).toFixed(3)}% ${(srcRect.left / SRCRECT_UNITS_PER_PERCENT).toFixed(3)}%)`,
|
|
497
501
|
);
|
|
498
502
|
}
|
|
503
|
+
// N11.b filter effects → CSS filter on the img element.
|
|
504
|
+
const softEdgeRadius = node.attrs.softEdgeRadius as number | null;
|
|
505
|
+
const outerShadow = node.attrs.outerShadow as {
|
|
506
|
+
blurRad: number; dist: number; dir: number; color: string;
|
|
507
|
+
} | null;
|
|
508
|
+
const glow = node.attrs.glow as { radius: number; color: string } | null;
|
|
509
|
+
const filterParts: string[] = [];
|
|
510
|
+
if (softEdgeRadius) {
|
|
511
|
+
filterParts.push(`blur(${(softEdgeRadius / EMU_PER_PX).toFixed(2)}px)`);
|
|
512
|
+
}
|
|
513
|
+
if (glow) {
|
|
514
|
+
filterParts.push(`drop-shadow(0 0 ${(glow.radius / EMU_PER_PX).toFixed(2)}px #${glow.color})`);
|
|
515
|
+
}
|
|
516
|
+
if (outerShadow) {
|
|
517
|
+
const blurPx = (outerShadow.blurRad / EMU_PER_PX).toFixed(2);
|
|
518
|
+
const distPx = outerShadow.dist / EMU_PER_PX;
|
|
519
|
+
const dirRad = (outerShadow.dir / ROTATION_UNITS_PER_DEGREE) * (Math.PI / 180);
|
|
520
|
+
const dx = (distPx * Math.cos(dirRad)).toFixed(2);
|
|
521
|
+
const dy = (distPx * Math.sin(dirRad)).toFixed(2);
|
|
522
|
+
filterParts.push(`drop-shadow(${dx}px ${dy}px ${blurPx}px #${outerShadow.color})`);
|
|
523
|
+
}
|
|
499
524
|
// N9 float-wrap → CSS float + shape-outside on the wrapper span.
|
|
500
525
|
const wrapMode = node.attrs.wrapMode as string | null;
|
|
501
526
|
const positionH = node.attrs.positionH as { align?: string } | null;
|
|
@@ -527,6 +552,7 @@ export const editorSchema = new Schema({
|
|
|
527
552
|
heightPx ? `height:${heightPx}px` : "",
|
|
528
553
|
transformParts.length > 0 ? `transform:${transformParts.join(" ")}` : "",
|
|
529
554
|
clipParts.length > 0 ? `clip-path:${clipParts[0]}` : "",
|
|
555
|
+
filterParts.length > 0 ? `filter:${filterParts.join(" ")}` : "",
|
|
530
556
|
].filter(Boolean).join(";");
|
|
531
557
|
const wrapperStyle = wrapperStyleParts.join(";");
|
|
532
558
|
const wrapperAttrs: Record<string, string> = {
|
|
@@ -464,6 +464,10 @@ function buildInlineContent(
|
|
|
464
464
|
wrapMode: segment.anchor?.wrapMode ?? null,
|
|
465
465
|
distMargins: segment.anchor?.distMargins ?? null,
|
|
466
466
|
positionH: segment.anchor?.positionH ?? null,
|
|
467
|
+
// Lane 6d N11.b — filter effects.
|
|
468
|
+
softEdgeRadius: segment.pictureEffects?.softEdgeRadius ?? null,
|
|
469
|
+
outerShadow: segment.pictureEffects?.outerShadow ?? null,
|
|
470
|
+
glow: segment.pictureEffects?.glow ?? null,
|
|
467
471
|
}),
|
|
468
472
|
];
|
|
469
473
|
}
|
|
@@ -198,6 +198,8 @@ export interface TwReviewWorkspaceProps {
|
|
|
198
198
|
selectionContextAnalytics?: RuntimeContextAnalyticsSnapshot | null;
|
|
199
199
|
currentScopeContextAnalytics?: RuntimeContextAnalyticsSnapshot | null;
|
|
200
200
|
commands: EditorCommandBag;
|
|
201
|
+
/** N6 — release the grabbed image/shape. Wired to `runtime.deselectObject()` by the host. */
|
|
202
|
+
onDeselectObject?: () => void;
|
|
201
203
|
activeSelectionTool?: ActiveSelectionToolModel | null;
|
|
202
204
|
selectionToolAnchor?: SelectionToolAnchor | null;
|
|
203
205
|
documentNavigation?: DocumentNavigationSnapshot;
|
|
@@ -680,6 +682,23 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
|
|
|
680
682
|
// above bumps `renderFrameRevision` on the same kinds; including it
|
|
681
683
|
// in the dependency list re-runs this memo without a separate
|
|
682
684
|
// subscription.
|
|
685
|
+
// N6 — resolve grabbed-object segment offsets from the surface so the
|
|
686
|
+
// selection overlay can query the anchor index without a full surface walk.
|
|
687
|
+
const grabbedSegmentOffsets = useMemo(() => {
|
|
688
|
+
const objectId = snapshot.grabbedObjectId ?? null;
|
|
689
|
+
if (!objectId || !snapshot.surface) return null;
|
|
690
|
+
for (const block of snapshot.surface.blocks) {
|
|
691
|
+
if (!("segments" in block)) continue;
|
|
692
|
+
for (const seg of (block as { segments?: unknown[] }).segments ?? []) {
|
|
693
|
+
const s = seg as { kind?: string; mediaId?: string; from?: number; to?: number };
|
|
694
|
+
if ((s.kind === "image" || s.kind === "shape") && s.mediaId === objectId && s.from != null) {
|
|
695
|
+
return { from: s.from, to: s.to ?? s.from + 1 };
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
return null;
|
|
700
|
+
}, [snapshot.grabbedObjectId, snapshot.surface]);
|
|
701
|
+
|
|
683
702
|
const statusBarPageFacts = useMemo(() => {
|
|
684
703
|
const facet = props.layoutFacet;
|
|
685
704
|
if (!facet) {
|
|
@@ -1662,6 +1681,10 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
|
|
|
1662
1681
|
{props.layoutFacet ? (
|
|
1663
1682
|
<TwChromeOverlay
|
|
1664
1683
|
facet={props.layoutFacet}
|
|
1684
|
+
grabbedObjectId={snapshot.grabbedObjectId ?? null}
|
|
1685
|
+
grabbedObjectFromOffset={grabbedSegmentOffsets?.from ?? null}
|
|
1686
|
+
grabbedObjectToOffset={grabbedSegmentOffsets?.to ?? null}
|
|
1687
|
+
onDeselectObject={props.onDeselectObject}
|
|
1665
1688
|
tableContext={props.tableContext}
|
|
1666
1689
|
onSetColumnWidth={props.onSetColumnWidth}
|
|
1667
1690
|
onSetRowHeight={props.onSetRowHeight}
|