diff --git a/backlog/tasks/task-70 - Default-selector-tool-with-marquee-multi-select.md b/backlog/tasks/task-70 - Default-selector-tool-with-marquee-multi-select.md new file mode 100644 index 0000000..b344ac3 --- /dev/null +++ b/backlog/tasks/task-70 - Default-selector-tool-with-marquee-multi-select.md @@ -0,0 +1,45 @@ +--- +id: TASK-70 +title: Default selector tool with marquee multi-select +status: Done +assignee: [] +created_date: '2026-02-28 00:36' +updated_date: '2026-02-28 00:36' +labels: + - canvas + - UX + - selection +dependencies: [] +references: + - lib/folk-shape.ts + - website/canvas.html +priority: high +--- + +## Description + + +Replace single-click-to-pan with selector as the default canvas tool. Left-click-drag on background draws a blue marquee rectangle to select multiple shapes. Shift/Ctrl+click toggles additive selection. Panning moves to Space+drag, middle-click, or wheel/trackpad. Delete/Backspace removes all selected shapes. folk-shape highlighted state shows blue selection outline. + + +## Acceptance Criteria + +- [x] #1 Click canvas background → nothing selected, no pan +- [x] #2 Click-drag on background → blue marquee rectangle appears +- [x] #3 Release marquee → all overlapping shapes highlighted with blue outline +- [x] #4 Shift+click a shape → adds/removes from selection +- [x] #5 Click a single shape → only that shape selected +- [x] #6 Hold Space + drag → pans the canvas (cursor shows grab) +- [x] #7 Middle-click + drag → pans the canvas +- [x] #8 Two-finger scroll → still pans (unchanged) +- [x] #9 Ctrl+scroll → still zooms (unchanged) +- [x] #10 Delete/Backspace → removes all selected shapes +- [x] #11 Click toolbar tool → crosshair cursor → click to place (unchanged) +- [x] #12 bunx tsc --noEmit passes + + +## Final Summary + + +Implemented default selector tool with marquee multi-select. Changed folk-shape.ts highlighted CSS to show blue outline. Rewrote canvas.html pointer handlers: left-click-drag draws marquee selection rectangle, Space+drag and middle-click for panning, Shift/Ctrl+click for additive selection, Delete/Backspace to remove selected shapes. Commit 1d8fc2b on dev, merged to main. + diff --git a/lib/folk-blender.ts b/lib/folk-blender.ts index e62d5db..ad95fd9 100644 --- a/lib/folk-blender.ts +++ b/lib/folk-blender.ts @@ -411,7 +411,7 @@ export class FolkBlender extends FolkShape { if (this.#renderUrl) { this.#previewArea.innerHTML = `3D Render`; } else { - this.#previewArea.innerHTML = '
\u2705Script generated (see Script tab)
'; + this.#previewArea.innerHTML = '
Script generated (see Script tab)
'; } } diff --git a/lib/folk-booking.ts b/lib/folk-booking.ts index f207930..8d03289 100644 --- a/lib/folk-booking.ts +++ b/lib/folk-booking.ts @@ -150,15 +150,15 @@ const styles = css` `; const BOOKING_TYPE_ICONS: Record = { - FLIGHT: "\u2708\uFE0F", - HOTEL: "\uD83C\uDFE8", - CAR_RENTAL: "\uD83D\uDE97", - TRAIN: "\uD83D\uDE84", - BUS: "\uD83D\uDE8C", - FERRY: "\u26F4\uFE0F", - ACTIVITY: "\uD83C\uDFAF", - RESTAURANT: "\uD83C\uDF7D\uFE0F", - OTHER: "\uD83D\uDCCC", + FLIGHT: "✈️", + HOTEL: "🏨", + CAR_RENTAL: "🚗", + TRAIN: "🚄", + BUS: "🚌", + FERRY: "⛴️", + ACTIVITY: "🎯", + RESTAURANT: "🍽️", + OTHER: "📌", }; declare global { @@ -253,11 +253,11 @@ export class FolkBooking extends FolkShape { wrapper.innerHTML = html`
- \uD83D\uDCCC + 📌 Booking
- +
diff --git a/lib/folk-budget.ts b/lib/folk-budget.ts index 1e54fa7..1e599db 100644 --- a/lib/folk-budget.ts +++ b/lib/folk-budget.ts @@ -243,11 +243,11 @@ export class FolkBudget extends FolkShape { wrapper.innerHTML = html`
- \uD83D\uDCB0 + 💰 Budget
- +
diff --git a/lib/folk-calendar.ts b/lib/folk-calendar.ts index 6759b22..ef8febd 100644 --- a/lib/folk-calendar.ts +++ b/lib/folk-calendar.ts @@ -242,18 +242,18 @@ export class FolkCalendar extends FolkShape { wrapper.innerHTML = html`
- \u{1F4C5} + 📅 Calendar
- +
- + - +
Sun diff --git a/lib/folk-canvas.ts b/lib/folk-canvas.ts index 313c85a..ddc9f78 100644 --- a/lib/folk-canvas.ts +++ b/lib/folk-canvas.ts @@ -277,15 +277,15 @@ export class FolkCanvas extends FolkShape { wrapper.innerHTML = html`
- \u{1F5BC} + 🖼
- - - + + +
@@ -331,7 +331,7 @@ export class FolkCanvas extends FolkShape { collapseBtn.addEventListener("click", (e) => { e.stopPropagation(); this.collapsed = !this.#collapsed; - collapseBtn.textContent = this.#collapsed ? "\u25B6" : "\u25BC"; + collapseBtn.textContent = this.#collapsed ? "▶" : "▼"; this.dispatchEvent(new CustomEvent("content-change", { detail: { collapsed: this.#collapsed } })); }); @@ -448,10 +448,10 @@ export class FolkCanvas extends FolkShape { if (this.#collapsed) { content.innerHTML = `
-
\u{1F5BC}
+
🖼
${this.#label || this.#sourceSlug}
${this.#nestedShapes.size} shapes
- +
`; statusBar.style.display = "none"; diff --git a/lib/folk-destination.ts b/lib/folk-destination.ts index 7e1807d..0e72123 100644 --- a/lib/folk-destination.ts +++ b/lib/folk-destination.ts @@ -194,11 +194,11 @@ export class FolkDestination extends FolkShape { wrapper.innerHTML = html`
- \uD83D\uDCCD + 📍 Destination
- +
diff --git a/lib/folk-embed.ts b/lib/folk-embed.ts index 77224a2..1f8cf65 100644 --- a/lib/folk-embed.ts +++ b/lib/folk-embed.ts @@ -239,11 +239,11 @@ export class FolkEmbed extends FolkShape { wrapper.innerHTML = html`
- \u{1F517} + 🔗 Embed
- +
@@ -347,7 +347,7 @@ export class FolkEmbed extends FolkShape { urlInputContainer.innerHTML = `

This content cannot be embedded in an iframe.

- +
`; const openBtn = urlInputContainer.querySelector(".open-link"); diff --git a/lib/folk-freecad.ts b/lib/folk-freecad.ts index 2e9088a..740a692 100644 --- a/lib/folk-freecad.ts +++ b/lib/folk-freecad.ts @@ -341,7 +341,7 @@ export class FolkFreeCAD extends FolkShape { if (this.#previewUrl) { this.#previewArea.innerHTML = `CAD Preview`; } else { - this.#previewArea.innerHTML = '
\u2705Model generated! Download files below.
'; + this.#previewArea.innerHTML = '
Model generated! Download files below.
'; } } diff --git a/lib/folk-google-item.ts b/lib/folk-google-item.ts index 2f809c2..9c1d5d8 100644 --- a/lib/folk-google-item.ts +++ b/lib/folk-google-item.ts @@ -5,10 +5,10 @@ export type GoogleService = "gmail" | "drive" | "photos" | "calendar"; export type ItemVisibility = "local" | "shared"; const SERVICE_ICONS: Record = { - gmail: "\u{1F4E7}", - drive: "\u{1F4C1}", - photos: "\u{1F4F7}", - calendar: "\u{1F4C5}", + gmail: "📧", + drive: "📁", + photos: "📷", + calendar: "📅", }; const styles = css` @@ -213,7 +213,7 @@ export class FolkGoogleItem extends FolkShape { wrapper.innerHTML = html`
- ${this.#visibility === "local" ? "\u{1F512}" : "\u{1F310}"} + ${this.#visibility === "local" ? "🔒" : "🌐"}
${SERVICE_ICONS[this.#service]} @@ -245,7 +245,7 @@ export class FolkGoogleItem extends FolkShape { e.stopPropagation(); this.#visibility = this.#visibility === "local" ? "shared" : "local"; - badge.textContent = this.#visibility === "local" ? "\u{1F512}" : "\u{1F310}"; + badge.textContent = this.#visibility === "local" ? "🔒" : "🌐"; card.classList.remove("local", "shared"); card.classList.add(this.#visibility); diff --git a/lib/folk-image-gen.ts b/lib/folk-image-gen.ts index 40ca1ff..a1b8cd7 100644 --- a/lib/folk-image-gen.ts +++ b/lib/folk-image-gen.ts @@ -364,7 +364,7 @@ export class FolkImageGen extends FolkShape { if (!this.#imageArea) return; this.#imageArea.innerHTML = `
${this.#escapeHtml(this.#error || "Unknown error")}
- ${this.#images.length > 0 ? this.#renderImageList() : '
\u{1F5BC}Try again with a different prompt
'} + ${this.#images.length > 0 ? this.#renderImageList() : '
🖼Try again with a different prompt
'} `; } @@ -374,7 +374,7 @@ export class FolkImageGen extends FolkShape { if (this.#images.length === 0) { this.#imageArea.innerHTML = `
- \u{1F5BC} + 🖼 Enter a prompt and click Generate
`; diff --git a/lib/folk-itinerary.ts b/lib/folk-itinerary.ts index 2a16d11..6dc385e 100644 --- a/lib/folk-itinerary.ts +++ b/lib/folk-itinerary.ts @@ -156,13 +156,13 @@ export interface ItineraryEntry { } const CATEGORY_ICONS: Record = { - FLIGHT: "\u2708\uFE0F", - TRANSPORT: "\uD83D\uDE8C", - ACCOMMODATION: "\uD83C\uDFE8", - ACTIVITY: "\uD83C\uDFAF", - MEAL: "\uD83C\uDF7D\uFE0F", - FREE_TIME: "\u2600\uFE0F", - OTHER: "\uD83D\uDCCC", + FLIGHT: "✈️", + TRANSPORT: "🚌", + ACCOMMODATION: "🏨", + ACTIVITY: "🎯", + MEAL: "🍽️", + FREE_TIME: "☀️", + OTHER: "📌", }; declare global { @@ -222,11 +222,11 @@ export class FolkItinerary extends FolkShape { wrapper.innerHTML = html`
- \uD83D\uDCC5 + 📅 Itinerary
- +
diff --git a/lib/folk-kicad.ts b/lib/folk-kicad.ts index 46dc55f..eeb7ecd 100644 --- a/lib/folk-kicad.ts +++ b/lib/folk-kicad.ts @@ -434,14 +434,14 @@ export class FolkKiCAD extends FolkShape { if (this.#schematicSvg) { this.#previewArea.innerHTML = `Schematic`; } else { - this.#previewArea.innerHTML = '
\u{1F4CB}Schematic will appear here
'; + this.#previewArea.innerHTML = '
📋Schematic will appear here
'; } break; case "board": if (this.#boardSvg) { this.#previewArea.innerHTML = `Board Layout`; } else { - this.#previewArea.innerHTML = '
\u{1F4DF}Board layout will appear here
'; + this.#previewArea.innerHTML = '
📟Board layout will appear here
'; } break; case "drc": @@ -451,14 +451,14 @@ export class FolkKiCAD extends FolkShape { this.#previewArea.innerHTML = `

${passed - ? '\u2705 DRC Passed' - : `\u274C ${violations.length} Violation(s)` + ? '✅ DRC Passed' + : `❌ ${violations.length} Violation(s)` }

- ${violations.map((v: any) => `

\u2022 ${this.#escapeHtml(v.message || v)}

`).join("")} + ${violations.map((v: any) => `

• ${this.#escapeHtml(v.message || v)}

`).join("")}
`; } else { - this.#previewArea.innerHTML = '
\u2705DRC results will appear here
'; + this.#previewArea.innerHTML = '
DRC results will appear here
'; } break; } diff --git a/lib/folk-map.ts b/lib/folk-map.ts index d83336a..6f18743 100644 --- a/lib/folk-map.ts +++ b/lib/folk-map.ts @@ -290,20 +290,20 @@ export class FolkMap extends FolkShape { wrapper.innerHTML = html`
- \u{1F5FA} + 🗺 Map
- +
Loading map...
- +
`; diff --git a/lib/folk-obs-note.ts b/lib/folk-obs-note.ts index 5582269..a9b2e5a 100644 --- a/lib/folk-obs-note.ts +++ b/lib/folk-obs-note.ts @@ -325,12 +325,12 @@ export class FolkObsNote extends FolkShape { wrapper.innerHTML = html`
- \u{1F4DD} + 📝
- - + +
@@ -340,8 +340,8 @@ export class FolkObsNote extends FolkShape { - - + +
@@ -359,7 +359,7 @@ export class FolkObsNote extends FolkShape { 0 characters
- \u2713 + Saved
@@ -609,10 +609,10 @@ export class FolkObsNote extends FolkShape { if (this.#isDirty) { this.#saveStatusEl.className = "save-status unsaved"; - this.#saveStatusEl.innerHTML = "\u2022Unsaved"; + this.#saveStatusEl.innerHTML = "Unsaved"; } else { this.#saveStatusEl.className = "save-status saved"; - this.#saveStatusEl.innerHTML = "\u2713Saved"; + this.#saveStatusEl.innerHTML = "Saved"; } } diff --git a/lib/folk-packing-list.ts b/lib/folk-packing-list.ts index 86a4d37..c1264bc 100644 --- a/lib/folk-packing-list.ts +++ b/lib/folk-packing-list.ts @@ -117,7 +117,7 @@ const styles = css` } .checkbox.checked::after { - content: "\u2713"; + content: "✓"; color: white; font-size: 10px; font-weight: 700; @@ -224,11 +224,11 @@ export class FolkPackingList extends FolkShape { wrapper.innerHTML = html`
- \uD83C\uDF92 + 🎒 Packing List
- +
diff --git a/lib/folk-piano.ts b/lib/folk-piano.ts index 095c572..9c0b33f 100644 --- a/lib/folk-piano.ts +++ b/lib/folk-piano.ts @@ -171,16 +171,16 @@ export class FolkPiano extends FolkShape { wrapper.innerHTML = html`
- \u{1F3B9} + 🎹 Loading Shared Piano...
- +
`; @@ -231,14 +231,14 @@ export class FolkPiano extends FolkShape { minimizeBtn?.addEventListener("click", (e) => { e.stopPropagation(); this.isMinimized = !this.#isMinimized; - minimizeBtn.textContent = this.#isMinimized ? "\u{1F53D}" : "\u{1F53C}"; + minimizeBtn.textContent = this.#isMinimized ? "🔽" : "🔼"; }); // Click minimized view to expand this.#minimizedEl?.addEventListener("click", (e) => { e.stopPropagation(); this.isMinimized = false; - if (minimizeBtn) minimizeBtn.textContent = "\u{1F53C}"; + if (minimizeBtn) minimizeBtn.textContent = "🔼"; }); // Retry button diff --git a/lib/folk-prompt.ts b/lib/folk-prompt.ts index d692f0b..584f7de 100644 --- a/lib/folk-prompt.ts +++ b/lib/folk-prompt.ts @@ -91,7 +91,7 @@ const styles = css` } .message.streaming::after { - content: "\u258C"; + content: "▌"; animation: blink 1s infinite; } @@ -262,18 +262,18 @@ export class FolkPrompt extends FolkShape { wrapper.innerHTML = html`
- \u{1F4AC} + 💬 AI Prompt
- +
- \u{1F916} + 🤖 Ask me anything! I can help with code, writing, analysis, and more
@@ -287,7 +287,7 @@ export class FolkPrompt extends FolkShape {
- +
@@ -418,7 +418,7 @@ export class FolkPrompt extends FolkShape { if (this.#messages.length === 0 && !this.#error) { this.#messagesEl.innerHTML = `
- \u{1F916} + 🤖 Ask me anything! I can help with code, writing, analysis, and more
diff --git a/lib/folk-shape.ts b/lib/folk-shape.ts index 46e5049..69bacb1 100644 --- a/lib/folk-shape.ts +++ b/lib/folk-shape.ts @@ -363,6 +363,16 @@ export class FolkShape extends FolkElement { return this.#readonlyRect; } + /** Compute the effective CSS transform scale of the parent (e.g. canvas zoom). */ + #getParentScale(): number { + const parent = this.parentElement; + if (!parent) return 1; + const rect = parent.getBoundingClientRect(); + const w = parent.offsetWidth; + if (!w || !rect.width) return 1; + return rect.width / w; + } + handleEvent(event: PointerEvent | KeyboardEvent | TouchEvent) { // Handle touch events for mobile drag support if (event instanceof TouchEvent) { @@ -390,7 +400,7 @@ export class FolkShape extends FolkElement { if (event.type === "touchmove" && this.#isTouchDragging && event.touches.length === 1) { const touch = event.touches[0]; if (this.#lastTouchPos) { - const zoom = window.visualViewport?.scale ?? 1; + const zoom = (window.visualViewport?.scale ?? 1) * this.#getParentScale(); const moveDelta = { x: (touch.clientX - this.#lastTouchPos.x) / zoom, y: (touch.clientY - this.#lastTouchPos.y) / zoom, @@ -478,7 +488,7 @@ export class FolkShape extends FolkElement { }; } else if (event.type === "pointermove") { if (!target) return; - const zoom = window.visualViewport?.scale ?? 1; + const zoom = (window.visualViewport?.scale ?? 1) * this.#getParentScale(); moveDelta = { x: event.movementX / zoom, y: event.movementY / zoom, @@ -659,9 +669,10 @@ export class FolkShape extends FolkElement { /** * After moving, push this shape away from any overlapping siblings. - * Resolves by minimum penetration on the axis aligned with movement direction. + * Uses minimum penetration depth — picks the smallest displacement + * among all four directions to resolve each overlap. */ - #resolveOverlaps(dx: number, dy: number) { + #resolveOverlaps(_dx: number, _dy: number) { const parent = this.parentElement; if (!parent) return; @@ -680,21 +691,25 @@ export class FolkShape extends FolkElement { if (!overlapX || !overlapY) continue; - // Distance to clear on each side - const clearRight = (other.x + other.w + gap) - me.x; // push me right of other - const clearLeft = other.x - (me.x + me.w + gap); // push me left of other (negative) - const clearDown = (other.y + other.h + gap) - me.y; // push me below other - const clearUp = other.y - (me.y + me.h + gap); // push me above other (negative) + // Distance to clear on each side (4 possible escape directions) + const clearRight = (other.x + other.w + gap) - me.x; + const clearLeft = other.x - (me.x + me.w + gap); + const clearDown = (other.y + other.h + gap) - me.y; + const clearUp = other.y - (me.y + me.h + gap); - // Pick push direction per axis based on movement direction - const pushX = dx >= 0 ? clearRight : clearLeft; - const pushY = dy >= 0 ? clearDown : clearUp; + // Pick the direction with smallest absolute displacement + const candidates = [ + { axis: "x" as const, d: clearRight }, + { axis: "x" as const, d: clearLeft }, + { axis: "y" as const, d: clearDown }, + { axis: "y" as const, d: clearUp }, + ]; + const best = candidates.reduce((a, b) => Math.abs(a.d) < Math.abs(b.d) ? a : b); - // Apply the axis with the smallest absolute displacement - if (Math.abs(pushX) <= Math.abs(pushY)) { - this.#rect.x += pushX; + if (best.axis === "x") { + this.#rect.x += best.d; } else { - this.#rect.y += pushY; + this.#rect.y += best.d; } me.x = this.#rect.x; diff --git a/lib/folk-social-post.ts b/lib/folk-social-post.ts index d0f7a0a..9f11ae6 100644 --- a/lib/folk-social-post.ts +++ b/lib/folk-social-post.ts @@ -370,13 +370,13 @@ const PLATFORM_CONFIG: Record< SocialPlatform, { icon: string; label: string; color: string } > = { - x: { icon: "\ud835\udd4f", label: "X", color: "#000000" }, + x: { icon: "𝕏", label: "X", color: "#000000" }, linkedin: { icon: "in", label: "LinkedIn", color: "#0A66C2" }, - instagram: { icon: "\ud83d\udcf7", label: "Instagram", color: "#E4405F" }, - youtube: { icon: "\u25b6", label: "YouTube", color: "#FF0000" }, + instagram: { icon: "📷", label: "Instagram", color: "#E4405F" }, + youtube: { icon: "▶", label: "YouTube", color: "#FF0000" }, threads: { icon: "@", label: "Threads", color: "#000000" }, - bluesky: { icon: "\ud83e\ude77", label: "Bluesky", color: "#0085FF" }, - tiktok: { icon: "\u266b", label: "TikTok", color: "#010101" }, + bluesky: { icon: "🩷", label: "Bluesky", color: "#0085FF" }, + tiktok: { icon: "♫", label: "TikTok", color: "#010101" }, facebook: { icon: "f", label: "Facebook", color: "#1877F2" }, }; @@ -561,8 +561,8 @@ export class FolkSocialPost extends FolkShape { ${config.label}
- - + +
@@ -572,7 +572,7 @@ export class FolkSocialPost extends FolkShape {
- \ud83d\uddbc + 🖼 No media
@@ -580,7 +580,7 @@ export class FolkSocialPost extends FolkShape {