@agent-native/core 0.85.5 → 0.85.7
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/corpus/README.md +1 -1
- package/corpus/core/CHANGELOG.md +12 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/client/AssistantChat.tsx +1 -1
- package/corpus/core/src/client/use-pinch-zoom.ts +76 -11
- package/corpus/core/src/collab/presence.ts +63 -3
- package/corpus/templates/clips/desktop/src/lib/recorder.ts +245 -77
- package/corpus/templates/design/.agents/skills/design-generation/SKILL.md +190 -13
- package/corpus/templates/design/.agents/skills/design-systems/SKILL.md +16 -4
- package/corpus/templates/design/.agents/skills/export-handoff/SKILL.md +46 -3
- package/corpus/templates/design/AGENTS.md +40 -4
- package/corpus/templates/design/actions/apply-component-prop-edit.ts +20 -73
- package/corpus/templates/design/actions/apply-motion-edit.ts +29 -2
- package/corpus/templates/design/actions/apply-visual-edit.ts +4 -2
- package/corpus/templates/design/actions/connect-localhost.ts +25 -18
- package/corpus/templates/design/actions/edit-design.ts +5 -3
- package/corpus/templates/design/actions/export-pdf.ts +8 -1
- package/corpus/templates/design/actions/export-zip.ts +10 -1
- package/corpus/templates/design/actions/get-motion-timeline.ts +26 -20
- package/corpus/templates/design/actions/grant-localhost-write-consent.ts +10 -0
- package/corpus/templates/design/actions/insert-asset.ts +190 -7
- package/corpus/templates/design/actions/open-visual-edit.ts +27 -5
- package/corpus/templates/design/actions/present-design-variants.ts +12 -8
- package/corpus/templates/design/actions/revoke-localhost-write-consent.ts +18 -14
- package/corpus/templates/design/actions/run-design-audit.ts +7 -4
- package/corpus/templates/design/actions/write-local-file.ts +47 -22
- package/corpus/templates/design/app/components/design/DesignCanvas.tsx +221 -66
- package/corpus/templates/design/app/components/design/EditPanel.tsx +650 -237
- package/corpus/templates/design/app/components/design/LayersPanel.tsx +526 -127
- package/corpus/templates/design/app/components/design/MotionDock.tsx +234 -46
- package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +1520 -410
- package/corpus/templates/design/app/components/design/StatesPanel.tsx +25 -2
- package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +640 -72
- package/corpus/templates/design/app/components/design/bridge/motion-preview.bridge.ts +605 -0
- package/corpus/templates/design/app/components/design/canvas-primitive-style.ts +50 -26
- package/corpus/templates/design/app/components/design/inspector/AutoLayoutMatrix.tsx +16 -4
- package/corpus/templates/design/app/components/design/inspector/DesignColorPicker.tsx +90 -103
- package/corpus/templates/design/app/components/design/inspector/GradientEditor.tsx +111 -5
- package/corpus/templates/design/app/components/design/inspector/ImageFillControls.tsx +13 -2
- package/corpus/templates/design/app/components/design/inspector/ScrubInput.tsx +42 -2
- package/corpus/templates/design/app/components/design/inspector/scrub-input-utils.ts +11 -2
- package/corpus/templates/design/app/components/design/types.ts +16 -0
- package/corpus/templates/design/app/components/visual-editor/CanvasCommentPins.tsx +24 -6
- package/corpus/templates/design/app/components/visual-editor/DrawOverlay.tsx +15 -1
- package/corpus/templates/design/app/hooks/use-question-flow.ts +2 -2
- package/corpus/templates/design/app/hooks/useDesignHotkeys.ts +77 -0
- package/corpus/templates/design/app/i18n/zh-TW.ts +1 -0
- package/corpus/templates/design/app/i18n-data.ts +18 -0
- package/corpus/templates/design/app/lib/design-import.ts +95 -0
- package/corpus/templates/design/app/pages/DesignEditor.tsx +4015 -840
- package/corpus/templates/design/changelog/2026-07-02-creating-two-frames-in-a-row-no-longer-freezes-and-reloads-t.md +6 -0
- package/corpus/templates/design/changelog/2026-07-03-canvas-interactions-now-match-figma-rotation-aware-resizing-.md +6 -0
- package/corpus/templates/design/changelog/2026-07-03-design-retries-stalled-screen-edits-with-a-smaller-safer-fol.md +6 -0
- package/corpus/templates/design/changelog/2026-07-03-inspector-fixes-mixed-selections-show-mixed-instead-of-wrong.md +6 -0
- package/corpus/templates/design/changelog/2026-07-03-keyframe-timeline-works-reliably-drag-keyframes-smoothly-pic.md +6 -0
- package/corpus/templates/design/changelog/2026-07-03-layers-panel-matches-figma-top-layer-on-top-drag-with-auto-s.md +6 -0
- package/corpus/templates/design/changelog/2026-07-03-much-faster-editor-smooth-dragging-zooming-and-panel-updates.md +6 -0
- package/corpus/templates/design/changelog/2026-07-03-pen-tool-refinements-click-the-first-point-to-close-with-a-d.md +6 -0
- package/corpus/templates/design/changelog/2026-07-03-text-editing-enter-adds-a-line-break-international-ime-typin.md +6 -0
- package/corpus/templates/design/changelog/2026-07-03-undo-and-redo-are-dependable-across-agent-edits-screen-switc.md +6 -0
- package/corpus/templates/design/server/lib/design-export.ts +43 -1
- package/corpus/templates/design/server/plugins/agent-chat.ts +1 -1
- package/corpus/templates/design/shared/board-file.ts +25 -5
- package/corpus/templates/design/shared/canvas-math.ts +754 -9
- package/corpus/templates/design/shared/code-layer.ts +304 -26
- package/corpus/templates/design/shared/color-utils.ts +84 -0
- package/corpus/templates/design/shared/design-source-capabilities.ts +16 -7
- package/corpus/templates/design/shared/motion-compiler.ts +75 -31
- package/corpus/templates/design/shared/motion-timeline.ts +335 -0
- package/corpus/templates/design/shared/pen-path.ts +200 -23
- package/dist/client/AssistantChat.js +1 -1
- package/dist/client/AssistantChat.js.map +1 -1
- package/dist/client/use-pinch-zoom.d.ts.map +1 -1
- package/dist/client/use-pinch-zoom.js +76 -11
- package/dist/client/use-pinch-zoom.js.map +1 -1
- package/dist/collab/awareness.d.ts +2 -2
- package/dist/collab/awareness.d.ts.map +1 -1
- package/dist/collab/presence.d.ts.map +1 -1
- package/dist/collab/presence.js +54 -3
- package/dist/collab/presence.js.map +1 -1
- package/dist/collab/routes.d.ts +1 -1
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/file-upload/actions/upload-image.d.ts +2 -2
- package/dist/observability/routes.d.ts +2 -2
- package/dist/secrets/routes.d.ts +3 -3
- package/package.json +1 -1
|
@@ -264,11 +264,18 @@ export function ImageFillControls({
|
|
|
264
264
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
265
265
|
const [urlDraft, setUrlDraft] = useState(value.url);
|
|
266
266
|
const urlDraftRef = useRef(value.url);
|
|
267
|
+
// Guard re-syncing the draft from an external value change while the field
|
|
268
|
+
// is focused (mirrors ScrubInput's `focused` pattern): without this, an
|
|
269
|
+
// incoming prop update while the user is mid-typing a URL — e.g. a
|
|
270
|
+
// selection-driven re-render, or another control committing a sibling
|
|
271
|
+
// style in the same patch — clobbers their in-progress keystrokes.
|
|
272
|
+
const [focused, setFocused] = useState(false);
|
|
267
273
|
|
|
268
274
|
useEffect(() => {
|
|
275
|
+
if (focused) return;
|
|
269
276
|
urlDraftRef.current = value.url;
|
|
270
277
|
setUrlDraft(value.url);
|
|
271
|
-
}, [value.url]);
|
|
278
|
+
}, [focused, value.url]);
|
|
272
279
|
|
|
273
280
|
const commitUrl = () => {
|
|
274
281
|
onChange({ ...value, url: urlDraftRef.current.trim() });
|
|
@@ -352,7 +359,11 @@ export function ImageFillControls({
|
|
|
352
359
|
urlDraftRef.current = event.target.value;
|
|
353
360
|
setUrlDraft(event.target.value);
|
|
354
361
|
}}
|
|
355
|
-
|
|
362
|
+
onFocus={() => setFocused(true)}
|
|
363
|
+
onBlur={() => {
|
|
364
|
+
setFocused(false);
|
|
365
|
+
commitUrl();
|
|
366
|
+
}}
|
|
356
367
|
onKeyDown={(event) => {
|
|
357
368
|
if (event.key === "Enter") {
|
|
358
369
|
event.preventDefault();
|
|
@@ -28,6 +28,10 @@ import {
|
|
|
28
28
|
|
|
29
29
|
type ScrubInputIcon = (props: { className?: string }) => ReactNode;
|
|
30
30
|
|
|
31
|
+
/** Cumulative pointer movement (px) required before a label pointerdown→move
|
|
32
|
+
* is treated as a scrub drag rather than jitter during a plain click. */
|
|
33
|
+
const DRAG_THRESHOLD_PX = 3;
|
|
34
|
+
|
|
31
35
|
export interface ScrubInputChangeMeta {
|
|
32
36
|
source: "commit" | "keyboard" | "scrub";
|
|
33
37
|
expression?: string;
|
|
@@ -127,7 +131,10 @@ export function ScrubInput({
|
|
|
127
131
|
|
|
128
132
|
draftRef.current = parsed.normalized;
|
|
129
133
|
setDraft(parsed.normalized);
|
|
130
|
-
|
|
134
|
+
// From a mixed selection every explicitly typed value must commit, even
|
|
135
|
+
// when it equals the placeholder `value` prop (e.g. typing "0"): the
|
|
136
|
+
// selected objects hold differing values, so "no change" is meaningless.
|
|
137
|
+
if (parsed.value !== value || mixed) {
|
|
131
138
|
onChange(parsed.value, { source: "commit", expression: currentDraft });
|
|
132
139
|
}
|
|
133
140
|
};
|
|
@@ -135,12 +142,28 @@ export function ScrubInput({
|
|
|
135
142
|
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
|
136
143
|
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
|
|
137
144
|
event.preventDefault();
|
|
145
|
+
// Mixed selection: the `value` prop is only a placeholder (typically 0),
|
|
146
|
+
// so stepping from it would snap every selected object to a value the
|
|
147
|
+
// user never chose. Require an explicit typed value first — typing then
|
|
148
|
+
// committing applies to all, which is the design-editor convention.
|
|
149
|
+
if (mixed) return;
|
|
138
150
|
const direction = event.key === "ArrowUp" ? 1 : -1;
|
|
139
151
|
// getScrubStepFromEvent handles shiftKey (×10) and altKey (÷10).
|
|
140
152
|
// Cmd (metaKey) mirrors Shift for ×10 — editor convention on macOS.
|
|
141
153
|
const baseStep = getScrubStepFromEvent(event, step);
|
|
142
154
|
const cmdMultiplier = event.metaKey && !event.shiftKey ? 10 : 1;
|
|
143
|
-
|
|
155
|
+
// Step from the currently typed draft, not the last-committed `value`
|
|
156
|
+
// prop — otherwise an in-progress, uncommitted edit (typed but not yet
|
|
157
|
+
// blurred/entered) is silently discarded the moment an arrow key is
|
|
158
|
+
// pressed. Parse the draft the same way commitDraft does, falling back
|
|
159
|
+
// to `value` only when the draft doesn't parse (e.g. empty/invalid).
|
|
160
|
+
const draftParsed = parseScrubExpression(
|
|
161
|
+
draftRef.current,
|
|
162
|
+
value,
|
|
163
|
+
options,
|
|
164
|
+
);
|
|
165
|
+
const base = draftParsed ? draftParsed.value : value;
|
|
166
|
+
setNextValue(base + direction * baseStep * cmdMultiplier, {
|
|
144
167
|
source: "keyboard",
|
|
145
168
|
});
|
|
146
169
|
return;
|
|
@@ -179,8 +202,25 @@ export function ScrubInput({
|
|
|
179
202
|
|
|
180
203
|
const handlePointerMove = (event: PointerEvent<HTMLLabelElement>) => {
|
|
181
204
|
if (!dragging || dragRef.current.pointerId !== event.pointerId) return;
|
|
205
|
+
// Mixed selection: scrubbing has no meaningful base value (the `value`
|
|
206
|
+
// prop is a placeholder), so committing drag deltas would snap every
|
|
207
|
+
// selected object to a step-from-0 value. Keep the drag inert; releasing
|
|
208
|
+
// without a committed drag focuses the input so the user can type an
|
|
209
|
+
// explicit value that then applies to all.
|
|
210
|
+
if (mixed) return;
|
|
182
211
|
const incr = event.clientX - dragRef.current.prevX;
|
|
183
212
|
if (incr === 0) return;
|
|
213
|
+
// Require a small cumulative movement before treating this as a real
|
|
214
|
+
// drag. Without a threshold, ordinary mouse jitter between pointerdown
|
|
215
|
+
// and pointerup (a couple of px is common on real hardware even for a
|
|
216
|
+
// plain click) sets hasDragged=true on the very first move event, which
|
|
217
|
+
// makes endDrag skip its "focus the input for typing" path even though
|
|
218
|
+
// the user only meant to click.
|
|
219
|
+
const cumulativeDelta = Math.abs(event.clientX - dragRef.current.startX);
|
|
220
|
+
if (!dragRef.current.hasDragged && cumulativeDelta < DRAG_THRESHOLD_PX) {
|
|
221
|
+
dragRef.current.prevX = event.clientX;
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
184
224
|
dragRef.current.prevX = event.clientX;
|
|
185
225
|
dragRef.current.hasDragged = true;
|
|
186
226
|
// Use incremental deltas from the last move so that clamped/rounded values
|
|
@@ -17,6 +17,11 @@ type Token =
|
|
|
17
17
|
| { type: "operator"; value: MathOperator };
|
|
18
18
|
|
|
19
19
|
const NUMBER_CHAR_PATTERN = /[0-9.]/;
|
|
20
|
+
// Comma is only treated as a digit character while scanning a number token
|
|
21
|
+
// (see tokenizeExpression) so a locale-style decimal comma ("12,5") parses as
|
|
22
|
+
// 12.5, without swallowing the "," that a caller might use to separate
|
|
23
|
+
// distinct expressions elsewhere.
|
|
24
|
+
const NUMBER_OR_COMMA_CHAR_PATTERN = /[0-9.,]/;
|
|
20
25
|
|
|
21
26
|
export function parseScrubExpression(
|
|
22
27
|
input: string,
|
|
@@ -157,8 +162,12 @@ function tokenizeExpression(expression: string): Token[] {
|
|
|
157
162
|
if (NUMBER_CHAR_PATTERN.test(char) || signedNumber) {
|
|
158
163
|
const start = index;
|
|
159
164
|
index += 1;
|
|
160
|
-
|
|
161
|
-
|
|
165
|
+
// Allow a single comma decimal separator within this number token
|
|
166
|
+
// ("12,5" → 12.5), matching common European locale input. A second
|
|
167
|
+
// comma/period is left for the caller's Number() parse to reject.
|
|
168
|
+
while (NUMBER_OR_COMMA_CHAR_PATTERN.test(expression[index] ?? ""))
|
|
169
|
+
index += 1;
|
|
170
|
+
const value = Number(expression.slice(start, index).replace(",", "."));
|
|
162
171
|
if (!Number.isFinite(value)) return [];
|
|
163
172
|
tokens.push({ type: "number", value });
|
|
164
173
|
previousWasOperator = false;
|
|
@@ -18,6 +18,22 @@ export interface ElementInfo {
|
|
|
18
18
|
selector?: string;
|
|
19
19
|
classes: string[];
|
|
20
20
|
computedStyles: Record<string, string>;
|
|
21
|
+
/**
|
|
22
|
+
* Raw authored `el.style` values (not computed) for a bounded set of
|
|
23
|
+
* layout-relevant properties: position, left, right, top, bottom, width,
|
|
24
|
+
* height, transform, whiteSpace. Populated on SELECTION payloads only
|
|
25
|
+
* (not hover). Optional because older/hover payloads omit it — callers
|
|
26
|
+
* must fall back to computedStyles-based inference when absent.
|
|
27
|
+
*/
|
|
28
|
+
inlineStyles?: Record<string, string>;
|
|
29
|
+
/**
|
|
30
|
+
* Value of the element's `data-an-primitive` attribute (e.g. "text",
|
|
31
|
+
* "rectangle", "frame", "ellipse") when present. Canvas-drawn primitives —
|
|
32
|
+
* including T-tool text, which is a plain `div` — carry this marker so the
|
|
33
|
+
* inspector can identify them without relying on tagName alone. Optional
|
|
34
|
+
* because older payloads and non-primitive/source-backed elements omit it.
|
|
35
|
+
*/
|
|
36
|
+
primitiveKind?: string;
|
|
21
37
|
portableStyleSnapshot?: PortableStyleSnapshot;
|
|
22
38
|
boundingRect: { x: number; y: number; width: number; height: number };
|
|
23
39
|
textContent?: string;
|
|
@@ -340,16 +340,34 @@ export function CanvasCommentPins({
|
|
|
340
340
|
const ro = new ResizeObserver(bump);
|
|
341
341
|
ro.observe(canvas);
|
|
342
342
|
|
|
343
|
-
// Scroll listeners on
|
|
344
|
-
//
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
343
|
+
// Scroll listeners on every scrollable ancestor and the window cover both
|
|
344
|
+
// page-level scrolling and container-level panning. Walk the ancestor
|
|
345
|
+
// chain with getComputedStyle instead of a class-name heuristic so
|
|
346
|
+
// inline-styled, nested, and multiple scroll containers are all covered.
|
|
347
|
+
const isScrollable = (value: string) =>
|
|
348
|
+
value === "auto" || value === "scroll" || value === "overlay";
|
|
349
|
+
const scrollTargets: Array<HTMLElement | Window> = [];
|
|
350
|
+
for (let el: HTMLElement | null = canvas; el; el = el.parentElement) {
|
|
351
|
+
const style = getComputedStyle(el);
|
|
352
|
+
if (
|
|
353
|
+
isScrollable(style.overflow) ||
|
|
354
|
+
isScrollable(style.overflowX) ||
|
|
355
|
+
isScrollable(style.overflowY)
|
|
356
|
+
) {
|
|
357
|
+
scrollTargets.push(el);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
scrollTargets.push(window);
|
|
361
|
+
for (const target of scrollTargets) {
|
|
362
|
+
target.addEventListener("scroll", bump, { passive: true });
|
|
363
|
+
}
|
|
348
364
|
window.addEventListener("resize", bump, { passive: true });
|
|
349
365
|
|
|
350
366
|
return () => {
|
|
351
367
|
ro.disconnect();
|
|
352
|
-
|
|
368
|
+
for (const target of scrollTargets) {
|
|
369
|
+
target.removeEventListener("scroll", bump);
|
|
370
|
+
}
|
|
353
371
|
window.removeEventListener("resize", bump);
|
|
354
372
|
};
|
|
355
373
|
}, [canvasEl]);
|
|
@@ -158,6 +158,10 @@ export function DrawOverlay({
|
|
|
158
158
|
value: string;
|
|
159
159
|
} | null>(null);
|
|
160
160
|
const textInputRef = useRef<HTMLInputElement>(null);
|
|
161
|
+
// Escape cancels the pending text annotation, but unmounting the input also
|
|
162
|
+
// fires its blur handler, which would commit the very annotation the user
|
|
163
|
+
// just cancelled. This flag lets the blur handler skip that commit.
|
|
164
|
+
const cancelingTextRef = useRef(false);
|
|
161
165
|
const [instruction, setInstruction] = useState("");
|
|
162
166
|
const drawing = useRef(false);
|
|
163
167
|
const canvasSizeRef = useRef({ w: 0, h: 0 });
|
|
@@ -252,6 +256,7 @@ export function DrawOverlay({
|
|
|
252
256
|
const rect = canvasRef.current?.getBoundingClientRect();
|
|
253
257
|
if (!rect) return;
|
|
254
258
|
if (textMode) {
|
|
259
|
+
cancelingTextRef.current = false;
|
|
255
260
|
setTextInput({
|
|
256
261
|
xFrac: (e.clientX - rect.left) / rect.width,
|
|
257
262
|
yFrac: (e.clientY - rect.top) / rect.height,
|
|
@@ -708,6 +713,12 @@ export function DrawOverlay({
|
|
|
708
713
|
)
|
|
709
714
|
}
|
|
710
715
|
onBlur={() => {
|
|
716
|
+
// Escape unmounts the input, which fires this blur — skip
|
|
717
|
+
// the commit for a cancelled annotation.
|
|
718
|
+
if (cancelingTextRef.current) {
|
|
719
|
+
cancelingTextRef.current = false;
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
711
722
|
if (textInput.value.trim()) commitTextAnnotation();
|
|
712
723
|
}}
|
|
713
724
|
onKeyDown={(e) => {
|
|
@@ -715,7 +726,10 @@ export function DrawOverlay({
|
|
|
715
726
|
e.preventDefault();
|
|
716
727
|
commitTextAnnotation();
|
|
717
728
|
}
|
|
718
|
-
if (e.key === "Escape")
|
|
729
|
+
if (e.key === "Escape") {
|
|
730
|
+
cancelingTextRef.current = true;
|
|
731
|
+
setTextInput(null);
|
|
732
|
+
}
|
|
719
733
|
}}
|
|
720
734
|
className="h-7 w-48 border-primary bg-background text-sm"
|
|
721
735
|
autoFocus
|
|
@@ -41,7 +41,7 @@ export function useQuestionFlow(
|
|
|
41
41
|
formattedAnswers,
|
|
42
42
|
"",
|
|
43
43
|
designId
|
|
44
|
-
? 'Now continue the design. Honor any answer about variations: if the user asked to explore options, call present-design-variants with 2-5 concise directions using label, description, accentColor, and feature bullets; omit large content HTML when needed because the action can render compact representative screens - wait for their chat pick, delete each unchosen variant screen at most once, call get-design-snapshot exactly once with fileId for the kept screen, then call edit-design exactly once on that same fileId in a bounded pass. Use mode "replace-file" when expanding the representative placeholder into the
|
|
44
|
+
? 'Now continue the design. Honor any answer about variations: if the user asked to explore options, call present-design-variants with 2-5 concise directions using label, description, accentColor, and feature bullets; omit large content HTML when needed because the action can render compact representative screens - wait for their chat pick, delete each unchosen variant screen at most once, call get-design-snapshot exactly once with fileId for the kept screen, then call edit-design exactly once on that same fileId in a bounded pass. Use mode "replace-file" when expanding the representative placeholder into a complete but compact product UI in the chosen direction. Prioritize the primary workflow and render secondary details as visible controls, states, or affordances if the feature list is too large for one reliable edit. Do not repeat delete/snapshot cycles. Do not call generate-design after a variant pick. Stop after the first successful edit-design save. Otherwise call generate-design with one complete, renderable index.html first. Do not ask another question unless a required decision is still genuinely missing.'
|
|
45
45
|
: "Now continue the design. Honor any answer about variations: use variants only if requested; otherwise generate one polished direction.",
|
|
46
46
|
]
|
|
47
47
|
.filter(Boolean)
|
|
@@ -79,7 +79,7 @@ export function useQuestionFlow(
|
|
|
79
79
|
formattedAnswers,
|
|
80
80
|
"",
|
|
81
81
|
designId
|
|
82
|
-
? 'Now continue the design. Honor any answer about variations: if the user asked to explore options, call present-design-variants with 2-5 concise directions using label, description, accentColor, and feature bullets; omit large content HTML when needed because the action can render compact representative screens - wait for their chat pick, delete each unchosen variant screen at most once, call get-design-snapshot exactly once with fileId for the kept screen, then call edit-design exactly once on that same fileId in a bounded pass. Use mode "replace-file" when expanding the representative placeholder into the
|
|
82
|
+
? 'Now continue the design. Honor any answer about variations: if the user asked to explore options, call present-design-variants with 2-5 concise directions using label, description, accentColor, and feature bullets; omit large content HTML when needed because the action can render compact representative screens - wait for their chat pick, delete each unchosen variant screen at most once, call get-design-snapshot exactly once with fileId for the kept screen, then call edit-design exactly once on that same fileId in a bounded pass. Use mode "replace-file" when expanding the representative placeholder into a complete but compact product UI in the chosen direction. Prioritize the primary workflow and render secondary details as visible controls, states, or affordances if the feature list is too large for one reliable edit. Do not repeat delete/snapshot cycles. Do not call generate-design after a variant pick. Stop after the first successful edit-design save. Otherwise call generate-design with one complete, renderable index.html first. Do not ask another question unless a required decision is still genuinely missing.'
|
|
83
83
|
: "Now continue the design. Honor any answer about variations: use variants only if requested; otherwise generate one polished direction.",
|
|
84
84
|
]
|
|
85
85
|
.filter(Boolean)
|
|
@@ -7,6 +7,9 @@ export type DesignHotkeyTool =
|
|
|
7
7
|
| "move"
|
|
8
8
|
| "frame"
|
|
9
9
|
| "rectangle"
|
|
10
|
+
| "line"
|
|
11
|
+
| "arrow"
|
|
12
|
+
| "ellipse"
|
|
10
13
|
| "text"
|
|
11
14
|
| "pen"
|
|
12
15
|
| "hand"
|
|
@@ -33,6 +36,11 @@ export interface DesignHotkeyTabDetails extends DesignHotkeyDetails {
|
|
|
33
36
|
backwards: boolean;
|
|
34
37
|
}
|
|
35
38
|
|
|
39
|
+
export interface DesignHotkeyOpacityDetails extends DesignHotkeyDetails {
|
|
40
|
+
/** 1-100. Digit "1".."9" (no modifier) map to 10-90; "0" maps to 100. */
|
|
41
|
+
opacity: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
36
44
|
export type DesignHotkeyTarget = Window | Document | HTMLElement;
|
|
37
45
|
export type DesignHotkeyHandler = (details: DesignHotkeyDetails) => void;
|
|
38
46
|
export type DesignHotkeyToolHandler = (
|
|
@@ -43,6 +51,9 @@ export type DesignHotkeyNudgeHandler = (
|
|
|
43
51
|
details: DesignHotkeyNudgeDetails,
|
|
44
52
|
) => void;
|
|
45
53
|
export type DesignHotkeyTabHandler = (details: DesignHotkeyTabDetails) => void;
|
|
54
|
+
export type DesignHotkeyOpacityHandler = (
|
|
55
|
+
details: DesignHotkeyOpacityDetails,
|
|
56
|
+
) => void;
|
|
46
57
|
|
|
47
58
|
export interface UseDesignHotkeysProps {
|
|
48
59
|
enabled?: boolean;
|
|
@@ -55,6 +66,9 @@ export interface UseDesignHotkeysProps {
|
|
|
55
66
|
onMoveTool?: DesignHotkeyHandler;
|
|
56
67
|
onFrameTool?: DesignHotkeyHandler;
|
|
57
68
|
onRectangleTool?: DesignHotkeyHandler;
|
|
69
|
+
onLineTool?: DesignHotkeyHandler;
|
|
70
|
+
onArrowTool?: DesignHotkeyHandler;
|
|
71
|
+
onEllipseTool?: DesignHotkeyHandler;
|
|
58
72
|
onTextTool?: DesignHotkeyHandler;
|
|
59
73
|
onPenTool?: DesignHotkeyHandler;
|
|
60
74
|
onHandTool?: DesignHotkeyHandler;
|
|
@@ -88,6 +102,17 @@ export interface UseDesignHotkeysProps {
|
|
|
88
102
|
onZoomReset?: DesignHotkeyHandler;
|
|
89
103
|
onZoomToFit?: DesignHotkeyHandler;
|
|
90
104
|
onZoomToSelection?: DesignHotkeyHandler;
|
|
105
|
+
/** Figma's Shift+0 — zoom to 100%. Distinct from onZoomReset (Cmd+0). */
|
|
106
|
+
onZoomTo100?: DesignHotkeyHandler;
|
|
107
|
+
/** Figma's Cmd+Alt+K — create component from the current selection. */
|
|
108
|
+
onCreateComponent?: DesignHotkeyHandler;
|
|
109
|
+
/**
|
|
110
|
+
* Figma's plain digit 1-9 / 0 — set selection opacity (10-90%, 0 = 100%).
|
|
111
|
+
* Only fires when a layer is selected (caller decides via presence of the
|
|
112
|
+
* handler / its own guard) and the event isn't a modifier combo or an
|
|
113
|
+
* editable-target keystroke (already filtered by ignoreEditableTargets).
|
|
114
|
+
*/
|
|
115
|
+
onOpacityChange?: DesignHotkeyOpacityHandler;
|
|
91
116
|
}
|
|
92
117
|
|
|
93
118
|
const TOOL_SHORTCUTS: Record<
|
|
@@ -97,6 +122,8 @@ const TOOL_SHORTCUTS: Record<
|
|
|
97
122
|
v: { tool: "move", handler: "onMoveTool" },
|
|
98
123
|
f: { tool: "frame", handler: "onFrameTool" },
|
|
99
124
|
r: { tool: "rectangle", handler: "onRectangleTool" },
|
|
125
|
+
o: { tool: "ellipse", handler: "onEllipseTool" },
|
|
126
|
+
l: { tool: "line", handler: "onLineTool" },
|
|
100
127
|
t: { tool: "text", handler: "onTextTool" },
|
|
101
128
|
p: { tool: "pen", handler: "onPenTool" },
|
|
102
129
|
h: { tool: "hand", handler: "onHandTool" },
|
|
@@ -104,6 +131,17 @@ const TOOL_SHORTCUTS: Record<
|
|
|
104
131
|
k: { tool: "scale", handler: "onScaleTool" },
|
|
105
132
|
};
|
|
106
133
|
|
|
134
|
+
// H1: shift+key variants of a base tool shortcut (Figma muscle-memory), e.g.
|
|
135
|
+
// Shift+L selects the arrow tool while plain L selects the line tool. Keyed
|
|
136
|
+
// by the same lowercased key as TOOL_SHORTCUTS; only consulted when
|
|
137
|
+
// event.shiftKey is true so it never shadows the unshifted binding.
|
|
138
|
+
const SHIFT_TOOL_SHORTCUTS: Record<
|
|
139
|
+
string,
|
|
140
|
+
{ tool: DesignHotkeyTool; handler: keyof UseDesignHotkeysProps }
|
|
141
|
+
> = {
|
|
142
|
+
l: { tool: "arrow", handler: "onArrowTool" },
|
|
143
|
+
};
|
|
144
|
+
|
|
107
145
|
const ARROW_DIRECTIONS: Record<string, DesignHotkeyDirection> = {
|
|
108
146
|
ArrowUp: "up",
|
|
109
147
|
ArrowRight: "right",
|
|
@@ -255,6 +293,18 @@ function handleDesignHotkey(
|
|
|
255
293
|
return true;
|
|
256
294
|
};
|
|
257
295
|
|
|
296
|
+
if (!primary && !event.altKey && event.shiftKey) {
|
|
297
|
+
// H1: shift+key variant (e.g. Shift+L → arrow tool) takes priority over
|
|
298
|
+
// the base binding for the same key while shift is held.
|
|
299
|
+
const shiftToolShortcut = SHIFT_TOOL_SHORTCUTS[key];
|
|
300
|
+
if (shiftToolShortcut) {
|
|
301
|
+
return runTool(
|
|
302
|
+
shiftToolShortcut.tool,
|
|
303
|
+
props[shiftToolShortcut.handler] as DesignHotkeyHandler | undefined,
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
258
308
|
if (!primary && !event.altKey && !event.shiftKey) {
|
|
259
309
|
const toolShortcut = TOOL_SHORTCUTS[key];
|
|
260
310
|
if (toolShortcut) {
|
|
@@ -319,6 +369,11 @@ function handleDesignHotkey(
|
|
|
319
369
|
if (primary && key === "-") return run(props.onZoomOut);
|
|
320
370
|
if (primary && key === "0") return run(props.onZoomReset);
|
|
321
371
|
|
|
372
|
+
// H2: Cmd+Alt+K — create component from the current selection.
|
|
373
|
+
if (primary && event.altKey && key === "k") {
|
|
374
|
+
return run(props.onCreateComponent);
|
|
375
|
+
}
|
|
376
|
+
|
|
322
377
|
const digit = digitFromEvent(event);
|
|
323
378
|
if (event.shiftKey && !primary && digit === "1") {
|
|
324
379
|
return run(props.onZoomToFit);
|
|
@@ -326,6 +381,28 @@ function handleDesignHotkey(
|
|
|
326
381
|
if (event.shiftKey && !primary && digit === "2") {
|
|
327
382
|
return run(props.onZoomToSelection);
|
|
328
383
|
}
|
|
384
|
+
// H2: Shift+0 — zoom to 100%. Distinct from Cmd+0 (onZoomReset above).
|
|
385
|
+
if (event.shiftKey && !primary && !event.altKey && digit === "0") {
|
|
386
|
+
return run(props.onZoomTo100);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// H2: plain digit 1-9/0 (no modifier) — set selection opacity. Figma maps
|
|
390
|
+
// 1-9 to 10%-90% and 0 to 100%. Only handled when nothing else claimed the
|
|
391
|
+
// digit (e.g. Shift+1/Shift+2 zoom above) and no modifier is held; the
|
|
392
|
+
// caller supplies onOpacityChange only when a layer is selected and canvas
|
|
393
|
+
// has focus, so an absent handler naturally no-ops here.
|
|
394
|
+
if (
|
|
395
|
+
!primary &&
|
|
396
|
+
!event.altKey &&
|
|
397
|
+
!event.shiftKey &&
|
|
398
|
+
digit &&
|
|
399
|
+
props.onOpacityChange
|
|
400
|
+
) {
|
|
401
|
+
const opacity = digit === "0" ? 100 : Number(digit) * 10;
|
|
402
|
+
prevent();
|
|
403
|
+
props.onOpacityChange({ ...details, opacity });
|
|
404
|
+
return true;
|
|
405
|
+
}
|
|
329
406
|
|
|
330
407
|
if (primary && key === "]") {
|
|
331
408
|
return event.altKey ? run(props.onBringToFront) : run(props.onBringForward);
|
|
@@ -499,6 +499,7 @@ const messages = {
|
|
|
499
499
|
propsPasted: "屬性已貼上",
|
|
500
500
|
primitiveInsertFailed: "無法將該圖層新增到畫面",
|
|
501
501
|
layerMoveFailed: "無法移動該圖層",
|
|
502
|
+
layerRenameSelectLayer: "在面板中連按兩下圖層即可重新命名",
|
|
502
503
|
duplicateElementFailed: "無法複製該元素",
|
|
503
504
|
saveCopyError: "無法儲存這個設計的副本",
|
|
504
505
|
auditRunFailed: "無法執行設計稽核",
|
|
@@ -511,6 +511,8 @@ const enUS = {
|
|
|
511
511
|
propsPasted: "Properties pasted",
|
|
512
512
|
primitiveInsertFailed: "Could not add that layer to the screen",
|
|
513
513
|
layerMoveFailed: "Could not move that layer",
|
|
514
|
+
layerRenameSelectLayer:
|
|
515
|
+
"Double-click the layer in the panel to rename it",
|
|
514
516
|
duplicateElementFailed: "Could not duplicate that element",
|
|
515
517
|
saveCopyError: "Could not save a copy of this design",
|
|
516
518
|
auditRunFailed: "Unable to run design audit",
|
|
@@ -8228,6 +8230,7 @@ const designCanvasFeatureOverrides = {
|
|
|
8228
8230
|
propsPasted: "属性已粘贴",
|
|
8229
8231
|
primitiveInsertFailed: "无法将该图层添加到画面",
|
|
8230
8232
|
layerMoveFailed: "无法移动该图层",
|
|
8233
|
+
layerRenameSelectLayer: "双击面板中的图层即可重命名",
|
|
8231
8234
|
duplicateElementFailed: "无法复制该元素",
|
|
8232
8235
|
},
|
|
8233
8236
|
},
|
|
@@ -8288,6 +8291,8 @@ const designCanvasFeatureOverrides = {
|
|
|
8288
8291
|
propsPasted: "Propiedades pegadas",
|
|
8289
8292
|
primitiveInsertFailed: "No se pudo añadir esa capa a la pantalla",
|
|
8290
8293
|
layerMoveFailed: "No se pudo mover esa capa",
|
|
8294
|
+
layerRenameSelectLayer:
|
|
8295
|
+
"Haz doble clic en la capa del panel para renombrarla",
|
|
8291
8296
|
duplicateElementFailed: "No se pudo duplicar ese elemento",
|
|
8292
8297
|
},
|
|
8293
8298
|
},
|
|
@@ -8348,6 +8353,8 @@ const designCanvasFeatureOverrides = {
|
|
|
8348
8353
|
propsPasted: "Propriétés collées",
|
|
8349
8354
|
primitiveInsertFailed: "Impossible d’ajouter ce calque à l’écran",
|
|
8350
8355
|
layerMoveFailed: "Impossible de déplacer ce calque",
|
|
8356
|
+
layerRenameSelectLayer:
|
|
8357
|
+
"Double-cliquez sur le calque dans le panneau pour le renommer",
|
|
8351
8358
|
duplicateElementFailed: "Impossible de dupliquer cet élément",
|
|
8352
8359
|
},
|
|
8353
8360
|
},
|
|
@@ -8409,6 +8416,8 @@ const designCanvasFeatureOverrides = {
|
|
|
8409
8416
|
primitiveInsertFailed:
|
|
8410
8417
|
"Diese Ebene konnte nicht zur Ansicht hinzugefügt werden",
|
|
8411
8418
|
layerMoveFailed: "Diese Ebene konnte nicht verschoben werden",
|
|
8419
|
+
layerRenameSelectLayer:
|
|
8420
|
+
"Doppelklicken Sie auf die Ebene im Panel, um sie umzubenennen",
|
|
8412
8421
|
duplicateElementFailed: "Dieses Element konnte nicht dupliziert werden",
|
|
8413
8422
|
},
|
|
8414
8423
|
},
|
|
@@ -8469,6 +8478,8 @@ const designCanvasFeatureOverrides = {
|
|
|
8469
8478
|
propsPasted: "プロパティを貼り付けました",
|
|
8470
8479
|
primitiveInsertFailed: "そのレイヤーを画面に追加できませんでした",
|
|
8471
8480
|
layerMoveFailed: "そのレイヤーを移動できませんでした",
|
|
8481
|
+
layerRenameSelectLayer:
|
|
8482
|
+
"パネル内のレイヤーをダブルクリックすると名前を変更できます",
|
|
8472
8483
|
duplicateElementFailed: "その要素を複製できませんでした",
|
|
8473
8484
|
},
|
|
8474
8485
|
},
|
|
@@ -8529,6 +8540,8 @@ const designCanvasFeatureOverrides = {
|
|
|
8529
8540
|
propsPasted: "속성이 붙여넣어짐",
|
|
8530
8541
|
primitiveInsertFailed: "해당 레이어를 화면에 추가할 수 없습니다",
|
|
8531
8542
|
layerMoveFailed: "해당 레이어를 이동할 수 없습니다",
|
|
8543
|
+
layerRenameSelectLayer:
|
|
8544
|
+
"패널에서 레이어를 더블클릭하면 이름을 바꿀 수 있습니다",
|
|
8532
8545
|
duplicateElementFailed: "해당 요소를 복제할 수 없습니다",
|
|
8533
8546
|
},
|
|
8534
8547
|
},
|
|
@@ -8589,6 +8602,8 @@ const designCanvasFeatureOverrides = {
|
|
|
8589
8602
|
propsPasted: "Propriedades coladas",
|
|
8590
8603
|
primitiveInsertFailed: "Não foi possível adicionar essa camada à tela",
|
|
8591
8604
|
layerMoveFailed: "Não foi possível mover essa camada",
|
|
8605
|
+
layerRenameSelectLayer:
|
|
8606
|
+
"Clique duas vezes na camada no painel para renomeá-la",
|
|
8592
8607
|
duplicateElementFailed: "Não foi possível duplicar esse elemento",
|
|
8593
8608
|
},
|
|
8594
8609
|
},
|
|
@@ -8649,6 +8664,7 @@ const designCanvasFeatureOverrides = {
|
|
|
8649
8664
|
propsPasted: "गुण चिपकाए गए",
|
|
8650
8665
|
primitiveInsertFailed: "उस परत को स्क्रीन में नहीं जोड़ा जा सका",
|
|
8651
8666
|
layerMoveFailed: "उस परत को स्थानांतरित नहीं किया जा सका",
|
|
8667
|
+
layerRenameSelectLayer: "नाम बदलने के लिए पैनल में परत पर डबल-क्लिक करें",
|
|
8652
8668
|
duplicateElementFailed: "उस तत्व की प्रतिलिपि नहीं बनाई जा सकी",
|
|
8653
8669
|
},
|
|
8654
8670
|
},
|
|
@@ -8709,6 +8725,8 @@ const designCanvasFeatureOverrides = {
|
|
|
8709
8725
|
propsPasted: "تم لصق الخصائص",
|
|
8710
8726
|
primitiveInsertFailed: "تعذرت إضافة تلك الطبقة إلى الشاشة",
|
|
8711
8727
|
layerMoveFailed: "تعذر نقل تلك الطبقة",
|
|
8728
|
+
layerRenameSelectLayer:
|
|
8729
|
+
"انقر نقرًا مزدوجًا فوق الطبقة في اللوحة لإعادة تسميتها",
|
|
8712
8730
|
duplicateElementFailed: "تعذّر تكرار هذا العنصر",
|
|
8713
8731
|
},
|
|
8714
8732
|
},
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { PortableStyleSnapshot } from "@/components/design/types";
|
|
2
|
+
|
|
1
3
|
export interface ImportResult {
|
|
2
4
|
designId?: string;
|
|
3
5
|
files?: Array<{ id: string; filename: string }>;
|
|
@@ -43,3 +45,96 @@ export function importResultSummary(
|
|
|
43
45
|
if (count === 1) return `Imported ${result!.files![0]!.filename}.`;
|
|
44
46
|
return `Imported ${count} screens.`;
|
|
45
47
|
}
|
|
48
|
+
|
|
49
|
+
// --- Cross-tab / system-clipboard round-trip (U4/U6) ---
|
|
50
|
+
//
|
|
51
|
+
// The in-memory clipboard refs (copiedLayerEntriesRef etc.) never survive a
|
|
52
|
+
// tab switch, a page reload, or a copy made in another window, and pasting
|
|
53
|
+
// from the OS clipboard after copying elsewhere silently reuses the stale
|
|
54
|
+
// in-memory entries instead. To fix that, every copy embeds a serialized
|
|
55
|
+
// marker comment in the text actually written to navigator.clipboard, and
|
|
56
|
+
// paste parses that marker back out of the live clipboard content so a
|
|
57
|
+
// cross-tab paste (or a same-tab paste after an external copy) round-trips
|
|
58
|
+
// the original entries instead of just raw concatenated HTML.
|
|
59
|
+
|
|
60
|
+
export interface DesignClipboardLayerEntry {
|
|
61
|
+
html: string;
|
|
62
|
+
rootNodeId?: string;
|
|
63
|
+
sourceFileId: string;
|
|
64
|
+
portableStyleSnapshot?: PortableStyleSnapshot;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface DesignClipboardScreenEntry {
|
|
68
|
+
filename: string;
|
|
69
|
+
fileType?: string;
|
|
70
|
+
content: string;
|
|
71
|
+
canvasFrame?: {
|
|
72
|
+
x?: number;
|
|
73
|
+
y?: number;
|
|
74
|
+
width?: number;
|
|
75
|
+
height?: number;
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface DesignClipboardPayload {
|
|
80
|
+
version: 1;
|
|
81
|
+
entries: DesignClipboardLayerEntry[];
|
|
82
|
+
screens?: DesignClipboardScreenEntry[];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const CLIPBOARD_MARKER_PREFIX = "agent-native-clipboard-v1:";
|
|
86
|
+
|
|
87
|
+
function encodeClipboardMarkerData(payload: DesignClipboardPayload): string {
|
|
88
|
+
// btoa is UTF-16-unsafe for non-Latin1 text; encodeURIComponent first so
|
|
89
|
+
// arbitrary copied HTML (emoji, non-Latin scripts, etc.) round-trips.
|
|
90
|
+
return btoa(encodeURIComponent(JSON.stringify(payload)));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function decodeClipboardMarkerData(
|
|
94
|
+
data: string,
|
|
95
|
+
): DesignClipboardPayload | null {
|
|
96
|
+
try {
|
|
97
|
+
const json = decodeURIComponent(atob(data));
|
|
98
|
+
const parsed = JSON.parse(json) as unknown;
|
|
99
|
+
if (
|
|
100
|
+
!parsed ||
|
|
101
|
+
typeof parsed !== "object" ||
|
|
102
|
+
(parsed as { version?: unknown }).version !== 1 ||
|
|
103
|
+
!Array.isArray((parsed as { entries?: unknown }).entries)
|
|
104
|
+
) {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
return parsed as DesignClipboardPayload;
|
|
108
|
+
} catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Appends an invisible marker comment (safe inside HTML and plain text alike)
|
|
115
|
+
* encoding the full clipboard payload after the human-visible clipboard text.
|
|
116
|
+
*/
|
|
117
|
+
export function serializeDesignClipboardPayload(
|
|
118
|
+
visibleText: string,
|
|
119
|
+
payload: DesignClipboardPayload,
|
|
120
|
+
): string {
|
|
121
|
+
const marker = `<!--${CLIPBOARD_MARKER_PREFIX}${encodeClipboardMarkerData(payload)}-->`;
|
|
122
|
+
return `${visibleText}\n${marker}`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Extracts a previously-serialized payload from clipboard text, if present.
|
|
127
|
+
* Returns null for clipboard content that was never written by this app (a
|
|
128
|
+
* plain copy from elsewhere, or another app's clipboard payload).
|
|
129
|
+
*/
|
|
130
|
+
export function parseDesignClipboardMarker(
|
|
131
|
+
text: string | null | undefined,
|
|
132
|
+
): DesignClipboardPayload | null {
|
|
133
|
+
if (!text) return null;
|
|
134
|
+
const markerIndex = text.lastIndexOf(`<!--${CLIPBOARD_MARKER_PREFIX}`);
|
|
135
|
+
if (markerIndex === -1) return null;
|
|
136
|
+
const start = markerIndex + 4 + CLIPBOARD_MARKER_PREFIX.length;
|
|
137
|
+
const end = text.indexOf("-->", start);
|
|
138
|
+
if (end === -1) return null;
|
|
139
|
+
return decodeClipboardMarkerData(text.slice(start, end));
|
|
140
|
+
}
|