Skip to content
Merged
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
178 changes: 134 additions & 44 deletions src/app/components/ColorPalette/ColorPalette.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { HexColorPicker } from 'react-colorful'
import { useState, useRef, useEffect } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { useProjectStore } from '../../store/useProjectStore.js'
import { usePresetStore } from '../../store/usePresetStore.js'
import { BUILT_IN_PRESETS } from '../../data/builtInPresets.js'
import { BTN } from '../../styles/buttonStyles.js'
import { InfoTooltip } from '../ui/InfoTooltip.jsx'

Expand Down Expand Up @@ -89,6 +91,90 @@ function SortableColor({ color, id, isOpen, onOpen, onClose, onColorChange }) {
)
}

function PalettePresets({ onApply }) {
const { customPresets, saveCurrentPreset, deletePreset } = usePresetStore()
const [saveName, setSaveName] = useState('')
const [showSaveInput, setShowSaveInput] = useState(false)

function onSave() {
const name = saveName.trim()
if (!name) return
saveCurrentPreset(name)
setSaveName('')
setShowSaveInput(false)
}

const allPresets = [...BUILT_IN_PRESETS, ...customPresets]

return (
<div className="space-y-2">
<div className="flex flex-wrap gap-1">
{allPresets.map((preset) => (
<div key={preset.id} className="group relative">
<button
type="button"
onClick={() => onApply(preset.settings.colors)}
className="flex items-center gap-1.5 rounded border border-zinc-700 bg-zinc-900 px-2 py-1 hover:border-zinc-500 focus:border-emerald-500 focus:outline-none"
>
<div className="flex gap-0.5">
{preset.settings.colors.slice(0, 4).map((c, i) => (
<div key={i} className="h-2.5 w-2.5 rounded-sm" style={{ backgroundColor: c }} />
))}
</div>
<span className="text-xs text-zinc-300">{preset.name}</span>
</button>
{preset.category === 'custom' && (
<button
type="button"
onClick={() => deletePreset(preset.id)}
className="absolute -right-1 -top-1 hidden h-3.5 w-3.5 items-center justify-center rounded-full bg-zinc-700 text-zinc-400 hover:text-red-400 group-hover:flex"
aria-label={`Delete preset ${preset.name}`}
>
✕
</button>
)}
</div>
))}
</div>

{showSaveInput ? (
<div className="flex gap-1">
<input
type="text"
value={saveName}
onChange={(e) => setSaveName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && onSave()}
placeholder="Preset name…"
autoFocus
className="min-w-0 flex-1 rounded bg-zinc-800 px-2 py-1 text-xs text-zinc-200 focus:outline-none focus:ring-1 focus:ring-emerald-500"
/>
<button type="button" onClick={onSave} className={`${BTN.base} ${BTN.primary} text-xs`}>
Save
</button>
<button
type="button"
onClick={() => {
setShowSaveInput(false)
setSaveName('')
}}
className={`${BTN.base} ${BTN.secondary} text-xs`}
>
Cancel
</button>
</div>
) : (
<button
type="button"
onClick={() => setShowSaveInput(true)}
className={`w-full ${BTN.base} ${BTN.secondary} text-xs`}
>
+ Save current as preset
</button>
)}
</div>
)
}

function ColorPalette() {
// Finding 19: combine selectors with useShallow
const { colors, setColors, addColor, removeColor, setColorAt } = useProjectStore(
Expand All @@ -114,52 +200,56 @@ function ColorPalette() {
}

return (
<section className="space-y-2">
{/* Finding 26: clearer label with tooltip */}
<div className="flex items-center justify-between text-xs tracking-wide text-zinc-400">
<span>Dark (shadows)</span>
<span className="flex items-center gap-1">
Bright (highlights)
<InfoTooltip content="Colors are mapped by brightness: leftmost = darkest areas, rightmost = brightest areas. Drag to reorder. Double-click a swatch to edit its color." />
</span>
</div>
<section className="space-y-3">
<PalettePresets onApply={setColors} />

<DndContext collisionDetection={closestCenter} onDragEnd={onDragEnd}>
<SortableContext items={ids} strategy={horizontalListSortingStrategy}>
<div className="flex gap-2">
{colors.map((color, index) => (
<SortableColor
key={ids[index]}
id={ids[index]}
color={color}
isOpen={openPickerId === ids[index]}
onOpen={() => setOpenPickerId(ids[index])}
onClose={() => setOpenPickerId(null)}
onColorChange={(nextColor) => setColorAt(index, nextColor)}
/>
))}
</div>
</SortableContext>
</DndContext>
<div className="border-t border-zinc-800 pt-3">
{/* Finding 26: clearer label with tooltip */}
<div className="mb-2 flex items-center justify-between text-xs tracking-wide text-zinc-400">
<span>Dark (shadows)</span>
<span className="flex items-center gap-1">
Bright (highlights)
<InfoTooltip content="Colors are mapped by brightness: leftmost = darkest areas, rightmost = brightest areas. Drag to reorder. Double-click a swatch to edit its color." />
</span>
</div>

{/* Finding 22: standardized button styles */}
<div className="flex gap-2">
<button
type="button"
className={`${BTN.base} ${BTN.secondary}`}
onClick={() => addColor()}
disabled={colors.length >= 6}
>
Add
</button>
<button
type="button"
className={`${BTN.base} ${BTN.secondary}`}
onClick={() => removeColor(colors.length - 1)}
disabled={colors.length <= 2}
>
Remove
</button>
<DndContext collisionDetection={closestCenter} onDragEnd={onDragEnd}>
<SortableContext items={ids} strategy={horizontalListSortingStrategy}>
<div className="flex gap-2">
{colors.map((color, index) => (
<SortableColor
key={ids[index]}
id={ids[index]}
color={color}
isOpen={openPickerId === ids[index]}
onOpen={() => setOpenPickerId(ids[index])}
onClose={() => setOpenPickerId(null)}
onColorChange={(nextColor) => setColorAt(index, nextColor)}
/>
))}
</div>
</SortableContext>
</DndContext>

{/* Finding 22: standardized button styles */}
<div className="mt-2 flex gap-2">
<button
type="button"
className={`${BTN.base} ${BTN.secondary}`}
onClick={() => addColor()}
disabled={colors.length >= 6}
>
Add
</button>
<button
type="button"
className={`${BTN.base} ${BTN.secondary}`}
onClick={() => removeColor(colors.length - 1)}
disabled={colors.length <= 2}
>
Remove
</button>
</div>
</div>
</section>
)
Expand Down
6 changes: 1 addition & 5 deletions src/app/components/Layout/Layout.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import { LightDirection } from '../LightDirection/LightDirection.jsx'
import { RotationGizmoPanel } from '../RotationGizmo/RotationGizmoPanel.jsx'
import { ExportPanel } from '../ExportPanel/ExportPanel.jsx'
import { ErrorBoundary } from '../ErrorBoundary/ErrorBoundary.jsx'
import { PresetGallery } from '../PresetGallery/PresetGallery.jsx'
import { useProjectStore } from '../../store/useProjectStore.js'

function Section({ title, children, defaultOpen = true }) {
Expand Down Expand Up @@ -131,10 +130,7 @@ function Layout() {
<Section title="Input">
<InputSource />
</Section>
<Section title="Presets">
<PresetGallery />
</Section>
<Section title="Color Strip">
<Section title="Color Palette">
<ColorPalette />
</Section>
<Section title="Quality">
Expand Down
4 changes: 3 additions & 1 deletion src/app/components/PreviewCanvas/PreviewCanvas.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ function PreviewCanvas() {
extrudeDepth,
bevelEnabled,
fontFamily,
letterSpacing,
imageSource
} = useProjectStore(useShallow(selectInputSource))
const isLoading = useProjectStore((state) => state.status.loading)
Expand Down Expand Up @@ -166,7 +167,7 @@ function PreviewCanvas() {
case 'text': {
useProjectStore.getState().setStatus({ loading: true, error: '' })
manager
.loadText(textContent, { fontFamily, fontSize, extrudeDepth, bevelEnabled })
.loadText(textContent, { fontFamily, fontSize, extrudeDepth, bevelEnabled, letterSpacing })
.then(() => {
if (!cancelled && sceneManagerRef.current === manager) {
useProjectStore.getState().setStatus({ loading: false, message: '' })
Expand Down Expand Up @@ -215,6 +216,7 @@ function PreviewCanvas() {
fontSize,
extrudeDepth,
bevelEnabled,
letterSpacing,
imageSource
])

Expand Down
52 changes: 39 additions & 13 deletions src/app/components/TextInput/TextInput.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,23 +7,26 @@ function TextInput() {
const extrudeDepth = useProjectStore((state) => state.extrudeDepth)
const bevelEnabled = useProjectStore((state) => state.bevelEnabled)
const fontFamily = useProjectStore((state) => state.fontFamily)
const letterSpacing = useProjectStore((state) => state.letterSpacing)
const setTextContent = useProjectStore((state) => state.setTextContent)
const setFontSize = useProjectStore((state) => state.setFontSize)
const setExtrudeDepth = useProjectStore((state) => state.setExtrudeDepth)
const setBevelEnabled = useProjectStore((state) => state.setBevelEnabled)
const setFontFamily = useProjectStore((state) => state.setFontFamily)
const setLetterSpacing = useProjectStore((state) => state.setLetterSpacing)
const resetTextConfig = useProjectStore((state) => state.resetTextConfig)

return (
<div className="space-y-3">
<div>
<label className="mb-1 block text-xs text-zinc-400">Text</label>
<input
type="text"
<textarea
value={textContent}
onChange={(e) => setTextContent(e.target.value)}
placeholder="BitmapForge"
className="w-full rounded bg-zinc-800 px-2 py-1.5 text-sm text-zinc-100 placeholder-zinc-600 focus:outline-none focus:ring-1 focus:ring-emerald-500"
maxLength={32}
rows={3}
maxLength={200}
className="w-full resize-none rounded bg-zinc-800 px-2 py-1.5 text-sm text-zinc-100 placeholder-zinc-600 focus:outline-none focus:ring-1 focus:ring-emerald-500"
/>
</div>

Expand All @@ -36,6 +39,8 @@ function TextInput() {
>
<option value="helvetiker">Helvetiker</option>
<option value="helvetikerBold">Helvetiker Bold</option>
<option value="gentilisRegular">Gentilis</option>
<option value="gentilisBold">Gentilis Bold</option>
<option value="optimer">Optimer</option>
<option value="optimerBold">Optimer Bold</option>
<option value="droidMono">Droid Mono</option>
Expand All @@ -58,6 +63,22 @@ function TextInput() {
/>
</div>

<div>
<label className="flex justify-between text-xs text-zinc-400">
<span>Letter Spacing</span>
<span>{letterSpacing.toFixed(2)}</span>
</label>
<input
type="range"
min="-0.2"
max="1"
step="0.05"
value={letterSpacing}
onChange={(e) => setLetterSpacing(Number(e.target.value))}
className="w-full"
/>
</div>

<div>
<label className="flex justify-between text-xs text-zinc-400">
<span>Extrude Depth</span>
Expand All @@ -74,15 +95,20 @@ function TextInput() {
/>
</div>

<label className="flex items-center gap-2 text-xs text-zinc-400">
<input
type="checkbox"
checked={bevelEnabled}
onChange={(e) => setBevelEnabled(e.target.checked)}
className="accent-emerald-500"
/>
Bevel
</label>
<div className="flex items-center justify-between">
<label className="flex items-center gap-2 text-xs text-zinc-400">
<input
type="checkbox"
checked={bevelEnabled}
onChange={(e) => setBevelEnabled(e.target.checked)}
className="accent-emerald-500"
/>
Bevel
</label>
<button onClick={resetTextConfig} className="text-xs text-zinc-500 hover:text-zinc-300">
Reset defaults
</button>
</div>
</div>
)
}
Expand Down
1 change: 1 addition & 0 deletions src/app/store/selectors.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,5 +50,6 @@ export const selectInputSource = (state) => ({
extrudeDepth: state.extrudeDepth,
bevelEnabled: state.bevelEnabled,
fontFamily: state.fontFamily,
letterSpacing: state.letterSpacing,
imageSource: state.imageSource
})
4 changes: 3 additions & 1 deletion src/app/store/selectors.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ function makeState() {
extrudeDepth: 0.2,
bevelEnabled: false,
fontFamily: 'helvetiker',
letterSpacing: 0,
imageSource: null
}
}
Expand Down Expand Up @@ -128,7 +129,7 @@ describe('selectAnimationOptions', () => {
})

describe('selectInputSource', () => {
it('returns object with all 10 input source fields', () => {
it('returns object with all 11 input source fields', () => {
const result = selectInputSource(makeState())
const keys = [
'inputType',
Expand All @@ -140,6 +141,7 @@ describe('selectInputSource', () => {
'extrudeDepth',
'bevelEnabled',
'fontFamily',
'letterSpacing',
'imageSource'
]
for (const key of keys) {
Expand Down
11 changes: 11 additions & 0 deletions src/app/store/slices/inputSlice.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export const INPUT_DEFAULTS = {
extrudeDepth: 0.3,
bevelEnabled: true,
fontFamily: 'helvetiker',
letterSpacing: 0,
imageSource: null
}

Expand Down Expand Up @@ -38,5 +39,15 @@ export const createInputSlice = (set, _get) => ({
setExtrudeDepth: (extrudeDepth) => set({ extrudeDepth: Math.max(0.05, extrudeDepth) }),
setBevelEnabled: (bevelEnabled) => set({ bevelEnabled }),
setFontFamily: (fontFamily) => set({ fontFamily }),
setLetterSpacing: (letterSpacing) => set({ letterSpacing }),
resetTextConfig: () =>
set({
textContent: INPUT_DEFAULTS.textContent,
fontSize: INPUT_DEFAULTS.fontSize,
extrudeDepth: INPUT_DEFAULTS.extrudeDepth,
bevelEnabled: INPUT_DEFAULTS.bevelEnabled,
fontFamily: INPUT_DEFAULTS.fontFamily,
letterSpacing: INPUT_DEFAULTS.letterSpacing
}),
setImageSource: (imageSource) => set({ imageSource })
})
Loading
Loading