Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions client/src/components/colorHexPreview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { Typography } from "antd";
import { normalizeHex } from "../utils/colorFormatting";
import SpoolIcon from "./spoolIcon";

interface ColorHexPreviewProps {
colorHex?: string | null;
multiColorHexes?: string | null;
multiColorDirection?: string | null;
}

const SMALL_TEXT_STYLE = {
fontSize: 12,
color: "rgba(255,255,255,0.45)",
lineHeight: 1.2,
};

export default function ColorHexPreview({
colorHex,
multiColorHexes,
multiColorDirection,
}: Readonly<ColorHexPreviewProps>) {
const colors =
multiColorHexes
?.split(",")
.map((hex) => hex.trim())
.filter((hex) => hex.length > 0)
.map(normalizeHex) ?? [];

if (colors.length <= 1) {
const singleColor = colorHex ? normalizeHex(colorHex) : colors[0];
if (!singleColor) return null;
return (
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<SpoolIcon color={singleColor} size="large" no_margin />
<Typography.Text style={SMALL_TEXT_STYLE}>{singleColor}</Typography.Text>
</div>
);
}

const isLongitudinal = multiColorDirection === "longitudinal";
if (isLongitudinal) {
// Longitudinal multi-color spools are shown as separate swatches because the
// stripe order matters more than the combined spool silhouette.
return (
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
{colors.map((hex, index) => (
<div key={`${hex}-${index}`} style={{ display: "flex", alignItems: "center", gap: 8 }}>
<div
style={{
width: 56,
height: 22,
borderRadius: 5,
border: "1px solid rgba(255,255,255,0.22)",
background: hex,
}}
/>
<Typography.Text style={SMALL_TEXT_STYLE}>{hex}</Typography.Text>
</div>
))}
</div>
);
}

return (
<div style={{ display: "flex", alignItems: "flex-start", gap: 0 }}>
{colors.map((hex, index) => (
<div
key={`${hex}-${index}`}
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
width: 64,
gap: 4,
}}
>
<SpoolIcon color={hex} size="large" no_margin />
<Typography.Text style={{ ...SMALL_TEXT_STYLE, textAlign: "center" }}>{hex}</Typography.Text>
</div>
))}
</div>
);
}
196 changes: 170 additions & 26 deletions client/src/components/multiColorPicker.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { CloseOutlined, PlusOutlined } from "@ant-design/icons";
import { Badge, Button, ColorPicker, Space } from "antd";
import { normalizeHex } from "../utils/colorFormatting";

function generateRandomColor() {
return "000000".replace(/0/g, function () {
Expand All @@ -26,46 +27,189 @@ export function MultiColorPicker(props: {
onChange?: (value: string | null | undefined) => void;
min?: number;
max?: number;
layout?: "horizontal" | "vertical";
showHex?: boolean;
hexPosition?: "right" | "bottom";
swatchWidth?: number;
swatchHeight?: number;
}) {
const values = props.value ? props.value.split(",") : generateInitialColors(props.min ?? 0);
if (!props.value && props.onChange) {
// Update value immediately
// Seed the form value immediately so a newly toggled multi-color field persists a
// valid minimum color set instead of rendering transient placeholder swatches only.
props.onChange(values.join(","));
}
const pickers = values.map((value, idx) => (
<Badge
key={idx}
count={
values.length > (props.min ?? 0) ? (
<span className="ant-badge-count">
<CloseOutlined
const layout = props.layout ?? "horizontal";
const showHex = props.showHex ?? false;
const hexPosition = props.hexPosition ?? "bottom";
const swatchWidth = props.swatchWidth ?? 38;
const swatchHeight = props.swatchHeight ?? 38;
const hexTextStyle = {
fontSize: 12,
color: "rgba(255, 255, 255, 0.5)",
lineHeight: 1.2,
textTransform: "uppercase" as const,
fontFamily: "monospace",
};
const swatchStyle = {
borderRadius: 8,
border: "1px solid rgba(255,255,255,0.2)",
} as const;

const pickers = values.map((value, idx) => {
const formattedHex = normalizeHex(value);
return (
<Badge
key={idx}
count={
values.length > (props.min ?? 0) ? (
<span className="ant-badge-count">
<CloseOutlined
onClick={() => {
if (props.onChange) {
props.onChange(values.filter((v, i) => i !== idx).join(","));
}
}}
/>
</span>
) : (
<></>
)
}
>
<div
style={{
display: "flex",
flexDirection: showHex ? (hexPosition === "right" ? "row" : "column") : "column",
alignItems: showHex && hexPosition === "right" ? "center" : "flex-start",
gap: showHex ? 8 : 0,
}}
>
<ColorPicker
value={value}
onChange={(clr) => {
if (props.onChange) {
props.onChange(values.map((v, i) => (i === idx ? clr.toHex() : v)).join(","));
}
}}
>
<div
style={{
...swatchStyle,
width: swatchWidth,
height: swatchHeight,
backgroundColor: `#${value.replace("#", "")}`,
}}
/>
</ColorPicker>
{showHex && <span style={hexTextStyle}>{formattedHex}</span>}
</div>
</Badge>
);
});

const isVerticalWithRightHex = layout === "vertical" && showHex && hexPosition === "right";
if (isVerticalWithRightHex) {
const canRemove = values.length > (props.min ?? 0);
const canAdd = values.length < (props.max ?? Infinity);
// Longitudinal filaments read more naturally as stacked rows with the swatch and
// hex side by side, so switch to explicit row controls instead of the generic Badge layout.
const actionSize = Math.max(20, Math.min(28, swatchHeight));
const rowGap = 12;
return (
<div
style={{
display: "flex",
alignItems: "flex-start",
gap: 10,
marginTop: "1em",
}}
>
<Space direction="vertical" size={rowGap}>
{values.map((_, idx) => (
<Button
key={`remove-${idx}`}
danger
shape="circle"
icon={<CloseOutlined />}
disabled={!canRemove}
style={{
width: actionSize,
minWidth: actionSize,
height: actionSize,
padding: 0,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
}}
onClick={() => {
// Remove this picker
if (!canRemove) return;
if (props.onChange) {
props.onChange(values.filter((v, i) => i !== idx).join(","));
}
}}
/>
</span>
) : (
<></>
)
}
>
<ColorPicker
value={value}
onChange={(clr) => {
if (props.onChange) {
props.onChange(values.map((v, i) => (i === idx ? clr.toHex() : v)).join(","));
}
}}
/>
</Badge>
));
))}
<Button
key="add"
shape="circle"
icon={<PlusOutlined />}
disabled={!canAdd}
style={{
width: actionSize,
minWidth: actionSize,
height: actionSize,
padding: 0,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
}}
onClick={() => {
if (!canAdd) return;
if (props.onChange) {
props.onChange(values.concat(generateRandomColor()).join(","));
}
}}
/>
</Space>
<Space direction="vertical" size={rowGap} style={{ paddingLeft: 10 }}>
{values.map((value, idx) => (
<div
key={`${value}-${idx}`}
style={{
display: "flex",
alignItems: "center",
gap: 8,
}}
>
<ColorPicker
value={value}
onChange={(clr) => {
if (props.onChange) {
props.onChange(values.map((v, i) => (i === idx ? clr.toHex() : v)).join(","));
}
}}
>
<div
style={{
...swatchStyle,
width: swatchWidth,
height: swatchHeight,
backgroundColor: `#${value.replace("#", "")}`,
}}
/>
</ColorPicker>
<span style={hexTextStyle}>{normalizeHex(value)}</span>
</div>
))}
</Space>
</div>
);
}

return (
<>
<Space direction="horizontal" size="middle" style={{ marginTop: "1em" }}>
<Space direction={layout === "vertical" ? "vertical" : "horizontal"} size="middle" style={{ marginTop: "1em" }}>
{pickers}
{values.length < (props.max ?? Infinity) && (
<Button
Expand Down
32 changes: 30 additions & 2 deletions client/src/pages/filaments/edit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ export const FilamentEdit = () => {
optionLabel: "name",
pagination: { mode: "off" },
});
const watchedColorHex = Form.useWatch(["color_hex"], formProps.form);
const watchedMultiColorDirection = Form.useWatch(["multi_color_direction"], formProps.form);

// Add the vendor_id field to the form
if (formProps.initialValues) {
Expand All @@ -65,6 +67,7 @@ export const FilamentEdit = () => {
formProps.onFinish = (allValues: IFilamentParsedExtras) => {
if (allValues !== undefined && allValues !== null) {
if (colorType == "single") {
// Clear the hidden multi-color payload so switching modes does not submit stale comma-separated values.
allValues.multi_color_hexes = "";
}
// Lot of stupidity here to make types work
Expand Down Expand Up @@ -170,9 +173,24 @@ export const FilamentEdit = () => {
return e?.toHex();
}}
>
<ColorPicker />
<ColorPicker>
<div
style={{
width: 74,
height: 74,
borderRadius: 10,
border: "1px solid rgba(255,255,255,0.2)",
backgroundColor: `#${(watchedColorHex ?? "000000").replace("#", "")}`,
}}
/>
</ColorPicker>
</Form.Item>
)}
{colorType == "single" && watchedColorHex && (
<Typography.Text type="secondary" style={{ display: "block", marginTop: -12, marginBottom: 12 }}>
#{`${watchedColorHex}`.replace("#", "").toUpperCase()}
</Typography.Text>
)}
{colorType == "multi" && (
<Form.Item
name={"multi_color_direction"}
Expand All @@ -199,7 +217,17 @@ export const FilamentEdit = () => {
},
]}
>
<MultiColorPicker min={2} max={14} />
<MultiColorPicker
// Match the editor layout to the final preview so the user is editing
// colors in the same visual orientation they will later see on show pages.
min={2}
max={14}
layout={watchedMultiColorDirection === "longitudinal" ? "vertical" : "horizontal"}
showHex
hexPosition={watchedMultiColorDirection === "longitudinal" ? "right" : "bottom"}
swatchWidth={watchedMultiColorDirection === "longitudinal" ? 66 : 74}
swatchHeight={watchedMultiColorDirection === "longitudinal" ? 24 : 74}
/>
</Form.Item>
)}
<Form.Item
Expand Down
Loading