diff --git a/packages/react-grab/e2e/edit-panel-helpers.ts b/packages/react-grab/e2e/edit-panel-helpers.ts index df036860a..376170ceb 100644 --- a/packages/react-grab/e2e/edit-panel-helpers.ts +++ b/packages/react-grab/e2e/edit-panel-helpers.ts @@ -178,9 +178,9 @@ export const hoverVisibleSlider = async (page: Page): Promise => { ); }; -export const getActiveTailwindLabelOrder = async ( +const getActiveTailwindLabelInfo = async ( page: Page, -): Promise<{ tailwindLeft: number | null; valueLeft: number | null }> => +): Promise<{ text: string | null; tailwindLeft: number | null; valueLeft: number | null }> => page.evaluate( ({ attrName, tailwindLabelAttr }) => { const host = document.querySelector(`[${attrName}]`); @@ -197,6 +197,7 @@ export const getActiveTailwindLabelOrder = async ( ?.closest("[data-react-grab-value]") ?.querySelector("[data-react-grab-value-text]") ?? null; return { + text: tailwindLabel?.textContent ?? null, tailwindLeft: tailwindLabel?.getBoundingClientRect().left ?? null, valueLeft: valueText?.getBoundingClientRect().left ?? null, }; @@ -204,6 +205,16 @@ export const getActiveTailwindLabelOrder = async ( { attrName: ATTRIBUTE_NAME, tailwindLabelAttr: TAILWIND_LABEL_ATTR }, ); +export const getActiveTailwindLabelOrder = async ( + page: Page, +): Promise<{ tailwindLeft: number | null; valueLeft: number | null }> => { + const { tailwindLeft, valueLeft } = await getActiveTailwindLabelInfo(page); + return { tailwindLeft, valueLeft }; +}; + +export const getActiveTailwindLabelText = async (page: Page): Promise => + (await getActiveTailwindLabelInfo(page)).text; + export interface SearchInputFocusVisualState { isFocusVisible: boolean; outlineStyle: string; @@ -536,8 +547,7 @@ export const openEditPanel = async ( selector: string, ): Promise => { await reactGrab.activate(); - await reactGrab.hoverElement(selector); - await reactGrab.waitForSelectionBox(); + await reactGrab.hoverUntilSelected(selector); await reactGrab.rightClickElement(selector); await reactGrab.clickContextMenuItem("Style"); await expect.poll(() => isEditPanelVisible(reactGrab.page)).toBe(true); diff --git a/packages/react-grab/e2e/edit-panel.spec.ts b/packages/react-grab/e2e/edit-panel.spec.ts index 46b40428a..d33b8b9bc 100644 --- a/packages/react-grab/e2e/edit-panel.spec.ts +++ b/packages/react-grab/e2e/edit-panel.spec.ts @@ -18,6 +18,7 @@ import { getActivePropertyValue, getActiveSliderVisualState, getActiveTailwindLabelOrder, + getActiveTailwindLabelText, getEditPanelCompactAttr, getInlineStyleAttribute, getInlineStyleProperty, @@ -39,6 +40,11 @@ import { typeInSearchInput, } from "./edit-panel-helpers.js"; +// Leaf element (text-only table cell with uniform `p-2`) whose center +// point hits the element itself — nested containers like the card +// select whichever child sits at their center. +const UNIFORM_PADDING_SELECTOR = "[data-testid='th-1']"; + test.describe("Style Panel", () => { test.beforeEach(async ({ reactGrab }) => { await clearEditStorage(reactGrab.page); @@ -631,6 +637,58 @@ test.describe("Style Panel", () => { } }); + test("Tailwind label names the aggregate class on a uniform padding row", async ({ + reactGrab, + }) => { + await openEditPanel(reactGrab, UNIFORM_PADDING_SELECTOR); + await expect.poll(() => getActivePropertyKey(reactGrab.page)).toBe("padding"); + await reactGrab.page.keyboard.down("Shift"); + try { + await expect.poll(() => getActiveTailwindLabelText(reactGrab.page)).toBe("p-2"); + } finally { + await reactGrab.page.keyboard.up("Shift"); + } + }); + + test("stepping an out-of-range value never jumps against the arrow direction", async ({ + reactGrab, + }) => { + // 200px border-radius is above the 96px row max but applies no + // layout (unlike a 128px font, whose reflow makes the hover-based + // panel open flaky) so the regression is isolated cleanly. + await reactGrab.page.evaluate((buttonSelector) => { + const button = document.querySelector(buttonSelector); + if (button instanceof HTMLElement) button.style.borderRadius = "200px"; + }, BUTTON_SELECTOR); + await openEditPanel(reactGrab, BUTTON_SELECTOR); + await setSearchInputValue(reactGrab.page, "rounded"); + await expect.poll(() => getActivePropertyKey(reactGrab.page)).toBe("border-radius"); + + // Stepping up from an above-max value is a no-op; settle then + // assert it held (can't poll for the absence of a change). + await reactGrab.page.keyboard.press("ArrowRight"); + await reactGrab.page.waitForTimeout(80); + expect(await getActivePropertyValue(reactGrab.page)).toBe("200px"); + + // ArrowLeft → 199 proves the step came off the real 200, not a + // clamp to the 96px max (which would land on 95). + await reactGrab.page.keyboard.press("ArrowLeft"); + await expect.poll(() => getActivePropertyValue(reactGrab.page)).toBe("199px"); + }); + + test("rounded-full's infinite radius displays as a finite clamped value", async ({ + reactGrab, + }) => { + await reactGrab.page.evaluate((buttonSelector) => { + const button = document.querySelector(buttonSelector); + if (button instanceof HTMLElement) button.style.borderRadius = "calc(infinity * 1px)"; + }, BUTTON_SELECTOR); + await openEditPanel(reactGrab, BUTTON_SELECTOR); + await setSearchInputValue(reactGrab.page, "rounded"); + await expect.poll(() => getActivePropertyKey(reactGrab.page)).toBe("border-radius"); + expect(await getActivePropertyValue(reactGrab.page)).toBe("96px"); + }); + test("ArrowUp / ArrowDown navigate the list, not the value", async ({ reactGrab }) => { await openEditPanel(reactGrab, BUTTON_SELECTOR); const initialActivePropertyKey = await getActivePropertyKey(reactGrab.page); @@ -971,6 +1029,27 @@ test.describe("Style Panel", () => { expect(marginTopAfterTyping).toContain("20"); }); + test("typing -m-4 applies a negative margin", async ({ reactGrab }) => { + await openEditPanel(reactGrab, BUTTON_SELECTOR); + await setSearchInputValue(reactGrab.page, "-m-4"); + + await expect + .poll(() => getInlineStyleProperty(reactGrab.page, BUTTON_SELECTOR, "margin-top")) + .toBe("-16px"); + expect(await getInlineStyleProperty(reactGrab.page, BUTTON_SELECTOR, "margin-left")).toBe( + "-16px", + ); + }); + + test("typing -mt-[8px] applies a negative arbitrary margin", async ({ reactGrab }) => { + await openEditPanel(reactGrab, BUTTON_SELECTOR); + await setSearchInputValue(reactGrab.page, "-mt-[8px]"); + + await expect + .poll(() => getInlineStyleProperty(reactGrab.page, BUTTON_SELECTOR, "margin-top")) + .toBe("-8px"); + }); + test("typing font-mono applies font family + compact", async ({ reactGrab }) => { await openEditPanel(reactGrab, BUTTON_SELECTOR); await reactGrab.page.keyboard.type("font-mono"); @@ -1148,6 +1227,126 @@ test.describe("Style Panel", () => { expect(afterCommit).toBe(inlineStyleAfterTweak); }); + test("copied prompt matches the preview when aggregate and longhand tweaks overlap", async ({ + reactGrab, + }) => { + await openEditPanel(reactGrab, UNIFORM_PADDING_SELECTOR); + await setSearchInputValue(reactGrab.page, "pt-8"); + await expect + .poll(() => getInlineStyleProperty(reactGrab.page, UNIFORM_PADDING_SELECTOR, "padding-top")) + .toBe("32px"); + await setSearchInputValue(reactGrab.page, "p-6"); + await expect + .poll(() => getInlineStyleProperty(reactGrab.page, UNIFORM_PADDING_SELECTOR, "padding-top")) + .toBe("24px"); + await setSearchInputValue(reactGrab.page, "pt-1"); + await expect + .poll(() => getInlineStyleProperty(reactGrab.page, UNIFORM_PADDING_SELECTOR, "padding-top")) + .toBe("4px"); + expect( + await getInlineStyleProperty(reactGrab.page, UNIFORM_PADDING_SELECTOR, "padding-right"), + ).toBe("24px"); + + await reactGrab.page.keyboard.press("Enter"); + await expect.poll(() => isEditPanelVisible(reactGrab.page)).toBe(false); + await expect.poll(() => reactGrab.getClipboardContent()).toContain("padding-top: 4px;"); + const clipboardContent = await reactGrab.getClipboardContent(); + expect(clipboardContent).toContain("padding-right: 24px;"); + expect(clipboardContent).not.toContain("padding-top: 24px;"); + }); + + test("copied prompt emits a longhand stepped back to its original under a changed aggregate", async ({ + reactGrab, + }) => { + await openEditPanel(reactGrab, UNIFORM_PADDING_SELECTOR); + await setSearchInputValue(reactGrab.page, "pt-8"); + await expect + .poll(() => getInlineStyleProperty(reactGrab.page, UNIFORM_PADDING_SELECTOR, "padding-top")) + .toBe("32px"); + await setSearchInputValue(reactGrab.page, "p-6"); + await expect + .poll(() => getInlineStyleProperty(reactGrab.page, UNIFORM_PADDING_SELECTOR, "padding-top")) + .toBe("24px"); + // Back to the original 8px: the padding-top override must still + // be emitted or the prompt claims the p-6 fan-out covers the top. + await setSearchInputValue(reactGrab.page, "pt-2"); + await expect + .poll(() => getInlineStyleProperty(reactGrab.page, UNIFORM_PADDING_SELECTOR, "padding-top")) + .toBe("8px"); + + await reactGrab.page.keyboard.press("Enter"); + await expect.poll(() => isEditPanelVisible(reactGrab.page)).toBe(false); + await expect.poll(() => reactGrab.getClipboardContent()).toContain("padding-top: 8px;"); + const clipboardContent = await reactGrab.getClipboardContent(); + expect(clipboardContent).toContain("padding-right: 24px;"); + expect(clipboardContent).not.toContain("padding-top: 24px;"); + }); + + test("copied prompt matches the preview across overlapping full, longhand, and axis aggregates", async ({ + reactGrab, + }) => { + await openEditPanel(reactGrab, UNIFORM_PADDING_SELECTOR); + await setSearchInputValue(reactGrab.page, "p-6"); + await expect + .poll(() => + getInlineStyleProperty(reactGrab.page, UNIFORM_PADDING_SELECTOR, "padding-left"), + ) + .toBe("24px"); + await setSearchInputValue(reactGrab.page, "pt-8"); + await expect + .poll(() => getInlineStyleProperty(reactGrab.page, UNIFORM_PADDING_SELECTOR, "padding-top")) + .toBe("32px"); + // px-2 sends left/right back to their 8px original on top of the + // p-6 fan-out; it must still be emitted so the prompt doesn't claim + // padding: 24px covers the horizontal sides. + await setSearchInputValue(reactGrab.page, "px-2"); + await expect + .poll(() => + getInlineStyleProperty(reactGrab.page, UNIFORM_PADDING_SELECTOR, "padding-left"), + ) + .toBe("8px"); + + await reactGrab.page.keyboard.press("Enter"); + await expect.poll(() => isEditPanelVisible(reactGrab.page)).toBe(false); + await expect.poll(() => reactGrab.getClipboardContent()).toContain("padding-left: 8px;"); + const clipboardContent = await reactGrab.getClipboardContent(); + expect(clipboardContent).toContain("padding-top: 32px;"); + expect(clipboardContent).toContain("padding-right: 8px;"); + expect(clipboardContent).toContain("padding-bottom: 24px;"); + expect(clipboardContent).not.toContain("padding-right: 24px;"); + }); + + test("re-committing an aggregate over a prior longhand keeps prompt and preview in sync", async ({ + reactGrab, + }) => { + await openEditPanel(reactGrab, UNIFORM_PADDING_SELECTOR); + await setSearchInputValue(reactGrab.page, "p-6"); + await expect + .poll(() => getInlineStyleProperty(reactGrab.page, UNIFORM_PADDING_SELECTOR, "padding-top")) + .toBe("24px"); + await setSearchInputValue(reactGrab.page, "pt-2"); + await expect + .poll(() => getInlineStyleProperty(reactGrab.page, UNIFORM_PADDING_SELECTOR, "padding-top")) + .toBe("8px"); + // Re-committing the wider padding aggregate fans out to every side + // (the preview overwrites the pt override wholesale), so the dropped + // longhand keeps prompt == preview: top must follow, not stay at 8px. + await setSearchInputValue(reactGrab.page, "p-7"); + await expect + .poll(() => getInlineStyleProperty(reactGrab.page, UNIFORM_PADDING_SELECTOR, "padding-top")) + .toBe("28px"); + expect( + await getInlineStyleProperty(reactGrab.page, UNIFORM_PADDING_SELECTOR, "padding-right"), + ).toBe("28px"); + + await reactGrab.page.keyboard.press("Enter"); + await expect.poll(() => isEditPanelVisible(reactGrab.page)).toBe(false); + const clipboardContent = await reactGrab.getClipboardContent(); + expect(clipboardContent).toContain("padding-top: 28px;"); + expect(clipboardContent).toContain("padding-right: 28px;"); + expect(clipboardContent).not.toContain("padding-top: 8px;"); + }); + test("header Copy button appears after a pending tweak and submits", async ({ reactGrab }) => { await openEditPanel(reactGrab, BUTTON_SELECTOR); expect(await isHeaderCopyButtonVisible(reactGrab.page)).toBe(false); diff --git a/packages/react-grab/e2e/fixtures.ts b/packages/react-grab/e2e/fixtures.ts index 46284ac5b..fffd6ffc4 100644 --- a/packages/react-grab/e2e/fixtures.ts +++ b/packages/react-grab/e2e/fixtures.ts @@ -11,6 +11,8 @@ const TOOLBAR_SNAP_ANIMATION_WAIT_MS = 300; const TOOLBAR_SNAP_EDGE_THRESHOLD_PX = 30; const TOUCH_DRAG_STEPS = 10; const PAGE_SETUP_RETRY_BACKOFF_MS = 250; +const SELECTION_HOVER_ATTEMPTS = 3; +const SELECTION_HOVER_TIMEOUT_MS = 4_000; interface ContextMenuInfo { isVisible: boolean; @@ -96,13 +98,14 @@ export interface ReactGrabPageObject { getOverlayHost: () => Locator; getShadowRoot: () => Promise; hoverElement: (selector: string) => Promise; + hoverUntilSelected: (selector: string) => Promise; clickElement: (selector: string) => Promise; rightClickElement: (selector: string) => Promise; rightClickAtPosition: (x: number, y: number) => Promise; dragSelect: (startSelector: string, endSelector: string) => Promise; getClipboardContent: () => Promise; captureNextClipboardWrites: () => Promise>; - waitForSelectionBox: () => Promise; + waitForSelectionBox: (timeout?: number) => Promise; waitForSelectionSource: () => Promise; isContextMenuVisible: () => Promise; getContextMenuInfo: () => Promise; @@ -356,7 +359,7 @@ const createReactGrabPageObject = ( }); }; - const waitForSelectionBox = async () => { + const waitForSelectionBox = async (timeout = 10_000) => { await page.waitForFunction( () => { const api = ( @@ -373,10 +376,26 @@ const createReactGrabPageObject = ( return state?.isSelectionBoxVisible || state?.targetElement !== null; }, undefined, - { timeout: 10_000 }, + { timeout }, ); }; + // A single synthetic hover can race scroll-into-view for elements low + // in the page, leaving react-grab without a selection. Re-hover (moving + // the pointer away first so the move re-fires) until it registers. + const hoverUntilSelected = async (selector: string) => { + for (let attempt = 1; attempt <= SELECTION_HOVER_ATTEMPTS; attempt++) { + if (attempt > 1) await page.mouse.move(0, 0); + await hoverElement(selector); + try { + await waitForSelectionBox(SELECTION_HOVER_TIMEOUT_MS); + return; + } catch (error) { + if (attempt === SELECTION_HOVER_ATTEMPTS) throw error; + } + } + }; + const waitForSelectionSource = async () => { await page.waitForFunction( () => { @@ -608,32 +627,7 @@ const createReactGrabPageObject = ( const enterPromptMode = async (selector: string) => { await activate(); - await hoverElement(selector); - const isSelected = await page - .waitForFunction( - () => { - const api = ( - window as { - __REACT_GRAB__?: { - getState: () => { - isSelectionBoxVisible: boolean; - targetElement: unknown; - }; - }; - } - ).__REACT_GRAB__; - const state = api?.getState(); - return state?.isSelectionBoxVisible || state?.targetElement !== null; - }, - undefined, - { timeout: 5000 }, - ) - .then(() => true) - .catch(() => false); - if (!isSelected) { - await hoverElement(selector); - await waitForSelectionBox(); - } + await hoverUntilSelected(selector); await rightClickElement(selector); await clickContextMenuItem("Comment"); await waitForPromptMode(true); @@ -1727,6 +1721,7 @@ const createReactGrabPageObject = ( getOverlayHost, getShadowRoot, hoverElement, + hoverUntilSelected, clickElement, rightClickElement, rightClickAtPosition, diff --git a/packages/react-grab/src/components/edit-panel/active-property-control.tsx b/packages/react-grab/src/components/edit-panel/active-property-control.tsx index 65b6a67f1..5c261beb4 100644 --- a/packages/react-grab/src/components/edit-panel/active-property-control.tsx +++ b/packages/react-grab/src/components/edit-panel/active-property-control.tsx @@ -61,7 +61,7 @@ export const ActivePropertyControl: Component = (pro value={enumProp().value} options={enumProp().options} activeKey={props.activeKey} - onCommit={props.onCommit} + onStep={props.onStep} /> )} diff --git a/packages/react-grab/src/components/edit-panel/cycle-control.tsx b/packages/react-grab/src/components/edit-panel/cycle-control.tsx index 0a5991a43..ec91ee66c 100644 --- a/packages/react-grab/src/components/edit-panel/cycle-control.tsx +++ b/packages/react-grab/src/components/edit-panel/cycle-control.tsx @@ -1,6 +1,5 @@ import { Show, type Component } from "solid-js"; import type { EnumEditableOption } from "../../types.js"; -import { pickNextOption } from "../../utils/pick-next-option.js"; import { EDIT_LABEL_CLASS } from "./constants.js"; import { StepArrow } from "./step-arrow.js"; @@ -9,7 +8,7 @@ interface CycleControlProps { value: string; options: ReadonlyArray; activeKey: "left" | "right" | null; - onCommit: (value: string) => void; + onStep: (direction: 1 | -1) => void; } export const CycleControl: Component = (props) => { @@ -18,11 +17,6 @@ export const CycleControl: Component = (props) => { return match?.label ?? props.value; }; - const advance = (direction: 1 | -1) => { - const nextOption = pickNextOption(props.options, props.value, direction); - if (nextOption) props.onCommit(nextOption.value); - }; - return (
@@ -34,7 +28,7 @@ export const CycleControl: Component = (props) => { advance(-1)} + onPointerDown={() => props.onStep(-1)} /> = (props) => { onClick={(event) => { event.preventDefault(); event.stopPropagation(); - advance(1); + props.onStep(1); }} onContextMenu={(event) => { event.preventDefault(); event.stopPropagation(); - advance(-1); + props.onStep(-1); }} > {currentLabel()} @@ -61,7 +55,7 @@ export const CycleControl: Component = (props) => { advance(1)} + onPointerDown={() => props.onStep(1)} />
diff --git a/packages/react-grab/src/components/edit-panel/index.tsx b/packages/react-grab/src/components/edit-panel/index.tsx index 61625517d..e7277ca3b 100644 --- a/packages/react-grab/src/components/edit-panel/index.tsx +++ b/packages/react-grab/src/components/edit-panel/index.tsx @@ -47,8 +47,8 @@ import { arePropertyValuesEqual } from "./property-values-equal.js"; import { createShiftTracker } from "./shift-tracker.js"; import { createStepController } from "./step-controller.js"; import { stepProperty } from "./step-property.js"; +import { createStyleStore } from "./style-store.js"; import { createTailwindAutoApply } from "./tailwind-autoapply.js"; -import { createTweakStore } from "./tweak-store.js"; interface EditPanelProps { state: EditPanelState | null; @@ -93,20 +93,20 @@ const EditPanelBody: Component = (props) => { const [searchQuery, setSearchQuery] = createSignal(props.state.initialSearchQuery ?? ""); const [inlineNumericSearchQuery, setInlineNumericSearchQuery] = createSignal(null); const [activeKey, setActiveKey] = createSignal<"left" | "right" | null>(null); - const tweakStore = createTweakStore({ + const styleStore = createStyleStore({ initialProperties, searchQuery: () => inlineNumericSearchQuery() ?? searchQuery(), }); // Colors are pinned on top but aren't slider-steppable, so the arrow-key // cursor lands on the first numeric row instead. const firstNumericActiveIndex = (): number => { - const numericIndex = tweakStore + const numericIndex = styleStore .filteredProperties() .findIndex((property) => property.kind === "numeric"); return numericIndex > 0 ? numericIndex : 0; }; const [activeIndex, setActiveIndex] = createSignal(firstNumericActiveIndex()); - const hasPendingTweaks = createMemo(() => tweakStore.hasPendingTweaks()); + const hasPendingStyles = createMemo(() => styleStore.hasPendingStyles()); const [isCompact, setIsCompact] = createSignal(false); let activeKeyTimerId: ReturnType | undefined; @@ -114,7 +114,7 @@ const EditPanelBody: Component = (props) => { let inlineNumericReplaceTimerId: ReturnType | undefined; let shouldReplaceInlineNumericInput = false; const [isTransientInteraction, setIsTransientInteraction] = createSignal(false); - const isInteracting = createMemo(() => isTransientInteraction() || hasPendingTweaks()); + const isInteracting = createMemo(() => isTransientInteraction() || hasPendingStyles()); const [isHeaderHovered, setIsHeaderHovered] = createSignal(false); const tagDisplay = createMemo(() => @@ -124,10 +124,8 @@ const EditPanelBody: Component = (props) => { }), ); - const filteredProperties = tweakStore.filteredProperties; - const activeProperty = createMemo(() => { - const properties = filteredProperties(); + const properties = styleStore.filteredProperties(); if (properties.length === 0) return null; const index = Math.min(Math.max(0, activeIndex()), properties.length - 1); return properties[index]; @@ -246,7 +244,7 @@ const EditPanelBody: Component = (props) => { nextValue: number | string, options: CommitOptions = {}, ) => { - tweakStore.applyTweak(property, nextValue); + styleStore.applyStyle(property, nextValue); preview.apply(property.cssProperties, formatEditableValue(property, nextValue)); markAsInteracting(); if (!options.isFromKeyRepeat) discardConfirmation.hide(); @@ -257,7 +255,7 @@ const EditPanelBody: Component = (props) => { const isShiftHeld = createShiftTracker(); - const commitTweak = ( + const stepActiveProperty = ( direction: 1 | -1, shift: boolean, fromRepeat: boolean, @@ -278,11 +276,7 @@ const EditPanelBody: Component = (props) => { }; const stepFromKeyboard = (direction: 1 | -1, shift: boolean, fromRepeat: boolean) => { - if (commitTweak(direction, shift, fromRepeat)) setIsCompact(true); - }; - - const stepFromPointer = (direction: 1 | -1) => { - commitTweak(direction, false, false); + if (stepActiveProperty(direction, shift, fromRepeat)) setIsCompact(true); }; const stepController = createStepController({ step: stepFromKeyboard, isShiftHeld }); @@ -303,8 +297,7 @@ const EditPanelBody: Component = (props) => { if (!isShiftHeld()) return null; const property = activeProperty(); if (!property || property.kind !== "numeric") return null; - const cssKey = property.cssProperties[0]; - return cssKey ? findTailwindClass(cssKey, property.value) : null; + return findTailwindClass(property.key, property.value); }); const autoApply = createTailwindAutoApply({ @@ -321,7 +314,7 @@ const EditPanelBody: Component = (props) => { ); const handleSubmit = () => { - const pendingEdits = tweakStore.buildPendingEdits(); + const pendingEdits = styleStore.buildPendingEdits(); const entry = { filePath: props.state.filePath ?? "", lineNumber: props.state.lineNumber ?? 0, @@ -331,7 +324,6 @@ const EditPanelBody: Component = (props) => { }; const discardConfirmation = createDiscardConfirmation(); - const isPendingDismiss = discardConfirmation.isPending; let panelSurfaceRef: HTMLDivElement | undefined; const playShake = () => { @@ -350,11 +342,11 @@ const EditPanelBody: Component = (props) => { const attemptDismiss = (source: OverlayDismissSource) => { stepController.cancelRepeat(); - if (isPendingDismiss()) { + if (discardConfirmation.isPending()) { closePanel("discard"); return; } - if (!hasPendingTweaks()) { + if (!hasPendingStyles()) { closePanel(preview.hasAppliedStyles() ? "discard" : "preserve"); return; } @@ -369,7 +361,7 @@ const EditPanelBody: Component = (props) => { }; const navigateActive = (direction: 1 | -1) => { - const properties = filteredProperties(); + const properties = styleStore.filteredProperties(); if (properties.length === 0) return; setActiveIndex((current) => (current + direction + properties.length) % properties.length); expandPanel(); @@ -412,16 +404,16 @@ const EditPanelBody: Component = (props) => { ArrowRight: (event) => pressArrowOrOpenColorPicker("ArrowRight", event), Tab: (event) => navigateActive(event.shiftKey ? -1 : 1), Enter: () => { - if (isPendingDismiss()) return; + if (discardConfirmation.isPending()) return; const property = activeProperty(); const colorPickerTrigger = getCurrentColorPickerTrigger(); // Opening the picker is only a convenience for an untouched color // row with nothing else staged. If any edit is pending, Enter must // submit it — otherwise the picker interaction dismisses the panel // and the pending change is discarded instead of copied. - const isUntweakedColor = - property?.kind === "color" && !tweakStore.hasChangedTweakFor(property.key); - if (isUntweakedColor && colorPickerTrigger && !hasPendingTweaks()) { + const isUnchangedColor = + property?.kind === "color" && !styleStore.hasChangedStyleFor(property.key); + if (isUnchangedColor && colorPickerTrigger && !hasPendingStyles()) { colorPickerTrigger(); return; } @@ -433,7 +425,7 @@ const EditPanelBody: Component = (props) => { const handleSearchKeyDown = (event: KeyboardEvent) => { // Chromium reports keyCode 229 on the IME commit tick after isComposing resets. if (event.isComposing || event.keyCode === IME_COMPOSING_KEY_CODE) return; - if (isPendingDismiss()) { + if (discardConfirmation.isPending()) { const target = event.composedPath()[0]; const isOnDiscardButton = target instanceof HTMLElement && @@ -476,7 +468,7 @@ const EditPanelBody: Component = (props) => { shouldIgnoreKeyboardEvent: (event) => { const target = event.composedPath()[0]; return ( - isPendingDismiss() && + discardConfirmation.isPending() && target instanceof HTMLElement && target.closest("[data-react-grab-discard-button]") !== null ); @@ -557,13 +549,13 @@ const EditPanelBody: Component = (props) => { }} >
setIsHeaderHovered(true)} onMouseLeave={() => setIsHeaderHovered(false)} @@ -575,7 +567,7 @@ const EditPanelBody: Component = (props) => { onClick={() => {}} shrink /> - +
- 0}> + 0}>
= (props) => { style={isCompact() ? HIDDEN_FOCUS_PRESERVING_STYLE : undefined} > stepActiveProperty(direction, false, false)} onCommit={commitActive} onColorPickerRegister={registerColorPickerTrigger} onEditComplete={ensureSearchFocused} @@ -742,7 +734,7 @@ const EditPanelBody: Component = (props) => { stepActiveProperty(direction, false, false)} onCommit={commitActive} onEditComplete={ensureSearchFocused} onInvalidCommit={playShake} diff --git a/packages/react-grab/src/components/edit-panel/step-property.ts b/packages/react-grab/src/components/edit-panel/step-property.ts index 365d336f6..43821cddd 100644 --- a/packages/react-grab/src/components/edit-panel/step-property.ts +++ b/packages/react-grab/src/components/edit-panel/step-property.ts @@ -17,8 +17,16 @@ export const stepProperty = ( } if (property.kind !== "numeric") return null; const multiplier = shift ? EDIT_SHIFT_STEP_MULTIPLIER : 1; + // Widen the range to include out-of-range originals (text-9xl is + // 128px against a 96px font-size cap) so the first step nudges from + // the real value instead of teleporting to the clamp bound — + // possibly against the pressed direction. const candidate = roundEditableNumericValue( - clampToRange(property.value + direction * multiplier, property.min, property.max), + clampToRange( + property.value + direction * multiplier, + Math.min(property.min, property.value), + Math.max(property.max, property.value), + ), ); return candidate === property.value ? null : candidate; }; diff --git a/packages/react-grab/src/components/edit-panel/style-store.ts b/packages/react-grab/src/components/edit-panel/style-store.ts new file mode 100644 index 000000000..e317db3f4 --- /dev/null +++ b/packages/react-grab/src/components/edit-panel/style-store.ts @@ -0,0 +1,205 @@ +import { createMemo, createSignal } from "solid-js"; +import { EDIT_PROPERTY_MAX_COUNT } from "../../constants.js"; +import type { EditableProperty, PendingEdit } from "../../types.js"; +import { createPropertySearchIndex } from "../../utils/property-search-index.js"; +import { arePropertyValuesEqual } from "./property-values-equal.js"; + +type PropertyStyle = + | { kind: "numeric"; value: number } + | { kind: "color"; value: string } + | { kind: "enum"; value: string }; + +interface CreateStyleStoreOptions { + initialProperties: EditableProperty[]; + searchQuery: () => string; +} + +interface StyleStore { + filteredProperties: () => EditableProperty[]; + applyStyle: (property: EditableProperty, nextValue: number | string) => void; + buildPendingEdits: () => PendingEdit[]; + hasPendingStyles: () => boolean; + hasChangedStyleFor: (key: string) => boolean; +} + +const hasChangedFromOriginal = (property: EditableProperty, style: PropertyStyle): boolean => { + return ( + property.kind === style.kind && + !arePropertyValuesEqual(property, style.value, property.original) + ); +}; + +export const createStyleStore = (options: CreateStyleStoreOptions): StyleStore => { + const { initialProperties, searchQuery } = options; + const [stylesByKey, setStylesByKey] = createSignal>({}); + const propertySearchIndex = createPropertySearchIndex(initialProperties); + + const baseFilteredProperties = createMemo(() => { + const query = searchQuery(); + const currentStyles = stylesByKey(); + const candidates = query + ? initialProperties + : initialProperties.filter( + (property) => + property.isPrioritized || + (property.isCanonical && !property.isDefault) || + currentStyles[property.key] !== undefined, + ); + return query ? propertySearchIndex.search(query) : candidates.slice(0, EDIT_PROPERTY_MAX_COUNT); + }); + + const propertyByKey = new Map(initialProperties.map((property) => [property.key, property])); + + const filteredProperties = createMemo(() => { + const currentStyles = stylesByKey(); + const styledKeys = Object.keys(currentStyles); + if (styledKeys.length === 0) return baseFilteredProperties(); + + const numericValueByLonghand = new Map(); + for (const styledKey of styledKeys) { + const style = currentStyles[styledKey]; + if (style.kind !== "numeric") continue; + const styledProperty = propertyByKey.get(styledKey); + if (!styledProperty) continue; + for (const longhand of styledProperty.cssProperties) { + numericValueByLonghand.set(longhand, style.value); + } + } + + return baseFilteredProperties().map((property) => { + const directStyle = currentStyles[property.key]; + if (directStyle !== undefined) { + return overrideValue(property, directStyle); + } + if (property.kind !== "numeric") return property; + const firstLonghandValue = numericValueByLonghand.get(property.cssProperties[0]); + if (firstLonghandValue === undefined) return property; + const allCoveredSameValue = property.cssProperties.every( + (longhand) => numericValueByLonghand.get(longhand) === firstLonghandValue, + ); + return allCoveredSameValue ? { ...property, value: firstLonghandValue } : property; + }); + }); + + const overrideValue = (property: EditableProperty, style: PropertyStyle): EditableProperty => { + if (property.kind === "numeric" && style.kind === "numeric") + return { ...property, value: style.value }; + if (property.kind === "color" && style.kind === "color") + return { ...property, value: style.value }; + if (property.kind === "enum" && style.kind === "enum") + return { ...property, value: style.value }; + return property; + }; + + // Overlapping styles resolve last-write-wins, so every consumer that + // reads them (filteredProperties' fan-out, buildPendingEdits' forward + // scan) must see them in commit order. A Record preserves insertion + // order, so the just-committed key is kept last: re-committing the + // current last key spreads it in place; committing any other key + // rebuilds the map, appends that key, and drops styles whose longhands + // are now fully covered (their preview was overwritten wholesale). + const applyStyle = (property: EditableProperty, nextValue: number | string) => { + let style: PropertyStyle; + if (property.kind === "numeric" && typeof nextValue === "number") { + style = { kind: "numeric", value: nextValue }; + } else if (property.kind === "color" && typeof nextValue === "string") { + style = { kind: "color", value: nextValue }; + } else if (property.kind === "enum" && typeof nextValue === "string") { + style = { kind: "enum", value: nextValue }; + } else { + return; + } + setStylesByKey((current) => { + const existingKeys = Object.keys(current); + if (existingKeys[existingKeys.length - 1] === property.key) { + return { ...current, [property.key]: style }; + } + const next: Record = {}; + for (const existingKey of existingKeys) { + if (existingKey === property.key) continue; + const existingProperty = propertyByKey.get(existingKey); + const isCoveredByNewStyle = + existingProperty !== undefined && + existingProperty.cssProperties.every((longhand) => + property.cssProperties.includes(longhand), + ); + if (isCoveredByNewStyle) continue; + next[existingKey] = current[existingKey]; + } + next[property.key] = style; + return next; + }); + }; + + const buildPendingEdits = (): PendingEdit[] => { + const currentStyles = stylesByKey(); + const pendingEdits: PendingEdit[] = []; + const changedCssProperties = new Set(); + for (const styledKey of Object.keys(currentStyles)) { + const style = currentStyles[styledKey]; + const property = propertyByKey.get(styledKey); + if (!property || property.kind !== style.kind) continue; + const isChanged = hasChangedFromOriginal(property, style); + // A back-to-original style still matters when an earlier (wider) + // style changed one of its longhands: `pt-4`, `p-6`, then top + // back to its original must emit `padding-top` or the prompt + // claims the `padding: 24px` fan-out applies to the top side too. + const isOverridingChangedStyle = + !isChanged && + property.cssProperties.some((cssProperty) => changedCssProperties.has(cssProperty)); + if (!isChanged && !isOverridingChangedStyle) continue; + if (isChanged) { + for (const cssProperty of property.cssProperties) changedCssProperties.add(cssProperty); + } + if (property.kind === "numeric" && style.kind === "numeric") { + pendingEdits.push({ + kind: "numeric", + key: property.key, + cssProperties: property.cssProperties, + value: style.value, + unit: property.unit, + }); + } else if (property.kind === "color" && style.kind === "color") { + pendingEdits.push({ + kind: "color", + key: property.key, + cssProperties: property.cssProperties, + value: style.value, + }); + } else if (property.kind === "enum" && style.kind === "enum") { + pendingEdits.push({ + kind: "enum", + key: property.key, + cssProperties: property.cssProperties, + value: style.value, + }); + } + } + return pendingEdits; + }; + + const hasPendingStyles = () => { + const currentStyles = stylesByKey(); + for (const property of initialProperties) { + const style = currentStyles[property.key]; + if (style && hasChangedFromOriginal(property, style)) return true; + } + return false; + }; + + // A no-op commit must not count as changed, or Enter would submit + // instead of opening the inline editor. + const hasChangedStyleFor = (key: string): boolean => { + const style = stylesByKey()[key]; + const property = propertyByKey.get(key); + return Boolean(style && property && hasChangedFromOriginal(property, style)); + }; + + return { + filteredProperties, + applyStyle, + buildPendingEdits, + hasPendingStyles, + hasChangedStyleFor, + }; +}; diff --git a/packages/react-grab/src/components/edit-panel/tailwind-autoapply.ts b/packages/react-grab/src/components/edit-panel/tailwind-autoapply.ts index 855ffe2da..9f53b65fa 100644 --- a/packages/react-grab/src/components/edit-panel/tailwind-autoapply.ts +++ b/packages/react-grab/src/components/edit-panel/tailwind-autoapply.ts @@ -11,6 +11,7 @@ import { expandAggregateLonghands } from "../../utils/expand-aggregate-longhands import { roundEditableNumericValue } from "../../utils/format-css-value.js"; import { isNumericDraftQuery } from "../../utils/is-numeric-draft-query.js"; import { parseAnyColor } from "../../utils/parse-any-color.js"; +import { splitNegativePrefix } from "../../utils/split-negative-prefix.js"; import { normalizeTailwindClassInput, tailwindClassToEnumValue, @@ -41,30 +42,30 @@ const LITERAL_NUMBER_KEYS = new Set([ const findNumeric = ( properties: readonly EditableProperty[], - cssKey: string, + propertyKey: string, ): NumericEditableProperty | null => { for (const property of properties) { - if (property.key === cssKey && property.kind === "numeric") return property; + if (property.key === propertyKey && property.kind === "numeric") return property; } return null; }; const findEnum = ( properties: readonly EditableProperty[], - cssKey: string, + propertyKey: string, ): EnumEditableProperty | null => { for (const property of properties) { - if (property.key === cssKey && property.kind === "enum") return property; + if (property.key === propertyKey && property.kind === "enum") return property; } return null; }; const findColor = ( properties: readonly EditableProperty[], - cssKey: string, + propertyKey: string, ): ColorEditableProperty | null => { for (const property of properties) { - if (property.key === cssKey && property.kind === "color") return property; + if (property.key === propertyKey && property.kind === "color") return property; } return null; }; @@ -98,9 +99,9 @@ const parseArbitraryLengthPx = (value: string, allowUnitless: boolean): number | const findNumericLonghands = ( properties: readonly EditableProperty[], - cssKey: string, + propertyKey: string, ): NumericEditableProperty[] => { - const longhands = expandAggregateLonghands(cssKey); + const longhands = expandAggregateLonghands(propertyKey); const matchedByRowKey = new Map(); for (const longhand of longhands) { for (const property of properties) { @@ -146,13 +147,16 @@ export const createTailwindAutoApply = ( const { initialProperties, searchQuery, isCompact, activeProperty, commit, setIsCompact } = options; - const hasTrackableTarget = (cssKey: string): boolean => { + const hasTrackableTarget = (propertyKey: string): boolean => { for (const property of initialProperties) { - if (property.key === cssKey && (property.kind === "numeric" || property.kind === "enum")) { + if ( + property.key === propertyKey && + (property.kind === "numeric" || property.kind === "enum") + ) { return true; } } - return findNumericLonghands(initialProperties, cssKey).length > 0; + return findNumericLonghands(initialProperties, propertyKey).length > 0; }; const parseInlineNumericValue = (query: string): InlineNumericValue | null => { @@ -197,8 +201,8 @@ export const createTailwindAutoApply = ( return true; }; - const commitLengthPx = (cssKey: string, value: number): boolean => { - const numericTarget = findNumeric(initialProperties, cssKey); + const commitLengthPx = (propertyKey: string, value: number): boolean => { + const numericTarget = findNumeric(initialProperties, propertyKey); if (numericTarget) { // px lengths only apply to px-measured props, not opacity/z-index. if (numericTarget.unit !== "px") return false; @@ -206,7 +210,7 @@ export const createTailwindAutoApply = ( commit(numericTarget, clampedFor(numericTarget, value), { shouldCompact: true }); return true; } - const sideProperties = findNumericLonghands(initialProperties, cssKey); + const sideProperties = findNumericLonghands(initialProperties, propertyKey); if (sideProperties.length === 0) return false; setIsCompact(true); batch(() => { @@ -244,58 +248,71 @@ export const createTailwindAutoApply = ( const lengthPx = parseArbitraryLengthPx(value, ARBITRARY_LENGTH_HINT.test(rawValue.trim())); if (lengthPx === null) return false; - const cssKey = tailwindPrefixToProperty(arbitraryPrefix(arbitraryMatch[1])); - return cssKey ? commitLengthPx(cssKey, lengthPx) : false; + const { isNegative, base: basePrefix } = splitNegativePrefix(arbitraryMatch[1]); + const propertyKey = tailwindPrefixToProperty(arbitraryPrefix(basePrefix)); + return propertyKey ? commitLengthPx(propertyKey, isNegative ? -lengthPx : lengthPx) : false; }; const applySingleClass = (rawQuery: string) => { if (applyArbitraryClass(rawQuery)) return; const query = normalizeTailwindClassInput(rawQuery); - const intentPrefix = query.replace(/-\d*$/, "").replace(/-$/, ""); - const intentCssKey = intentPrefix ? tailwindPrefixToProperty(intentPrefix) : null; - if (intentCssKey && hasTrackableTarget(intentCssKey)) setIsCompact(true); - - if (applyColorClass(query, null)) return; - - const enumMapping = tailwindClassToEnumValue(query); - if (enumMapping) { - const enumTarget = findEnum(initialProperties, enumMapping.property); - if (!enumTarget) return; - const option = enumTarget.options.find((entry) => entry.value === enumMapping.value); - if (option) { - setIsCompact(true); - commit(enumTarget, option.value, { shouldCompact: true }); + // Canonical negative spelling: `-m-4` is margin -16px. Only the + // numeric path below understands the sign; colors and enums have + // no negative forms. + const { isNegative: isNegativePrefixed, base: baseQuery } = splitNegativePrefix(query); + const intentPrefix = baseQuery.replace(/-\d*$/, "").replace(/-$/, ""); + const intentPropertyKey = intentPrefix ? tailwindPrefixToProperty(intentPrefix) : null; + if (intentPropertyKey && hasTrackableTarget(intentPropertyKey)) setIsCompact(true); + + if (!isNegativePrefixed) { + if (applyColorClass(query, null)) return; + + const enumMapping = tailwindClassToEnumValue(query); + if (enumMapping) { + const enumTarget = findEnum(initialProperties, enumMapping.property); + if (!enumTarget) return; + const option = enumTarget.options.find((entry) => entry.value === enumMapping.value); + if (option) { + setIsCompact(true); + commit(enumTarget, option.value, { shouldCompact: true }); + } + return; } - return; } - const tailwindClassMatch = query.match(TAILWIND_CLASS_PATTERN); + const tailwindClassMatch = baseQuery.match(TAILWIND_CLASS_PATTERN); if (!tailwindClassMatch) return; - const cssKey = tailwindPrefixToProperty(tailwindClassMatch[1]); - if (!cssKey) return; + const propertyKey = tailwindPrefixToProperty(tailwindClassMatch[1]); + if (!propertyKey) return; const rawNumber = Number.parseFloat(tailwindClassMatch[2]); if (!Number.isFinite(rawNumber)) return; - const candidate = LITERAL_NUMBER_KEYS.has(cssKey) - ? rawNumber - : rawNumber * TAILWIND_SPACING_UNIT_PX; + const signedNumber = isNegativePrefixed ? -rawNumber : rawNumber; + const candidate = LITERAL_NUMBER_KEYS.has(propertyKey) + ? signedNumber + : signedNumber * TAILWIND_SPACING_UNIT_PX; - const numericTarget = findNumeric(initialProperties, cssKey); + const numericTarget = findNumeric(initialProperties, propertyKey); if (numericTarget) { commit(numericTarget, clampedFor(numericTarget, candidate), { shouldCompact: true }); return; } - const enumTarget = findEnum(initialProperties, cssKey); - if (enumTarget) { - const optionValue = String(rawNumber); - const option = enumTarget.options.find((entry) => entry.value === optionValue); - if (option) { - commit(enumTarget, option.value, { shouldCompact: true }); - return; + // Enum spellings like `font-700` are unsigned-only; a negative prefix + // (`-font-700`) has no meaning, so don't let the unsigned rawNumber + // resolve an enum option behind the sign's back. + if (!isNegativePrefixed) { + const enumTarget = findEnum(initialProperties, propertyKey); + if (enumTarget) { + const optionValue = String(rawNumber); + const option = enumTarget.options.find((entry) => entry.value === optionValue); + if (option) { + commit(enumTarget, option.value, { shouldCompact: true }); + return; + } } } - const sideProperties = findNumericLonghands(initialProperties, cssKey); + const sideProperties = findNumericLonghands(initialProperties, propertyKey); if (sideProperties.length === 0) return; batch(() => { for (const sideProperty of sideProperties) { @@ -306,12 +323,13 @@ export const createTailwindAutoApply = ( const isApplicableSingleClass = (normalizedQuery: string): boolean => { if (tailwindClassToEnumValue(normalizedQuery)) return true; - const tailwindClassMatch = normalizedQuery.match(TAILWIND_CLASS_PATTERN); + const { base: baseQuery } = splitNegativePrefix(normalizedQuery); + const tailwindClassMatch = baseQuery.match(TAILWIND_CLASS_PATTERN); if (!tailwindClassMatch) return false; - const cssKey = tailwindPrefixToProperty(tailwindClassMatch[1]); - if (!cssKey) return false; - if (hasTrackableTarget(cssKey)) return true; - return findEnum(initialProperties, cssKey) !== null; + const propertyKey = tailwindPrefixToProperty(tailwindClassMatch[1]); + if (!propertyKey) return false; + if (hasTrackableTarget(propertyKey)) return true; + return findEnum(initialProperties, propertyKey) !== null; }; const applyTailwindClass = (rawQuery: string) => { diff --git a/packages/react-grab/src/components/edit-panel/tweak-store.ts b/packages/react-grab/src/components/edit-panel/tweak-store.ts deleted file mode 100644 index 67871255e..000000000 --- a/packages/react-grab/src/components/edit-panel/tweak-store.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { createMemo, createSignal } from "solid-js"; -import { EDIT_PROPERTY_MAX_COUNT } from "../../constants.js"; -import type { EditableProperty, PendingEdit } from "../../types.js"; -import { createPropertySearchIndex } from "../../utils/property-search-index.js"; -import { arePropertyValuesEqual } from "./property-values-equal.js"; - -type PropertyTweak = - | { kind: "numeric"; value: number } - | { kind: "color"; value: string } - | { kind: "enum"; value: string }; - -interface CreateTweakStoreOptions { - initialProperties: EditableProperty[]; - searchQuery: () => string; -} - -interface TweakStore { - filteredProperties: () => EditableProperty[]; - applyTweak: (property: EditableProperty, nextValue: number | string) => void; - buildPendingEdits: () => PendingEdit[]; - hasPendingTweaks: () => boolean; - hasChangedTweakFor: (key: string) => boolean; -} - -const hasChangedFromOriginal = (property: EditableProperty, tweak: PropertyTweak): boolean => { - return ( - property.kind === tweak.kind && - !arePropertyValuesEqual(property, tweak.value, property.original) - ); -}; - -export const createTweakStore = (options: CreateTweakStoreOptions): TweakStore => { - const { initialProperties, searchQuery } = options; - const [tweaksByKey, setTweaksByKey] = createSignal>({}); - const propertySearchIndex = createPropertySearchIndex(initialProperties); - - const baseFilteredProperties = createMemo(() => { - const query = searchQuery(); - const currentTweaks = tweaksByKey(); - const candidates = query - ? initialProperties - : initialProperties.filter( - (property) => - property.isPrioritized || - (property.isCanonical && !property.isDefault) || - currentTweaks[property.key] !== undefined, - ); - return query ? propertySearchIndex.search(query) : candidates.slice(0, EDIT_PROPERTY_MAX_COUNT); - }); - - const propertyByKey = new Map(initialProperties.map((property) => [property.key, property])); - - const filteredProperties = createMemo(() => { - const currentTweaks = tweaksByKey(); - const tweakedKeys = Object.keys(currentTweaks); - if (tweakedKeys.length === 0) return baseFilteredProperties(); - - const numericValueByLonghand = new Map(); - for (const tweakedKey of tweakedKeys) { - const tweak = currentTweaks[tweakedKey]; - if (tweak.kind !== "numeric") continue; - const tweakedProperty = propertyByKey.get(tweakedKey); - if (!tweakedProperty) continue; - for (const longhand of tweakedProperty.cssProperties) { - numericValueByLonghand.set(longhand, tweak.value); - } - } - - return baseFilteredProperties().map((property) => { - const directTweak = currentTweaks[property.key]; - if (directTweak !== undefined) { - return overrideValue(property, directTweak); - } - if (property.kind !== "numeric") return property; - const firstLonghandValue = numericValueByLonghand.get(property.cssProperties[0]); - if (firstLonghandValue === undefined) return property; - const allCoveredSameValue = property.cssProperties.every( - (longhand) => numericValueByLonghand.get(longhand) === firstLonghandValue, - ); - return allCoveredSameValue ? { ...property, value: firstLonghandValue } : property; - }); - }); - - const overrideValue = (property: EditableProperty, tweak: PropertyTweak): EditableProperty => { - if (property.kind === "numeric" && tweak.kind === "numeric") - return { ...property, value: tweak.value }; - if (property.kind === "color" && tweak.kind === "color") - return { ...property, value: tweak.value }; - if (property.kind === "enum" && tweak.kind === "enum") - return { ...property, value: tweak.value }; - return property; - }; - - const applyTweak = (property: EditableProperty, nextValue: number | string) => { - let tweak: PropertyTweak; - if (property.kind === "numeric" && typeof nextValue === "number") { - tweak = { kind: "numeric", value: nextValue }; - } else if (property.kind === "color" && typeof nextValue === "string") { - tweak = { kind: "color", value: nextValue }; - } else if (property.kind === "enum" && typeof nextValue === "string") { - tweak = { kind: "enum", value: nextValue }; - } else { - return; - } - setTweaksByKey((current) => ({ ...current, [property.key]: tweak })); - }; - - const buildPendingEdits = (): PendingEdit[] => { - const currentTweaks = tweaksByKey(); - const pendingEdits: PendingEdit[] = []; - for (const tweakedKey of Object.keys(currentTweaks)) { - const tweak = currentTweaks[tweakedKey]; - const property = propertyByKey.get(tweakedKey); - if (!property || !hasChangedFromOriginal(property, tweak)) continue; - if (property.kind === "numeric" && tweak.kind === "numeric") { - pendingEdits.push({ - kind: "numeric", - key: property.key, - cssProperties: property.cssProperties, - value: tweak.value, - unit: property.unit, - }); - } else if (property.kind === "color" && tweak.kind === "color") { - pendingEdits.push({ - kind: "color", - key: property.key, - cssProperties: property.cssProperties, - value: tweak.value, - }); - } else if (property.kind === "enum" && tweak.kind === "enum") { - pendingEdits.push({ - kind: "enum", - key: property.key, - cssProperties: property.cssProperties, - value: tweak.value, - }); - } - } - return pendingEdits; - }; - - const hasPendingTweaks = () => { - const currentTweaks = tweaksByKey(); - for (const property of initialProperties) { - const tweak = currentTweaks[property.key]; - if (tweak && hasChangedFromOriginal(property, tweak)) return true; - } - return false; - }; - // A no-op commit must not count as tweaked, or Enter would submit - // instead of opening the inline editor. - const hasChangedTweakFor = (key: string): boolean => { - const tweak = tweaksByKey()[key]; - const property = propertyByKey.get(key); - return Boolean(tweak && property && hasChangedFromOriginal(property, tweak)); - }; - - return { - filteredProperties, - applyTweak, - buildPendingEdits, - hasPendingTweaks, - hasChangedTweakFor, - }; -}; diff --git a/packages/react-grab/src/components/toolbar/index.tsx b/packages/react-grab/src/components/toolbar/index.tsx index b377e2013..e2a45de5e 100644 --- a/packages/react-grab/src/components/toolbar/index.tsx +++ b/packages/react-grab/src/components/toolbar/index.tsx @@ -23,7 +23,6 @@ import { import { freezeUpdates } from "../../utils/freeze-updates.js"; import { freezeGlobalAnimations, unfreezeGlobalAnimations } from "../../utils/freeze-animations.js"; import { freezePseudoStates, unfreezePseudoStates } from "../../utils/freeze-pseudo-states.js"; -import { getButtonSpacingClass } from "../../utils/toolbar-layout.js"; import { ToolbarContent } from "./toolbar-content.js"; import { getVisualViewport } from "../../utils/get-visual-viewport.js"; import { @@ -114,7 +113,7 @@ export const Toolbar: Component = (props) => { const isVertical = () => snapEdge() === "left" || snapEdge() === "right"; - const buttonSpacingClass = () => getButtonSpacingClass(isVertical()); + const buttonSpacingClass = () => (isVertical() ? "mb-1.5" : "mr-1.5"); const isActionActive = (actionId: string) => props.activeActionId === actionId; // Activation paths that bypass the toolbar buttons (keyboard hold, api.activate, diff --git a/packages/react-grab/src/components/toolbar/toolbar-content.tsx b/packages/react-grab/src/components/toolbar/toolbar-content.tsx index e612bffba..f8ba2c53e 100644 --- a/packages/react-grab/src/components/toolbar/toolbar-content.tsx +++ b/packages/react-grab/src/components/toolbar/toolbar-content.tsx @@ -1,7 +1,6 @@ import type { Component, JSX } from "solid-js"; import { cn } from "../../utils/cn.js"; import { IconChevron } from "../icons/icon-chevron.jsx"; -import { getMinDimensionClass } from "../../utils/toolbar-layout.js"; interface ToolbarContentProps { isCollapsed?: boolean; @@ -31,7 +30,7 @@ export const ToolbarContent: Component = (props) => { ? `transition-[grid-template-rows] ${sizeDurationClass()} ease-drawer` : `transition-[grid-template-columns] ${sizeDurationClass()} ease-drawer`; - const minDimensionClass = () => getMinDimensionClass(isVertical()); + const minDimensionClass = () => (isVertical() ? "min-h-0" : "min-w-0"); const collapsedEdgeClasses = () => { if (!props.isCollapsed) return ""; diff --git a/packages/react-grab/src/constants.ts b/packages/react-grab/src/constants.ts index efaf6384d..48fe6436b 100644 --- a/packages/react-grab/src/constants.ts +++ b/packages/react-grab/src/constants.ts @@ -237,6 +237,12 @@ export const EDIT_SEARCH_LENGTH_PENALTY = 1; export const EDIT_COMPACT_SLIDER_MIN_WIDTH_PX = 96; export const CSS_VALUE_DECIMAL_PLACES = 2; +// Tailwind v4's `rounded-full` is calc(infinity * 1px). Browsers clamp +// it to their internal length maximum (~3.36e7px in Chromium, ~1.79e7px +// in Firefox, float max in WebKit) — all far past this threshold and +// none a real length. Cap to the row's max bound instead of showing +// (and re-emitting) 33554400px. +export const CSS_LENGTH_INFINITY_THRESHOLD_PX = 1e7; export const OPACITY_PERCENT_MAX = 100; export const FONT_SIZE_LINE_HEIGHT_RATIO = 1.2; // Tailwind's spacing scale: `p-1` = 0.25rem = 4px, so each "unit" diff --git a/packages/react-grab/src/core/edit-mode.ts b/packages/react-grab/src/core/edit-mode.ts index 48f76cfd9..3588f35e7 100644 --- a/packages/react-grab/src/core/edit-mode.ts +++ b/packages/react-grab/src/core/edit-mode.ts @@ -74,7 +74,7 @@ export const createEditModeController = ( position: Position, overrides: EditModeOverrides = {}, ): boolean => { - // Re-entry would desync the existing preview from the panel's tweak store. + // Re-entry would desync the existing preview from the panel's style store. if (state() !== null) return false; const properties = buildEditableProperties(element); if (properties.length === 0) return false; diff --git a/packages/react-grab/src/utils/css-baseline-measurement.ts b/packages/react-grab/src/utils/css-baseline-measurement.ts index 47c4b54d1..64ba9cb7b 100644 --- a/packages/react-grab/src/utils/css-baseline-measurement.ts +++ b/packages/react-grab/src/utils/css-baseline-measurement.ts @@ -36,12 +36,21 @@ export const measureBaseline = (target: Element): ComputedSnapshot | null => { } }; +// The baseline clone is forced to display:none, so its computed +// `display` is always "none" and its width/height never resolve to +// used layout values — comparing those keys against the target would +// mark them "modified" on every element. Fall back to the heuristic. +const LAYOUT_DEPENDENT_BASELINE_KEYS: ReadonlySet = new Set(["display", "width", "height"]); + export const isDefaultByBaseline = ( property: EditableProperty, currentSnapshot: ComputedSnapshot, baselineSnapshot: ComputedSnapshot, ): boolean => { if (property.kind === "color") return false; + if (property.cssProperties.some((key) => LAYOUT_DEPENDENT_BASELINE_KEYS.has(key))) { + return isDefaultByHeuristic(property); + } return property.cssProperties.every((key) => { const currentValue = currentSnapshot[key]; const baselineValue = baselineSnapshot[key]; @@ -72,6 +81,7 @@ export const isDefaultByHeuristic = (property: EditableProperty): boolean => { if ( propertyKey === "width" || propertyKey === "height" || + propertyKey === "width,height" || propertyKey.startsWith("max-") || propertyKey.startsWith("min-") ) { diff --git a/packages/react-grab/src/utils/css-property-builders.ts b/packages/react-grab/src/utils/css-property-builders.ts index 6430bf5ce..8d7d4cfe8 100644 --- a/packages/react-grab/src/utils/css-property-builders.ts +++ b/packages/react-grab/src/utils/css-property-builders.ts @@ -4,7 +4,7 @@ import type { EnumEditableProperty, NumericEditableProperty, } from "../types.js"; -import { EDIT_TRANSPARENT_COLOR_HEX } from "../constants.js"; +import { CSS_LENGTH_INFINITY_THRESHOLD_PX, EDIT_TRANSPARENT_COLOR_HEX } from "../constants.js"; import { propertyBounds } from "./css-property-bounds.js"; import { normalizeForEdit } from "./css-value-resolution.js"; import { parseAnyColor } from "./parse-any-color.js"; @@ -19,7 +19,20 @@ export const buildNumericProperty = ( isCanonical: boolean, ): NumericEditableProperty => { const normalized = normalizeForEdit(definition.key, rawValue); - const bounds = propertyBounds(definition.key, normalized.value, normalized.unit); + // `rounded-full` resolves to calc(infinity * 1px), which browsers clamp + // to a huge px length. Only px-measured rows can hit it — unitless rows + // (z-index) keep their real value even when it legitimately exceeds the + // threshold. Pass 0 into propertyBounds so a value-derived max (size = + // value * 2) isn't itself inflated before we clamp to the bound. + const isInfiniteLength = + normalized.unit === "px" && Math.abs(normalized.value) > CSS_LENGTH_INFINITY_THRESHOLD_PX; + const bounds = propertyBounds( + definition.key, + isInfiniteLength ? 0 : normalized.value, + normalized.unit, + ); + const clampedInfiniteValue = normalized.value < 0 ? bounds.min : bounds.max; + const value = isInfiniteLength ? clampedInfiniteValue : normalized.value; return { kind: "numeric", key: definition.key, @@ -27,8 +40,8 @@ export const buildNumericProperty = ( cssProperties: definition.longhands, min: bounds.min, max: bounds.max, - value: normalized.value, - original: normalized.value, + value, + original: value, unit: normalized.unit, tailwindAliases: tailwindAliasesForProperty(definition.key), isPrioritized: false, diff --git a/packages/react-grab/src/utils/expand-aggregate-longhands.ts b/packages/react-grab/src/utils/expand-aggregate-longhands.ts index 595156d53..5b8a77a6a 100644 --- a/packages/react-grab/src/utils/expand-aggregate-longhands.ts +++ b/packages/react-grab/src/utils/expand-aggregate-longhands.ts @@ -7,8 +7,8 @@ for (const group of AGGREGATE_GROUPS) { } } -export const expandAggregateLonghands = (cssKey: string): string[] => { - if (cssKey.includes(",")) return cssKey.split(","); - const expansion = AGGREGATE_LONGHANDS.get(cssKey); - return expansion ? [...expansion] : [cssKey]; +export const expandAggregateLonghands = (propertyKey: string): string[] => { + if (propertyKey.includes(",")) return propertyKey.split(","); + const expansion = AGGREGATE_LONGHANDS.get(propertyKey); + return expansion ? [...expansion] : [propertyKey]; }; diff --git a/packages/react-grab/src/utils/find-tailwind-class.ts b/packages/react-grab/src/utils/find-tailwind-class.ts index 3d9c1cdf5..444985630 100644 --- a/packages/react-grab/src/utils/find-tailwind-class.ts +++ b/packages/react-grab/src/utils/find-tailwind-class.ts @@ -6,72 +6,68 @@ import { TAILWIND_Z_INDEX_MAX, } from "../constants.js"; -const SPACING_PROPERTIES: ReadonlySet = new Set([ - "padding", - "padding-top", - "padding-right", - "padding-bottom", - "padding-left", - "margin", - "margin-top", - "margin-right", - "margin-bottom", - "margin-left", - "gap", - "column-gap", - "row-gap", - "width", - "height", - "min-width", - "max-width", - "min-height", - "max-height", - "top", - "right", - "bottom", - "left", -]); +// Tailwind suffix scales the chip number differently per family: +// spacing/size/inset use the 4px unit, border-width and z-index are +// literal px/integers, opacity is a percent step. +type TailwindChipScale = "spacing" | "border-width" | "z-index" | "opacity"; -// Most-specific Tailwind prefix per CSS key — `pt` over `p` for -// padding-top so the chip names the longhand the user would type. -const PROPERTY_TO_BEST_PREFIX: Record = { - padding: "p", - "padding-top": "pt", - "padding-right": "pr", - "padding-bottom": "pb", - "padding-left": "pl", - margin: "m", - "margin-top": "mt", - "margin-right": "mr", - "margin-bottom": "mb", - "margin-left": "ml", - gap: "gap", - "column-gap": "gap-x", - "row-gap": "gap-y", - width: "w", - height: "h", - "min-width": "min-w", - "max-width": "max-w", - "min-height": "min-h", - "max-height": "max-h", - top: "top", - right: "right", - bottom: "bottom", - left: "left", - "border-width": "border", - "border-top-width": "border-t", - "border-right-width": "border-r", - "border-bottom-width": "border-b", - "border-left-width": "border-l", - "z-index": "z", - opacity: "opacity", +interface TailwindChipRule { + prefix: string; + scale: TailwindChipScale; +} + +// Single source of truth for the chip: the most-specific Tailwind prefix +// per row key (`pt` over `p` for padding-top, so the chip names the class +// the user would type) plus the suffix scale that prefix uses. Keys are +// EditableProperty.key spellings, including the comma-joined aggregate +// rows ("padding-top,padding-bottom" → `py`). +const PROPERTY_CHIP_RULES: Record = { + padding: { prefix: "p", scale: "spacing" }, + "padding-top,padding-bottom": { prefix: "py", scale: "spacing" }, + "padding-left,padding-right": { prefix: "px", scale: "spacing" }, + "padding-top": { prefix: "pt", scale: "spacing" }, + "padding-right": { prefix: "pr", scale: "spacing" }, + "padding-bottom": { prefix: "pb", scale: "spacing" }, + "padding-left": { prefix: "pl", scale: "spacing" }, + margin: { prefix: "m", scale: "spacing" }, + "margin-top,margin-bottom": { prefix: "my", scale: "spacing" }, + "margin-left,margin-right": { prefix: "mx", scale: "spacing" }, + "margin-top": { prefix: "mt", scale: "spacing" }, + "margin-right": { prefix: "mr", scale: "spacing" }, + "margin-bottom": { prefix: "mb", scale: "spacing" }, + "margin-left": { prefix: "ml", scale: "spacing" }, + gap: { prefix: "gap", scale: "spacing" }, + "column-gap": { prefix: "gap-x", scale: "spacing" }, + "row-gap": { prefix: "gap-y", scale: "spacing" }, + "width,height": { prefix: "size", scale: "spacing" }, + width: { prefix: "w", scale: "spacing" }, + height: { prefix: "h", scale: "spacing" }, + "min-width": { prefix: "min-w", scale: "spacing" }, + "max-width": { prefix: "max-w", scale: "spacing" }, + "min-height": { prefix: "min-h", scale: "spacing" }, + "max-height": { prefix: "max-h", scale: "spacing" }, + "top,right,bottom,left": { prefix: "inset", scale: "spacing" }, + "top,bottom": { prefix: "inset-y", scale: "spacing" }, + "left,right": { prefix: "inset-x", scale: "spacing" }, + top: { prefix: "top", scale: "spacing" }, + right: { prefix: "right", scale: "spacing" }, + bottom: { prefix: "bottom", scale: "spacing" }, + left: { prefix: "left", scale: "spacing" }, + "border-width": { prefix: "border", scale: "border-width" }, + "border-top-width": { prefix: "border-t", scale: "border-width" }, + "border-right-width": { prefix: "border-r", scale: "border-width" }, + "border-bottom-width": { prefix: "border-b", scale: "border-width" }, + "border-left-width": { prefix: "border-l", scale: "border-width" }, + "z-index": { prefix: "z", scale: "z-index" }, + opacity: { prefix: "opacity", scale: "opacity" }, }; -export const findTailwindClass = (cssKey: string, value: number): string | null => { - const prefix = PROPERTY_TO_BEST_PREFIX[cssKey]; - if (!prefix) return null; +export const findTailwindClass = (propertyKey: string, value: number): string | null => { + const rule = PROPERTY_CHIP_RULES[propertyKey]; + if (!rule) return null; + const { prefix, scale } = rule; - if (SPACING_PROPERTIES.has(cssKey)) { + if (scale === "spacing") { if (value < 0) return null; const units = value / TAILWIND_SPACING_UNIT_PX; if (!Number.isInteger(units)) return null; @@ -79,7 +75,7 @@ export const findTailwindClass = (cssKey: string, value: number): string | null return `${prefix}-${units}`; } - if (cssKey === "border-width" || cssKey.endsWith("-width")) { + if (scale === "border-width") { if (!Number.isInteger(value) || value < 0 || value > TAILWIND_BORDER_MAX_PX) return null; // 1px is the suffix-less form: `border` / `border-t`. Others get // numeric suffix: `border-2`, `border-t-4`, … @@ -87,18 +83,14 @@ export const findTailwindClass = (cssKey: string, value: number): string | null return `${prefix}-${value}`; } - if (cssKey === "z-index") { + if (scale === "z-index") { if (!Number.isInteger(value) || value < 0 || value > TAILWIND_Z_INDEX_MAX) return null; return `${prefix}-${value}`; } - if (cssKey === "opacity") { - // Opacity is stored as percent [0, 100] (build-editable-properties - // normalizes), so `value` already matches the chip number. - if (!Number.isFinite(value) || value < 0 || value > 100) return null; - if (value % TAILWIND_OPACITY_GRANULARITY !== 0) return null; - return `${prefix}-${value}`; - } - - return null; + // Opacity is stored as percent [0, 100] (build-editable-properties + // normalizes), so `value` already matches the chip number. + if (!Number.isFinite(value) || value < 0 || value > 100) return null; + if (value % TAILWIND_OPACITY_GRANULARITY !== 0) return null; + return `${prefix}-${value}`; }; diff --git a/packages/react-grab/src/utils/parse-any-color.ts b/packages/react-grab/src/utils/parse-any-color.ts index a2457b48c..0d34ddcb8 100644 --- a/packages/react-grab/src/utils/parse-any-color.ts +++ b/packages/react-grab/src/utils/parse-any-color.ts @@ -23,7 +23,8 @@ const REJECTED_FILL_STYLE_SENTINEL = "rgba(0, 0, 0, 0)"; const OPAQUE_HEX_LENGTH = 7; const OKLCH_PATTERN = /^oklch\(\s*([+-]?\d*\.?\d+%?)\s+([+-]?\d*\.?\d+%?)\s+([+-]?\d*\.?\d+)(deg|grad|rad|turn)?(?:\s*\/\s*([+-]?\d*\.?\d+%?))?\s*\)$/i; -const OKLCH_PERCENT_CHROMA_SCALE = 0.004; +// Per CSS Color 4, 100% chroma in oklch() corresponds to 0.4. +const OKLCH_PERCENT_CHROMA_SCALE = 0.4; const DEGREES_PER_GRAD = 0.9; const DEGREES_PER_RADIAN = 180 / Math.PI; const DEGREES_PER_TURN = 360; diff --git a/packages/react-grab/src/utils/property-definitions.ts b/packages/react-grab/src/utils/property-definitions.ts index 2c9b9ab29..44123d873 100644 --- a/packages/react-grab/src/utils/property-definitions.ts +++ b/packages/react-grab/src/utils/property-definitions.ts @@ -72,16 +72,6 @@ export const FALLBACK_ZERO_PX: ReadonlySet = new Set([ "column-gap", ]); -export const POSITION_KEYS: ReadonlySet = new Set([ - "top", - "right", - "bottom", - "left", - "top,right,bottom,left", - "top,bottom", - "left,right", -]); - // Keys whose CSS value is a pure number (no unit). Writing `10px` here // would be invalid CSS. export const UNITLESS_KEYS: ReadonlySet = new Set(["z-index"]); @@ -218,6 +208,12 @@ const INSET_AGGREGATES: readonly AggregateDefinition[] = [ { key: "left", label: "left", longhands: ["left"] }, ]; +// Position rows allow negative values (overlays at `top: -8px`). The set +// is exactly the inset aggregate keys — derived so the two can't drift. +export const POSITION_KEYS: ReadonlySet = new Set( + INSET_AGGREGATES.map((definition) => definition.key), +); + const BORDER_WIDTH_AGGREGATES: readonly AggregateDefinition[] = [ { key: "border-width", diff --git a/packages/react-grab/src/utils/sort-properties-by-recommendation.ts b/packages/react-grab/src/utils/sort-properties-by-recommendation.ts index 431e97bff..ea2b87c0e 100644 --- a/packages/react-grab/src/utils/sort-properties-by-recommendation.ts +++ b/packages/react-grab/src/utils/sort-properties-by-recommendation.ts @@ -62,6 +62,9 @@ const RECOMMENDED_KEY_ORDER: readonly string[] = [ "border-color", "align-items", "justify-content", + "top,right,bottom,left", + "left,right", + "top,bottom", "top", "right", "bottom", diff --git a/packages/react-grab/src/utils/split-negative-prefix.ts b/packages/react-grab/src/utils/split-negative-prefix.ts new file mode 100644 index 000000000..b0e4650cd --- /dev/null +++ b/packages/react-grab/src/utils/split-negative-prefix.ts @@ -0,0 +1,9 @@ +// Canonical Tailwind negatives are written with a leading dash (`-m-4`, +// `-mt-[8px]`). Only numeric/length utilities carry a sign — colors and +// enums have no negative form — so the parser strips a single leading +// `-`, works on the unsigned spelling, and the caller re-applies the +// sign to the magnitude. +export const splitNegativePrefix = (query: string): { isNegative: boolean; base: string } => { + const isNegative = query.startsWith("-"); + return { isNegative, base: isNegative ? query.slice(1) : query }; +}; diff --git a/packages/react-grab/src/utils/toolbar-layout.ts b/packages/react-grab/src/utils/toolbar-layout.ts deleted file mode 100644 index 5c37520c3..000000000 --- a/packages/react-grab/src/utils/toolbar-layout.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const getButtonSpacingClass = (isVertical: boolean): string => - isVertical ? "mb-1.5" : "mr-1.5"; - -export const getMinDimensionClass = (isVertical: boolean): string => - isVertical ? "min-h-0" : "min-w-0";