@beyondwork/docx-react-component 1.0.88 → 1.0.90
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/v3/_runtime-handle.ts +5 -0
- package/src/api/v3/ai/replacement.ts +82 -0
- package/src/api/v3/runtime/content.ts +3 -0
- package/src/api/v3/runtime/formatting.ts +64 -0
- package/src/core/commands/formatting-commands.ts +107 -0
- package/src/core/state/text-transaction.ts +11 -4
- package/src/runtime/document-runtime.ts +51 -0
- package/src/runtime/scopes/_scope-dependencies.ts +1 -0
- package/src/runtime/scopes/action-validation.ts +12 -3
- package/src/runtime/scopes/audit-bundle.ts +2 -2
- package/src/runtime/scopes/compiler-service.ts +70 -0
- package/src/runtime/scopes/formatting/apply.ts +262 -0
- package/src/runtime/scopes/index.ts +12 -0
- package/src/runtime/scopes/replacement/propose.ts +2 -0
- package/src/runtime/scopes/scope-kinds/paragraph.ts +1 -0
- package/src/runtime/scopes/semantic-scope-types.ts +48 -4
- package/src/runtime/scopes/workflow-overlap.ts +9 -11
- package/src/shell/session-bootstrap.ts +1 -0
- package/src/ui/WordReviewEditor.tsx +277 -28
- package/src/ui/editor-command-bag.ts +11 -0
- package/src/ui/editor-shell-view.tsx +10 -0
- package/src/ui/headless/chrome-registry.ts +6 -6
- package/src/ui/headless/role-action-sets.ts +4 -10
- package/src/ui/headless/selection-tool-resolver.ts +11 -0
- package/src/ui-tailwind/chrome/editor-action-registry.ts +1 -1
- package/src/ui-tailwind/chrome/tw-context-band.tsx +7 -7
- package/src/ui-tailwind/chrome/tw-detach-handle.tsx +13 -18
- package/src/ui-tailwind/chrome/tw-image-context-toolbar.tsx +8 -5
- package/src/ui-tailwind/chrome/tw-inline-find-bar.tsx +100 -0
- package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +0 -40
- package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +9 -7
- package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +17 -1
- package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +6 -1
- package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +17 -7
- package/src/ui-tailwind/editor-surface/preserve-position.ts +30 -5
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +2 -1
- package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +5 -1
- package/src/ui-tailwind/review/tw-review-rail.tsx +5 -0
- package/src/ui-tailwind/review/tw-workflow-tab.tsx +32 -38
- package/src/ui-tailwind/review-workspace/types.ts +2 -0
- package/src/ui-tailwind/theme/editor-theme.css +25 -12
- package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +20 -37
- package/src/ui-tailwind/toolbar/tw-scope-posture-menu.tsx +15 -27
- package/src/ui-tailwind/toolbar/tw-toolbar.tsx +24 -15
- package/src/ui-tailwind/tw-review-workspace.tsx +32 -18
- package/src/ui-tailwind/workflow-scope-layers.ts +70 -0
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.90",
|
|
5
5
|
"description": "Embeddable React Word (docx) editor with review, comments, tracked changes, and round-trip OOXML fidelity.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"sideEffects": [
|
|
@@ -117,6 +117,10 @@ export type RuntimeApiHandle = Pick<
|
|
|
117
117
|
// ReplacementScope` + `runtime.content.replaceText(scopeId, …)`
|
|
118
118
|
// route compiled plans through this seam.
|
|
119
119
|
| "applyScopeReplacement"
|
|
120
|
+
// Layer-08 scoped formatting actions. `ai.applyScopeAction` and
|
|
121
|
+
// `runtime.formatting.applyToScope` route exact mark mutations through
|
|
122
|
+
// this seam so formatting edits do not replace text payloads.
|
|
123
|
+
| "applyScopeFormatting"
|
|
120
124
|
// Nested facets (runtime.layout + runtime.geometry families,
|
|
121
125
|
// plus debug telemetry bus for UxResponse emission)
|
|
122
126
|
| "debug"
|
|
@@ -187,6 +191,7 @@ export const RUNTIME_API_HANDLE_SHAPE_CHECK: Record<keyof RuntimeApiHandle, true
|
|
|
187
191
|
compileScopeCardById: true,
|
|
188
192
|
compileScopeRailSnapshot: true,
|
|
189
193
|
applyScopeReplacement: true,
|
|
194
|
+
applyScopeFormatting: true,
|
|
190
195
|
debug: true,
|
|
191
196
|
layout: true,
|
|
192
197
|
geometry: true,
|
|
@@ -28,14 +28,17 @@ import {
|
|
|
28
28
|
import type {
|
|
29
29
|
ReplacementOperationKind,
|
|
30
30
|
ReplacementPreservePolicy,
|
|
31
|
+
ScopeFormattingAction,
|
|
31
32
|
ValidationResult as CompilerValidationResult,
|
|
32
33
|
} from "../../../runtime/scopes/index.ts";
|
|
33
34
|
import type { AIAction } from "../../../runtime/workflow/ai-action-policy.ts";
|
|
35
|
+
import type { TextFormattingDirective } from "../../public-types.ts";
|
|
34
36
|
|
|
35
37
|
export interface ReplacementProposalInput {
|
|
36
38
|
readonly targetScopeId: string;
|
|
37
39
|
readonly operation: ReplacementOperationKind;
|
|
38
40
|
readonly proposedText?: string;
|
|
41
|
+
readonly formatting?: TextFormattingDirective;
|
|
39
42
|
readonly preserve?: ReplacementPreservePolicy;
|
|
40
43
|
readonly reason?: string;
|
|
41
44
|
readonly actionId?: AIAction;
|
|
@@ -150,6 +153,7 @@ export interface ApplyReplacementScopeInput {
|
|
|
150
153
|
readonly text?: string;
|
|
151
154
|
readonly structured?: unknown;
|
|
152
155
|
};
|
|
156
|
+
readonly formatting?: TextFormattingDirective;
|
|
153
157
|
readonly preserve?: ReplacementPreservePolicy;
|
|
154
158
|
readonly reason?: string;
|
|
155
159
|
readonly actionId?: AIAction;
|
|
@@ -185,6 +189,42 @@ export const applyReplacementScopeMetadata: ApiV3FnMetadata = {
|
|
|
185
189
|
"§AI API § ai.applyReplacementScope. Adapter: widened to {targetScopeId, operation, proposedText?, preserve?, actionId?, proposalId?}; routes through applyScopeReplacement (re-validates live state, compiles per-kind plan, dispatches via DocumentRuntime.applyScopeReplacement, emits one ScopeActionAudit on the scope telemetry channel).",
|
|
186
190
|
};
|
|
187
191
|
|
|
192
|
+
export interface ApplyScopeActionInput {
|
|
193
|
+
readonly targetScopeId: string;
|
|
194
|
+
readonly action: ScopeFormattingAction;
|
|
195
|
+
readonly reason?: string;
|
|
196
|
+
readonly actionId?: AIAction;
|
|
197
|
+
readonly actorId?: string;
|
|
198
|
+
readonly origin?: "ui" | "agent" | "host";
|
|
199
|
+
readonly proposalId?: string;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export const applyScopeActionMetadata: ApiV3FnMetadata = {
|
|
203
|
+
name: "ai.applyScopeAction",
|
|
204
|
+
status: "live-with-adapter",
|
|
205
|
+
sourceLayer: "semantic-scope-compiler",
|
|
206
|
+
liveEvidence: {
|
|
207
|
+
runnerTest: "test/runtime/scopes/formatting-actions.test.ts",
|
|
208
|
+
commit: "refactor-08-scope-formatting-actions",
|
|
209
|
+
},
|
|
210
|
+
uxIntent: {
|
|
211
|
+
uiVisible: true,
|
|
212
|
+
expectsUxResponse: "inline-change",
|
|
213
|
+
expectedDelta: "formatting inside target scope changes",
|
|
214
|
+
},
|
|
215
|
+
agentMetadata: {
|
|
216
|
+
readOrMutate: "mutate",
|
|
217
|
+
boundedScope: "scope",
|
|
218
|
+
auditCategory: "formatting-apply",
|
|
219
|
+
policyHints: ["requires-approval-for-formatting"],
|
|
220
|
+
},
|
|
221
|
+
stateClass: "A-canonical",
|
|
222
|
+
persistsTo: "canonical",
|
|
223
|
+
broadcastsVia: "crdt",
|
|
224
|
+
rwdReference:
|
|
225
|
+
"§AI API § ai.applyScopeAction. Scope-targeted formatting writer over Layer-08; validates live scope/global guards, dispatches one undoable runtime edit, and emits one ScopeActionAudit. Use for exact mark actions such as clearing only w:highlight.",
|
|
226
|
+
};
|
|
227
|
+
|
|
188
228
|
/**
|
|
189
229
|
* Input shape for `validateReplacementScope`. Slice 4 widens the input
|
|
190
230
|
* to carry enough information for a fresh compile + compose, since the
|
|
@@ -314,6 +354,7 @@ export function createReplacementFamily(runtime: RuntimeApiHandle) {
|
|
|
314
354
|
: input.proposedText !== undefined
|
|
315
355
|
? { proposedText: input.proposedText }
|
|
316
356
|
: {}),
|
|
357
|
+
...(input.formatting ? { formatting: input.formatting } : {}),
|
|
317
358
|
...(input.preserve ? { preserve: input.preserve } : {}),
|
|
318
359
|
...(input.reason ? { reason: input.reason } : {}),
|
|
319
360
|
...(input.actionId ? { actionId: input.actionId } : {}),
|
|
@@ -341,6 +382,47 @@ export function createReplacementFamily(runtime: RuntimeApiHandle) {
|
|
|
341
382
|
authoredRevisionIds: result.authoredRevisionIds,
|
|
342
383
|
};
|
|
343
384
|
},
|
|
385
|
+
|
|
386
|
+
applyScopeAction(input: ApplyScopeActionInput): ApplyResult {
|
|
387
|
+
// @endStateApi — live-with-adapter. Routes through the scope
|
|
388
|
+
// compiler's applyFormatting + the same ApplyResult projection
|
|
389
|
+
// used by applyReplacementScope.
|
|
390
|
+
const proposalId =
|
|
391
|
+
input.proposalId ??
|
|
392
|
+
mockId(
|
|
393
|
+
runtime.getSessionState().documentId,
|
|
394
|
+
`scope-action-${input.targetScopeId}-${input.action.kind}`,
|
|
395
|
+
);
|
|
396
|
+
|
|
397
|
+
const result = compiler.applyFormatting({
|
|
398
|
+
targetScopeId: input.targetScopeId,
|
|
399
|
+
action: input.action,
|
|
400
|
+
...(input.reason ? { reason: input.reason } : {}),
|
|
401
|
+
...(input.actionId ? { actionId: input.actionId } : {}),
|
|
402
|
+
actorId: input.actorId ?? "agent",
|
|
403
|
+
origin: input.origin ?? "agent",
|
|
404
|
+
emittedAtUtc: new Date(0).toISOString(),
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
emitUxResponse(runtime, {
|
|
408
|
+
apiFn: applyScopeActionMetadata.name,
|
|
409
|
+
intent: applyScopeActionMetadata.uxIntent.expectedDelta ?? "",
|
|
410
|
+
mockOrLive: "live",
|
|
411
|
+
uiVisible: true,
|
|
412
|
+
expectedDelta: applyScopeActionMetadata.uxIntent.expectedDelta,
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
return {
|
|
416
|
+
proposalId,
|
|
417
|
+
applied: result.applied,
|
|
418
|
+
...(result.reason ? { reason: result.reason } : {}),
|
|
419
|
+
...(result.validation.blockedReasons.length > 0
|
|
420
|
+
? { blockers: Object.freeze([...result.validation.blockedReasons]) }
|
|
421
|
+
: {}),
|
|
422
|
+
...(result.audit ? { auditHint: result.audit.actionId } : {}),
|
|
423
|
+
authoredRevisionIds: result.authoredRevisionIds,
|
|
424
|
+
};
|
|
425
|
+
},
|
|
344
426
|
};
|
|
345
427
|
}
|
|
346
428
|
|
|
@@ -17,6 +17,7 @@ import type {
|
|
|
17
17
|
EditorAnchorProjection,
|
|
18
18
|
SurfaceBlockSnapshot,
|
|
19
19
|
SurfaceInlineSegment,
|
|
20
|
+
TextFormattingDirective,
|
|
20
21
|
} from "../../public-types.ts";
|
|
21
22
|
import { emitUxResponse } from "../_ux-response.ts";
|
|
22
23
|
import { createScopeCompilerService } from "../../../runtime/scopes/index.ts";
|
|
@@ -78,6 +79,7 @@ export const getLocationMetadata: ApiV3FnMetadata = {
|
|
|
78
79
|
export interface ReplaceTextInput {
|
|
79
80
|
readonly scopeId: string;
|
|
80
81
|
readonly replacement: string;
|
|
82
|
+
readonly formatting?: TextFormattingDirective;
|
|
81
83
|
}
|
|
82
84
|
|
|
83
85
|
export interface ReplaceTextResult {
|
|
@@ -200,6 +202,7 @@ export function createContentFamily(runtime: RuntimeApiHandle) {
|
|
|
200
202
|
targetScopeId: input.scopeId,
|
|
201
203
|
operation: "replace",
|
|
202
204
|
proposedText: input.replacement,
|
|
205
|
+
...(input.formatting ? { formatting: input.formatting } : {}),
|
|
203
206
|
actorId: "user",
|
|
204
207
|
origin: "ui",
|
|
205
208
|
emittedAtUtc: new Date(0).toISOString(),
|
|
@@ -14,6 +14,10 @@
|
|
|
14
14
|
import type { RuntimeApiHandle } from "../_runtime-handle.ts";
|
|
15
15
|
import type { ApiV3FnMetadata } from "../_layer-metadata.ts";
|
|
16
16
|
import { emitUxResponse } from "../_ux-response.ts";
|
|
17
|
+
import {
|
|
18
|
+
createScopeCompilerService,
|
|
19
|
+
type ScopeFormattingAction,
|
|
20
|
+
} from "../../../runtime/scopes/index.ts";
|
|
17
21
|
import {
|
|
18
22
|
resolveEffectiveFormatting,
|
|
19
23
|
createFormattingContext,
|
|
@@ -159,7 +163,43 @@ export const applyMetadata: ApiV3FnMetadata = {
|
|
|
159
163
|
"§Runtime API § runtime.formatting.apply. Live adapter over DocumentRuntime.applyFormattingOperation(); supports direct formatting/paragraph alignment/indentation and authors bounded property-change suggestions when the effective mode is suggesting. Style suggestions are intentionally not accepted by this operation shape.",
|
|
160
164
|
};
|
|
161
165
|
|
|
166
|
+
export interface FormattingApplyToScopeInput {
|
|
167
|
+
readonly scopeId: string;
|
|
168
|
+
readonly action: ScopeFormattingAction;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export interface FormattingApplyToScopeResult {
|
|
172
|
+
readonly applied: boolean;
|
|
173
|
+
readonly reason?: string;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export const applyToScopeMetadata: ApiV3FnMetadata = {
|
|
177
|
+
name: "runtime.formatting.applyToScope",
|
|
178
|
+
status: "live-with-adapter",
|
|
179
|
+
sourceLayer: "semantic-scope-compiler",
|
|
180
|
+
liveEvidence: {
|
|
181
|
+
runnerTest: "test/runtime/scopes/formatting-actions.test.ts",
|
|
182
|
+
commit: "refactor-08-scope-formatting-actions",
|
|
183
|
+
},
|
|
184
|
+
stateClass: "A-canonical",
|
|
185
|
+
persistsTo: "canonical",
|
|
186
|
+
broadcastsVia: "crdt",
|
|
187
|
+
uxIntent: {
|
|
188
|
+
uiVisible: true,
|
|
189
|
+
expectsUxResponse: "inline-change",
|
|
190
|
+
expectedDelta: "formatting changes inside the target scope",
|
|
191
|
+
},
|
|
192
|
+
agentMetadata: {
|
|
193
|
+
readOrMutate: "mutate",
|
|
194
|
+
boundedScope: "scope",
|
|
195
|
+
auditCategory: "formatting-write",
|
|
196
|
+
},
|
|
197
|
+
rwdReference:
|
|
198
|
+
"§Runtime API § runtime.formatting.applyToScope. Scope-targeted formatting writer over Layer-08; resolves target scope by id, validates scope/global guards, applies exact text-mark action, emits one ScopeActionAudit, and enters the normal undo stack.",
|
|
199
|
+
};
|
|
200
|
+
|
|
162
201
|
export function createFormattingFamily(runtime: RuntimeApiHandle) {
|
|
202
|
+
const compiler = createScopeCompilerService(runtime);
|
|
163
203
|
return {
|
|
164
204
|
apply(operation: FormattingOperation): void {
|
|
165
205
|
// @endStateApi — live. Delegates to the runtime-owned mutation seam
|
|
@@ -175,6 +215,30 @@ export function createFormattingFamily(runtime: RuntimeApiHandle) {
|
|
|
175
215
|
});
|
|
176
216
|
},
|
|
177
217
|
|
|
218
|
+
applyToScope(input: FormattingApplyToScopeInput): FormattingApplyToScopeResult {
|
|
219
|
+
// @endStateApi — live-with-adapter. Routes through the scope
|
|
220
|
+
// compiler's applyFormatting with actor=user + origin=ui; returns
|
|
221
|
+
// the authored-revision + audit shape projected for v3 callers.
|
|
222
|
+
const result = compiler.applyFormatting({
|
|
223
|
+
targetScopeId: input.scopeId,
|
|
224
|
+
action: input.action,
|
|
225
|
+
actorId: "user",
|
|
226
|
+
origin: "ui",
|
|
227
|
+
emittedAtUtc: new Date(0).toISOString(),
|
|
228
|
+
});
|
|
229
|
+
emitUxResponse(runtime, {
|
|
230
|
+
apiFn: applyToScopeMetadata.name,
|
|
231
|
+
intent: applyToScopeMetadata.uxIntent.expectedDelta ?? "",
|
|
232
|
+
mockOrLive: "live",
|
|
233
|
+
uiVisible: true,
|
|
234
|
+
expectedDelta: applyToScopeMetadata.uxIntent.expectedDelta,
|
|
235
|
+
});
|
|
236
|
+
return {
|
|
237
|
+
applied: result.applied,
|
|
238
|
+
...(result.reason ? { reason: result.reason } : {}),
|
|
239
|
+
};
|
|
240
|
+
},
|
|
241
|
+
|
|
178
242
|
getEffective(
|
|
179
243
|
nodeRef: FormattingNodeRef,
|
|
180
244
|
opts?: FormattingGetEffectiveOpts,
|
|
@@ -588,6 +588,113 @@ export function applyFormattingOperationToDocument(
|
|
|
588
588
|
};
|
|
589
589
|
}
|
|
590
590
|
|
|
591
|
+
export type TextMarkClearTarget = TextMark["type"] | "visualHighlight";
|
|
592
|
+
|
|
593
|
+
export type TextMarkRangeOperation =
|
|
594
|
+
| { type: "clear-mark"; mark: TextMarkClearTarget }
|
|
595
|
+
| { type: "set-mark"; mark: TextMark };
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* Apply an exact canonical text-mark mutation over a document-coordinate
|
|
599
|
+
* range without relying on the current selection or a mounted render surface.
|
|
600
|
+
* This is the scope-action path for source-layer edits such as removing only
|
|
601
|
+
* `w:highlight` while preserving shading and every unrelated run mark.
|
|
602
|
+
*/
|
|
603
|
+
export function applyTextMarkOperationToDocumentRange(
|
|
604
|
+
document: CanonicalDocumentEnvelope,
|
|
605
|
+
range: { from: number; to: number },
|
|
606
|
+
operation: TextMarkRangeOperation,
|
|
607
|
+
): FormattingMutationResult {
|
|
608
|
+
const selectionFrom = Math.min(range.from, range.to);
|
|
609
|
+
const selectionTo = Math.max(range.from, range.to);
|
|
610
|
+
const selection: RuntimeRenderSnapshot["selection"] = {
|
|
611
|
+
anchor: selectionFrom,
|
|
612
|
+
head: selectionTo,
|
|
613
|
+
isCollapsed: selectionFrom === selectionTo,
|
|
614
|
+
activeRange: {
|
|
615
|
+
kind: "range",
|
|
616
|
+
from: selectionFrom,
|
|
617
|
+
to: selectionTo,
|
|
618
|
+
assoc: { start: -1, end: 1 },
|
|
619
|
+
},
|
|
620
|
+
};
|
|
621
|
+
|
|
622
|
+
if (selectionFrom === selectionTo) {
|
|
623
|
+
return { document, selection, changed: false };
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
const nextDocument = structuredClone(document);
|
|
627
|
+
const root = nextDocument.content as DocumentRootNode;
|
|
628
|
+
const updateMarks = resolveTextMarkRangeUpdater(operation);
|
|
629
|
+
let changed = false;
|
|
630
|
+
let cursor = 0;
|
|
631
|
+
|
|
632
|
+
for (let blockIndex = 0; blockIndex < root.children.length; blockIndex += 1) {
|
|
633
|
+
const block = root.children[blockIndex];
|
|
634
|
+
const blockFrom = cursor;
|
|
635
|
+
const blockLength =
|
|
636
|
+
block?.type === "paragraph"
|
|
637
|
+
? block.children.reduce(
|
|
638
|
+
(total, child) => total + inlineNodeLength(child as InlineNode),
|
|
639
|
+
0,
|
|
640
|
+
)
|
|
641
|
+
: 1;
|
|
642
|
+
const blockTo = blockFrom + blockLength;
|
|
643
|
+
|
|
644
|
+
if (
|
|
645
|
+
block?.type === "paragraph" &&
|
|
646
|
+
rangesOverlap(selectionFrom, selectionTo, blockFrom, blockTo)
|
|
647
|
+
) {
|
|
648
|
+
const transformed = transformInlineNodes(
|
|
649
|
+
block.children,
|
|
650
|
+
blockFrom,
|
|
651
|
+
selectionFrom,
|
|
652
|
+
selectionTo,
|
|
653
|
+
updateMarks,
|
|
654
|
+
);
|
|
655
|
+
if (transformed.changed) {
|
|
656
|
+
block.children = transformed.nodes;
|
|
657
|
+
changed = true;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
cursor = blockTo;
|
|
662
|
+
if (blockIndex < root.children.length - 1) {
|
|
663
|
+
cursor += 1;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
return {
|
|
668
|
+
document: changed ? nextDocument : document,
|
|
669
|
+
selection,
|
|
670
|
+
changed,
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function resolveTextMarkRangeUpdater(
|
|
675
|
+
operation: TextMarkRangeOperation,
|
|
676
|
+
): (marks?: TextMark[]) => TextMark[] | undefined {
|
|
677
|
+
if (operation.type === "set-mark") {
|
|
678
|
+
return (marks) => {
|
|
679
|
+
const nextMarks = cloneMarks(marks).filter(
|
|
680
|
+
(candidate) => candidate.type !== operation.mark.type,
|
|
681
|
+
);
|
|
682
|
+
nextMarks.push({ ...operation.mark } as TextMark);
|
|
683
|
+
return nextMarks.length > 0 ? nextMarks : undefined;
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
return (marks) => {
|
|
688
|
+
const nextMarks = cloneMarks(marks).filter((candidate) => {
|
|
689
|
+
if (operation.mark === "visualHighlight") {
|
|
690
|
+
return candidate.type !== "highlight" && candidate.type !== "backgroundColor";
|
|
691
|
+
}
|
|
692
|
+
return candidate.type !== operation.mark;
|
|
693
|
+
});
|
|
694
|
+
return nextMarks.length > 0 ? nextMarks : undefined;
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
|
|
591
698
|
function resolveMarkUpdater(
|
|
592
699
|
snapshot: RuntimeRenderSnapshot,
|
|
593
700
|
operation: Exclude<
|
|
@@ -829,7 +829,7 @@ function createInsertionUnits(
|
|
|
829
829
|
* - If the range is collapsed, use the marks of the text unit immediately left of
|
|
830
830
|
* the caret (Word-matching behavior for empty-range inserts).
|
|
831
831
|
* - Otherwise walk text units in `[from, to)`. If every text unit shares the same
|
|
832
|
-
* marks (by type), use them. Mixed → fall back to paragraph-default (`undefined`).
|
|
832
|
+
* marks (by type and value), use them. Mixed → fall back to paragraph-default (`undefined`).
|
|
833
833
|
*/
|
|
834
834
|
function resolveMarksForInsertion(
|
|
835
835
|
intent: TextTransactionIntent,
|
|
@@ -887,9 +887,16 @@ function marksAreEqual(left: TextMark[] | undefined, right: TextMark[] | undefin
|
|
|
887
887
|
if (!left && !right) return true;
|
|
888
888
|
if (!left || !right) return false;
|
|
889
889
|
if (left.length !== right.length) return false;
|
|
890
|
-
const sortedLeft = [...left].map(
|
|
891
|
-
const sortedRight = [...right].map(
|
|
892
|
-
return sortedLeft.every((
|
|
890
|
+
const sortedLeft = [...left].map(stableMarkKey).sort();
|
|
891
|
+
const sortedRight = [...right].map(stableMarkKey).sort();
|
|
892
|
+
return sortedLeft.every((key, index) => key === sortedRight[index]);
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
function stableMarkKey(mark: TextMark): string {
|
|
896
|
+
const entries = Object.entries(mark).sort(([left], [right]) =>
|
|
897
|
+
left.localeCompare(right),
|
|
898
|
+
);
|
|
899
|
+
return JSON.stringify(entries);
|
|
893
900
|
}
|
|
894
901
|
|
|
895
902
|
function resolveParagraphPropertiesAtPosition(
|
|
@@ -191,6 +191,7 @@ import {
|
|
|
191
191
|
} from "../core/commands/add-scope.ts";
|
|
192
192
|
import {
|
|
193
193
|
applyFormattingOperationToDocument,
|
|
194
|
+
applyTextMarkOperationToDocumentRange,
|
|
194
195
|
type FormattingOperation,
|
|
195
196
|
} from "../core/commands/formatting-commands.ts";
|
|
196
197
|
import { resolveActiveParagraphIndex } from "../core/commands/paragraph-layout-commands.ts";
|
|
@@ -450,6 +451,12 @@ export interface DocumentRuntime {
|
|
|
450
451
|
* UI paths.
|
|
451
452
|
*/
|
|
452
453
|
applyScopeReplacement(plan: RuntimeOperationPlan): void;
|
|
454
|
+
/**
|
|
455
|
+
* Layer-08 scoped formatting dispatch. Returns true when at least one
|
|
456
|
+
* document.replace transaction was committed, which lets the compiler
|
|
457
|
+
* suppress no-op audits.
|
|
458
|
+
*/
|
|
459
|
+
applyScopeFormatting(plan: RuntimeOperationPlan): boolean;
|
|
453
460
|
dispatch(command: EditorCommand): void;
|
|
454
461
|
/**
|
|
455
462
|
* Apply a command received from a remote collaborator. The command
|
|
@@ -3384,6 +3391,7 @@ export function createDocumentRuntime(
|
|
|
3384
3391
|
{
|
|
3385
3392
|
type: "text.insert",
|
|
3386
3393
|
text: step.text,
|
|
3394
|
+
...(step.formatting ? { formatting: step.formatting } : {}),
|
|
3387
3395
|
origin: createOrigin("api", timestamp),
|
|
3388
3396
|
},
|
|
3389
3397
|
{
|
|
@@ -3458,6 +3466,7 @@ export function createDocumentRuntime(
|
|
|
3458
3466
|
{
|
|
3459
3467
|
type: "text.insert",
|
|
3460
3468
|
text: step.text,
|
|
3469
|
+
...(step.formatting ? { formatting: step.formatting } : {}),
|
|
3461
3470
|
origin: createOrigin("api", timestamp),
|
|
3462
3471
|
},
|
|
3463
3472
|
{
|
|
@@ -3506,6 +3515,48 @@ export function createDocumentRuntime(
|
|
|
3506
3515
|
}
|
|
3507
3516
|
}
|
|
3508
3517
|
},
|
|
3518
|
+
applyScopeFormatting(plan: RuntimeOperationPlan) {
|
|
3519
|
+
let changed = false;
|
|
3520
|
+
for (const step of plan.steps) {
|
|
3521
|
+
if (
|
|
3522
|
+
step.kind !== "formatting-apply" ||
|
|
3523
|
+
!step.range ||
|
|
3524
|
+
!step.formattingAction
|
|
3525
|
+
) {
|
|
3526
|
+
continue;
|
|
3527
|
+
}
|
|
3528
|
+
|
|
3529
|
+
const operation =
|
|
3530
|
+
step.formattingAction.kind === "clear-mark"
|
|
3531
|
+
? {
|
|
3532
|
+
type: "clear-mark" as const,
|
|
3533
|
+
mark: step.formattingAction.mark,
|
|
3534
|
+
}
|
|
3535
|
+
: {
|
|
3536
|
+
type: "set-mark" as const,
|
|
3537
|
+
mark: step.formattingAction.mark,
|
|
3538
|
+
};
|
|
3539
|
+
const result = applyTextMarkOperationToDocumentRange(
|
|
3540
|
+
state.document,
|
|
3541
|
+
step.range,
|
|
3542
|
+
operation,
|
|
3543
|
+
);
|
|
3544
|
+
if (!result.changed) {
|
|
3545
|
+
continue;
|
|
3546
|
+
}
|
|
3547
|
+
|
|
3548
|
+
const timestamp = clock();
|
|
3549
|
+
this.dispatch({
|
|
3550
|
+
type: "document.replace",
|
|
3551
|
+
document: { ...result.document, updatedAt: timestamp },
|
|
3552
|
+
mapping: createEmptyMapping(),
|
|
3553
|
+
selection: toInternalSelectionSnapshot(result.selection),
|
|
3554
|
+
origin: createOrigin("api", timestamp),
|
|
3555
|
+
});
|
|
3556
|
+
changed = true;
|
|
3557
|
+
}
|
|
3558
|
+
return changed;
|
|
3559
|
+
},
|
|
3509
3560
|
insertFragment(fragment, target) {
|
|
3510
3561
|
// I2 Tier B Slice 1 — dispatch `fragment.insert` against the active story. The
|
|
3511
3562
|
// runtime command handler routes into `applyFragmentInsert` (structure-ops).
|
|
@@ -62,8 +62,8 @@ import {
|
|
|
62
62
|
} from "./preservation-boundary.ts";
|
|
63
63
|
import { resolveScopeRange } from "./scope-range.ts";
|
|
64
64
|
import type {
|
|
65
|
-
ReplacementOperationKind,
|
|
66
65
|
ReplacementScope,
|
|
66
|
+
ScopeActionOperationKind,
|
|
67
67
|
SemanticScope,
|
|
68
68
|
ValidationApproval,
|
|
69
69
|
ValidationIssue,
|
|
@@ -85,7 +85,7 @@ export interface ScopeValidationRuntime {
|
|
|
85
85
|
|
|
86
86
|
export interface ComposeScopeValidationInputs {
|
|
87
87
|
readonly scope: SemanticScope;
|
|
88
|
-
readonly operation:
|
|
88
|
+
readonly operation: ScopeActionOperationKind;
|
|
89
89
|
readonly proposedContent: ReplacementScope["proposedContent"];
|
|
90
90
|
readonly runtime: ScopeValidationRuntime;
|
|
91
91
|
/**
|
|
@@ -119,6 +119,11 @@ export interface ComposeScopeValidationInputs {
|
|
|
119
119
|
* here; the compile step consumes them to drive per-step behavior.
|
|
120
120
|
*/
|
|
121
121
|
readonly preservePolicy?: ReplacementScope["preserve"];
|
|
122
|
+
/**
|
|
123
|
+
* Formatting-only actions do not destroy opaque fragments or nested scope
|
|
124
|
+
* markers, so they bypass the replacement preservation-boundary check.
|
|
125
|
+
*/
|
|
126
|
+
readonly skipPreservation?: boolean;
|
|
122
127
|
}
|
|
123
128
|
|
|
124
129
|
/**
|
|
@@ -127,9 +132,12 @@ export interface ComposeScopeValidationInputs {
|
|
|
127
132
|
* entry in the 37-op policy matrix.
|
|
128
133
|
*/
|
|
129
134
|
function inferActionId(
|
|
130
|
-
operation:
|
|
135
|
+
operation: ScopeActionOperationKind,
|
|
131
136
|
content: ReplacementScope["proposedContent"],
|
|
132
137
|
): AIAction {
|
|
138
|
+
if (operation === "formatting") {
|
|
139
|
+
return "fix_formatting";
|
|
140
|
+
}
|
|
133
141
|
switch (operation) {
|
|
134
142
|
case "replace":
|
|
135
143
|
return content.kind === "text" ? "rewrite_paragraph" : "generate_text";
|
|
@@ -287,6 +295,7 @@ function collectPreservationVerdict(
|
|
|
287
295
|
warnings: ValidationIssue[],
|
|
288
296
|
): void {
|
|
289
297
|
const { document, scope, positionMap } = inputs;
|
|
298
|
+
if (inputs.skipPreservation === true) return;
|
|
290
299
|
if (!document) return;
|
|
291
300
|
const pm = positionMap ?? buildScopePositionMap(document);
|
|
292
301
|
const range = inputs.enumeratedScope
|
|
@@ -10,9 +10,9 @@
|
|
|
10
10
|
|
|
11
11
|
import type { TelemetryBus } from "../debug/telemetry-bus.ts";
|
|
12
12
|
import type {
|
|
13
|
-
ReplacementScope,
|
|
14
13
|
RuntimeOperationPlan,
|
|
15
14
|
ScopeActionAudit,
|
|
15
|
+
ScopeActionProposal,
|
|
16
16
|
SemanticScope,
|
|
17
17
|
ValidationResult,
|
|
18
18
|
} from "./semantic-scope-types.ts";
|
|
@@ -24,7 +24,7 @@ export interface EmitScopeActionAuditInputs {
|
|
|
24
24
|
readonly documentHashBefore: string;
|
|
25
25
|
readonly documentHashAfter?: string;
|
|
26
26
|
readonly targetScopeSnapshot: SemanticScope;
|
|
27
|
-
readonly proposed:
|
|
27
|
+
readonly proposed: ScopeActionProposal;
|
|
28
28
|
readonly plan: RuntimeOperationPlan;
|
|
29
29
|
readonly validation: ValidationResult;
|
|
30
30
|
readonly emittedAtUtc: string;
|