mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-09-29 16:24:22 +10:00
fix(stylesheet): keep color picker state aligned with source (#3431)
* fix(stylesheet): keep color picker state aligned with source * fix(stylesheet): serialize picker edits as hex with alpha * fix: preserve contextual colors in stylesheet editor
This commit is contained in:
@@ -32,11 +32,21 @@ type ColorPickerProps = {
|
||||
value?: string;
|
||||
defaultValue?: string;
|
||||
onChange?: (value: string) => void;
|
||||
open?: boolean;
|
||||
onOpenChange?: React.ComponentProps<typeof Popover>["onOpenChange"];
|
||||
trigger?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
export function ColorPicker({ value, defaultValue, onChange, trigger, children }: ColorPickerProps) {
|
||||
export function ColorPicker({
|
||||
value,
|
||||
defaultValue,
|
||||
onChange,
|
||||
open,
|
||||
onOpenChange,
|
||||
trigger,
|
||||
children,
|
||||
}: ColorPickerProps) {
|
||||
const [currentValue, setCurrentValue] = useControlledState<string>({
|
||||
value,
|
||||
defaultValue,
|
||||
@@ -51,7 +61,7 @@ export function ColorPicker({ value, defaultValue, onChange, trigger, children }
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<Popover open={open} onOpenChange={onOpenChange}>
|
||||
{trigger ?? (
|
||||
<PopoverTrigger>
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { serializeStylesheetColor, toStylesheetPickerColor } from "./color-format";
|
||||
|
||||
describe("stylesheet picker color formatting", () => {
|
||||
it.each([
|
||||
["rgba(231, 0, 11, 1)", "#e7000b"],
|
||||
["rgba(21, 93, 252, 0)", "#155dfc00"],
|
||||
["rgba(21, 93, 252, 0.5)", "#155dfc80"],
|
||||
["rgba(255, 255, 255, 0.25)", "#ffffff40"],
|
||||
])("serializes %s with alpha only when needed", (input, expected) => {
|
||||
expect(serializeStylesheetColor(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it("does not invent a resolved value for currentcolor", () => {
|
||||
expect(toStylesheetPickerColor("currentcolor")).toBe("currentcolor");
|
||||
});
|
||||
|
||||
it.each(["#e7000b", "#155dfc00", "#155dfc80", "#ffffff40", "#abcdef01", "#abcdeffe"])(
|
||||
"retains every color and alpha byte when reopening %s",
|
||||
(value) => expect(serializeStylesheetColor(toStylesheetPickerColor(value))).toBe(value),
|
||||
);
|
||||
|
||||
it.each([
|
||||
["red", "#ff0000"],
|
||||
["BLUE", "#0000ff"],
|
||||
["rgb(100% 0% 0% / 50%)", "#ff000080"],
|
||||
["rgb(21 93 252 / 0.5)", "#155dfc80"],
|
||||
["#F00", "#ff0000"],
|
||||
["#F000", "#ff000000"],
|
||||
["#f008", "#ff000088"],
|
||||
["hsl(120, 100%, 50%)", "#00ff00"],
|
||||
["hsla(120, 100%, 50%, 0.5)", "#00ff0080"],
|
||||
["hsl(120 100% 50% / 25%)", "#00ff0040"],
|
||||
["rgba(21, 93, 252, 0.5)", "#155dfc80"],
|
||||
["transparent", "#00000000"],
|
||||
])("adapts existing %s to the picker without losing its color", (input, expected) => {
|
||||
expect(serializeStylesheetColor(toStylesheetPickerColor(input))).toBe(expected);
|
||||
});
|
||||
|
||||
it.each(["invalid", "rgba(1, 2, 3, nope)", "rgba(256, 0, 0, 1)", "rgba(1, 2, 3, 1.1)"])(
|
||||
"rejects invalid picker output %s",
|
||||
(value) => expect(serializeStylesheetColor(value)).toBeNull(),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { hexToRgba, hslaStringToHsva, hsvaToRgbaString, rgbaStringToHsva } from "@uiw/color-convert";
|
||||
import { parseColorString } from "@reactive-resume/utils/color";
|
||||
|
||||
// Literal names exposed by compiler-confirmed editor swatches. currentcolor stays contextual.
|
||||
const namedColors: Readonly<Record<string, string>> = {
|
||||
aqua: "#00ffff",
|
||||
black: "#000000",
|
||||
blue: "#0000ff",
|
||||
fuchsia: "#ff00ff",
|
||||
gray: "#808080",
|
||||
green: "#008000",
|
||||
lime: "#00ff00",
|
||||
maroon: "#800000",
|
||||
navy: "#000080",
|
||||
olive: "#808000",
|
||||
orange: "#ffa500",
|
||||
purple: "#800080",
|
||||
red: "#ff0000",
|
||||
silver: "#c0c0c0",
|
||||
teal: "#008080",
|
||||
white: "#ffffff",
|
||||
yellow: "#ffff00",
|
||||
};
|
||||
|
||||
// The shared picker consumes RGBA; keep stylesheet hex/HSL source intact until an edit.
|
||||
export function toStylesheetPickerColor(value: string): string {
|
||||
const literal = value.replaceAll(/\/\*[\s\S]*?\*\//g, " ").trim();
|
||||
const normalized = namedColors[literal.toLowerCase()] ?? literal;
|
||||
if (/^#(?:[\da-f]{3,4}|[\da-f]{6}|[\da-f]{8})$/i.test(normalized)) {
|
||||
const hex =
|
||||
normalized.length <= 5 ? `#${[...normalized.slice(1)].map((digit) => digit + digit).join("")}` : normalized;
|
||||
const { r, g, b, a } = hexToRgba(hex);
|
||||
return `rgba(${r}, ${g}, ${b}, ${a})`;
|
||||
}
|
||||
if (/^hsla?\(/i.test(normalized)) return hsvaToRgbaString(hslaStringToHsva(normalized));
|
||||
if (/^rgba?\(/i.test(normalized)) return hsvaToRgbaString(rgbaStringToHsva(normalized));
|
||||
if (normalized.toLowerCase() === "transparent") return "rgba(0, 0, 0, 0)";
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function serializeStylesheetColor(value: string): string | null {
|
||||
const color = parseColorString(value);
|
||||
if (!color) return null;
|
||||
const { r, g, b, a } = color;
|
||||
if (
|
||||
![r, g, b, a].every(Number.isFinite) ||
|
||||
[r, g, b].some((channel) => channel < 0 || channel > 255) ||
|
||||
a < 0 ||
|
||||
a > 1
|
||||
)
|
||||
return null;
|
||||
const byte = (channel: number) =>
|
||||
Math.round(Math.max(0, Math.min(255, channel)))
|
||||
.toString(16)
|
||||
.padStart(2, "0");
|
||||
return `#${byte(r)}${byte(g)}${byte(b)}${a < 1 ? byte(a * 255) : ""}`;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { compileStylesheet } from "@reactive-resume/resume/stylesheet";
|
||||
import { collectCompiledColorTokens } from "./color-tokens";
|
||||
|
||||
describe("compiler color source ranges", () => {
|
||||
it.each([
|
||||
"rgba(231, 0, 11, 1)",
|
||||
"rgb(0 0 0 / 50%)",
|
||||
"hsl(210, 50%, 40%)",
|
||||
"hsla(210, 50%, 40%, 0.7)",
|
||||
"hsl(210 50% 40% / 0.7)",
|
||||
])("keeps the exact %s source text after compiler normalization", (value) => {
|
||||
const source = `@version 1;\nsection { color: ${value} !important; }`;
|
||||
const compiled = compileStylesheet({ languageVersion: 1, text: source });
|
||||
expect(compiled.diagnostics).toEqual([]);
|
||||
const from = source.indexOf(value);
|
||||
expect(collectCompiledColorTokens(source, compiled.program)).toEqual([{ from, to: from + value.length, value }]);
|
||||
});
|
||||
|
||||
it("ignores comments, strings, and uncompiled values while deduplicating expanded border colors", () => {
|
||||
const value = "rgba(231, 0, 11, 1)";
|
||||
const source = `@version 1;\nsection { color /*: red */: /* red */ red; border: 1pt solid ${value}; color: "red"; font-family: red; }`;
|
||||
const compiled = compileStylesheet({ languageVersion: 1, text: source });
|
||||
const red = source.indexOf("red;");
|
||||
const rgba = source.indexOf(value);
|
||||
expect(collectCompiledColorTokens(source, compiled.program)).toEqual([
|
||||
{ from: red, to: red + 3, value: "red" },
|
||||
{ from: rgba, to: rgba + value.length, value },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -7,8 +7,22 @@ export type SemanticCssColorToken = {
|
||||
value: string;
|
||||
};
|
||||
|
||||
// Contextual currentcolor cannot be resolved by the literal color picker.
|
||||
const colorValue =
|
||||
/^(?:#[\da-f]{3,8}|(?:rgb|rgba|hsl|hsla)\([^)]*\)|(?:aqua|black|blue|currentcolor|fuchsia|gray|green|lime|maroon|navy|olive|orange|purple|red|silver|teal|transparent|white|yellow))$/i;
|
||||
/^(?:#[\da-f]{3,8}|(?:rgb|rgba|hsl|hsla)\([^)]*\)|(?:aqua|black|blue|fuchsia|gray|green|lime|maroon|navy|olive|orange|purple|red|silver|teal|transparent|white|yellow))$/i;
|
||||
|
||||
// Keep comments and strings whole so their contents cannot be mistaken for editable color values.
|
||||
const declarationToken =
|
||||
/\/\*[\s\S]*?\*\/|"(?:\\[\s\S]|[^"\\])*"|'(?:\\[\s\S]|[^'\\])*'|:|(?:rgba?|hsla?)\([^)]*\)|#[\da-f]{3,8}\b|\b[a-z]+\b/gi;
|
||||
|
||||
function normalizeColor(value: string): string {
|
||||
return value
|
||||
.replaceAll(/\/\*[\s\S]*?\*\//g, "")
|
||||
.replaceAll(/\s+/g, " ")
|
||||
.replaceAll(/\s*([(),/])\s*/g, "$1")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
const isColorProperty = (property: string) =>
|
||||
PROPERTY_REGISTRY_V1[property] !== undefined &&
|
||||
@@ -25,11 +39,16 @@ export function collectCompiledColorTokens(
|
||||
for (const declaration of rule.declarations) {
|
||||
if (!isColorProperty(declaration.property) || !colorValue.test(declaration.value)) continue;
|
||||
const declarationSource = source.slice(declaration.range.start.offset, declaration.range.end.offset);
|
||||
const valueOffset = declarationSource.indexOf(declaration.value, declarationSource.indexOf(":") + 1);
|
||||
if (valueOffset < 0) continue;
|
||||
const from = declaration.range.start.offset + valueOffset;
|
||||
const token = { from, to: from + declaration.value.length, value: declaration.value };
|
||||
tokens.set(`${token.from}:${token.to}`, token);
|
||||
const compiledColor = normalizeColor(declaration.value);
|
||||
let inValue = false;
|
||||
for (const match of declarationSource.matchAll(declarationToken)) {
|
||||
const value = match[0];
|
||||
if (value === ":") inValue = true;
|
||||
if (!inValue || !colorValue.test(value) || normalizeColor(value) !== compiledColor) continue;
|
||||
const from = declaration.range.start.offset + match.index;
|
||||
const token = { from, to: from + value.length, value };
|
||||
tokens.set(`${token.from}:${token.to}`, token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,8 +6,10 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { I18nProvider } from "@lingui/react";
|
||||
import { compileStylesheet } from "@reactive-resume/resume/stylesheet";
|
||||
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||
import { TooltipProvider } from "@reactive-resume/ui/components/tooltip";
|
||||
import { collectCompiledColorTokens } from "./color-tokens";
|
||||
import StylesheetEditorShell, { StylesheetCodeEditor } from "./editor";
|
||||
import { LegacyStylesheetBanner } from "./legacy-banner";
|
||||
import { StylesheetStatus } from "./status";
|
||||
@@ -109,6 +111,25 @@ beforeEach(() => {
|
||||
|
||||
const renderWithI18n = (element: React.ReactNode) => render(<I18nProvider i18n={i18n}>{element}</I18nProvider>);
|
||||
|
||||
function renderColorEditor(source: string) {
|
||||
const onChange = vi.fn();
|
||||
const editor = (value: string) => (
|
||||
<I18nProvider i18n={i18n}>
|
||||
<StylesheetCodeEditor
|
||||
value={value}
|
||||
diagnostics={[]}
|
||||
colorTokens={collectCompiledColorTokens(value, compileStylesheet({ languageVersion: 1, text: value }).program)}
|
||||
theme="light"
|
||||
onChange={onChange}
|
||||
onUndo={vi.fn()}
|
||||
onRedo={vi.fn()}
|
||||
/>
|
||||
</I18nProvider>
|
||||
);
|
||||
const result = render(editor(source));
|
||||
return { ...result, onChange, replaceSource: (value: string) => result.rerender(editor(value)) };
|
||||
}
|
||||
|
||||
describe("stylesheet editor status", () => {
|
||||
it("explains that fatal source falls back to base styles", () => {
|
||||
renderWithI18n(<StylesheetStatus mode="semantic" status="idle" diagnostics={[fatalError]} />);
|
||||
@@ -147,6 +168,84 @@ describe("stylesheet editor status", () => {
|
||||
});
|
||||
|
||||
describe("StylesheetCodeEditor", () => {
|
||||
it("preserves contextual currentcolor without exposing an editable literal swatch", () => {
|
||||
const source = "@version 1;\nsection { color: red; border-color: currentcolor; }";
|
||||
expect(compileStylesheet({ languageVersion: 1, text: source }).diagnostics).toEqual([]);
|
||||
const { onChange } = renderColorEditor(source);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Edit color red" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Edit color currentcolor" })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("textbox", { name: "Semantic CSS stylesheet" })).toHaveTextContent(
|
||||
"border-color: currentcolor",
|
||||
);
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("removes the temporary color trigger when the picker closes", async () => {
|
||||
const { container } = renderColorEditor("@version 1;\nsection { color: #f00; }");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Edit color #f00" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Use color rgba(231, 0, 11, 1)" }));
|
||||
fireEvent.keyDown(document.activeElement ?? document.body, { key: "Escape" });
|
||||
|
||||
await waitFor(() => expect(screen.queryByText("Presets")).not.toBeInTheDocument());
|
||||
expect(container.querySelector("[data-semantic-css-color-picker-trigger]")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the picker open for successive presets without replacing the next color", async () => {
|
||||
const source = "@version 1;\nsection { color: #f00; background-color: #fff; }";
|
||||
const { onChange, replaceSource } = renderColorEditor(source);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Edit color #f00" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Use color rgba(0, 0, 0, 1)" }));
|
||||
const first = source.replace("#f00", "#000000");
|
||||
expect(onChange).toHaveBeenLastCalledWith(first);
|
||||
replaceSource(first);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Use color rgba(231, 0, 11, 1)" }));
|
||||
expect(onChange).toHaveBeenLastCalledWith(source.replace("#f00", "#e7000b"));
|
||||
expect(screen.getByText("Presets")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["undo", "rgba(231, 0, 11, 1)", "#f00"],
|
||||
["redo", "#f00", "rgba(231, 0, 11, 1)"],
|
||||
])("closes a stale selection when %s replaces the document", async (_action, initial, replacement) => {
|
||||
const source = `@version 1;\nsection { color: ${initial}; background-color: #fff; }`;
|
||||
const { container, onChange, replaceSource } = renderColorEditor(source);
|
||||
fireEvent.click(screen.getByRole("button", { name: `Edit color ${initial}` }));
|
||||
await screen.findByText("Presets");
|
||||
const nextSource = source.replace(initial, replacement);
|
||||
replaceSource(nextSource);
|
||||
|
||||
await waitFor(() => expect(screen.queryByText("Presets")).not.toBeInTheDocument());
|
||||
expect(container.querySelector("[data-semantic-css-color-picker-trigger]")).not.toBeInTheDocument();
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Edit color #fff" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Use color rgba(0, 0, 0, 1)" }));
|
||||
expect(onChange).toHaveBeenLastCalledWith(nextSource.replace("#fff", "#000000"));
|
||||
});
|
||||
|
||||
it("closes the picker after text edits and rejects stale compiler color ranges", async () => {
|
||||
const source = "@version 1;\nsection { color: #f00; background-color: #fff; }";
|
||||
const { container, onChange } = renderColorEditor(source);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Edit color #f00" }));
|
||||
await screen.findByText("Presets");
|
||||
const view = EditorView.findFromDOM(screen.getByRole("textbox", { name: "Semantic CSS stylesheet" }));
|
||||
if (!view) throw new Error("Missing editor view");
|
||||
const from = source.indexOf("#f00");
|
||||
act(() => view.dispatch({ changes: { from, to: from + 4, insert: "#00f" } }));
|
||||
|
||||
await waitFor(() => expect(screen.queryByText("Presets")).not.toBeInTheDocument());
|
||||
expect(container.querySelector("[data-semantic-css-color-picker-trigger]")).not.toBeInTheDocument();
|
||||
onChange.mockClear();
|
||||
|
||||
// The compile worker has not sent updated tokens yet; the old swatch must not overwrite the edited color.
|
||||
fireEvent.click(screen.getByRole("button", { name: "Edit color #f00" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Use color rgba(0, 0, 0, 1)" }));
|
||||
expect(view.state.doc.toString()).toBe(source.replace("#f00", "#00f"));
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("owns one LTR EditorView and ignores externally replaced documents", () => {
|
||||
const onChange = vi.fn();
|
||||
const destroy = vi.spyOn(EditorView.prototype, "destroy");
|
||||
@@ -203,7 +302,7 @@ describe("StylesheetCodeEditor", () => {
|
||||
const source = "section { color: #f00; background-color: #fff; }";
|
||||
const first = source.indexOf("#f00");
|
||||
const second = source.indexOf("#fff");
|
||||
const { container } = render(
|
||||
const { container } = renderWithI18n(
|
||||
<StylesheetCodeEditor
|
||||
value={source}
|
||||
diagnostics={[]}
|
||||
@@ -220,9 +319,10 @@ describe("StylesheetCodeEditor", () => {
|
||||
const swatches = container.querySelectorAll<HTMLButtonElement>(".semantic-css-color-swatch");
|
||||
expect(swatches).toHaveLength(2);
|
||||
|
||||
swatches[0]?.click();
|
||||
if (!swatches[0] || !swatches[1]) throw new Error("Missing color swatches");
|
||||
fireEvent.click(swatches[0]);
|
||||
await waitFor(() => expect(container.querySelectorAll("[data-semantic-css-color-picker-trigger]")).toHaveLength(1));
|
||||
swatches[1]?.click();
|
||||
fireEvent.click(swatches[1]);
|
||||
await waitFor(() => expect(container.querySelectorAll("[data-semantic-css-color-picker-trigger]")).toHaveLength(1));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,6 +35,7 @@ import { ColorPicker } from "@/components/input/color-picker";
|
||||
import { useIsResumeLocked, useResumeData, useResumeStore, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useTheme } from "@/features/theme/provider";
|
||||
import { useBuilderSidebarStore } from "@/routes/builder/$resumeId/-store/sidebar";
|
||||
import { serializeStylesheetColor, toStylesheetPickerColor } from "./color-format";
|
||||
import { compositionAwareDocumentListener, createSemanticCssEditorExtensions } from "./editor-extensions";
|
||||
import { enterStylesheetFocusMode } from "./focus-mode";
|
||||
import { formatEditorDocument } from "./formatter";
|
||||
@@ -44,6 +45,7 @@ import { StylesheetToolbar } from "./toolbar";
|
||||
import { createCompileWorkerClient } from "./worker-client";
|
||||
|
||||
const externalReplacement = Annotation.define<boolean>();
|
||||
const colorPickerEdit = Annotation.define<boolean>();
|
||||
const emptyMetadata: SemanticCssEditorMetadata = {
|
||||
semanticTree: { key: "resume", kind: "resume", attributes: {}, roles: [], children: [] },
|
||||
templateParts: [],
|
||||
@@ -118,8 +120,6 @@ export function StylesheetCodeEditor({
|
||||
}: StylesheetCodeEditorProps) {
|
||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||
const viewRef = useRef<EditorView | null>(null);
|
||||
const colorTriggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
const openColorPickerRef = useRef(false);
|
||||
const compartmentsRef = useRef<EditorCompartments | null>(null);
|
||||
const initialPropsRef = useRef({ value, diagnostics, colorTokens, metadata, theme, readOnly, label });
|
||||
const onChangeRef = useRef(onChange);
|
||||
@@ -135,7 +135,6 @@ export function StylesheetCodeEditor({
|
||||
const selectColor = useCallback((token: SemanticCssColorToken, rect: DOMRect) => {
|
||||
const hostRect = hostRef.current?.getBoundingClientRect();
|
||||
if (!hostRect) return;
|
||||
openColorPickerRef.current = true;
|
||||
setSelectedColor({ token, left: rect.left - hostRect.left, top: rect.top - hostRect.top });
|
||||
}, []);
|
||||
|
||||
@@ -206,6 +205,15 @@ export function StylesheetCodeEditor({
|
||||
(source) => onChangeRef.current(source),
|
||||
(update) => update.transactions.some((transaction) => transaction.annotation(externalReplacement)),
|
||||
),
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (
|
||||
update.transactions.some(
|
||||
(transaction) => transaction.docChanged && !transaction.annotation(colorPickerEdit),
|
||||
)
|
||||
) {
|
||||
setSelectedColor(null);
|
||||
}
|
||||
}),
|
||||
compartments.theme.of(editorTheme(initial.theme === "dark")),
|
||||
compartments.readOnly.of(readOnlyExtensions(initial.readOnly)),
|
||||
compartments.intelligence.of(
|
||||
@@ -268,19 +276,25 @@ export function StylesheetCodeEditor({
|
||||
});
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedColor || !openColorPickerRef.current) return;
|
||||
openColorPickerRef.current = false;
|
||||
queueMicrotask(() => colorTriggerRef.current?.click());
|
||||
}, [selectedColor]);
|
||||
|
||||
const updateColor = (value: string) => {
|
||||
const updateColor = (pickerValue: string) => {
|
||||
const value = serializeStylesheetColor(pickerValue);
|
||||
if (value === null) return;
|
||||
const view = viewRef.current;
|
||||
if (!view || !selectedColor) return;
|
||||
const { from, to } = selectedColor.token;
|
||||
if (
|
||||
view.state.readOnly ||
|
||||
from < 0 ||
|
||||
to > view.state.doc.length ||
|
||||
from >= to ||
|
||||
view.state.doc.sliceString(from, to) !== selectedColor.token.value
|
||||
) {
|
||||
setSelectedColor(null);
|
||||
return;
|
||||
}
|
||||
view.dispatch({
|
||||
changes: { from, to, insert: value },
|
||||
annotations: Transaction.userEvent.of("input"),
|
||||
annotations: [Transaction.userEvent.of("input"), colorPickerEdit.of(true)],
|
||||
});
|
||||
setSelectedColor((current) =>
|
||||
current
|
||||
@@ -297,13 +311,23 @@ export function StylesheetCodeEditor({
|
||||
{selectedColor && (
|
||||
<div className="pointer-events-none absolute z-20" style={{ left: selectedColor.left, top: selectedColor.top }}>
|
||||
<ColorPicker
|
||||
value={selectedColor.token.value}
|
||||
open
|
||||
onOpenChange={(open, details) => {
|
||||
const target = details.event.target;
|
||||
if (
|
||||
target instanceof Element &&
|
||||
target.closest(".semantic-css-color-swatch") &&
|
||||
hostRef.current?.contains(target)
|
||||
)
|
||||
return;
|
||||
if (!open) setSelectedColor(null);
|
||||
}}
|
||||
value={toStylesheetPickerColor(selectedColor.token.value)}
|
||||
onChange={updateColor}
|
||||
trigger={
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<button
|
||||
ref={colorTriggerRef}
|
||||
data-semantic-css-color-picker-trigger=""
|
||||
type="button"
|
||||
title={t`Edit color ${selectedColor.token.value}`}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { createSemanticCssResume, readStylesheetSource, seedSemanticCssResume } from "../../fixtures/semantic-css";
|
||||
import { expect, test } from "../../fixtures/test";
|
||||
|
||||
test("@semantic-css keeps color picker edits and swatches aligned through dismissal and undo", async ({
|
||||
authPage: page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(60_000);
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
const resumeId = await createSemanticCssResume(page, testInfo);
|
||||
const source = "@version 1;\nsection {\n\tcolor: #f00;\n\tbackground-color: #fff;\n}";
|
||||
await seedSemanticCssResume(page, resumeId, {
|
||||
stylesheet: { mode: "semantic", source: { languageVersion: 1, text: source } },
|
||||
});
|
||||
const swatches = page.locator(".semantic-css-color-swatch");
|
||||
const trigger = page.locator("[data-semantic-css-color-picker-trigger]");
|
||||
const picker = page.getByRole("dialog").filter({ has: page.getByText("Presets", { exact: true }) });
|
||||
await expect(swatches).toHaveCount(2);
|
||||
await page.getByRole("button", { name: "Edit color #f00", exact: true }).click();
|
||||
|
||||
const first = source.replace("#f00", "#000000");
|
||||
await picker.getByRole("button", { name: "Use color rgba(0, 0, 0, 1)", exact: true }).click();
|
||||
await expect.poll(() => readStylesheetSource(page)).toBe(first);
|
||||
await expect(page.getByText("Presets", { exact: true })).toBeVisible();
|
||||
|
||||
const second = source.replace("#f00", "#e7000b");
|
||||
await picker.getByRole("button", { name: "Use color rgba(231, 0, 11, 1)", exact: true }).click();
|
||||
await expect.poll(() => readStylesheetSource(page)).toBe(second);
|
||||
await expect(swatches).toHaveCount(2);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByText("Presets", { exact: true })).toHaveCount(0);
|
||||
await expect(trigger).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "Edit color #e7000b", exact: true }).click();
|
||||
await expect(page.getByText("Presets", { exact: true })).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(trigger).toHaveCount(0);
|
||||
|
||||
await page.getByRole("button", { name: "Undo stylesheet edit", exact: true }).click();
|
||||
// Rapid presets can coalesce into one undo step; either complete prior stylesheet is valid.
|
||||
await expect.poll(async () => [source, first].includes(await readStylesheetSource(page))).toBe(true);
|
||||
await expect(trigger).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "Redo stylesheet edit", exact: true }).click();
|
||||
await expect.poll(() => readStylesheetSource(page)).toBe(second);
|
||||
await expect(swatches).toHaveCount(2);
|
||||
|
||||
await page.getByRole("button", { name: "Edit color #fff", exact: true }).click();
|
||||
await picker.getByRole("button", { name: "Use color rgba(21, 93, 252, 1)", exact: true }).click();
|
||||
await expect.poll(() => readStylesheetSource(page)).toBe(second.replace("#fff", "#155dfc"));
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(trigger).toHaveCount(0);
|
||||
await expect(swatches).toHaveCount(2);
|
||||
await expect(page.getByRole("button", { name: "Edit color #e7000b", exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Edit color #155dfc", exact: true })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Open focus mode", exact: true }).click();
|
||||
await expect(page.getByRole("button", { name: "Edit color #e7000b", exact: true })).toBeInViewport();
|
||||
await expect(page.getByRole("button", { name: "Edit color #155dfc", exact: true })).toBeInViewport();
|
||||
await expect(page.getByText("Saved", { exact: true })).toBeVisible();
|
||||
await page.screenshot({ path: testInfo.outputPath("color-picker-after-undo.png"), animations: "disabled" });
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
for (const inputColor of ["#FF000080", "red", "rgb(100% 0% 0% / 50%)"]) {
|
||||
test(`@semantic-css preserves alpha when editing ${inputColor} and reopening hex colors`, async ({
|
||||
authPage: page,
|
||||
}, testInfo) => {
|
||||
const resumeId = await createSemanticCssResume(page, testInfo);
|
||||
const source = `@version 1;\nsection { color: ${inputColor}; background-color: #fff; }`;
|
||||
await seedSemanticCssResume(page, resumeId, {
|
||||
stylesheet: { mode: "semantic", source: { languageVersion: 1, text: source } },
|
||||
});
|
||||
await page.getByRole("button", { name: `Edit color ${inputColor}`, exact: true }).click();
|
||||
const picker = page.getByRole("dialog").filter({ has: page.getByText("Presets", { exact: true }) });
|
||||
await expect(picker).toBeVisible();
|
||||
expect(await readStylesheetSource(page)).toBe(source);
|
||||
const alpha = picker.locator(".w-color-alpha:not(.w-color-hue)");
|
||||
const bounds = await alpha.boundingBox();
|
||||
if (!bounds) throw new Error("Missing alpha control");
|
||||
await alpha.click({ position: { x: 0, y: bounds.height / 2 } });
|
||||
await expect.poll(() => readStylesheetSource(page)).toBe(source.replace(inputColor, "#ff000000"));
|
||||
await page.keyboard.press("Escape");
|
||||
await page.getByRole("button", { name: "Edit color #ff000000", exact: true }).click();
|
||||
await expect(picker).toBeVisible();
|
||||
await alpha.click({ position: { x: bounds.width / 2, y: bounds.height / 2 } });
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const value = await readStylesheetSource(page);
|
||||
const match = value.match(/color: #ff0000([\da-f]{2});/);
|
||||
return match ? Number.parseInt(match[1] ?? "", 16) : -1;
|
||||
})
|
||||
.toBeGreaterThanOrEqual(127);
|
||||
const halfTransparent = await readStylesheetSource(page);
|
||||
// Browser pointer coordinates round to pixels; the midpoint can land on either adjacent alpha byte.
|
||||
expect(halfTransparent).toMatch(/color: #ff0000(?:7f|80);/);
|
||||
expect(halfTransparent).toContain("background-color: #fff;");
|
||||
await page.keyboard.press("Escape");
|
||||
await page.getByRole("button", { name: /^Edit color #ff0000(?:7f|80)$/ }).click();
|
||||
await expect(picker).toBeVisible();
|
||||
expect(await readStylesheetSource(page)).toBe(halfTransparent);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user