@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
|
@@ -67,6 +67,7 @@ import {
|
|
|
67
67
|
} from "@tabler/icons-react";
|
|
68
68
|
import { useQueryClient } from "@tanstack/react-query";
|
|
69
69
|
import {
|
|
70
|
+
memo,
|
|
70
71
|
useCallback,
|
|
71
72
|
useEffect,
|
|
72
73
|
useMemo,
|
|
@@ -82,6 +83,7 @@ import {
|
|
|
82
83
|
DropdownMenu,
|
|
83
84
|
DropdownMenuContent,
|
|
84
85
|
DropdownMenuItem,
|
|
86
|
+
DropdownMenuSeparator,
|
|
85
87
|
DropdownMenuTrigger,
|
|
86
88
|
} from "@/components/ui/dropdown-menu";
|
|
87
89
|
import { Input } from "@/components/ui/input";
|
|
@@ -152,7 +154,7 @@ function sameOrMixed(values: string[]): string {
|
|
|
152
154
|
return values.every((value) => value === first) ? first : MIXED_VALUE;
|
|
153
155
|
}
|
|
154
156
|
|
|
155
|
-
function mixedElementFromSelection(
|
|
157
|
+
export function mixedElementFromSelection(
|
|
156
158
|
elements: ElementInfo[],
|
|
157
159
|
): ElementInfo | null {
|
|
158
160
|
const base = elements[elements.length - 1];
|
|
@@ -167,6 +169,27 @@ function mixedElementFromSelection(
|
|
|
167
169
|
sameOrMixed(elements.map((element) => element.computedStyles[key] ?? "")),
|
|
168
170
|
]),
|
|
169
171
|
);
|
|
172
|
+
// Mix inlineStyles the same way as computedStyles so authoredStyleValue()
|
|
173
|
+
// sees a proper Mixed sentinel across a multi-selection instead of
|
|
174
|
+
// silently inheriting the last-selected element's raw inline value
|
|
175
|
+
// (spreading ...base alone would leak that stale single-element value).
|
|
176
|
+
const inlineStyleKeys = new Set<string>();
|
|
177
|
+
elements.forEach((element) => {
|
|
178
|
+
Object.keys(element.inlineStyles ?? {}).forEach((key) =>
|
|
179
|
+
inlineStyleKeys.add(key),
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
const inlineStyles =
|
|
183
|
+
inlineStyleKeys.size > 0
|
|
184
|
+
? Object.fromEntries(
|
|
185
|
+
Array.from(inlineStyleKeys).map((key) => [
|
|
186
|
+
key,
|
|
187
|
+
sameOrMixed(
|
|
188
|
+
elements.map((element) => element.inlineStyles?.[key] ?? ""),
|
|
189
|
+
),
|
|
190
|
+
]),
|
|
191
|
+
)
|
|
192
|
+
: undefined;
|
|
170
193
|
const minX = Math.min(...elements.map((element) => element.boundingRect.x));
|
|
171
194
|
const minY = Math.min(...elements.map((element) => element.boundingRect.y));
|
|
172
195
|
const maxX = Math.max(
|
|
@@ -187,6 +210,12 @@ function mixedElementFromSelection(
|
|
|
187
210
|
selector: base.selector,
|
|
188
211
|
classes: [],
|
|
189
212
|
computedStyles,
|
|
213
|
+
inlineStyles,
|
|
214
|
+
// Mix like tagName above — otherwise isTextElement() would trust
|
|
215
|
+
// base.primitiveKind alone and misclassify a mixed text+shape selection.
|
|
216
|
+
primitiveKind: sameOrMixed(
|
|
217
|
+
elements.map((element) => element.primitiveKind ?? ""),
|
|
218
|
+
),
|
|
190
219
|
boundingRect: {
|
|
191
220
|
x: minX,
|
|
192
221
|
y: minY,
|
|
@@ -393,6 +422,9 @@ function PropInput({
|
|
|
393
422
|
}) {
|
|
394
423
|
const [draft, setDraft] = useState(value);
|
|
395
424
|
const mixed = isMixedValue(value);
|
|
425
|
+
// Escape reverts and blurs; the blur handler must then skip its commit or it
|
|
426
|
+
// would re-commit the stale draft closure (mirrors ScrubInput's Escape path).
|
|
427
|
+
const skipNextBlurCommitRef = useRef(false);
|
|
396
428
|
|
|
397
429
|
useEffect(() => {
|
|
398
430
|
setDraft(value);
|
|
@@ -431,12 +463,27 @@ function PropInput({
|
|
|
431
463
|
// defaultUnit) keep the responsive live-update behavior.
|
|
432
464
|
if (defaultUnit === undefined) onChange(e.target.value);
|
|
433
465
|
}}
|
|
434
|
-
onBlur={
|
|
466
|
+
onBlur={() => {
|
|
467
|
+
if (skipNextBlurCommitRef.current) {
|
|
468
|
+
skipNextBlurCommitRef.current = false;
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
commit();
|
|
472
|
+
}}
|
|
435
473
|
onKeyDown={(e) => {
|
|
436
474
|
if (e.key === "Enter") {
|
|
437
475
|
e.preventDefault();
|
|
438
476
|
commit();
|
|
439
477
|
(e.currentTarget as HTMLInputElement).blur();
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
if (e.key === "Escape") {
|
|
481
|
+
e.preventDefault();
|
|
482
|
+
// Revert the draft to the last committed value and blur, matching
|
|
483
|
+
// ScrubInput's Escape behavior.
|
|
484
|
+
setDraft(value);
|
|
485
|
+
skipNextBlurCommitRef.current = true;
|
|
486
|
+
(e.currentTarget as HTMLInputElement).blur();
|
|
440
487
|
}
|
|
441
488
|
}}
|
|
442
489
|
placeholder={placeholder}
|
|
@@ -534,6 +581,19 @@ function ColorInput({
|
|
|
534
581
|
}, [activeStopIds, selectedStopId]);
|
|
535
582
|
|
|
536
583
|
const setNext = (next: string) => {
|
|
584
|
+
// Guard rail for callers that don't wire onBackgroundImageChange (i.e.
|
|
585
|
+
// supportsLayeredFills is false, e.g. the text-fill "color" row): the
|
|
586
|
+
// picker manages gradient/image paint-type selection as *local* UI state
|
|
587
|
+
// independent of props (see DesignColorPicker's localPaintType), so a
|
|
588
|
+
// user can still open the Gradient/Image tab there even when this
|
|
589
|
+
// ColorInput never offered layered fills. When that happens,
|
|
590
|
+
// emitPaintValue falls back to this onChange with a full gradient/url()
|
|
591
|
+
// CSS string, which is invalid for a plain color property (color /
|
|
592
|
+
// backgroundColor) and gets silently dropped by the browser — but not
|
|
593
|
+
// before clobbering the last-known-good value in this component's own
|
|
594
|
+
// state. Reject anything that doesn't parse as a plain solid color in
|
|
595
|
+
// that case instead of forwarding it.
|
|
596
|
+
if (!supportsLayeredFills && !parseCssColor(next)) return;
|
|
537
597
|
setDraft(next);
|
|
538
598
|
onChange(next);
|
|
539
599
|
};
|
|
@@ -911,6 +971,46 @@ function averageGradientOpacity(stops: DesignGradientStop[]): number {
|
|
|
911
971
|
return Math.round(total / stops.length);
|
|
912
972
|
}
|
|
913
973
|
|
|
974
|
+
/**
|
|
975
|
+
* Marker used to non-destructively hide a single backgroundImage layer
|
|
976
|
+
* (gradient or image). CSS strips comments from computed style values — a
|
|
977
|
+
* trailing comment appended to a backgroundImage layer does not survive
|
|
978
|
+
* getComputedStyle (verified: browsers normalize/serialize computed values
|
|
979
|
+
* without their source comments) — so we can't tag the layer text itself.
|
|
980
|
+
* Instead we pair the untouched original layer with a zero-size
|
|
981
|
+
* background-size entry at the same index: `background-size: 0px 0px` makes
|
|
982
|
+
* that layer render nothing while backgroundImage keeps the exact original
|
|
983
|
+
* CSS text. Both backgroundImage and backgroundSize are real, valid,
|
|
984
|
+
* positionally-paired CSS lists that DO round-trip through computed style,
|
|
985
|
+
* so hiding survives reselect/reload with no React state stash required.
|
|
986
|
+
*/
|
|
987
|
+
const HIDDEN_LAYER_SIZE_MARKER = "0px 0px";
|
|
988
|
+
|
|
989
|
+
export function isLayerHiddenBySize(sizeEntry: string | undefined): boolean {
|
|
990
|
+
return (
|
|
991
|
+
(sizeEntry ?? "").trim().replace(/\s+/g, " ") === HIDDEN_LAYER_SIZE_MARKER
|
|
992
|
+
);
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
/**
|
|
996
|
+
* Rewrites the background-size list so `index` is hidden/shown via the
|
|
997
|
+
* zero-size marker, padding shorter lists with "auto" (the CSS default) so
|
|
998
|
+
* every other layer keeps rendering at its current/default size.
|
|
999
|
+
*/
|
|
1000
|
+
export function withLayerSizeMarker(
|
|
1001
|
+
sizeLayers: string[],
|
|
1002
|
+
layerCount: number,
|
|
1003
|
+
index: number,
|
|
1004
|
+
hidden: boolean,
|
|
1005
|
+
): string {
|
|
1006
|
+
const next = Array.from(
|
|
1007
|
+
{ length: layerCount },
|
|
1008
|
+
(_, i) => sizeLayers[i] || "auto",
|
|
1009
|
+
);
|
|
1010
|
+
next[index] = hidden ? HIDDEN_LAYER_SIZE_MARKER : "auto";
|
|
1011
|
+
return joinCssLayers(next);
|
|
1012
|
+
}
|
|
1013
|
+
|
|
914
1014
|
function splitCssLayers(value: string): string[] {
|
|
915
1015
|
const trimmed = value.trim();
|
|
916
1016
|
if (!trimmed || trimmed === "none") return [];
|
|
@@ -1578,8 +1678,30 @@ function parseScaleValue(value: string | undefined): [number, number] {
|
|
|
1578
1678
|
return [Number.isFinite(x) ? x : 1, Number.isFinite(y) ? y : 1];
|
|
1579
1679
|
}
|
|
1580
1680
|
|
|
1581
|
-
|
|
1582
|
-
|
|
1681
|
+
/**
|
|
1682
|
+
* Normalize an angle in degrees into the (-180, 180] range the inspector
|
|
1683
|
+
* displays and commits (design-tool convention: 270° reads as -90°, a full
|
|
1684
|
+
* 360° turn reads as 0°). Exported for tests.
|
|
1685
|
+
*/
|
|
1686
|
+
export function normalizeRotationDegrees(degrees: number): number {
|
|
1687
|
+
if (!Number.isFinite(degrees)) return 0;
|
|
1688
|
+
let normalized = degrees % 360;
|
|
1689
|
+
if (normalized > 180) normalized -= 360;
|
|
1690
|
+
else if (normalized <= -180) normalized += 360;
|
|
1691
|
+
return Object.is(normalized, -0) ? 0 : normalized;
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
/** Exported for tests. */
|
|
1695
|
+
export function mergeRotationValue(
|
|
1696
|
+
transform: string | undefined,
|
|
1697
|
+
degrees: number,
|
|
1698
|
+
) {
|
|
1699
|
+
// Round first, then normalize, so a rounded -179.96 → -180 still lands
|
|
1700
|
+
// inside (-180, 180] as +180.
|
|
1701
|
+
const normalizedDegrees = normalizeRotationDegrees(
|
|
1702
|
+
Math.round(degrees * 10) / 10,
|
|
1703
|
+
);
|
|
1704
|
+
const nextRotate = `rotate(${normalizedDegrees}deg)`;
|
|
1583
1705
|
if (!transform || transform === "none") return nextRotate;
|
|
1584
1706
|
// Replace an existing rotate()/rotateZ() in ANY unit so we don't append a
|
|
1585
1707
|
// second rotate() (which would compound, e.g. "rotate(0.5turn) rotate(30deg)").
|
|
@@ -1695,6 +1817,11 @@ function cssLengthNumber(value: string | undefined, fallback = 0): number {
|
|
|
1695
1817
|
return Number.isFinite(parsed) ? parsed : fallback;
|
|
1696
1818
|
}
|
|
1697
1819
|
|
|
1820
|
+
/** Round to one decimal place — matches the `precision={1}` ScrubInput controls advertise (e.g. stroke weight, font size) so 0.5-unit values aren't silently floored to whole numbers. */
|
|
1821
|
+
export function roundToOneDecimal(value: number): number {
|
|
1822
|
+
return Math.round(value * 10) / 10;
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1698
1825
|
function cssColorOrFallback(value: string | undefined, fallback: string) {
|
|
1699
1826
|
const normalized = value?.trim();
|
|
1700
1827
|
if (
|
|
@@ -1711,6 +1838,17 @@ function strokeIsVisible(width: string | undefined, style: string | undefined) {
|
|
|
1711
1838
|
return cssLengthNumber(width) > 0 && style !== "none";
|
|
1712
1839
|
}
|
|
1713
1840
|
|
|
1841
|
+
/**
|
|
1842
|
+
* True when a stroke's own color is the reason it's invisible (alpha
|
|
1843
|
+
* zeroed) rather than its width/style. Used so the eye toggle can hide a
|
|
1844
|
+
* stroke by zeroing color alpha — preserving the original border-style
|
|
1845
|
+
* (solid/dashed/dotted/etc, which has no "unset" round-trip once written as
|
|
1846
|
+
* "none") — instead of forcing borderStyle back to "solid" on every show.
|
|
1847
|
+
*/
|
|
1848
|
+
export function strokeHiddenByColor(color: string | undefined): boolean {
|
|
1849
|
+
return Boolean(color) && !colorHasVisibleAlpha(color);
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1714
1852
|
function swatchStyle(value: string | undefined) {
|
|
1715
1853
|
return {
|
|
1716
1854
|
background:
|
|
@@ -1894,8 +2032,29 @@ function parentFlexDirection(element: ElementInfo): AutoLayoutSizingAxis {
|
|
|
1894
2032
|
: "horizontal";
|
|
1895
2033
|
}
|
|
1896
2034
|
|
|
1897
|
-
function isTextElement(element: ElementInfo): boolean {
|
|
1898
|
-
|
|
2035
|
+
export function isTextElement(element: ElementInfo): boolean {
|
|
2036
|
+
const tag = (element.tagName || "").toLowerCase();
|
|
2037
|
+
if (TEXT_TAGS.has(tag)) return true;
|
|
2038
|
+
// T-tool text primitives are plain `div`s stamped with
|
|
2039
|
+
// data-an-primitive="text" (see DesignEditor primitive creation). The
|
|
2040
|
+
// bridge now forwards that marker as ElementInfo.primitiveKind — prefer it
|
|
2041
|
+
// when present since it's exact.
|
|
2042
|
+
if (element.primitiveKind) return element.primitiveKind === "text";
|
|
2043
|
+
// Fallback for older payloads that predate primitiveKind: approximate a
|
|
2044
|
+
// T-tool text div with a content heuristic — a childless div that has its
|
|
2045
|
+
// own text content and isn't already flagged as a layout container. This
|
|
2046
|
+
// intentionally excludes empty frames/shapes (no text) and containers with
|
|
2047
|
+
// element children.
|
|
2048
|
+
if (
|
|
2049
|
+
tag === "div" &&
|
|
2050
|
+
!element.isFlexContainer &&
|
|
2051
|
+
!element.isGridContainer &&
|
|
2052
|
+
(element.childElementCount ?? 0) === 0 &&
|
|
2053
|
+
Boolean(element.textContent?.trim())
|
|
2054
|
+
) {
|
|
2055
|
+
return true;
|
|
2056
|
+
}
|
|
2057
|
+
return false;
|
|
1899
2058
|
}
|
|
1900
2059
|
|
|
1901
2060
|
/**
|
|
@@ -3331,36 +3490,80 @@ function CornerRadiusControl({
|
|
|
3331
3490
|
}) {
|
|
3332
3491
|
const t = useT();
|
|
3333
3492
|
const independentCornersLabel = t("editPanel.labels.independentCorners");
|
|
3493
|
+
const cornerSources = {
|
|
3494
|
+
topLeft: styles.borderTopLeftRadius || styles.borderRadius,
|
|
3495
|
+
topRight: styles.borderTopRightRadius || styles.borderRadius,
|
|
3496
|
+
bottomRight: styles.borderBottomRightRadius || styles.borderRadius,
|
|
3497
|
+
bottomLeft: styles.borderBottomLeftRadius || styles.borderRadius,
|
|
3498
|
+
};
|
|
3499
|
+
const cornerMixed = {
|
|
3500
|
+
topLeft: isMixedValue(cornerSources.topLeft),
|
|
3501
|
+
topRight: isMixedValue(cornerSources.topRight),
|
|
3502
|
+
bottomRight: isMixedValue(cornerSources.bottomRight),
|
|
3503
|
+
bottomLeft: isMixedValue(cornerSources.bottomLeft),
|
|
3504
|
+
};
|
|
3505
|
+
// Guard cssLengthNumber against the Mixed sentinel — parseFloat("Mixed")
|
|
3506
|
+
// would silently coerce it to 0 and render a concrete value.
|
|
3334
3507
|
const corners = {
|
|
3335
|
-
topLeft:
|
|
3336
|
-
topRight:
|
|
3337
|
-
|
|
3338
|
-
|
|
3339
|
-
bottomRight:
|
|
3340
|
-
|
|
3341
|
-
|
|
3342
|
-
bottomLeft:
|
|
3343
|
-
|
|
3344
|
-
|
|
3508
|
+
topLeft: cornerMixed.topLeft ? 0 : cssLengthNumber(cornerSources.topLeft),
|
|
3509
|
+
topRight: cornerMixed.topRight
|
|
3510
|
+
? 0
|
|
3511
|
+
: cssLengthNumber(cornerSources.topRight),
|
|
3512
|
+
bottomRight: cornerMixed.bottomRight
|
|
3513
|
+
? 0
|
|
3514
|
+
: cssLengthNumber(cornerSources.bottomRight),
|
|
3515
|
+
bottomLeft: cornerMixed.bottomLeft
|
|
3516
|
+
? 0
|
|
3517
|
+
: cssLengthNumber(cornerSources.bottomLeft),
|
|
3345
3518
|
};
|
|
3346
|
-
const
|
|
3347
|
-
|
|
3348
|
-
|
|
3349
|
-
|
|
3519
|
+
const anyCornerMixed =
|
|
3520
|
+
cornerMixed.topLeft ||
|
|
3521
|
+
cornerMixed.topRight ||
|
|
3522
|
+
cornerMixed.bottomRight ||
|
|
3523
|
+
cornerMixed.bottomLeft;
|
|
3524
|
+
const allCornersMixed =
|
|
3525
|
+
cornerMixed.topLeft &&
|
|
3526
|
+
cornerMixed.topRight &&
|
|
3527
|
+
cornerMixed.bottomRight &&
|
|
3528
|
+
cornerMixed.bottomLeft;
|
|
3529
|
+
// With mixed sentinels the parsed numbers are placeholders, so compare
|
|
3530
|
+
// mixed-ness instead: all-mixed reads as uniform (each element may still be
|
|
3531
|
+
// uniform), partially-mixed means at least one element has differing corners.
|
|
3532
|
+
const cornersDiffer = anyCornerMixed
|
|
3533
|
+
? !allCornersMixed
|
|
3534
|
+
: corners.topLeft !== corners.topRight ||
|
|
3535
|
+
corners.topLeft !== corners.bottomRight ||
|
|
3536
|
+
corners.topLeft !== corners.bottomLeft;
|
|
3350
3537
|
const [showIndependentCorners, setShowIndependentCorners] =
|
|
3351
3538
|
useState(cornersDiffer);
|
|
3352
|
-
const
|
|
3353
|
-
|
|
3354
|
-
|
|
3539
|
+
const radiusMixed =
|
|
3540
|
+
anyCornerMixed || (!cornersDiffer && isMixedValue(styles.borderRadius));
|
|
3541
|
+
const radius = radiusMixed
|
|
3542
|
+
? 0
|
|
3543
|
+
: cornersDiffer
|
|
3544
|
+
? corners.topLeft
|
|
3545
|
+
: cssLengthNumber(styles.borderRadius || String(corners.topLeft));
|
|
3355
3546
|
const commitRadius = (value: number) => {
|
|
3356
3547
|
const next = `${Math.max(0, Math.round(value))}px`;
|
|
3548
|
+
// Always write the longhands along with the shorthand: stale inline
|
|
3549
|
+
// longhand declarations serialize after the shorthand and would override
|
|
3550
|
+
// it, turning uniform-radius commits into silent no-ops.
|
|
3357
3551
|
onStyleChange("borderRadius", next);
|
|
3358
|
-
if (!showIndependentCorners) return;
|
|
3359
3552
|
onStyleChange("borderTopLeftRadius", next);
|
|
3360
3553
|
onStyleChange("borderTopRightRadius", next);
|
|
3361
3554
|
onStyleChange("borderBottomRightRadius", next);
|
|
3362
3555
|
onStyleChange("borderBottomLeftRadius", next);
|
|
3363
3556
|
};
|
|
3557
|
+
const toggleIndependentCorners = () => {
|
|
3558
|
+
// Collapsing while corners differ flattens them to the displayed uniform
|
|
3559
|
+
// value; otherwise the stale longhands would keep overriding the shorthand
|
|
3560
|
+
// and the single field would silently no-op. Mixed selections collapse the
|
|
3561
|
+
// UI only — committing would stamp the placeholder 0 onto every object.
|
|
3562
|
+
if (showIndependentCorners && cornersDiffer && !radiusMixed) {
|
|
3563
|
+
commitRadius(radius);
|
|
3564
|
+
}
|
|
3565
|
+
setShowIndependentCorners(!showIndependentCorners);
|
|
3566
|
+
};
|
|
3364
3567
|
|
|
3365
3568
|
useEffect(() => {
|
|
3366
3569
|
if (cornersDiffer) setShowIndependentCorners(true);
|
|
@@ -3373,6 +3576,7 @@ function CornerRadiusControl({
|
|
|
3373
3576
|
icon={IconBorderRadius}
|
|
3374
3577
|
value={radius}
|
|
3375
3578
|
onChange={commitRadius}
|
|
3579
|
+
mixed={radiusMixed}
|
|
3376
3580
|
min={0}
|
|
3377
3581
|
precision={0}
|
|
3378
3582
|
/>
|
|
@@ -3389,7 +3593,7 @@ function CornerRadiusControl({
|
|
|
3389
3593
|
)}
|
|
3390
3594
|
aria-label={independentCornersLabel}
|
|
3391
3595
|
aria-pressed={showIndependentCorners}
|
|
3392
|
-
onClick={
|
|
3596
|
+
onClick={toggleIndependentCorners}
|
|
3393
3597
|
>
|
|
3394
3598
|
<IconBorderCorners className="size-3.5" />
|
|
3395
3599
|
</Button>
|
|
@@ -3409,6 +3613,7 @@ function CornerRadiusControl({
|
|
|
3409
3613
|
`${Math.max(0, Math.round(value))}px`,
|
|
3410
3614
|
)
|
|
3411
3615
|
}
|
|
3616
|
+
mixed={cornerMixed.topLeft}
|
|
3412
3617
|
min={0}
|
|
3413
3618
|
precision={1}
|
|
3414
3619
|
/>
|
|
@@ -3423,6 +3628,7 @@ function CornerRadiusControl({
|
|
|
3423
3628
|
`${Math.max(0, Math.round(value))}px`,
|
|
3424
3629
|
)
|
|
3425
3630
|
}
|
|
3631
|
+
mixed={cornerMixed.topRight}
|
|
3426
3632
|
min={0}
|
|
3427
3633
|
precision={1}
|
|
3428
3634
|
/>
|
|
@@ -3438,6 +3644,7 @@ function CornerRadiusControl({
|
|
|
3438
3644
|
`${Math.max(0, Math.round(value))}px`,
|
|
3439
3645
|
)
|
|
3440
3646
|
}
|
|
3647
|
+
mixed={cornerMixed.bottomLeft}
|
|
3441
3648
|
min={0}
|
|
3442
3649
|
precision={1}
|
|
3443
3650
|
/>
|
|
@@ -3452,6 +3659,7 @@ function CornerRadiusControl({
|
|
|
3452
3659
|
`${Math.max(0, Math.round(value))}px`,
|
|
3453
3660
|
)
|
|
3454
3661
|
}
|
|
3662
|
+
mixed={cornerMixed.bottomRight}
|
|
3455
3663
|
min={0}
|
|
3456
3664
|
precision={1}
|
|
3457
3665
|
/>
|
|
@@ -3520,8 +3728,16 @@ function BlendModeMenu({
|
|
|
3520
3728
|
styles.mixBlendMode || "normal",
|
|
3521
3729
|
"normal",
|
|
3522
3730
|
);
|
|
3523
|
-
|
|
3524
|
-
|
|
3731
|
+
// Recognize the Mixed sentinel BEFORE optionValue's fallback maps it to
|
|
3732
|
+
// "normal" — a mixed selection must not check a wrong concrete mode.
|
|
3733
|
+
// Isolation only disambiguates pass-through vs normal, so it only makes the
|
|
3734
|
+
// state mixed when the blend mode itself resolves to normal.
|
|
3735
|
+
const blendModeMixed =
|
|
3736
|
+
isMixedValue(styles.mixBlendMode) ||
|
|
3737
|
+
(blendMode === "normal" && isMixedValue(styles.isolation));
|
|
3738
|
+
const selectedBlendMode = blendModeMixed
|
|
3739
|
+
? MIXED_VALUE
|
|
3740
|
+
: blendMode === "normal" && styles.isolation !== "isolate"
|
|
3525
3741
|
? "pass-through"
|
|
3526
3742
|
: blendMode;
|
|
3527
3743
|
const options = [
|
|
@@ -3565,6 +3781,20 @@ function BlendModeMenu({
|
|
|
3565
3781
|
sideOffset={8}
|
|
3566
3782
|
className="z-[100010] w-48 rounded-xl border-[var(--design-editor-control-border)] bg-[var(--design-editor-panel-bg)] p-1 text-[13px] text-foreground shadow-2xl"
|
|
3567
3783
|
>
|
|
3784
|
+
{blendModeMixed ? (
|
|
3785
|
+
<>
|
|
3786
|
+
{/* Placeholder state for a mixed selection: the check sits next to
|
|
3787
|
+
"Mixed" instead of a wrong concrete mode. Picking any option
|
|
3788
|
+
below applies it to every selected object. */}
|
|
3789
|
+
<div className="flex h-9 items-center gap-3 rounded-md px-3 text-[13px] text-muted-foreground">
|
|
3790
|
+
<span className="flex size-4 shrink-0 items-center justify-center">
|
|
3791
|
+
<IconCheck className="size-4" />
|
|
3792
|
+
</span>
|
|
3793
|
+
<span>{MIXED_VALUE}</span>
|
|
3794
|
+
</div>
|
|
3795
|
+
<DropdownMenuSeparator />
|
|
3796
|
+
</>
|
|
3797
|
+
) : null}
|
|
3568
3798
|
{options.map((option) => (
|
|
3569
3799
|
<DropdownMenuItem
|
|
3570
3800
|
key={option.value}
|
|
@@ -3641,11 +3871,25 @@ function StrokeLayerControl({
|
|
|
3641
3871
|
: t("editPanel.labels.showLayer")
|
|
3642
3872
|
}
|
|
3643
3873
|
onClick={() => {
|
|
3874
|
+
// Hide/show by zeroing the stroke color's alpha (preserving its
|
|
3875
|
+
// RGB channels — same durable, comment-free technique as the
|
|
3876
|
+
// fill visibility toggle) instead of forcing borderStyle to
|
|
3877
|
+
// "none"/"solid". Writing "none" would lose a dashed/dotted
|
|
3878
|
+
// style permanently, since there is no round-trippable "unset"
|
|
3879
|
+
// for that keyword once it's overwritten.
|
|
3880
|
+
const parsed = parseCssColor(color);
|
|
3644
3881
|
if (visible) {
|
|
3645
|
-
onStyleChange(
|
|
3882
|
+
onStyleChange(
|
|
3883
|
+
`${prefix}Color`,
|
|
3884
|
+
parsed ? rgbaToCss(withColorOpacity(parsed, 0)) : "transparent",
|
|
3885
|
+
);
|
|
3646
3886
|
return;
|
|
3647
3887
|
}
|
|
3648
|
-
|
|
3888
|
+
const restoredColor = parsed
|
|
3889
|
+
? rgbaToCss(withColorOpacity(parsed, 100))
|
|
3890
|
+
: "#000000";
|
|
3891
|
+
onStyleChange(`${prefix}Color`, restoredColor);
|
|
3892
|
+
if (styleValue === "none") onStyleChange(`${prefix}Style`, "solid");
|
|
3649
3893
|
onStyleChange(
|
|
3650
3894
|
`${prefix}Width`,
|
|
3651
3895
|
width === "0px" ? "1px" : width || "1px",
|
|
@@ -3691,7 +3935,7 @@ function StrokeLayerControl({
|
|
|
3691
3935
|
onChange={(value) =>
|
|
3692
3936
|
onStyleChange(
|
|
3693
3937
|
`${prefix}Width`,
|
|
3694
|
-
`${Math.max(0,
|
|
3938
|
+
`${Math.max(0, roundToOneDecimal(value))}px`,
|
|
3695
3939
|
)
|
|
3696
3940
|
}
|
|
3697
3941
|
unit="px"
|
|
@@ -3789,7 +4033,9 @@ function serializeShadowLayers(layers: ShadowLayer[]) {
|
|
|
3789
4033
|
`${Math.round(layer.x)}px`,
|
|
3790
4034
|
`${Math.round(layer.y)}px`,
|
|
3791
4035
|
`${Math.max(0, Math.round(layer.blur))}px`,
|
|
3792
|
-
|
|
4036
|
+
// Spread radius may legitimately be negative for either inset or
|
|
4037
|
+
// drop shadows — only blur-radius is clamped to >= 0 in CSS.
|
|
4038
|
+
`${Math.round(layer.spread)}px`,
|
|
3793
4039
|
layer.color,
|
|
3794
4040
|
]
|
|
3795
4041
|
.filter(Boolean)
|
|
@@ -3937,11 +4183,12 @@ function ShadowEffectRow({
|
|
|
3937
4183
|
<ScrubInput
|
|
3938
4184
|
label={t("editPanel.labels.spread")}
|
|
3939
4185
|
value={layer.spread}
|
|
3940
|
-
|
|
3941
|
-
|
|
3942
|
-
|
|
4186
|
+
// Spread radius is valid negative for both inset AND drop
|
|
4187
|
+
// (non-inset) shadows in real CSS — negative spread shrinks
|
|
4188
|
+
// the shadow smaller than the box before blurring, a common
|
|
4189
|
+
// technique. Only blur-radius must stay >= 0.
|
|
4190
|
+
onChange={(value) => onChange({ spread: value })}
|
|
3943
4191
|
unit="px"
|
|
3944
|
-
min={layer.inset ? undefined : 0}
|
|
3945
4192
|
precision={1}
|
|
3946
4193
|
inputClassName="h-6"
|
|
3947
4194
|
/>
|
|
@@ -4058,16 +4305,48 @@ function TypographyProperties({
|
|
|
4058
4305
|
}));
|
|
4059
4306
|
const textAlign = styles.textAlign || "left";
|
|
4060
4307
|
|
|
4061
|
-
//
|
|
4062
|
-
//
|
|
4063
|
-
//
|
|
4064
|
-
//
|
|
4065
|
-
|
|
4066
|
-
|
|
4067
|
-
|
|
4068
|
-
|
|
4308
|
+
// Mixed-selection guards: a multi-selection with differing values injects
|
|
4309
|
+
// the MIXED_VALUE sentinel string into these computedStyles fields (see
|
|
4310
|
+
// mixedElementFromSelection/sameOrMixed). Parsing that sentinel with
|
|
4311
|
+
// parseNumericValue/Number() silently yields 0/NaN-fallback instead of
|
|
4312
|
+
// reflecting "differs across selection", which previously showed a
|
|
4313
|
+
// fabricated 0 (size), 1.2 (line-height), or blank (tracking) rather than
|
|
4314
|
+
// the Mixed state ScrubInput already knows how to render — same pattern as
|
|
4315
|
+
// the rotation field above.
|
|
4316
|
+
const fontWeightIsMixed = isMixedValue(styles.fontWeight);
|
|
4317
|
+
const fontSizeIsMixed = isMixedValue(styles.fontSize);
|
|
4318
|
+
const lineHeightIsMixed = isMixedValue(styles.lineHeight);
|
|
4319
|
+
const letterSpacingIsMixed = isMixedValue(styles.letterSpacing);
|
|
4320
|
+
|
|
4321
|
+
// M1 · Text resizing mode (auto-width / auto-height / fixed). the design
|
|
4322
|
+
// editor's text nodes always expose this segment. Read authored
|
|
4323
|
+
// (inlineStyles) values, not computed ones: an absolutely-positioned
|
|
4324
|
+
// element's computed width/height always resolve to a real px value even
|
|
4325
|
+
// when the author never set them, so "auto" and "a specific 200px" were
|
|
4326
|
+
// indistinguishable before — every text node misread as "fixed". Falls
|
|
4327
|
+
// back to the computed-style heuristic for older payloads that predate
|
|
4328
|
+
// inlineStyles. Convention (matches DesignEditor primitive creation and
|
|
4329
|
+
// setResizeMode below): auto-width = width unset/max-content + pre-wrap;
|
|
4330
|
+
// auto-height = fixed width + height unset/auto; fixed = both fixed. A
|
|
4331
|
+
// drag-created box (display:flex, explicit width+height, whiteSpace
|
|
4332
|
+
// unset→normal) correctly falls through to "fixed".
|
|
4333
|
+
const authoredResizeWidth = authoredStyleValue(element, "width");
|
|
4334
|
+
const authoredResizeHeight = authoredStyleValue(element, "height");
|
|
4335
|
+
const authoredWhiteSpace = authoredStyleValue(element, "whiteSpace");
|
|
4336
|
+
const hasInlineStyleInfo = Boolean(element.inlineStyles);
|
|
4337
|
+
const widthIsAuto = hasInlineStyleInfo
|
|
4338
|
+
? !authoredResizeWidth || authoredResizeWidth === "max-content"
|
|
4339
|
+
: !styles.width ||
|
|
4340
|
+
styles.width === "auto" ||
|
|
4341
|
+
styles.width === "max-content";
|
|
4342
|
+
const heightIsAuto = hasInlineStyleInfo
|
|
4343
|
+
? !authoredResizeHeight || authoredResizeHeight === "auto"
|
|
4344
|
+
: !styles.height || styles.height === "auto";
|
|
4345
|
+
const isPreWrapOrNoWrap = hasInlineStyleInfo
|
|
4346
|
+
? authoredWhiteSpace === "pre-wrap" || authoredWhiteSpace === "nowrap"
|
|
4347
|
+
: styles.whiteSpace === "nowrap";
|
|
4069
4348
|
const resizeMode: TextResizeMode =
|
|
4070
|
-
widthIsAuto &&
|
|
4349
|
+
widthIsAuto && isPreWrapOrNoWrap
|
|
4071
4350
|
? "auto-width"
|
|
4072
4351
|
: !heightIsAuto && !widthIsAuto
|
|
4073
4352
|
? "fixed"
|
|
@@ -4076,9 +4355,9 @@ function TypographyProperties({
|
|
|
4076
4355
|
const currentHeight = styles.height && !heightIsAuto ? styles.height : "48px";
|
|
4077
4356
|
const setResizeMode = (mode: TextResizeMode) => {
|
|
4078
4357
|
if (mode === "auto-width") {
|
|
4079
|
-
onStyleChange("width", "
|
|
4358
|
+
onStyleChange("width", "max-content");
|
|
4080
4359
|
onStyleChange("height", "auto");
|
|
4081
|
-
onStyleChange("whiteSpace", "
|
|
4360
|
+
onStyleChange("whiteSpace", "pre-wrap");
|
|
4082
4361
|
} else if (mode === "auto-height") {
|
|
4083
4362
|
onStyleChange("width", currentWidth);
|
|
4084
4363
|
onStyleChange("height", "auto");
|
|
@@ -4090,36 +4369,44 @@ function TypographyProperties({
|
|
|
4090
4369
|
}
|
|
4091
4370
|
};
|
|
4092
4371
|
|
|
4093
|
-
// M2 · Vertical text alignment (top / middle / bottom). For auto-layout
|
|
4094
|
-
//
|
|
4095
|
-
//
|
|
4096
|
-
//
|
|
4372
|
+
// M2 · Vertical text alignment (top / middle / bottom). For an auto-layout
|
|
4373
|
+
// text container (display:flex) this maps to whichever flex property
|
|
4374
|
+
// controls the vertical/cross axis — justifyContent when flex-direction is
|
|
4375
|
+
// column, alignItems when row (the DesignEditor drag-created default; see
|
|
4376
|
+
// primitive creation, which sets display:flex + alignItems:center with no
|
|
4377
|
+
// explicit flex-direction, i.e. row). For any non-flex display,
|
|
4378
|
+
// `verticalAlign` is a no-op: it only affects how an inline/inline-block/
|
|
4379
|
+
// table-cell box sits relative to *sibling* line-box content, not how its
|
|
4380
|
+
// own content sits within its own box — exactly the case for point text
|
|
4381
|
+
// (inline-block). So instead of ever writing verticalAlign, convert the
|
|
4382
|
+
// element to flex the same way a drag-created box is authored, then read/
|
|
4383
|
+
// write through the row-axis property (alignItems) like that default.
|
|
4097
4384
|
const display = (styles.display || "").toLowerCase();
|
|
4098
4385
|
const isFlexText = display.includes("flex");
|
|
4099
|
-
const
|
|
4100
|
-
|
|
4101
|
-
|
|
4102
|
-
|
|
4103
|
-
|
|
4104
|
-
|
|
4105
|
-
|
|
4386
|
+
const isColumnFlexText =
|
|
4387
|
+
isFlexText && styles.flexDirection?.includes("column");
|
|
4388
|
+
const verticalAlignSourceProp = isColumnFlexText
|
|
4389
|
+
? styles.justifyContent
|
|
4390
|
+
: styles.alignItems;
|
|
4391
|
+
const verticalAlign = !isFlexText
|
|
4392
|
+
? "top"
|
|
4393
|
+
: verticalAlignSourceProp === "center"
|
|
4106
4394
|
? "middle"
|
|
4107
|
-
:
|
|
4395
|
+
: verticalAlignSourceProp === "flex-end"
|
|
4108
4396
|
? "bottom"
|
|
4109
4397
|
: "top";
|
|
4110
4398
|
const setVerticalAlign = (mode: "top" | "middle" | "bottom") => {
|
|
4111
|
-
|
|
4112
|
-
|
|
4113
|
-
|
|
4114
|
-
|
|
4115
|
-
|
|
4116
|
-
|
|
4117
|
-
|
|
4118
|
-
|
|
4119
|
-
|
|
4120
|
-
|
|
4121
|
-
|
|
4122
|
-
}
|
|
4399
|
+
// Converting a non-flex element matches the drag-created fixed-size text
|
|
4400
|
+
// box exactly: display:flex, default (row) flex-direction — so the
|
|
4401
|
+
// vertical axis is alignItems, same as the pre-existing row case below.
|
|
4402
|
+
if (!isFlexText) onStyleChange("display", "flex");
|
|
4403
|
+
const cssValue =
|
|
4404
|
+
mode === "middle"
|
|
4405
|
+
? "center"
|
|
4406
|
+
: mode === "bottom"
|
|
4407
|
+
? "flex-end"
|
|
4408
|
+
: "flex-start";
|
|
4409
|
+
onStyleChange(isColumnFlexText ? "justifyContent" : "alignItems", cssValue);
|
|
4123
4410
|
};
|
|
4124
4411
|
|
|
4125
4412
|
return (
|
|
@@ -4158,13 +4445,22 @@ function TypographyProperties({
|
|
|
4158
4445
|
{/* Row 2: weight + size side by side */}
|
|
4159
4446
|
<div className="grid grid-cols-2 gap-1.5">
|
|
4160
4447
|
<Select
|
|
4161
|
-
value={styles.fontWeight || "400"}
|
|
4448
|
+
value={fontWeightIsMixed ? MIXED_VALUE : styles.fontWeight || "400"}
|
|
4162
4449
|
onValueChange={(v) => onStyleChange("fontWeight", v)}
|
|
4163
4450
|
>
|
|
4164
4451
|
<SelectTrigger className="h-6 rounded-md border-[var(--design-editor-control-border)] bg-[var(--design-editor-control-bg)] px-1.5 !text-[11px] shadow-none focus:ring-1 focus:ring-[var(--design-editor-accent-color)]">
|
|
4165
4452
|
<SelectValue />
|
|
4166
4453
|
</SelectTrigger>
|
|
4167
4454
|
<SelectContent>
|
|
4455
|
+
{fontWeightIsMixed ? (
|
|
4456
|
+
<SelectItem
|
|
4457
|
+
value={MIXED_VALUE}
|
|
4458
|
+
disabled
|
|
4459
|
+
className="!text-[11px] text-muted-foreground"
|
|
4460
|
+
>
|
|
4461
|
+
{MIXED_VALUE}
|
|
4462
|
+
</SelectItem>
|
|
4463
|
+
) : null}
|
|
4168
4464
|
{fontWeightOptions.map((opt) => (
|
|
4169
4465
|
<SelectItem
|
|
4170
4466
|
key={opt.value}
|
|
@@ -4180,9 +4476,19 @@ function TypographyProperties({
|
|
|
4180
4476
|
label={t("editPanel.labels.size")}
|
|
4181
4477
|
ariaLabel={t("editPanel.labels.size")}
|
|
4182
4478
|
icon={IconLetterCase}
|
|
4183
|
-
value={
|
|
4479
|
+
value={
|
|
4480
|
+
fontSizeIsMixed
|
|
4481
|
+
? 0
|
|
4482
|
+
: styles.fontSize
|
|
4483
|
+
? parseNumericValue(styles.fontSize)
|
|
4484
|
+
: 16
|
|
4485
|
+
}
|
|
4486
|
+
mixed={fontSizeIsMixed}
|
|
4184
4487
|
onChange={(value) =>
|
|
4185
|
-
onStyleChange(
|
|
4488
|
+
onStyleChange(
|
|
4489
|
+
"fontSize",
|
|
4490
|
+
`${Math.max(1, roundToOneDecimal(value))}px`,
|
|
4491
|
+
)
|
|
4186
4492
|
}
|
|
4187
4493
|
unit="px"
|
|
4188
4494
|
min={1}
|
|
@@ -4199,7 +4505,12 @@ function TypographyProperties({
|
|
|
4199
4505
|
label={t("editPanel.labels.lineHeight")}
|
|
4200
4506
|
ariaLabel={t("editPanel.labels.lineHeight")}
|
|
4201
4507
|
icon={IconLineHeight}
|
|
4202
|
-
value={
|
|
4508
|
+
value={
|
|
4509
|
+
lineHeightIsMixed
|
|
4510
|
+
? 0
|
|
4511
|
+
: resolveLineHeight(styles.lineHeight, styles.fontSize)
|
|
4512
|
+
}
|
|
4513
|
+
mixed={lineHeightIsMixed}
|
|
4203
4514
|
onChange={(value) =>
|
|
4204
4515
|
onStyleChange("lineHeight", String(Math.max(0.1, value)))
|
|
4205
4516
|
}
|
|
@@ -4215,8 +4526,13 @@ function TypographyProperties({
|
|
|
4215
4526
|
ariaLabel={t("editPanel.labels.tracking")}
|
|
4216
4527
|
icon={IconLetterSpacing}
|
|
4217
4528
|
value={
|
|
4218
|
-
|
|
4529
|
+
letterSpacingIsMixed
|
|
4530
|
+
? 0
|
|
4531
|
+
: styles.letterSpacing
|
|
4532
|
+
? parseNumericValue(styles.letterSpacing)
|
|
4533
|
+
: 0
|
|
4219
4534
|
}
|
|
4535
|
+
mixed={letterSpacingIsMixed}
|
|
4220
4536
|
onChange={(value) => onStyleChange("letterSpacing", `${value}px`)}
|
|
4221
4537
|
unit="px"
|
|
4222
4538
|
precision={1}
|
|
@@ -4801,6 +5117,23 @@ function ExportPreview({ element }: { element: ElementInfo | null }) {
|
|
|
4801
5117
|
);
|
|
4802
5118
|
}
|
|
4803
5119
|
|
|
5120
|
+
/**
|
|
5121
|
+
* Reads an authored (as-written) offset/size style, preferring the bridge's
|
|
5122
|
+
* `inlineStyles` capture (raw `el.style.<prop>` — "auto"/"" reliably means
|
|
5123
|
+
* unset) over `computedStyles` (which always resolves to a px value for
|
|
5124
|
+
* absolutely-positioned elements even when the author never set the
|
|
5125
|
+
* property, making "unset" indistinguishable from "0px"). Falls back to the
|
|
5126
|
+
* computed value for older payloads that predate `inlineStyles`.
|
|
5127
|
+
*/
|
|
5128
|
+
export function authoredStyleValue(
|
|
5129
|
+
element: ElementInfo,
|
|
5130
|
+
property: string,
|
|
5131
|
+
): string | undefined {
|
|
5132
|
+
const inline = element.inlineStyles?.[property];
|
|
5133
|
+
if (inline !== undefined) return inline === "auto" ? "" : inline;
|
|
5134
|
+
return element.computedStyles[property];
|
|
5135
|
+
}
|
|
5136
|
+
|
|
4804
5137
|
/** Position, size, and spacing properties */
|
|
4805
5138
|
function PositionLayoutProperties({
|
|
4806
5139
|
element,
|
|
@@ -4813,31 +5146,67 @@ function PositionLayoutProperties({
|
|
|
4813
5146
|
const styles = element.computedStyles;
|
|
4814
5147
|
const constrainedPosition =
|
|
4815
5148
|
styles.position === "absolute" || styles.position === "fixed";
|
|
4816
|
-
// Reflect the active packing in the alignment segments (the design editor
|
|
4817
|
-
// current alignment)
|
|
4818
|
-
|
|
4819
|
-
|
|
5149
|
+
// Reflect the active packing in the alignment segments (the design editor
|
|
5150
|
+
// highlights the current alignment) — these buttons align the selected
|
|
5151
|
+
// element's own children, the same operation FlexContainerControls' full
|
|
5152
|
+
// AutoLayoutMatrix alignment grid performs. A column-direction flex
|
|
5153
|
+
// container swaps which CSS axis is "horizontal"/"vertical" from the
|
|
5154
|
+
// user's point of view: justifyContent packs the main (here: vertical)
|
|
5155
|
+
// axis and alignItems packs the cross (horizontal) axis, so reading/
|
|
5156
|
+
// writing must go through autoLayoutAlignmentFromStyles /
|
|
5157
|
+
// horizontalToJustify / verticalToAlign like the matrix control does,
|
|
5158
|
+
// instead of assuming justifyContent is always horizontal.
|
|
5159
|
+
const positionFlexDirection: AutoLayoutMatrixValue["direction"] =
|
|
5160
|
+
styles.flexDirection?.includes("column") ? "vertical" : "horizontal";
|
|
5161
|
+
const positionAlignment = autoLayoutAlignmentFromStyles(
|
|
5162
|
+
styles,
|
|
5163
|
+
positionFlexDirection,
|
|
5164
|
+
);
|
|
5165
|
+
const alignH = positionAlignment.horizontal;
|
|
5166
|
+
const alignV = positionAlignment.vertical;
|
|
5167
|
+
const handlePositionAlignH = (value: AlignmentMatrixValue["horizontal"]) => {
|
|
5168
|
+
onStyleChange(
|
|
5169
|
+
positionFlexDirection === "vertical" ? "alignItems" : "justifyContent",
|
|
5170
|
+
horizontalToJustify(value),
|
|
5171
|
+
);
|
|
5172
|
+
};
|
|
5173
|
+
const handlePositionAlignV = (value: AlignmentMatrixValue["vertical"]) => {
|
|
5174
|
+
onStyleChange(
|
|
5175
|
+
positionFlexDirection === "vertical" ? "justifyContent" : "alignItems",
|
|
5176
|
+
verticalToAlign(value),
|
|
5177
|
+
);
|
|
5178
|
+
};
|
|
5179
|
+
// Authored (not computed) offsets: when inlineStyles is present, "auto"/absent
|
|
5180
|
+
// is treated as truly unset instead of the computed px fallback, so a plain
|
|
5181
|
+
// top-left-anchored element reads as "left" rather than always "left-right".
|
|
5182
|
+
const authoredLeft = authoredStyleValue(element, "left");
|
|
5183
|
+
const authoredRight = authoredStyleValue(element, "right");
|
|
5184
|
+
const authoredTop = authoredStyleValue(element, "top");
|
|
5185
|
+
const authoredBottom = authoredStyleValue(element, "bottom");
|
|
5186
|
+
const authoredWidth = authoredStyleValue(element, "width");
|
|
5187
|
+
const authoredHeight = authoredStyleValue(element, "height");
|
|
5188
|
+
const authoredTransform = authoredStyleValue(element, "transform");
|
|
4820
5189
|
const constraintsValue: ConstraintsValue = {
|
|
4821
5190
|
horizontal:
|
|
4822
5191
|
// Check scale before left+right: "scale" writes width:100% and clears
|
|
4823
5192
|
// left/right to auto, but legacy data may have 0px values that are truthy.
|
|
4824
|
-
|
|
5193
|
+
authoredWidth === "100%"
|
|
4825
5194
|
? "scale"
|
|
4826
|
-
:
|
|
5195
|
+
: authoredLeft && authoredRight
|
|
4827
5196
|
? "left-right"
|
|
4828
|
-
:
|
|
5197
|
+
: authoredRight
|
|
4829
5198
|
? "right"
|
|
4830
|
-
:
|
|
5199
|
+
: authoredTransform?.includes("translateX(-50%)")
|
|
4831
5200
|
? "center"
|
|
4832
5201
|
: "left",
|
|
4833
5202
|
vertical:
|
|
4834
|
-
|
|
5203
|
+
authoredHeight === "100%"
|
|
4835
5204
|
? "scale"
|
|
4836
|
-
:
|
|
5205
|
+
: authoredTop && authoredBottom
|
|
4837
5206
|
? "top-bottom"
|
|
4838
|
-
:
|
|
5207
|
+
: authoredBottom
|
|
4839
5208
|
? "bottom"
|
|
4840
|
-
:
|
|
5209
|
+
: authoredTransform?.includes("translateY(-50%)")
|
|
4841
5210
|
? "center"
|
|
4842
5211
|
: "top",
|
|
4843
5212
|
};
|
|
@@ -4854,7 +5223,7 @@ function PositionLayoutProperties({
|
|
|
4854
5223
|
const tyValue = value.vertical === "center" ? "-50%" : null;
|
|
4855
5224
|
// Start from the current transform, apply X, then apply Y on top.
|
|
4856
5225
|
const transformAfterX = mergeTranslateFunction(
|
|
4857
|
-
|
|
5226
|
+
authoredTransform,
|
|
4858
5227
|
"X",
|
|
4859
5228
|
txValue,
|
|
4860
5229
|
);
|
|
@@ -4867,7 +5236,7 @@ function PositionLayoutProperties({
|
|
|
4867
5236
|
if (value.horizontal === "left") {
|
|
4868
5237
|
onStyleChange(
|
|
4869
5238
|
"left",
|
|
4870
|
-
|
|
5239
|
+
authoredLeft || `${Math.round(element.boundingRect.x)}px`,
|
|
4871
5240
|
);
|
|
4872
5241
|
onStyleChange("right", "auto");
|
|
4873
5242
|
} else if (value.horizontal === "right") {
|
|
@@ -4876,7 +5245,7 @@ function PositionLayoutProperties({
|
|
|
4876
5245
|
} else if (value.horizontal === "left-right") {
|
|
4877
5246
|
onStyleChange(
|
|
4878
5247
|
"left",
|
|
4879
|
-
|
|
5248
|
+
authoredLeft || `${Math.round(element.boundingRect.x)}px`,
|
|
4880
5249
|
);
|
|
4881
5250
|
onStyleChange("right", "0px");
|
|
4882
5251
|
} else if (value.horizontal === "center") {
|
|
@@ -4893,7 +5262,7 @@ function PositionLayoutProperties({
|
|
|
4893
5262
|
if (value.vertical === "top") {
|
|
4894
5263
|
onStyleChange(
|
|
4895
5264
|
"top",
|
|
4896
|
-
|
|
5265
|
+
authoredTop || `${Math.round(element.boundingRect.y)}px`,
|
|
4897
5266
|
);
|
|
4898
5267
|
onStyleChange("bottom", "auto");
|
|
4899
5268
|
} else if (value.vertical === "bottom") {
|
|
@@ -4902,7 +5271,7 @@ function PositionLayoutProperties({
|
|
|
4902
5271
|
} else if (value.vertical === "top-bottom") {
|
|
4903
5272
|
onStyleChange(
|
|
4904
5273
|
"top",
|
|
4905
|
-
|
|
5274
|
+
authoredTop || `${Math.round(element.boundingRect.y)}px`,
|
|
4906
5275
|
);
|
|
4907
5276
|
onStyleChange("bottom", "0px");
|
|
4908
5277
|
} else if (value.vertical === "center") {
|
|
@@ -4922,9 +5291,9 @@ function PositionLayoutProperties({
|
|
|
4922
5291
|
element.boundingRect.x,
|
|
4923
5292
|
element.boundingRect.y,
|
|
4924
5293
|
onStyleChange,
|
|
4925
|
-
|
|
4926
|
-
|
|
4927
|
-
|
|
5294
|
+
authoredLeft,
|
|
5295
|
+
authoredTop,
|
|
5296
|
+
authoredTransform,
|
|
4928
5297
|
],
|
|
4929
5298
|
);
|
|
4930
5299
|
|
|
@@ -4955,21 +5324,21 @@ function PositionLayoutProperties({
|
|
|
4955
5324
|
<InspectorIconButton
|
|
4956
5325
|
label={t("editPanel.textAligns.left")}
|
|
4957
5326
|
active={alignH === "left"}
|
|
4958
|
-
onClick={() =>
|
|
5327
|
+
onClick={() => handlePositionAlignH("left")}
|
|
4959
5328
|
>
|
|
4960
5329
|
<IconLayoutAlignLeft className="size-3.5" />
|
|
4961
5330
|
</InspectorIconButton>
|
|
4962
5331
|
<InspectorIconButton
|
|
4963
5332
|
label={t("editPanel.textAligns.center")}
|
|
4964
5333
|
active={alignH === "center"}
|
|
4965
|
-
onClick={() =>
|
|
5334
|
+
onClick={() => handlePositionAlignH("center")}
|
|
4966
5335
|
>
|
|
4967
5336
|
<IconLayoutAlignCenter className="size-3.5" />
|
|
4968
5337
|
</InspectorIconButton>
|
|
4969
5338
|
<InspectorIconButton
|
|
4970
5339
|
label={t("editPanel.textAligns.right")}
|
|
4971
5340
|
active={alignH === "right"}
|
|
4972
|
-
onClick={() =>
|
|
5341
|
+
onClick={() => handlePositionAlignH("right")}
|
|
4973
5342
|
>
|
|
4974
5343
|
<IconLayoutAlignRight className="size-3.5" />
|
|
4975
5344
|
</InspectorIconButton>
|
|
@@ -4978,21 +5347,21 @@ function PositionLayoutProperties({
|
|
|
4978
5347
|
<InspectorIconButton
|
|
4979
5348
|
label={t("editPanel.alignSelfOptions.start")}
|
|
4980
5349
|
active={alignV === "top"}
|
|
4981
|
-
onClick={() =>
|
|
5350
|
+
onClick={() => handlePositionAlignV("top")}
|
|
4982
5351
|
>
|
|
4983
5352
|
<IconLayoutAlignTop className="size-3.5" />
|
|
4984
5353
|
</InspectorIconButton>
|
|
4985
5354
|
<InspectorIconButton
|
|
4986
5355
|
label={t("editPanel.alignSelfOptions.center")}
|
|
4987
5356
|
active={alignV === "middle"}
|
|
4988
|
-
onClick={() =>
|
|
5357
|
+
onClick={() => handlePositionAlignV("middle")}
|
|
4989
5358
|
>
|
|
4990
5359
|
<IconLayoutAlignMiddle className="size-3.5" />
|
|
4991
5360
|
</InspectorIconButton>
|
|
4992
5361
|
<InspectorIconButton
|
|
4993
5362
|
label={t("editPanel.alignSelfOptions.end")}
|
|
4994
5363
|
active={alignV === "bottom"}
|
|
4995
|
-
onClick={() =>
|
|
5364
|
+
onClick={() => handlePositionAlignV("bottom")}
|
|
4996
5365
|
>
|
|
4997
5366
|
<IconLayoutAlignBottom className="size-3.5" />
|
|
4998
5367
|
</InspectorIconButton>
|
|
@@ -5007,19 +5376,32 @@ function PositionLayoutProperties({
|
|
|
5007
5376
|
label="X"
|
|
5008
5377
|
ariaLabel="X-position"
|
|
5009
5378
|
tooltipLabel="X-position"
|
|
5010
|
-
value={
|
|
5379
|
+
value={
|
|
5380
|
+
isMixedValue(authoredLeft) ? MIXED_VALUE : authoredLeft || ""
|
|
5381
|
+
}
|
|
5011
5382
|
placeholder={element.boundingRect.x}
|
|
5012
5383
|
inputClassName="h-6"
|
|
5013
|
-
onChange={(v) =>
|
|
5384
|
+
onChange={(v) => {
|
|
5385
|
+
// Typing X/Y on a static (non-positioned) element is a no-op on
|
|
5386
|
+
// canvas unless we first give it a position to offset from —
|
|
5387
|
+
// mirror handleConstraintsChange, which always sets
|
|
5388
|
+
// position:absolute (the convention canvas drag/resize and
|
|
5389
|
+
// primitive creation both use) before writing left/top.
|
|
5390
|
+
if (!constrainedPosition) onStyleChange("position", "absolute");
|
|
5391
|
+
onStyleChange("left", `${Math.round(v)}px`);
|
|
5392
|
+
}}
|
|
5014
5393
|
/>
|
|
5015
5394
|
<ScrubStyleInput
|
|
5016
5395
|
label="Y"
|
|
5017
5396
|
ariaLabel="Y-position"
|
|
5018
5397
|
tooltipLabel="Y-position"
|
|
5019
|
-
value={
|
|
5398
|
+
value={isMixedValue(authoredTop) ? MIXED_VALUE : authoredTop || ""}
|
|
5020
5399
|
placeholder={element.boundingRect.y}
|
|
5021
5400
|
inputClassName="h-6"
|
|
5022
|
-
onChange={(v) =>
|
|
5401
|
+
onChange={(v) => {
|
|
5402
|
+
if (!constrainedPosition) onStyleChange("position", "absolute");
|
|
5403
|
+
onStyleChange("top", `${Math.round(v)}px`);
|
|
5404
|
+
}}
|
|
5023
5405
|
/>
|
|
5024
5406
|
<Tooltip>
|
|
5025
5407
|
<TooltipTrigger asChild>
|
|
@@ -5067,13 +5449,29 @@ function PositionLayoutProperties({
|
|
|
5067
5449
|
hideIcon={false}
|
|
5068
5450
|
icon={IconAngle}
|
|
5069
5451
|
labelClassName="[&>span]:sr-only"
|
|
5070
|
-
|
|
5452
|
+
// Detect the Mixed sentinel BEFORE parsing: parseRotationValue
|
|
5453
|
+
// would silently turn "Mixed" into 0 and render "0deg" instead
|
|
5454
|
+
// of the mixed state (mirrors the opacity field's guard).
|
|
5455
|
+
value={
|
|
5456
|
+
isMixedValue(styles.transform)
|
|
5457
|
+
? MIXED_VALUE
|
|
5458
|
+
: `${parseRotationValue(styles.transform)}deg`
|
|
5459
|
+
}
|
|
5071
5460
|
unit="deg"
|
|
5072
5461
|
inputClassName="h-6"
|
|
5073
5462
|
onChange={(v) =>
|
|
5074
5463
|
onStyleChange(
|
|
5075
5464
|
"transform",
|
|
5076
|
-
|
|
5465
|
+
// From a mixed selection the sentinel is not a transform —
|
|
5466
|
+
// treat it as absent so the typed value applies cleanly to
|
|
5467
|
+
// every selected object instead of producing
|
|
5468
|
+
// "Mixed rotate(…)".
|
|
5469
|
+
mergeRotationValue(
|
|
5470
|
+
isMixedValue(styles.transform)
|
|
5471
|
+
? undefined
|
|
5472
|
+
: styles.transform,
|
|
5473
|
+
v,
|
|
5474
|
+
),
|
|
5077
5475
|
)
|
|
5078
5476
|
}
|
|
5079
5477
|
/>
|
|
@@ -5115,21 +5513,21 @@ function FillProperties({
|
|
|
5115
5513
|
}) {
|
|
5116
5514
|
const t = useT();
|
|
5117
5515
|
const styles = element.computedStyles;
|
|
5118
|
-
const
|
|
5119
|
-
const fillProperty =
|
|
5120
|
-
const fillValue =
|
|
5516
|
+
const isTextFillElement = isTextElement(element);
|
|
5517
|
+
const fillProperty = isTextFillElement ? "color" : "backgroundColor";
|
|
5518
|
+
const fillValue = isTextFillElement
|
|
5121
5519
|
? styles.color || ""
|
|
5122
5520
|
: styles.backgroundColor || "";
|
|
5123
|
-
const backgroundLayers =
|
|
5521
|
+
const backgroundLayers = isTextFillElement
|
|
5124
5522
|
? []
|
|
5125
5523
|
: splitCssLayers(styles.backgroundImage || "");
|
|
5126
|
-
const backgroundSizeLayers =
|
|
5524
|
+
const backgroundSizeLayers = isTextFillElement
|
|
5127
5525
|
? []
|
|
5128
5526
|
: splitCssLayers(styles.backgroundSize || "");
|
|
5129
|
-
const backgroundRepeatLayers =
|
|
5527
|
+
const backgroundRepeatLayers = isTextFillElement
|
|
5130
5528
|
? []
|
|
5131
5529
|
: splitCssLayers(styles.backgroundRepeat || "");
|
|
5132
|
-
const backgroundPositionLayers =
|
|
5530
|
+
const backgroundPositionLayers = isTextFillElement
|
|
5133
5531
|
? []
|
|
5134
5532
|
: splitCssLayers(styles.backgroundPosition || "");
|
|
5135
5533
|
const fillIsMixed =
|
|
@@ -5138,39 +5536,36 @@ function FillProperties({
|
|
|
5138
5536
|
isMixedValue(styles.backgroundSize) ||
|
|
5139
5537
|
isMixedValue(styles.backgroundRepeat) ||
|
|
5140
5538
|
isMixedValue(styles.backgroundPosition);
|
|
5141
|
-
const hasBackgroundLayer = !
|
|
5539
|
+
const hasBackgroundLayer = !isTextFillElement && backgroundLayers.length > 0;
|
|
5142
5540
|
const hasVisibleFill =
|
|
5143
|
-
|
|
5144
|
-
|
|
5145
|
-
// Non-destructive fill hide:
|
|
5146
|
-
//
|
|
5147
|
-
//
|
|
5148
|
-
//
|
|
5149
|
-
|
|
5150
|
-
|
|
5151
|
-
|
|
5152
|
-
//
|
|
5153
|
-
//
|
|
5154
|
-
//
|
|
5155
|
-
const [hiddenLayerStash, setHiddenLayerStash] = useState<
|
|
5156
|
-
Record<string, string>
|
|
5157
|
-
>({});
|
|
5158
|
-
const stashKey = `${elementIdentityKey(element)}:${fillProperty}`;
|
|
5541
|
+
isTextFillElement || colorHasVisibleAlpha(fillValue) || hasBackgroundLayer;
|
|
5542
|
+
|
|
5543
|
+
// Non-destructive fill hide: instead of stashing the pre-hide color in
|
|
5544
|
+
// React state (lost on unmount — e.g. deselect then reselect the same
|
|
5545
|
+
// element, since FillProperties only mounts while something is selected),
|
|
5546
|
+
// zero the alpha channel while preserving the RGB channels in the
|
|
5547
|
+
// persisted CSS itself: rgba(r,g,b,0) renders identically to fully
|
|
5548
|
+
// transparent, but getComputedStyle round-trips the r/g/b losslessly (CSS
|
|
5549
|
+
// color-list channels are real data, unlike comments — verified
|
|
5550
|
+
// separately: computed style strips comments but keeps rgba() channels).
|
|
5551
|
+
// Showing again just restores alpha to 1 using those same channels, so no
|
|
5552
|
+
// stash is required and the hide survives reselect/reload.
|
|
5159
5553
|
const isHidden = !colorHasVisibleAlpha(fillValue);
|
|
5160
5554
|
const handleFillVisibilityToggle = () => {
|
|
5555
|
+
const parsed = parseCssColor(fillValue);
|
|
5161
5556
|
if (isHidden) {
|
|
5162
|
-
|
|
5163
|
-
|
|
5164
|
-
|
|
5557
|
+
const restored = parsed
|
|
5558
|
+
? rgbaToCss(withColorOpacity(parsed, 100))
|
|
5559
|
+
: isTextFillElement
|
|
5560
|
+
? "#000000"
|
|
5561
|
+
: "#ffffff";
|
|
5165
5562
|
onStyleChange(fillProperty, restored);
|
|
5166
|
-
|
|
5167
|
-
|
|
5168
|
-
delete next[stashKey];
|
|
5169
|
-
return next;
|
|
5170
|
-
});
|
|
5563
|
+
} else if (parsed) {
|
|
5564
|
+
onStyleChange(fillProperty, rgbaToCss(withColorOpacity(parsed, 0)));
|
|
5171
5565
|
} else {
|
|
5172
|
-
//
|
|
5173
|
-
|
|
5566
|
+
// Value wasn't a parseable color (e.g. already the literal
|
|
5567
|
+
// "transparent") — nothing to preserve, transparent is the best we
|
|
5568
|
+
// can do.
|
|
5174
5569
|
onStyleChange(fillProperty, "transparent");
|
|
5175
5570
|
}
|
|
5176
5571
|
};
|
|
@@ -5219,7 +5614,7 @@ function FillProperties({
|
|
|
5219
5614
|
);
|
|
5220
5615
|
return;
|
|
5221
5616
|
}
|
|
5222
|
-
if (
|
|
5617
|
+
if (isTextFillElement) {
|
|
5223
5618
|
onStyleChange(
|
|
5224
5619
|
"color",
|
|
5225
5620
|
cssColorOrFallback(styles.color, "#000000"),
|
|
@@ -5257,7 +5652,7 @@ function FillProperties({
|
|
|
5257
5652
|
</p>
|
|
5258
5653
|
) : hasVisibleFill ? (
|
|
5259
5654
|
<div className="space-y-1.5">
|
|
5260
|
-
{
|
|
5655
|
+
{isTextFillElement || colorHasVisibleAlpha(fillValue) ? (
|
|
5261
5656
|
/* design row: [swatch+hex trigger (flex-1)] [eye] [remove] */
|
|
5262
5657
|
<div className="group flex items-center gap-1.5">
|
|
5263
5658
|
<div className="min-w-0 flex-1">
|
|
@@ -5265,17 +5660,51 @@ function FillProperties({
|
|
|
5265
5660
|
label=""
|
|
5266
5661
|
value={fillValue}
|
|
5267
5662
|
onChange={(v) => onStyleChange(fillProperty, v)}
|
|
5268
|
-
|
|
5663
|
+
// Pass the real layer stack (not "") so that switching this
|
|
5664
|
+
// swatch's paint type to gradient/image composes a new
|
|
5665
|
+
// layer on top of any existing backgroundImage layers
|
|
5666
|
+
// (rendered as their own rows below) instead of clobbering
|
|
5667
|
+
// them — ColorInput derives its add/replace-layer logic
|
|
5668
|
+
// from this prop.
|
|
5669
|
+
backgroundImage={
|
|
5670
|
+
isTextFillElement ? "" : styles.backgroundImage
|
|
5671
|
+
}
|
|
5269
5672
|
blendMode={
|
|
5270
|
-
|
|
5673
|
+
isTextFillElement
|
|
5271
5674
|
? undefined
|
|
5272
5675
|
: styles.backgroundBlendMode || "normal"
|
|
5273
5676
|
}
|
|
5274
5677
|
onBlendModeChange={
|
|
5275
|
-
|
|
5678
|
+
isTextFillElement
|
|
5276
5679
|
? undefined
|
|
5277
5680
|
: (v) => onStyleChange("backgroundBlendMode", v)
|
|
5278
5681
|
}
|
|
5682
|
+
// Text fill ("color") can't hold a gradient/image paint —
|
|
5683
|
+
// there is no background-clip:text support here — so never
|
|
5684
|
+
// offer layered fills for it; the picker's Gradient/Image
|
|
5685
|
+
// tabs remain reachable as raw UI (it manages that tab
|
|
5686
|
+
// selection as local state independent of these props) but
|
|
5687
|
+
// ColorInput's setNext guard rejects non-color writes back
|
|
5688
|
+
// into `color` when supportsLayeredFills is false. For any
|
|
5689
|
+
// other element the base fill is a real backgroundImage
|
|
5690
|
+
// layer stack, so wire the same layered-fill handlers the
|
|
5691
|
+
// page background row uses (see PageProperties above).
|
|
5692
|
+
supportsLayeredFills={!isTextFillElement}
|
|
5693
|
+
onBackgroundImageChange={
|
|
5694
|
+
isTextFillElement
|
|
5695
|
+
? undefined
|
|
5696
|
+
: (v) => onStyleChange("backgroundImage", v)
|
|
5697
|
+
}
|
|
5698
|
+
onImageFillChange={
|
|
5699
|
+
isTextFillElement
|
|
5700
|
+
? undefined
|
|
5701
|
+
: (value) =>
|
|
5702
|
+
commitStylePatch(
|
|
5703
|
+
imageFillToBackgroundStyles(value),
|
|
5704
|
+
onStyleChange,
|
|
5705
|
+
onStylesChange,
|
|
5706
|
+
)
|
|
5707
|
+
}
|
|
5279
5708
|
documentColors={documentColors}
|
|
5280
5709
|
pickerKey={[
|
|
5281
5710
|
element.sourceId ??
|
|
@@ -5304,7 +5733,7 @@ function FillProperties({
|
|
|
5304
5733
|
<SectionIconButton
|
|
5305
5734
|
label={t("editPanel.labels.removeLayer")}
|
|
5306
5735
|
onClick={() => {
|
|
5307
|
-
if (
|
|
5736
|
+
if (isTextFillElement) {
|
|
5308
5737
|
onStyleChange(fillProperty, "transparent");
|
|
5309
5738
|
return;
|
|
5310
5739
|
}
|
|
@@ -5322,23 +5751,21 @@ function FillProperties({
|
|
|
5322
5751
|
</SectionIconButton>
|
|
5323
5752
|
</div>
|
|
5324
5753
|
) : null}
|
|
5325
|
-
{!
|
|
5754
|
+
{!isTextFillElement
|
|
5326
5755
|
? backgroundLayers.map((layer, index) => {
|
|
5327
5756
|
const gradient = parseGradientLayer(layer);
|
|
5757
|
+
// Hidden state lives in the real, persisted backgroundSize
|
|
5758
|
+
// marker (see withLayerSizeMarker) rather than React state,
|
|
5759
|
+
// so it survives deselect/reselect. Opacity still reflects
|
|
5760
|
+
// the gradient's own stop opacities for display, but no
|
|
5761
|
+
// longer drives hide/show — a layer can be a fully-opaque
|
|
5762
|
+
// gradient and still be hidden via zero-size.
|
|
5763
|
+
const hidden = isLayerHiddenBySize(backgroundSizeLayers[index]);
|
|
5328
5764
|
const opacity = gradient
|
|
5329
5765
|
? averageGradientOpacity(gradient.stops)
|
|
5330
5766
|
: 100;
|
|
5331
|
-
const layerStashKey = `${elementIdentityKey(element)}:layer:${index}`;
|
|
5332
|
-
const stashedLayer = hiddenLayerStash[layerStashKey];
|
|
5333
|
-
const hiddenImagePlaceholder = Boolean(
|
|
5334
|
-
stashedLayer && gradient && opacity <= 0,
|
|
5335
|
-
);
|
|
5336
5767
|
const label = gradient
|
|
5337
|
-
?
|
|
5338
|
-
? `${"Image" /* i18n-ignore design inspector paint row */} ${
|
|
5339
|
-
index + 1
|
|
5340
|
-
}`
|
|
5341
|
-
: `${gradientLabel(gradient.type)} ${index + 1}`
|
|
5768
|
+
? `${gradientLabel(gradient.type)} ${index + 1}`
|
|
5342
5769
|
: `${"Image" /* i18n-ignore design inspector paint row */} ${
|
|
5343
5770
|
index + 1
|
|
5344
5771
|
}`;
|
|
@@ -5356,6 +5783,25 @@ function FillProperties({
|
|
|
5356
5783
|
),
|
|
5357
5784
|
),
|
|
5358
5785
|
);
|
|
5786
|
+
onStyleChange(
|
|
5787
|
+
"backgroundSize",
|
|
5788
|
+
joinCssLayers(
|
|
5789
|
+
backgroundSizeLayers.filter(
|
|
5790
|
+
(_, layerIndex) => layerIndex !== index,
|
|
5791
|
+
),
|
|
5792
|
+
),
|
|
5793
|
+
);
|
|
5794
|
+
};
|
|
5795
|
+
const setLayerHidden = (nextHidden: boolean) => {
|
|
5796
|
+
onStyleChange(
|
|
5797
|
+
"backgroundSize",
|
|
5798
|
+
withLayerSizeMarker(
|
|
5799
|
+
backgroundSizeLayers,
|
|
5800
|
+
backgroundLayers.length,
|
|
5801
|
+
index,
|
|
5802
|
+
nextHidden,
|
|
5803
|
+
),
|
|
5804
|
+
);
|
|
5359
5805
|
};
|
|
5360
5806
|
|
|
5361
5807
|
return (
|
|
@@ -5378,7 +5824,7 @@ function FillProperties({
|
|
|
5378
5824
|
{label}
|
|
5379
5825
|
</span>
|
|
5380
5826
|
<span className="shrink-0 tabular-nums text-muted-foreground">
|
|
5381
|
-
{opacity}%
|
|
5827
|
+
{hidden ? 0 : opacity}%
|
|
5382
5828
|
</span>
|
|
5383
5829
|
</button>
|
|
5384
5830
|
</PopoverTrigger>
|
|
@@ -5389,7 +5835,7 @@ function FillProperties({
|
|
|
5389
5835
|
className="w-80 p-0"
|
|
5390
5836
|
>
|
|
5391
5837
|
<DesignColorPicker
|
|
5392
|
-
value={
|
|
5838
|
+
value={layer}
|
|
5393
5839
|
onPaintValueChange={replaceLayer}
|
|
5394
5840
|
onChange={(nextColor) => {
|
|
5395
5841
|
if (!gradient) return;
|
|
@@ -5434,78 +5880,14 @@ function FillProperties({
|
|
|
5434
5880
|
</Popover>
|
|
5435
5881
|
<SectionIconButton
|
|
5436
5882
|
label={
|
|
5437
|
-
|
|
5883
|
+
hidden
|
|
5438
5884
|
? t("editPanel.labels.showLayer")
|
|
5439
5885
|
: t("editPanel.labels.hideLayer")
|
|
5440
5886
|
}
|
|
5441
|
-
onClick={() =>
|
|
5442
|
-
|
|
5443
|
-
// Show: restore the exact pre-hide layer if stashed,
|
|
5444
|
-
// otherwise fall back to forcing every stop opaque.
|
|
5445
|
-
const stashed = hiddenLayerStash[layerStashKey];
|
|
5446
|
-
if (stashed !== undefined) {
|
|
5447
|
-
replaceLayer(stashed);
|
|
5448
|
-
setHiddenLayerStash((prev) => {
|
|
5449
|
-
const next = { ...prev };
|
|
5450
|
-
delete next[layerStashKey];
|
|
5451
|
-
return next;
|
|
5452
|
-
});
|
|
5453
|
-
} else if (gradient) {
|
|
5454
|
-
replaceLayer(
|
|
5455
|
-
buildGradientLayer(
|
|
5456
|
-
gradient.type,
|
|
5457
|
-
gradient.stops.map((stop) => ({
|
|
5458
|
-
...stop,
|
|
5459
|
-
opacity: 100,
|
|
5460
|
-
})),
|
|
5461
|
-
gradient.prefix,
|
|
5462
|
-
),
|
|
5463
|
-
);
|
|
5464
|
-
}
|
|
5465
|
-
return;
|
|
5466
|
-
}
|
|
5467
|
-
if (!gradient) {
|
|
5468
|
-
setHiddenLayerStash((prev) => ({
|
|
5469
|
-
...prev,
|
|
5470
|
-
[layerStashKey]: layer,
|
|
5471
|
-
}));
|
|
5472
|
-
replaceLayer(
|
|
5473
|
-
buildGradientLayer("linear", [
|
|
5474
|
-
{
|
|
5475
|
-
id: "stop-0",
|
|
5476
|
-
color: "rgba(0, 0, 0, 0)",
|
|
5477
|
-
position: 0,
|
|
5478
|
-
opacity: 0,
|
|
5479
|
-
},
|
|
5480
|
-
{
|
|
5481
|
-
id: "stop-1",
|
|
5482
|
-
color: "rgba(0, 0, 0, 0)",
|
|
5483
|
-
position: 100,
|
|
5484
|
-
opacity: 0,
|
|
5485
|
-
},
|
|
5486
|
-
]),
|
|
5487
|
-
);
|
|
5488
|
-
} else {
|
|
5489
|
-
// Hide: stash the current layer (with its real per-stop
|
|
5490
|
-
// opacities) before zeroing every stop's alpha.
|
|
5491
|
-
setHiddenLayerStash((prev) => ({
|
|
5492
|
-
...prev,
|
|
5493
|
-
[layerStashKey]: layer,
|
|
5494
|
-
}));
|
|
5495
|
-
replaceLayer(
|
|
5496
|
-
buildGradientLayer(
|
|
5497
|
-
gradient.type,
|
|
5498
|
-
gradient.stops.map((stop) => ({
|
|
5499
|
-
...stop,
|
|
5500
|
-
opacity: 0,
|
|
5501
|
-
})),
|
|
5502
|
-
gradient.prefix,
|
|
5503
|
-
),
|
|
5504
|
-
);
|
|
5505
|
-
}
|
|
5506
|
-
}}
|
|
5887
|
+
onClick={() => setLayerHidden(!hidden)}
|
|
5888
|
+
activateOnPointerDown
|
|
5507
5889
|
>
|
|
5508
|
-
{
|
|
5890
|
+
{hidden ? (
|
|
5509
5891
|
<IconEyeOff className="size-3.5" />
|
|
5510
5892
|
) : (
|
|
5511
5893
|
<IconEye className="size-3.5" />
|
|
@@ -5538,11 +5920,14 @@ function StrokeProperties({
|
|
|
5538
5920
|
}) {
|
|
5539
5921
|
const t = useT();
|
|
5540
5922
|
const styles = element.computedStyles;
|
|
5541
|
-
|
|
5542
|
-
|
|
5543
|
-
|
|
5544
|
-
styles.
|
|
5545
|
-
|
|
5923
|
+
// Visible requires: real width, style not "none" (legacy hide path), and
|
|
5924
|
+
// color not zero-alpha (current hide path — see strokeHiddenByColor).
|
|
5925
|
+
const borderVisible =
|
|
5926
|
+
strokeIsVisible(styles.borderWidth, styles.borderStyle) &&
|
|
5927
|
+
!strokeHiddenByColor(styles.borderColor);
|
|
5928
|
+
const outlineVisible =
|
|
5929
|
+
strokeIsVisible(styles.outlineWidth, styles.outlineStyle) &&
|
|
5930
|
+
!strokeHiddenByColor(styles.outlineColor);
|
|
5546
5931
|
const strokeIsMixed = [
|
|
5547
5932
|
styles.borderWidth,
|
|
5548
5933
|
styles.borderStyle,
|
|
@@ -5580,8 +5965,17 @@ function StrokeProperties({
|
|
|
5580
5965
|
return;
|
|
5581
5966
|
}
|
|
5582
5967
|
if (!borderVisible) {
|
|
5968
|
+
// Restore full alpha before falling back to cssColorOrFallback
|
|
5969
|
+
// — a border previously hidden via the eye toggle (zero-alpha,
|
|
5970
|
+
// real RGB preserved) is not "transparent" by that helper's
|
|
5971
|
+
// narrow literal check, so without this an "Add" click here
|
|
5972
|
+
// could silently re-add an invisible border.
|
|
5973
|
+
const existingBorderColor = styles.borderColor || styles.color;
|
|
5974
|
+
const existingParsed = parseCssColor(existingBorderColor || "");
|
|
5583
5975
|
const borderColor = cssColorOrFallback(
|
|
5584
|
-
|
|
5976
|
+
existingParsed
|
|
5977
|
+
? rgbaToCss(withColorOpacity(existingParsed, 100))
|
|
5978
|
+
: existingBorderColor,
|
|
5585
5979
|
"#000000",
|
|
5586
5980
|
);
|
|
5587
5981
|
commitStylePatch(
|
|
@@ -5742,7 +6136,15 @@ function AppearanceProperties({
|
|
|
5742
6136
|
unit="%"
|
|
5743
6137
|
precision={1}
|
|
5744
6138
|
/>
|
|
5745
|
-
|
|
6139
|
+
{/* Selection-stable key so per-selection UI state (the independent-
|
|
6140
|
+
corners toggle, which ratchets open while corners differ) resets on
|
|
6141
|
+
selection change instead of leaking to the next element — same
|
|
6142
|
+
pattern as ExportSettingsPanel. */}
|
|
6143
|
+
<CornerRadiusControl
|
|
6144
|
+
key={elementIdentityKey(element)}
|
|
6145
|
+
styles={styles}
|
|
6146
|
+
onStyleChange={onStyleChange}
|
|
6147
|
+
/>
|
|
5746
6148
|
</div>
|
|
5747
6149
|
</PanelSection>
|
|
5748
6150
|
);
|
|
@@ -6928,7 +7330,18 @@ export function ComponentSection({
|
|
|
6928
7330
|
);
|
|
6929
7331
|
}
|
|
6930
7332
|
|
|
6931
|
-
|
|
7333
|
+
// PF8: EditPanel re-renders on every DesignEditor state change (drag,
|
|
7334
|
+
// hover, zoom) unless memoized. Nearly all props are already stabilized at
|
|
7335
|
+
// the call site (useMemo/useCallback — see DesignEditor.tsx's
|
|
7336
|
+
// selectedInspectorElements/selectedScreenGeometry/pageStyles/tweaks/
|
|
7337
|
+
// sourceCapabilities/statesPanelProps/reviewPanelProps and the onXxx
|
|
7338
|
+
// handlers passed to <EditPanel>). `headerTrailing` and `aiActions` are
|
|
7339
|
+
// legitimately-dynamic ReactNode slots (the zoom control repaints its own
|
|
7340
|
+
// live percentage every zoom tick; aiActions depends on the live
|
|
7341
|
+
// selection) — their identity is expected to change often and a custom
|
|
7342
|
+
// comparator that ignored them would hide real content changes, so this
|
|
7343
|
+
// uses the default shallow comparison rather than special-casing them.
|
|
7344
|
+
export const EditPanel = memo(function EditPanel({
|
|
6932
7345
|
selectedElement,
|
|
6933
7346
|
selectedElements,
|
|
6934
7347
|
selectedScreenGeometry,
|
|
@@ -7344,4 +7757,4 @@ export function EditPanel({
|
|
|
7344
7757
|
) : null}
|
|
7345
7758
|
</div>
|
|
7346
7759
|
);
|
|
7347
|
-
}
|
|
7760
|
+
});
|