Skip to content

Commit ab9986e

Browse files
committed
flash red visuals
1 parent 1b21ff0 commit ab9986e

3 files changed

Lines changed: 152 additions & 38 deletions

File tree

src/components/Garden3DView.vue

Lines changed: 64 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -106,13 +106,13 @@ const props = defineProps<{
106106
labelMode?: 'off' | 'all';
107107
zoom?: number;
108108
addRowTop: () => void;
109-
removeRowTop: () => void;
109+
removeRowTop: () => boolean;
110110
addRowBottom: () => void;
111-
removeRowBottom: () => void;
111+
removeRowBottom: () => boolean;
112112
addColumnLeft: () => void;
113-
removeColumnLeft: () => void;
113+
removeColumnLeft: () => boolean;
114114
addColumnRight: () => void;
115-
removeColumnRight: () => void;
115+
removeColumnRight: () => boolean;
116116
}>();
117117
118118
const emit = defineEmits<{
@@ -154,6 +154,8 @@ const rootRef = ref<HTMLElement | null>(null);
154154
const threeContainer = ref<HTMLElement | null>(null);
155155
let resizeControlsGroup: THREE.Group | null = null;
156156
let resizeControlMeshes: THREE.Mesh[] = [];
157+
const resizeActionToMesh = new Map<ResizeAction, THREE.Mesh>();
158+
const resizeFlashTimeout = new Map<ResizeAction, number>();
157159
158160
type ResizeAction =
159161
| 'addRowTop'
@@ -224,6 +226,47 @@ const disposeResizeControls = () => {
224226
}
225227
resizeControlMeshes = [];
226228
resizeControlsGroup = null;
229+
resizeActionToMesh.clear();
230+
for (const t of resizeFlashTimeout.values()) {
231+
try {
232+
clearTimeout(t);
233+
} catch {
234+
// ignore
235+
}
236+
}
237+
resizeFlashTimeout.clear();
238+
};
239+
240+
const flashResizeControl = (action: ResizeAction) => {
241+
const mesh = resizeActionToMesh.get(action);
242+
if (!mesh) return;
243+
const matAny = mesh.material as any;
244+
// We only ever assign a single MeshBasicMaterial in mkButton(), but be defensive.
245+
const mat = Array.isArray(matAny) ? matAny[0] : matAny;
246+
if (!mat || !mat.color || typeof mat.color.getHex !== 'function') return;
247+
248+
const prev = mat.color.getHex();
249+
mat.color.set(0xffebee);
250+
mat.needsUpdate = true;
251+
252+
const existing = resizeFlashTimeout.get(action);
253+
if (existing) {
254+
try {
255+
clearTimeout(existing);
256+
} catch {
257+
// ignore
258+
}
259+
}
260+
261+
const t = window.setTimeout(() => {
262+
try {
263+
mat.color.set(prev);
264+
mat.needsUpdate = true;
265+
} finally {
266+
resizeFlashTimeout.delete(action);
267+
}
268+
}, 300);
269+
resizeFlashTimeout.set(action, t);
227270
};
228271
229272
const buildResizeControls = () => {
@@ -254,6 +297,7 @@ const buildResizeControls = () => {
254297
mesh.position.y = y;
255298
mesh.userData = { ...(mesh.userData || {}), resizeAction: action };
256299
resizeControlMeshes.push(mesh);
300+
resizeActionToMesh.set(action, mesh);
257301
group.add(mesh);
258302
return mesh;
259303
};
@@ -2001,13 +2045,25 @@ const onPointerUp = (ev: PointerEvent) => {
20012045
const resizeAction = pickResizeActionUnderPointer(ev);
20022046
if (resizeAction) {
20032047
if (resizeAction === 'addRowTop') props.addRowTop();
2004-
else if (resizeAction === 'removeRowTop') props.removeRowTop();
2048+
else if (resizeAction === 'removeRowTop') {
2049+
const ok = props.removeRowTop();
2050+
if (!ok) flashResizeControl(resizeAction);
2051+
}
20052052
else if (resizeAction === 'addRowBottom') props.addRowBottom();
2006-
else if (resizeAction === 'removeRowBottom') props.removeRowBottom();
2053+
else if (resizeAction === 'removeRowBottom') {
2054+
const ok = props.removeRowBottom();
2055+
if (!ok) flashResizeControl(resizeAction);
2056+
}
20072057
else if (resizeAction === 'addColumnLeft') props.addColumnLeft();
2008-
else if (resizeAction === 'removeColumnLeft') props.removeColumnLeft();
2058+
else if (resizeAction === 'removeColumnLeft') {
2059+
const ok = props.removeColumnLeft();
2060+
if (!ok) flashResizeControl(resizeAction);
2061+
}
20092062
else if (resizeAction === 'addColumnRight') props.addColumnRight();
2010-
else if (resizeAction === 'removeColumnRight') props.removeColumnRight();
2063+
else if (resizeAction === 'removeColumnRight') {
2064+
const ok = props.removeColumnRight();
2065+
if (!ok) flashResizeControl(resizeAction);
2066+
}
20112067
20122068
pointerDownAt = null;
20132069
pointerDragging = false;

src/components/GardenCanvas.vue

Lines changed: 66 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,8 @@
9797
</button>
9898
<button
9999
class="resize-button"
100-
@click="removeRowTop"
100+
:class="{ 'flash-red': flashRemoveTop }"
101+
@click="handleRemoveRowTop"
101102
title="Remove row from top"
102103
aria-label="Remove row from top"
103104
>
@@ -117,7 +118,8 @@
117118
</button>
118119
<button
119120
class="resize-button"
120-
@click="removeColumnLeft"
121+
:class="{ 'flash-red': flashRemoveLeft }"
122+
@click="handleRemoveColumnLeft"
121123
title="Remove column from left"
122124
aria-label="Remove column from left"
123125
>
@@ -137,7 +139,8 @@
137139
</button>
138140
<button
139141
class="resize-button"
140-
@click="removeColumnRight"
142+
:class="{ 'flash-red': flashRemoveRight }"
143+
@click="handleRemoveColumnRight"
141144
title="Remove column from right"
142145
aria-label="Remove column from right"
143146
>
@@ -157,7 +160,8 @@
157160
</button>
158161
<button
159162
class="resize-button"
160-
@click="removeRowBottom"
163+
:class="{ 'flash-red': flashRemoveBottom }"
164+
@click="handleRemoveRowBottom"
161165
title="Remove row from bottom"
162166
aria-label="Remove row from bottom"
163167
>
@@ -196,13 +200,13 @@ interface Props {
196200
zoom?: number;
197201
labelMode?: 'off' | 'all';
198202
addRowTop: () => void;
199-
removeRowTop: () => void;
203+
removeRowTop: () => boolean;
200204
addRowBottom: () => void;
201-
removeRowBottom: () => void;
205+
removeRowBottom: () => boolean;
202206
addColumnLeft: () => void;
203-
removeColumnLeft: () => void;
207+
removeColumnLeft: () => boolean;
204208
addColumnRight: () => void;
205-
removeColumnRight: () => void;
209+
removeColumnRight: () => boolean;
206210
}
207211
208212
const props = defineProps<Props>();
@@ -215,6 +219,36 @@ const gridRef = ref<HTMLElement | null>(null);
215219
const gridAreaRef = ref<HTMLElement | null>(null);
216220
const activeDrag = ref<{ type: 'place' | 'move'; plantId: string } | null>(null);
217221
222+
// Flash red when a resize "shrink" is blocked by existing plants
223+
const flashRemoveTop = ref(false);
224+
const flashRemoveBottom = ref(false);
225+
const flashRemoveLeft = ref(false);
226+
const flashRemoveRight = ref(false);
227+
228+
const flash = (flag: { value: boolean }) => {
229+
flag.value = true;
230+
window.setTimeout(() => {
231+
flag.value = false;
232+
}, 300);
233+
};
234+
235+
const handleRemoveRowTop = () => {
236+
const ok = props.removeRowTop();
237+
if (!ok) flash(flashRemoveTop);
238+
};
239+
const handleRemoveRowBottom = () => {
240+
const ok = props.removeRowBottom();
241+
if (!ok) flash(flashRemoveBottom);
242+
};
243+
const handleRemoveColumnLeft = () => {
244+
const ok = props.removeColumnLeft();
245+
if (!ok) flash(flashRemoveLeft);
246+
};
247+
const handleRemoveColumnRight = () => {
248+
const ok = props.removeColumnRight();
249+
if (!ok) flash(flashRemoveRight);
250+
};
251+
218252
// Pan state (spacebar + left click drag)
219253
const isSpacebarHeld = ref(false);
220254
const isPanning = ref(false);
@@ -612,10 +646,10 @@ const dragPreviewWrapperStyle = computed(() => {
612646
}
613647
614648
return {
615-
position: 'absolute',
649+
position: 'absolute' as const,
616650
left: `${previewX}px`,
617651
top: `${previewY}px`,
618-
pointerEvents: 'none',
652+
pointerEvents: 'none' as const,
619653
};
620654
});
621655
@@ -945,6 +979,28 @@ button.primary-bar.small.danger {
945979
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
946980
}
947981
982+
.resize-button.flash-red {
983+
animation: flashRed 0.3s ease-in-out;
984+
}
985+
986+
@keyframes flashRed {
987+
0% {
988+
background-color: white;
989+
border-color: #d1d5db;
990+
color: #374151;
991+
}
992+
50% {
993+
background-color: #ffebee;
994+
border-color: #d32f2f;
995+
color: #d32f2f;
996+
}
997+
100% {
998+
background-color: white;
999+
border-color: #d1d5db;
1000+
color: #374151;
1001+
}
1002+
}
1003+
9481004
.resize-button:hover {
9491005
background-color: #f9fafb;
9501006
border-color: #9ca3af;

src/composables/useGardenPlanner.ts

Lines changed: 22 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -720,24 +720,25 @@ export function useGardenPlanner() {
720720
// Create deep copy for history
721721
undoRedo.addState(placedPlants.value.map(p => ({ ...p })));
722722
},
723-
removeRowTop: () => {
723+
removeRowTop: (): boolean => {
724724
// Check if any plant is in the top row (y < 1)
725725
// Note: A plant at y=0 has height >= 1, so it occupies row 0.
726726
const hasPlantAtTop = placedPlants.value.some(p => p.y < 1);
727-
if (gridHeight.value > 1 && !hasPlantAtTop) {
728-
gridHeight.value -= 1;
729-
// Shift all plants up by 1
730-
placedPlants.value.forEach(p => p.y -= 1);
727+
if (gridHeight.value <= 1 || hasPlantAtTop) return false;
728+
729+
gridHeight.value -= 1;
730+
// Shift all plants up by 1
731+
placedPlants.value.forEach(p => p.y -= 1);
731732
// Trigger update
732733
placedPlants.value = [...placedPlants.value];
733734
// Create deep copy for history
734735
undoRedo.addState(placedPlants.value.map(p => ({ ...p })));
735-
}
736+
return true;
736737
},
737738
addRowBottom: () => {
738739
gridHeight.value += 1;
739740
},
740-
removeRowBottom: () => {
741+
removeRowBottom: (): boolean => {
741742
// Check if any plant is in the bottom row
742743
// A plant at y occupies rows [y, y+height-1].
743744
// We want to remove row index (gridHeight.value - 1).
@@ -746,9 +747,9 @@ export function useGardenPlanner() {
746747
const limit = gridHeight.value - 1;
747748
const hasPlantAtBottom = placedPlants.value.some(p => p.y + p.height > limit);
748749

749-
if (gridHeight.value > 1 && !hasPlantAtBottom) {
750-
gridHeight.value -= 1;
751-
}
750+
if (gridHeight.value <= 1 || hasPlantAtBottom) return false;
751+
gridHeight.value -= 1;
752+
return true;
752753
},
753754
addColumnLeft: () => {
754755
gridWidth.value += 1;
@@ -759,30 +760,31 @@ export function useGardenPlanner() {
759760
// Create deep copy for history
760761
undoRedo.addState(placedPlants.value.map(p => ({ ...p })));
761762
},
762-
removeColumnLeft: () => {
763+
removeColumnLeft: (): boolean => {
763764
// Check if any plant is in the left column (x < 1)
764765
const hasPlantAtLeft = placedPlants.value.some(p => p.x < 1);
765-
if (gridWidth.value > 1 && !hasPlantAtLeft) {
766-
gridWidth.value -= 1;
767-
// Shift all plants left by 1
768-
placedPlants.value.forEach(p => p.x -= 1);
766+
if (gridWidth.value <= 1 || hasPlantAtLeft) return false;
767+
768+
gridWidth.value -= 1;
769+
// Shift all plants left by 1
770+
placedPlants.value.forEach(p => p.x -= 1);
769771
// Trigger update
770772
placedPlants.value = [...placedPlants.value];
771773
// Create deep copy for history
772774
undoRedo.addState(placedPlants.value.map(p => ({ ...p })));
773-
}
775+
return true;
774776
},
775777
addColumnRight: () => {
776778
gridWidth.value += 1;
777779
},
778-
removeColumnRight: () => {
780+
removeColumnRight: (): boolean => {
779781
// Check if any plant is in the rightmost column
780782
const limit = gridWidth.value - 1;
781783
const hasPlantAtRight = placedPlants.value.some(p => p.x + p.width > limit);
782784

783-
if (gridWidth.value > 1 && !hasPlantAtRight) {
784-
gridWidth.value -= 1;
785-
}
785+
if (gridWidth.value <= 1 || hasPlantAtRight) return false;
786+
gridWidth.value -= 1;
787+
return true;
786788
},
787789

788790
// Grid size management methods

0 commit comments

Comments
 (0)