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
457 changes: 318 additions & 139 deletions e2e/floorplan.spec.ts

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta name="description" content="SnapDraft — quick floor plan sketching tool" />
<meta name="theme-color" content="#f5f0e8" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="SnapDraft" />
Expand Down
2 changes: 2 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { PropertiesPanel } from './components/PropertiesPanel/PropertiesPanel';
import { HelpOverlay } from './components/HelpOverlay/HelpOverlay';
import { ScaleBar } from './components/Canvas/ScaleBar/ScaleBar';
import { MultiSelectBar } from './components/Canvas/MultiSelectBar/MultiSelectBar';
import { MobileSelectionBar } from './components/Canvas/MobileSelectionBar/MobileSelectionBar';
import { useFloorplanStore } from './store/useFloorplanStore/useFloorplanStore';
import { decodePlanFromUrl } from './utils/storage/storage';
import styles from './App.module.css';
Expand Down Expand Up @@ -65,6 +66,7 @@ export default function App() {
<PropertiesPanel />
<ScaleBar />
<MultiSelectBar />
<MobileSelectionBar />
{showHelp && <HelpOverlay onClose={() => setShowHelp(false)} />}
</div>
);
Expand Down
2 changes: 1 addition & 1 deletion src/components/Canvas/BoxElement/BoxElement.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ export function BoxElement({ box, selected, onSelect, onGroupDrag }: Props) {
onDragStart={handleDragStart}
onDragMove={handleDragMove}
onDragEnd={handleDragEnd}
onClick={(e) => onSelect(Boolean(e.evt.shiftKey))}
onClick={(e) => onSelect(Boolean(e.evt?.shiftKey))}
onTap={() => onSelect(false)}
>
<Rect
Expand Down
81 changes: 78 additions & 3 deletions src/components/Canvas/DrawingCanvas/DrawingCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,10 @@ export function DrawingCanvas() {
initialZoom: number;
initialPan: { x: number; y: number };
initialMidpoint: { x: number; y: number };
initialAngle: number;
selectedBoxId: string | null;
initialBoxRotation: number;
panelWasOpen: boolean;
} | null>(null);
const isTwoFingerActiveRef = useRef(false);

Expand Down Expand Up @@ -189,17 +193,50 @@ export function DrawingCanvas() {
};
}

function getTouchAngle(touches: TouchList) {
const t1 = touches[0];
const t2 = touches[1];
return Math.atan2(t2.clientY - t1.clientY, t2.clientX - t1.clientX) * (180 / Math.PI);
}

function handleTouchStart(e: TouchEvent) {
if (e.touches.length === 2) {
isTwoFingerActiveRef.current = true;
const t1 = e.touches[0];
const t2 = e.touches[1];
const { zoom: currentZoom, pan: currentPan } = useToolStore.getState();

// Find selected box for rotation
const toolState = useToolStore.getState();
const selIds = toolState.selectedIds;
let selectedBoxId: string | null = null;
let initialBoxRotation = 0;
if (selIds.size === 1) {
const [id] = selIds;
const el = useFloorplanStore
.getState()
.activePlan()
?.elements.find((e) => e.id === id);
if (el?.type === 'box') {
selectedBoxId = id;
initialBoxRotation = el.rotation;
useFloorplanStore.getState().snapshotForUndo();
}
}

// Hide the properties panel during the gesture
const panelWasOpen = toolState.propertiesPanelOpen;
useToolStore.getState().setPropertiesPanelOpen(false);

touchGestureRef.current = {
initialDist: getTouchDist(t1, t2),
initialZoom: currentZoom,
initialPan: { ...currentPan },
initialMidpoint: getTouchMidpoint(t1, t2),
initialAngle: getTouchAngle(e.touches),
selectedBoxId,
initialBoxRotation,
panelWasOpen,
};
// Cancel any in-progress single-finger action
setPointerDown(null);
Expand All @@ -213,7 +250,15 @@ export function DrawingCanvas() {
e.preventDefault();
const t1 = e.touches[0];
const t2 = e.touches[1];
const { initialDist, initialZoom, initialPan, initialMidpoint } = touchGestureRef.current;
const {
initialDist,
initialZoom,
initialPan,
initialMidpoint,
initialAngle,
selectedBoxId,
initialBoxRotation,
} = touchGestureRef.current;

const currentDist = getTouchDist(t1, t2);
const currentMidpoint = getTouchMidpoint(t1, t2);
Expand All @@ -234,11 +279,22 @@ export function DrawingCanvas() {
y: currentMidpoint.y - worldUnderMidpoint.y * newZoom,
},
});

// Apply rotation to selected box
if (selectedBoxId !== null) {
const deltaAngle = getTouchAngle(e.touches) - initialAngle;
const raw = initialBoxRotation + deltaAngle;
const snapped = Math.round((((raw % 360) + 360) % 360) / 5) * 5;
useFloorplanStore.getState().updateElementSilent(selectedBoxId, { rotation: snapped });
}
}
}

function handleTouchEnd(e: TouchEvent) {
if (e.touches.length < 2) {
if (touchGestureRef.current) {
useToolStore.getState().setPropertiesPanelOpen(touchGestureRef.current.panelWasOpen);
}
touchGestureRef.current = null;
// Brief delay so the finger-lift pointer event doesn't trigger a tap
setTimeout(() => {
Expand Down Expand Up @@ -403,7 +459,7 @@ export function DrawingCanvas() {
setCursorSnappedToAxis(false);
} else {
const gridIncrement =
activeTool === 'wall' ? getWallSnapIncrement(Boolean(e.evt.shiftKey)) : undefined;
activeTool === 'wall' ? getWallSnapIncrement(Boolean(e.evt?.shiftKey)) : undefined;
const { point, snappedToEndpoint, snappedToSegment, snappedToAxis } = snapWithInfo(
world,
gridIncrement,
Expand Down Expand Up @@ -432,7 +488,7 @@ export function DrawingCanvas() {

const dragDist = distance(pointerDown.pos, world);
const isDrag = dragDist > DRAG_THRESHOLD_FT;
const wallSnapIncrement = getWallSnapIncrement(Boolean(e.evt.shiftKey));
const wallSnapIncrement = getWallSnapIncrement(Boolean(e.evt?.shiftKey));
const snappedEnd = activeTool === 'wall' ? snap(world, wallSnapIncrement) : snap(world);

if (activeTool === 'measure') {
Expand Down Expand Up @@ -481,6 +537,9 @@ export function DrawingCanvas() {
const rh = Math.abs(marquee.end.y - marquee.start.y);
const hit = elements.filter((el) => elementOverlapsRect(el, rx, ry, rw, rh));
setSelectedIds(new Set(hit.map((el) => el.id)));
if (hit.length === 1 && !shouldUseMobileOverlayLayout(window.innerWidth)) {
useToolStore.getState().setPropertiesPanelOpen(true);
}
} else if (isCanvasBackgroundTarget(e.target)) {
clearSelection();
}
Expand Down Expand Up @@ -801,10 +860,18 @@ export function DrawingCanvas() {
onSelect={(extendSelection) => {
if (activeTool !== 'select') return;
const toolStore = useToolStore.getState();
const isMobile = shouldUseMobileOverlayLayout(window.innerWidth);
if (extendSelection) {
toolStore.toggleSelectedId(el.id);
const single = useToolStore.getState().selectedIds.size === 1;
toolStore.setPropertiesPanelOpen(!isMobile && single);
} else {
const alreadySelected =
toolStore.selectedIds.has(el.id) && toolStore.selectedIds.size === 1;
toolStore.setSelectedId(el.id);
if (!isMobile || alreadySelected) {
toolStore.setPropertiesPanelOpen(true);
}
}
}}
onGroupDrag={handleGroupDrag}
Expand All @@ -817,10 +884,18 @@ export function DrawingCanvas() {
onSelect={(extendSelection) => {
if (activeTool !== 'select') return;
const toolStore = useToolStore.getState();
const isMobile = shouldUseMobileOverlayLayout(window.innerWidth);
if (extendSelection) {
toolStore.toggleSelectedId(el.id);
const single = useToolStore.getState().selectedIds.size === 1;
toolStore.setPropertiesPanelOpen(!isMobile && single);
} else {
const alreadySelected =
toolStore.selectedIds.has(el.id) && toolStore.selectedIds.size === 1;
toolStore.setSelectedId(el.id);
if (!isMobile || alreadySelected) {
toolStore.setPropertiesPanelOpen(true);
}
}
}}
onGroupDrag={handleGroupDrag}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
.bar {
position: fixed;
bottom: 12px;
left: 50%;
transform: translateX(-50%);
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(8px);
border: 1px solid #c8c4bc;
border-radius: 14px;
padding: 8px;
display: flex;
align-items: center;
justify-content: center;
gap: 2px;
font-family: 'Courier New', monospace;
z-index: 150;
box-shadow: 0 2px 16px rgba(0, 0, 0, 0.12);
user-select: none;
}

.btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
background: transparent;
border: none;
border-radius: 10px;
color: #262626;
font-family: inherit;
font-size: 12px;
font-weight: 800;
letter-spacing: 0.04em;
text-transform: uppercase;
padding: 10px 14px;
cursor: pointer;
min-height: 60px;
min-width: 60px;
touch-action: manipulation;
transition: background 0.15s, color 0.15s;
}

.icon {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
}

.icon svg {
width: 100%;
height: 100%;
}

.btn:hover {
background: #f0ede8;
}

.btn:disabled {
opacity: 0.35;
cursor: default;
}

.btn:disabled:hover {
background: transparent;
}

.btn:focus-visible {
outline: 2px solid #4a6fa5;
outline-offset: 2px;
}

.editBtn {
composes: btn;
}

.deleteBtn {
composes: btn;
color: #b83333;
}

.deleteBtn:hover {
background: #fce8e8;
}

.cancelBtn {
composes: btn;
}

.doneBtn {
composes: btn;
color: #2a7a4a;
}

.doneBtn:hover {
background: #e8f5ee;
}
Loading