nudge-ui 0.2.3 → 0.2.4
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/dist/client.mjs +17 -9
- package/dist/inspector/agent/verification.d.ts +4 -6
- package/dist/inspector/agent/verification.js +60 -16
- package/dist/inspector/canvas/CanvasCard.js +2 -0
- package/dist/inspector/canvas/CanvasElementOverlay.js +17 -3
- package/dist/inspector/canvas/CanvasWorkspace.js +46 -66
- package/dist/inspector/canvas/frameScrollbarStyles.d.ts +3 -0
- package/dist/inspector/canvas/frameScrollbarStyles.js +21 -0
- package/dist/inspector/changes/changesLog.d.ts +3 -1
- package/dist/inspector/changes/changesLog.js +8 -0
- package/dist/inspector/changes/codecs.d.ts +1 -0
- package/dist/inspector/changes/codecs.js +10 -1
- package/dist/inspector/changes/draftChanges.d.ts +2 -0
- package/dist/inspector/changes/draftChanges.js +4 -0
- package/dist/inspector/changes/editModel.d.ts +10 -0
- package/dist/inspector/changes/editModel.js +9 -2
- package/dist/inspector/changes/types.d.ts +7 -0
- package/dist/inspector/comments/element.js +10 -3
- package/dist/inspector/componentSemantics/adapterRegistry.d.ts +6 -1
- package/dist/inspector/componentSemantics/adapterRegistry.js +23 -1
- package/dist/inspector/componentSemantics/reactRuntime.d.ts +2 -1
- package/dist/inspector/componentSemantics/reactRuntime.js +38 -0
- package/dist/inspector/componentSemantics/runtimeBridge.d.ts +4 -2
- package/dist/inspector/componentSemantics/runtimeBridge.js +17 -0
- package/dist/inspector/componentSemantics/types.d.ts +11 -0
- package/dist/inspector/iterations/capture.js +2 -1
- package/dist/inspector/projection/renderedInstance.d.ts +15 -2
- package/dist/inspector/projection/renderedInstance.js +53 -6
- package/dist/inspector/projection/structuralProjection.js +2 -2
- package/dist/inspector/projection/textProjection.js +56 -11
- package/dist/inspector/prompt/clipboardHandoff.d.ts +5 -0
- package/dist/inspector/prompt/clipboardHandoff.js +5 -0
- package/dist/inspector/prompt/generatePrompt.js +5 -1
- package/dist/inspector/selection/sourceSite.d.ts +12 -0
- package/dist/inspector/selection/sourceSite.js +28 -0
- package/dist/inspector/shell/CopyPromptButton.js +49 -7
- package/dist/inspector/styleEditors/BackgroundEditor.js +1 -1
- package/dist/inspector/tokens/TokenField.js +6 -4
- package/dist/inspector/tokens/editActions.js +6 -0
- package/dist/inspector/ui/styles.js +1 -1
- package/dist/inspector/workspace/sentItems.d.ts +19 -0
- package/dist/inspector/workspace/sentItems.js +51 -0
- package/package.json +2 -2
|
@@ -1,17 +1,24 @@
|
|
|
1
1
|
import { resolveRenderedInstance } from "../projection/renderedInstance.js";
|
|
2
|
-
import { sourceSiteSelector } from "../selection/sourceSite.js";
|
|
2
|
+
import { relocateSourceSite, sourceSiteSelector } from "../selection/sourceSite.js";
|
|
3
3
|
/** Retains a changed target only when its source site still identifies one output. */
|
|
4
4
|
export function resolveCommentElement(doc, target) {
|
|
5
5
|
const exact = resolveRenderedInstance(doc, target);
|
|
6
6
|
if (exact.status === "resolved")
|
|
7
7
|
return exact.element;
|
|
8
|
-
const
|
|
8
|
+
const { props, ariaLabel } = target.locator;
|
|
9
|
+
const sourceSite = relocateSourceSite(doc, target.sourceSite, (element) => element.getAttribute("data-cprops") === props && element.getAttribute("aria-label") === (ariaLabel ?? null));
|
|
10
|
+
if (sourceSite) {
|
|
11
|
+
const moved = resolveRenderedInstance(doc, { ...target, sourceSite });
|
|
12
|
+
if (moved.status === "resolved")
|
|
13
|
+
return moved.element;
|
|
14
|
+
}
|
|
15
|
+
const site = sourceSite ?? target.sourceSite;
|
|
16
|
+
const selector = sourceSiteSelector(site.cid, site.src);
|
|
9
17
|
if (!selector)
|
|
10
18
|
return null;
|
|
11
19
|
const candidates = Array.from(doc.querySelectorAll(selector));
|
|
12
20
|
if (candidates.length === 1)
|
|
13
21
|
return candidates[0];
|
|
14
|
-
const { props, ariaLabel } = target.locator;
|
|
15
22
|
if (!props && !ariaLabel)
|
|
16
23
|
return null;
|
|
17
24
|
const matching = candidates.filter((element) => element.getAttribute("data-cprops") === props
|
|
@@ -1,7 +1,12 @@
|
|
|
1
|
-
import type { ComponentOverride, ComponentRuntimeAdapter, EditableComponentTarget, RuntimeComponentTarget } from "./types.js";
|
|
1
|
+
import type { ComponentOverride, ComponentRootInvocation, ComponentRuntimeAdapter, EditableComponentTarget, RuntimeComponentTarget } from "./types.js";
|
|
2
2
|
/** Register a framework adapter without coupling semantic resolution to React. */
|
|
3
3
|
export declare function registerComponentRuntimeAdapter(adapter: ComponentRuntimeAdapter): () => void;
|
|
4
4
|
export declare function inspectComponentTargets(element: HTMLElement): RuntimeComponentTarget[];
|
|
5
|
+
/**
|
|
6
|
+
* Returns the invocations, nearest first, whose entire rendered output is this
|
|
7
|
+
* element. Null means no enabled runtime could read the element's ancestry.
|
|
8
|
+
*/
|
|
9
|
+
export declare function inspectRootInvocations(element: HTMLElement): ComponentRootInvocation[] | null;
|
|
5
10
|
/**
|
|
6
11
|
* Resolve multiplicity through the owning runtime Adapter. The inspector does
|
|
7
12
|
* not infer it from DOM order or retain a React fiber as durable identity.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { getNudgeUiRuntimeConfig } from "../runtime/runtimeConfig.js";
|
|
2
|
-
import { copyComponentOverrides, copyRuntimeTarget, getHostRuntimeAdapters, registerHostRuntimeAdapter, replaceHostRuntimeAdapterOverrides, } from "./runtimeBridge.js";
|
|
2
|
+
import { copyComponentOverrides, copyRootInvocations, copyRuntimeTarget, getHostRuntimeAdapters, registerHostRuntimeAdapter, replaceHostRuntimeAdapterOverrides, } from "./runtimeBridge.js";
|
|
3
3
|
const diagnosedAdapters = new WeakSet();
|
|
4
4
|
function enabledRuntimeAdapters(ownerGlobal) {
|
|
5
5
|
if (!getNudgeUiRuntimeConfig().capabilities.componentSemantics)
|
|
@@ -42,6 +42,28 @@ export function inspectComponentTargets(element) {
|
|
|
42
42
|
}
|
|
43
43
|
return targets;
|
|
44
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* Returns the invocations, nearest first, whose entire rendered output is this
|
|
47
|
+
* element. Null means no enabled runtime could read the element's ancestry.
|
|
48
|
+
*/
|
|
49
|
+
export function inspectRootInvocations(element) {
|
|
50
|
+
let invocations = null;
|
|
51
|
+
const ownerGlobal = element.ownerDocument.defaultView ?? globalThis;
|
|
52
|
+
for (const adapter of enabledRuntimeAdapters(ownerGlobal)) {
|
|
53
|
+
if (!adapter.rootInvocations)
|
|
54
|
+
continue;
|
|
55
|
+
try {
|
|
56
|
+
const inspected = adapter.rootInvocations(element);
|
|
57
|
+
if (inspected === null || inspected === undefined)
|
|
58
|
+
continue;
|
|
59
|
+
invocations = [...(invocations ?? []), ...copyRootInvocations(inspected)];
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
diagnoseAdapter(adapter, error instanceof Error ? error.message : String(error));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return invocations;
|
|
66
|
+
}
|
|
45
67
|
function diagnoseAdapter(adapter, detail) {
|
|
46
68
|
if (diagnosedAdapters.has(adapter))
|
|
47
69
|
return;
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { type ReactElement } from "react";
|
|
2
|
-
import type { ComponentInvocationMeta, ComponentOverride, ComponentRuntimeAdapter, RuntimeComponentTarget } from "./types.js";
|
|
2
|
+
import type { ComponentInvocationMeta, ComponentOverride, ComponentRootInvocation, ComponentRuntimeAdapter, RuntimeComponentTarget } from "./types.js";
|
|
3
3
|
/** Returns null when this runtime has no mounted boundary for the callsite. */
|
|
4
4
|
export declare function getReactCallsiteMultiplicity(callsiteId: string): number | null;
|
|
5
5
|
/** Test/runtime teardown hook; canonical changes never depend on this state. */
|
|
6
6
|
export declare function resetReactComponentRuntime(): void;
|
|
7
7
|
export declare function instrumentReactComponent(element: ReactElement<Record<string, unknown>>, meta: ComponentInvocationMeta): ReactElement;
|
|
8
8
|
export declare function inspectReactComponentTargets(element: HTMLElement): RuntimeComponentTarget[];
|
|
9
|
+
export declare function inspectReactRootInvocations(element: HTMLElement): ComponentRootInvocation[] | null;
|
|
9
10
|
export declare function replaceReactComponentOverrides(overrides: ComponentOverride[]): void;
|
|
10
11
|
export declare const reactComponentRuntimeAdapter: ComponentRuntimeAdapter;
|
|
@@ -4,6 +4,9 @@ import { getNudgeUiRuntimeConfig } from "../runtime/runtimeConfig.js";
|
|
|
4
4
|
const BOUNDARY_MARKER = Symbol.for("nudge-ui.react-component-boundary");
|
|
5
5
|
const EMPTY_OVERRIDE = Object.freeze({});
|
|
6
6
|
const REACT_MAJOR_VERSION = Number.parseInt(reactVersion, 10);
|
|
7
|
+
/** React work tags for host output that has no string element type. */
|
|
8
|
+
const HOST_PORTAL_TAG = 4;
|
|
9
|
+
const HOST_TEXT_TAG = 6;
|
|
7
10
|
let overridesByCallsite = new Map();
|
|
8
11
|
const listeners = new Set();
|
|
9
12
|
/**
|
|
@@ -161,6 +164,40 @@ export function inspectReactComponentTargets(element) {
|
|
|
161
164
|
}
|
|
162
165
|
return targets;
|
|
163
166
|
}
|
|
167
|
+
function isHostOutput(fiber) {
|
|
168
|
+
return typeof fiber.type === "string" || fiber.tag === HOST_TEXT_TAG || fiber.tag === HOST_PORTAL_TAG;
|
|
169
|
+
}
|
|
170
|
+
/** Counts top-level host outputs below a fiber, stopping once there are two. */
|
|
171
|
+
function hostOutputCount(fiber) {
|
|
172
|
+
let count = 0;
|
|
173
|
+
const visit = (parent) => {
|
|
174
|
+
for (let child = parent.child ?? null; child && count < 2; child = child.sibling ?? null) {
|
|
175
|
+
if (isHostOutput(child))
|
|
176
|
+
count += 1;
|
|
177
|
+
else
|
|
178
|
+
visit(child);
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
visit(fiber);
|
|
182
|
+
return count;
|
|
183
|
+
}
|
|
184
|
+
export function inspectReactRootInvocations(element) {
|
|
185
|
+
if (!getNudgeUiRuntimeConfig().capabilities.componentSemantics)
|
|
186
|
+
return null;
|
|
187
|
+
const host = findFiber(element);
|
|
188
|
+
if (!host)
|
|
189
|
+
return null;
|
|
190
|
+
const invocations = [];
|
|
191
|
+
for (let fiber = host.return ?? null; fiber && !isHostOutput(fiber); fiber = fiber.return ?? null) {
|
|
192
|
+
if (!isBoundaryType(fiber.type) || !fiber.memoizedProps?.meta)
|
|
193
|
+
continue;
|
|
194
|
+
if (hostOutputCount(fiber) !== 1)
|
|
195
|
+
break;
|
|
196
|
+
const { callsiteId, componentName } = fiber.memoizedProps.meta;
|
|
197
|
+
invocations.push({ callsiteId, componentName });
|
|
198
|
+
}
|
|
199
|
+
return invocations;
|
|
200
|
+
}
|
|
164
201
|
export function replaceReactComponentOverrides(overrides) {
|
|
165
202
|
if (!getNudgeUiRuntimeConfig().capabilities.componentSemantics)
|
|
166
203
|
return;
|
|
@@ -182,5 +219,6 @@ export const reactComponentRuntimeAdapter = {
|
|
|
182
219
|
inspect: inspectReactComponentTargets,
|
|
183
220
|
replaceOverrides: replaceReactComponentOverrides,
|
|
184
221
|
getCallsiteMultiplicity: getReactCallsiteMultiplicity,
|
|
222
|
+
rootInvocations: inspectReactRootInvocations,
|
|
185
223
|
};
|
|
186
224
|
registerHostRuntimeAdapter(reactComponentRuntimeAdapter);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { ComponentOverride, ComponentRuntimeAdapter, RuntimeComponentTarget } from "./types.js";
|
|
2
|
-
export type { ComponentFramework, ComponentInvocationMeta, ComponentOverride, ComponentRuntimeAdapter, RuntimeComponentTarget, } from "./types.js";
|
|
1
|
+
import type { ComponentOverride, ComponentRootInvocation, ComponentRuntimeAdapter, RuntimeComponentTarget } from "./types.js";
|
|
2
|
+
export type { ComponentFramework, ComponentInvocationMeta, ComponentOverride, ComponentRootInvocation, ComponentRuntimeAdapter, RuntimeComponentTarget, } from "./types.js";
|
|
3
3
|
/** Version of the page-global host runtime Adapter Interface. */
|
|
4
4
|
export declare const NUDGE_UI_HOST_RUNTIME_VERSION = 1;
|
|
5
5
|
/**
|
|
@@ -21,5 +21,7 @@ export declare function replaceHostRuntimeAdapterOverrides(overrides: readonly C
|
|
|
21
21
|
* the host/client seam.
|
|
22
22
|
*/
|
|
23
23
|
export declare function copyRuntimeTarget(target: RuntimeComponentTarget): RuntimeComponentTarget;
|
|
24
|
+
/** Copies a host Adapter's root invocations into plain protocol data. */
|
|
25
|
+
export declare function copyRootInvocations(invocations: readonly ComponentRootInvocation[]): ComponentRootInvocation[];
|
|
24
26
|
/** Copies override commands before dispatching them into a host Adapter. */
|
|
25
27
|
export declare function copyComponentOverrides(overrides: readonly ComponentOverride[]): ComponentOverride[];
|
|
@@ -104,6 +104,23 @@ export function copyRuntimeTarget(target) {
|
|
|
104
104
|
...(target.mountedCount === undefined ? {} : { mountedCount: target.mountedCount }),
|
|
105
105
|
};
|
|
106
106
|
}
|
|
107
|
+
/** Copies a host Adapter's root invocations into plain protocol data. */
|
|
108
|
+
export function copyRootInvocations(invocations) {
|
|
109
|
+
if (!Array.isArray(invocations)) {
|
|
110
|
+
throw new TypeError("Nudge UI received invalid root invocations from a host Adapter.");
|
|
111
|
+
}
|
|
112
|
+
return invocations.map((invocation) => {
|
|
113
|
+
if (invocation === null
|
|
114
|
+
|| typeof invocation !== "object"
|
|
115
|
+
|| typeof invocation.callsiteId !== "string"
|
|
116
|
+
|| invocation.callsiteId.length === 0
|
|
117
|
+
|| typeof invocation.componentName !== "string"
|
|
118
|
+
|| invocation.componentName.length === 0) {
|
|
119
|
+
throw new TypeError("Nudge UI received an invalid root invocation from a host Adapter.");
|
|
120
|
+
}
|
|
121
|
+
return { callsiteId: invocation.callsiteId, componentName: invocation.componentName };
|
|
122
|
+
});
|
|
123
|
+
}
|
|
107
124
|
/** Copies override commands before dispatching them into a host Adapter. */
|
|
108
125
|
export function copyComponentOverrides(overrides) {
|
|
109
126
|
return overrides.map((override) => {
|
|
@@ -61,6 +61,11 @@ export interface RuntimeComponentTarget {
|
|
|
61
61
|
/** Adapter-reported mounted invocation count for this callsite. */
|
|
62
62
|
mountedCount?: number;
|
|
63
63
|
}
|
|
64
|
+
/** A component invocation whose entire rendered output is one DOM element. */
|
|
65
|
+
export interface ComponentRootInvocation {
|
|
66
|
+
callsiteId: string;
|
|
67
|
+
componentName: string;
|
|
68
|
+
}
|
|
64
69
|
export interface ComponentPropContract {
|
|
65
70
|
name: string;
|
|
66
71
|
control: "select" | "boolean" | "text";
|
|
@@ -93,4 +98,10 @@ export interface ComponentRuntimeAdapter {
|
|
|
93
98
|
* fail closed instead of broadening a semantic override.
|
|
94
99
|
*/
|
|
95
100
|
getCallsiteMultiplicity?(callsiteId: string): number | null | undefined;
|
|
101
|
+
/**
|
|
102
|
+
* Return, nearest first, the invocations whose entire rendered output is
|
|
103
|
+
* this element. A runtime that cannot read the element's ancestry must
|
|
104
|
+
* return null so structural identity fails closed.
|
|
105
|
+
*/
|
|
106
|
+
rootInvocations?(element: HTMLElement): ComponentRootInvocation[] | null;
|
|
96
107
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { HTML_ARTIFACT_REVISION_ATTRIBUTE } from "../../transport/artifacts.js";
|
|
2
|
+
import { FRAME_SCROLLBAR_STYLE_ID } from "../canvas/frameScrollbarStyles.js";
|
|
2
3
|
/** Captures the visible DOM of a same-origin frame as a script-free HTML iteration. */
|
|
3
4
|
export async function captureHtmlIteration(frame) {
|
|
4
5
|
const source = frame.contentDocument;
|
|
@@ -52,7 +53,7 @@ export async function captureHtmlIteration(frame) {
|
|
|
52
53
|
copy.querySelectorAll("base").forEach((element) => element.remove());
|
|
53
54
|
(copy.querySelector("head") ?? copy).prepend(base);
|
|
54
55
|
copy.querySelectorAll('script, iframe, object, embed, meta[http-equiv="refresh"], meta[http-equiv="Content-Security-Policy"], link[rel=modulepreload], link[rel=preload]').forEach((element) => element.remove());
|
|
55
|
-
copy.querySelectorAll(
|
|
56
|
+
copy.querySelectorAll(`[data-nudge-ui-mount], #nudge-ui-root, #${FRAME_SCROLLBAR_STYLE_ID}`).forEach((element) => element.remove());
|
|
56
57
|
copy.querySelectorAll("a, form").forEach((element) => {
|
|
57
58
|
element.removeAttribute("href");
|
|
58
59
|
element.removeAttribute("action");
|
|
@@ -23,10 +23,23 @@ export declare function captureRenderedInstance(el: HTMLElement): RenderedInstan
|
|
|
23
23
|
/**
|
|
24
24
|
* Resolves exactly one element, or declines to apply. Occurrence documents the
|
|
25
25
|
* captured placement, but structural previews can legitimately change it.
|
|
26
|
-
* Evidence
|
|
27
|
-
* be applied.
|
|
26
|
+
* Evidence, narrowed by a recorded invocation when present, must therefore
|
|
27
|
+
* identify one current candidate before an edit can be applied.
|
|
28
28
|
*/
|
|
29
29
|
export declare function resolveRenderedInstance(doc: Document, ref: RenderedInstanceRef): ResolutionResult;
|
|
30
|
+
/**
|
|
31
|
+
* Resolves a reference whose source position may have moved since capture.
|
|
32
|
+
* A relocated site still renders matching authored evidence, so an evidence
|
|
33
|
+
* miss there is reported as ambiguous rather than as proof of absence.
|
|
34
|
+
*/
|
|
35
|
+
export declare function resolveMovedRenderedInstance(doc: Document, ref: RenderedInstanceRef): ResolutionResult;
|
|
36
|
+
/**
|
|
37
|
+
* Captures identity for a structural target. When evidence matches several
|
|
38
|
+
* identical outputs, the nearest component invocation whose entire output is
|
|
39
|
+
* this element can still identify it. Otherwise the evidence-only reference
|
|
40
|
+
* is returned and resolves as ambiguous.
|
|
41
|
+
*/
|
|
42
|
+
export declare function captureStructuralTarget(el: HTMLElement): RenderedInstanceRef | null;
|
|
30
43
|
/** Checks stable source/evidence fields without treating a post-move ordinal as identity. */
|
|
31
44
|
export declare function matchesRenderedInstanceEvidence(el: HTMLElement, ref: RenderedInstanceRef): boolean;
|
|
32
45
|
/** Builds an instance override without changing the element's transient edit scope. */
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { getActiveDraftChanges, getDraftChanges } from "../changes/draftChanges.js";
|
|
2
|
+
import { inspectRootInvocations } from "../componentSemantics/adapterRegistry.js";
|
|
2
3
|
import { escapeAttrValue } from "./cssEscapes.js";
|
|
3
4
|
import { isNudgeUiDev } from "../runtime/devFlag.js";
|
|
4
|
-
import { sourceSiteSelector } from "../selection/sourceSite.js";
|
|
5
|
+
import { relocateSourceSite, sourceSiteSelector } from "../selection/sourceSite.js";
|
|
5
6
|
import { isRenderedInstanceOverride } from "../changes/editModel.js";
|
|
6
7
|
export { isRenderedInstanceOverride, isRenderedInstanceRef } from "../changes/editModel.js";
|
|
7
8
|
const PROJECTION_ATTR = "data-projection-instance";
|
|
@@ -50,19 +51,31 @@ export function captureRenderedInstance(el) {
|
|
|
50
51
|
/**
|
|
51
52
|
* Resolves exactly one element, or declines to apply. Occurrence documents the
|
|
52
53
|
* captured placement, but structural previews can legitimately change it.
|
|
53
|
-
* Evidence
|
|
54
|
-
* be applied.
|
|
54
|
+
* Evidence, narrowed by a recorded invocation when present, must therefore
|
|
55
|
+
* identify one current candidate before an edit can be applied.
|
|
55
56
|
*/
|
|
56
57
|
export function resolveRenderedInstance(doc, ref) {
|
|
57
58
|
const sourceCandidates = candidates(doc, ref.sourceSite);
|
|
58
|
-
const { props, text, ariaLabel = null } = ref.locator;
|
|
59
|
+
const { props, text, ariaLabel = null, invocation } = ref.locator;
|
|
59
60
|
// An ordinal alone is not an identity for a repeated source site.
|
|
60
|
-
if (sourceCandidates.length > 1 && props === null && text === null && ariaLabel === null) {
|
|
61
|
+
if (!invocation && sourceCandidates.length > 1 && props === null && text === null && ariaLabel === null) {
|
|
61
62
|
return { status: "ambiguous" };
|
|
62
63
|
}
|
|
63
|
-
|
|
64
|
+
let matchingEvidence = sourceCandidates.filter((element) => element.getAttribute("data-cprops") === props
|
|
64
65
|
&& normalizedText(element) === text
|
|
65
66
|
&& element.getAttribute("aria-label") === ariaLabel);
|
|
67
|
+
if (invocation) {
|
|
68
|
+
const scoped = [];
|
|
69
|
+
for (const element of matchingEvidence) {
|
|
70
|
+
const invocations = inspectRootInvocations(element);
|
|
71
|
+
// Without readable ancestry, absence of the invocation proves nothing.
|
|
72
|
+
if (invocations === null)
|
|
73
|
+
return { status: "ambiguous" };
|
|
74
|
+
if (invocations.some((candidate) => candidate.callsiteId === invocation.callsiteId))
|
|
75
|
+
scoped.push(element);
|
|
76
|
+
}
|
|
77
|
+
matchingEvidence = scoped;
|
|
78
|
+
}
|
|
66
79
|
if (matchingEvidence.length === 0)
|
|
67
80
|
return { status: "missing" };
|
|
68
81
|
// Evidence alone is never enough to choose between two identical rendered
|
|
@@ -72,6 +85,40 @@ export function resolveRenderedInstance(doc, ref) {
|
|
|
72
85
|
return { status: "ambiguous" };
|
|
73
86
|
return { status: "resolved", element: matchingEvidence[0] };
|
|
74
87
|
}
|
|
88
|
+
/**
|
|
89
|
+
* Resolves a reference whose source position may have moved since capture.
|
|
90
|
+
* A relocated site still renders matching authored evidence, so an evidence
|
|
91
|
+
* miss there is reported as ambiguous rather than as proof of absence.
|
|
92
|
+
*/
|
|
93
|
+
export function resolveMovedRenderedInstance(doc, ref) {
|
|
94
|
+
const exact = resolveRenderedInstance(doc, ref);
|
|
95
|
+
if (exact.status !== "missing")
|
|
96
|
+
return exact;
|
|
97
|
+
const { props, ariaLabel = null } = ref.locator;
|
|
98
|
+
const sourceSite = relocateSourceSite(doc, ref.sourceSite, (element) => element.getAttribute("data-cprops") === props && element.getAttribute("aria-label") === ariaLabel);
|
|
99
|
+
if (!sourceSite)
|
|
100
|
+
return exact;
|
|
101
|
+
const moved = resolveRenderedInstance(doc, { ...ref, sourceSite });
|
|
102
|
+
return moved.status === "missing" ? { status: "ambiguous" } : moved;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Captures identity for a structural target. When evidence matches several
|
|
106
|
+
* identical outputs, the nearest component invocation whose entire output is
|
|
107
|
+
* this element can still identify it. Otherwise the evidence-only reference
|
|
108
|
+
* is returned and resolves as ambiguous.
|
|
109
|
+
*/
|
|
110
|
+
export function captureStructuralTarget(el) {
|
|
111
|
+
const ref = captureRenderedInstance(el);
|
|
112
|
+
if (!ref || resolveRenderedInstance(el.ownerDocument, ref).status !== "ambiguous")
|
|
113
|
+
return ref;
|
|
114
|
+
for (const invocation of inspectRootInvocations(el) ?? []) {
|
|
115
|
+
const scoped = { ...ref, locator: { ...ref.locator, invocation } };
|
|
116
|
+
const resolved = resolveRenderedInstance(el.ownerDocument, scoped);
|
|
117
|
+
if (resolved.status === "resolved" && resolved.element === el)
|
|
118
|
+
return scoped;
|
|
119
|
+
}
|
|
120
|
+
return ref;
|
|
121
|
+
}
|
|
75
122
|
/** Checks stable source/evidence fields without treating a post-move ordinal as identity. */
|
|
76
123
|
export function matchesRenderedInstanceEvidence(el, ref) {
|
|
77
124
|
const { props, text, ariaLabel = null } = ref.locator;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { getActiveDraftChanges, getDraftChanges } from "../changes/draftChanges.js";
|
|
2
|
-
import { captureRenderedInstance, matchesRenderedInstanceEvidence, resolveRenderedInstance, } from "./renderedInstance.js";
|
|
2
|
+
import { captureRenderedInstance, captureStructuralTarget, matchesRenderedInstanceEvidence, resolveRenderedInstance, } from "./renderedInstance.js";
|
|
3
3
|
import { isStructuralProjectionReport } from "./structuralProjectionBoundary.js";
|
|
4
4
|
import { resetDraftChanges, draftChangeStore, } from "../changes/draftChanges.js";
|
|
5
5
|
export { isStructuralChange, isStructuralDelete, isStructuralMove, isStructuralProjectionReport, } from "./structuralProjectionBoundary.js";
|
|
@@ -182,7 +182,7 @@ function projectStructuralChanges(snapshot) {
|
|
|
182
182
|
}
|
|
183
183
|
/** Captures a delete intent once, in the controller, at the user gesture. */
|
|
184
184
|
export function createStructuralDelete(element, id = structuralId()) {
|
|
185
|
-
const target =
|
|
185
|
+
const target = captureStructuralTarget(element);
|
|
186
186
|
if (!target)
|
|
187
187
|
return null;
|
|
188
188
|
const route = documentRoute(element.ownerDocument);
|
|
@@ -49,6 +49,39 @@ function isEmptyTextAffordance(node) {
|
|
|
49
49
|
function hasInlineTextEditor(element) {
|
|
50
50
|
return element.querySelector('[data-inline-editor="true"]') !== null;
|
|
51
51
|
}
|
|
52
|
+
/**
|
|
53
|
+
* What the application last rendered into each projected Text node. A source
|
|
54
|
+
* update can render the projected value itself, so restoring `before` would
|
|
55
|
+
* overwrite real application output that the framework will not repaint.
|
|
56
|
+
*/
|
|
57
|
+
let applicationText = new WeakMap();
|
|
58
|
+
function isInlineEditorNode(node) {
|
|
59
|
+
return node.nodeType === 1 && (node.matches('[data-inline-editor="true"]')
|
|
60
|
+
|| hasInlineTextEditor(node));
|
|
61
|
+
}
|
|
62
|
+
function recordApplicationWrites(records) {
|
|
63
|
+
// The inline editor swaps its wrapper back for the original node, writing
|
|
64
|
+
// the draft, in one task. That write belongs to the editor.
|
|
65
|
+
if (records.some((record) => record.type === "childList"
|
|
66
|
+
&& [...record.addedNodes, ...record.removedNodes].some(isInlineEditorNode)))
|
|
67
|
+
return;
|
|
68
|
+
for (const record of records) {
|
|
69
|
+
if (record.type !== "characterData" || !isTextNode(record.target))
|
|
70
|
+
continue;
|
|
71
|
+
if (applicationText.has(record.target))
|
|
72
|
+
applicationText.set(record.target, record.target.nodeValue ?? "");
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/** Writes projection-owned text without mistaking it for an application render. */
|
|
76
|
+
function writeProjectedText(state, node, value) {
|
|
77
|
+
const pending = state.observer?.takeRecords() ?? [];
|
|
78
|
+
if (pending.length > 0) {
|
|
79
|
+
recordApplicationWrites(pending);
|
|
80
|
+
scheduleValidation(state.doc, state);
|
|
81
|
+
}
|
|
82
|
+
node.nodeValue = value;
|
|
83
|
+
state.observer?.takeRecords();
|
|
84
|
+
}
|
|
52
85
|
function readProjectionMarkers(element) {
|
|
53
86
|
const raw = element.getAttribute(TEXT_PROJECTION_ATTR);
|
|
54
87
|
if (!raw)
|
|
@@ -411,7 +444,7 @@ function textProjectionIdentityMatches(element, change) {
|
|
|
411
444
|
&& element.getAttribute("data-cprops") === change.target.props
|
|
412
445
|
&& element.getAttribute("aria-label") === change.target.ariaLabel;
|
|
413
446
|
}
|
|
414
|
-
function restoreAppliedProjection(applied) {
|
|
447
|
+
function restoreAppliedProjection(state, applied) {
|
|
415
448
|
const element = applied.element;
|
|
416
449
|
if (!element || !element.isConnected)
|
|
417
450
|
return;
|
|
@@ -426,7 +459,9 @@ function restoreAppliedProjection(applied) {
|
|
|
426
459
|
if (!textHostIsSafe(element, applied.change.after.length === 0, applied.change.target))
|
|
427
460
|
return;
|
|
428
461
|
if (textNode) {
|
|
429
|
-
|
|
462
|
+
const rendered = applicationText.get(textNode);
|
|
463
|
+
applicationText.delete(textNode);
|
|
464
|
+
writeProjectedText(state, textNode, rendered ?? applied.change.before);
|
|
430
465
|
}
|
|
431
466
|
else if (applied.change.target.textNodePath === undefined
|
|
432
467
|
&& applied.change.after.length === 0
|
|
@@ -467,7 +502,7 @@ function validationStatus(applied) {
|
|
|
467
502
|
* mutates; an overridden projection remains authoritative and is never
|
|
468
503
|
* re-applied.
|
|
469
504
|
*/
|
|
470
|
-
function resolvePendingProjection(doc, applied) {
|
|
505
|
+
function resolvePendingProjection(doc, state, applied) {
|
|
471
506
|
if (applied.status !== "missing" && applied.status !== "ambiguous") {
|
|
472
507
|
return validationStatus(applied);
|
|
473
508
|
}
|
|
@@ -484,7 +519,9 @@ function resolvePendingProjection(doc, applied) {
|
|
|
484
519
|
element.textContent = applied.change.after;
|
|
485
520
|
}
|
|
486
521
|
else {
|
|
487
|
-
|
|
522
|
+
if (!applicationText.has(textNode))
|
|
523
|
+
applicationText.set(textNode, textNode.nodeValue ?? "");
|
|
524
|
+
writeProjectedText(state, textNode, applied.change.after);
|
|
488
525
|
}
|
|
489
526
|
applied.element = element;
|
|
490
527
|
return "applied";
|
|
@@ -492,7 +529,7 @@ function resolvePendingProjection(doc, applied) {
|
|
|
492
529
|
function validateAppliedProjection(doc, state) {
|
|
493
530
|
let changed = false;
|
|
494
531
|
for (const applied of state.applied.values()) {
|
|
495
|
-
const status = resolvePendingProjection(doc, applied);
|
|
532
|
+
const status = resolvePendingProjection(doc, state, applied);
|
|
496
533
|
if (status !== applied.status) {
|
|
497
534
|
applied.status = status;
|
|
498
535
|
changed = true;
|
|
@@ -516,6 +553,7 @@ function getDocumentState(doc) {
|
|
|
516
553
|
if (existing)
|
|
517
554
|
return existing;
|
|
518
555
|
const state = {
|
|
556
|
+
doc,
|
|
519
557
|
applied: new Map(),
|
|
520
558
|
emptyAffordances: new Map(),
|
|
521
559
|
snapshotKey: null,
|
|
@@ -524,7 +562,10 @@ function getDocumentState(doc) {
|
|
|
524
562
|
};
|
|
525
563
|
const Observer = doc.defaultView?.MutationObserver;
|
|
526
564
|
if (Observer && doc.documentElement) {
|
|
527
|
-
state.observer = new Observer(() =>
|
|
565
|
+
state.observer = new Observer((records) => {
|
|
566
|
+
recordApplicationWrites(records);
|
|
567
|
+
scheduleValidation(doc, state);
|
|
568
|
+
});
|
|
528
569
|
state.observer.observe(doc.documentElement, {
|
|
529
570
|
childList: true,
|
|
530
571
|
subtree: true,
|
|
@@ -571,7 +612,7 @@ export function applyTextContentProjection(doc, changes) {
|
|
|
571
612
|
if (incomingIds.has(id))
|
|
572
613
|
continue;
|
|
573
614
|
removeEmptyTextAffordance(state, id);
|
|
574
|
-
restoreAppliedProjection(applied);
|
|
615
|
+
restoreAppliedProjection(state, applied);
|
|
575
616
|
if (applied.element)
|
|
576
617
|
removeProjectionMarker(applied.element, applied.change.target, id);
|
|
577
618
|
}
|
|
@@ -579,13 +620,13 @@ export function applyTextContentProjection(doc, changes) {
|
|
|
579
620
|
for (const change of changes) {
|
|
580
621
|
const existing = previous.get(change.id);
|
|
581
622
|
if (existing && JSON.stringify(existing.change) === JSON.stringify(change)) {
|
|
582
|
-
existing.status = resolvePendingProjection(doc, existing);
|
|
623
|
+
existing.status = resolvePendingProjection(doc, state, existing);
|
|
583
624
|
next.set(change.id, existing);
|
|
584
625
|
continue;
|
|
585
626
|
}
|
|
586
627
|
if (existing) {
|
|
587
628
|
removeEmptyTextAffordance(state, change.id);
|
|
588
|
-
restoreAppliedProjection(existing);
|
|
629
|
+
restoreAppliedProjection(state, existing);
|
|
589
630
|
if (existing.element)
|
|
590
631
|
removeProjectionMarker(existing.element, existing.change.target, change.id);
|
|
591
632
|
}
|
|
@@ -603,8 +644,11 @@ export function applyTextContentProjection(doc, changes) {
|
|
|
603
644
|
next.set(change.id, { change, element: null, status: "missing" });
|
|
604
645
|
continue;
|
|
605
646
|
}
|
|
606
|
-
if (textNode)
|
|
607
|
-
|
|
647
|
+
if (textNode) {
|
|
648
|
+
if (!applicationText.has(textNode))
|
|
649
|
+
applicationText.set(textNode, textNode.nodeValue ?? "");
|
|
650
|
+
writeProjectedText(state, textNode, change.after);
|
|
651
|
+
}
|
|
608
652
|
else
|
|
609
653
|
element.textContent = change.after;
|
|
610
654
|
next.set(change.id, { change, element, status: "applied" });
|
|
@@ -708,6 +752,7 @@ export function resetTextProjectionState() {
|
|
|
708
752
|
}
|
|
709
753
|
}
|
|
710
754
|
documentStates = new Map();
|
|
755
|
+
applicationText = new WeakMap();
|
|
711
756
|
canonicalChanges = new Map();
|
|
712
757
|
reportsByDocument = new WeakMap();
|
|
713
758
|
reportsByCanvasCard.clear();
|
|
@@ -21,6 +21,11 @@ export declare function clearClipboardHandoff(): void;
|
|
|
21
21
|
export declare function subscribeClipboardHandoff(listener: () => void): () => void;
|
|
22
22
|
export declare function getClipboardHandoffRevision(): number;
|
|
23
23
|
export declare function getLastClipboardReconciledCount(): number;
|
|
24
|
+
/** Records in one draft that still equal its copied, unverified snapshot. */
|
|
25
|
+
export declare function getClipboardHandoffRecords(draftId: string): {
|
|
26
|
+
changes: ChangeRecord[];
|
|
27
|
+
structuralChanges: StructuralChange[];
|
|
28
|
+
};
|
|
24
29
|
export declare function reconcileClipboardHandoff(doc?: Document): Promise<number>;
|
|
25
30
|
export declare function startClipboardHandoffController(doc?: Document): () => void;
|
|
26
31
|
export {};
|
|
@@ -87,6 +87,11 @@ function matchingRecords(snapshot) {
|
|
|
87
87
|
structuralChanges: workspace.structuralChanges.filter((change) => structures.get(change.id) === handoffStructuralFingerprint(change)),
|
|
88
88
|
};
|
|
89
89
|
}
|
|
90
|
+
/** Records in one draft that still equal its copied, unverified snapshot. */
|
|
91
|
+
export function getClipboardHandoffRecords(draftId) {
|
|
92
|
+
const snapshot = checkpoints.get(draftId);
|
|
93
|
+
return snapshot ? matchingRecords(snapshot) : { changes: [], structuralChanges: [] };
|
|
94
|
+
}
|
|
90
95
|
function pruneCheckpoints() {
|
|
91
96
|
let changed = false;
|
|
92
97
|
for (const [id, snapshot] of checkpoints) {
|
|
@@ -232,7 +232,10 @@ function renderedInstanceDescription(ref) {
|
|
|
232
232
|
function structuralChangeLine(change) {
|
|
233
233
|
const target = renderedInstanceDescription(change.target);
|
|
234
234
|
if (change.kind === "delete") {
|
|
235
|
-
|
|
235
|
+
const invocation = change.target.locator.invocation;
|
|
236
|
+
return invocation
|
|
237
|
+
? `- Remove the ${promptText(`<${invocation.componentName}>`)} invocation at ${invocation.callsiteId} from the source; keep its other invocations. It renders ${target}.`
|
|
238
|
+
: `- Remove ${target} from the source.`;
|
|
236
239
|
}
|
|
237
240
|
const before = change.destination.before;
|
|
238
241
|
const sourceParent = sourceSiteLabel(change.source.parent);
|
|
@@ -254,6 +257,7 @@ function renderedInstanceKey(ref) {
|
|
|
254
257
|
locator.props,
|
|
255
258
|
locator.text,
|
|
256
259
|
locator.ariaLabel ?? null,
|
|
260
|
+
locator.invocation?.callsiteId ?? null,
|
|
257
261
|
]);
|
|
258
262
|
}
|
|
259
263
|
/** Export final intent, not the gesture history required to replay the preview. */
|
|
@@ -1,2 +1,14 @@
|
|
|
1
1
|
/** Stable JSX instrumentation selector, shared by source and instance edits. */
|
|
2
2
|
export declare function sourceSiteSelector(cid: string, src: string): string | null;
|
|
3
|
+
/**
|
|
4
|
+
* Finds a source site whose position moved because its file gained or lost
|
|
5
|
+
* lines. It returns a site only when the original renders nothing and exactly
|
|
6
|
+
* one other position in the same file and component renders matching elements.
|
|
7
|
+
*/
|
|
8
|
+
export declare function relocateSourceSite(doc: Document, site: {
|
|
9
|
+
readonly cid: string;
|
|
10
|
+
readonly src: string;
|
|
11
|
+
}, matches: (element: HTMLElement) => boolean): {
|
|
12
|
+
cid: string;
|
|
13
|
+
src: string;
|
|
14
|
+
} | null;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { escapeAttrValue } from "../projection/cssEscapes.js";
|
|
2
|
+
const SOURCE_POSITION = /^(.+):\d+:\d+$/;
|
|
2
3
|
/** Stable JSX instrumentation selector, shared by source and instance edits. */
|
|
3
4
|
export function sourceSiteSelector(cid, src) {
|
|
4
5
|
if (!cid)
|
|
@@ -10,3 +11,30 @@ export function sourceSiteSelector(cid, src) {
|
|
|
10
11
|
// single formatted line share a managed rule.
|
|
11
12
|
return `[data-cid="${escapeAttrValue(cid)}"][data-src="${escapeAttrValue(src)}"]`;
|
|
12
13
|
}
|
|
14
|
+
/**
|
|
15
|
+
* Finds a source site whose position moved because its file gained or lost
|
|
16
|
+
* lines. It returns a site only when the original renders nothing and exactly
|
|
17
|
+
* one other position in the same file and component renders matching elements.
|
|
18
|
+
*/
|
|
19
|
+
export function relocateSourceSite(doc, site, matches) {
|
|
20
|
+
const file = SOURCE_POSITION.exec(site.src)?.[1];
|
|
21
|
+
const exact = sourceSiteSelector(site.cid, site.src);
|
|
22
|
+
if (!file || !exact)
|
|
23
|
+
return null;
|
|
24
|
+
try {
|
|
25
|
+
if (doc.querySelector(exact))
|
|
26
|
+
return null;
|
|
27
|
+
const sites = new Set();
|
|
28
|
+
const sameFile = `[data-cid="${escapeAttrValue(site.cid)}"][data-src^="${escapeAttrValue(`${file}:`)}"]`;
|
|
29
|
+
for (const element of doc.querySelectorAll(sameFile)) {
|
|
30
|
+
const src = element.getAttribute("data-src") ?? "";
|
|
31
|
+
if (SOURCE_POSITION.exec(src)?.[1] === file && matches(element))
|
|
32
|
+
sites.add(src);
|
|
33
|
+
}
|
|
34
|
+
const [src] = sites;
|
|
35
|
+
return sites.size === 1 && src ? { cid: site.cid, src } : null;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|