diff --git a/shared/components/rstack-app-switcher.ts b/shared/components/rstack-app-switcher.ts index 5846163..6724e82 100644 --- a/shared/components/rstack-app-switcher.ts +++ b/shared/components/rstack-app-switcher.ts @@ -16,6 +16,40 @@ export interface AppSwitcherModule { standaloneDomain?: string; } +// Category definitions for the rApp dropdown (display-only grouping) +const MODULE_CATEGORIES: Record = { + canvas: "Creating", + notes: "Creating", + pubs: "Creating", + swag: "Creating", + splat: "Creating", + cal: "Planning", + trips: "Planning", + work: "Planning", + forum: "Discussing & Deciding", + inbox: "Discussing & Deciding", + choices: "Discussing & Deciding", + vote: "Discussing & Deciding", + funds: "Funding & Commerce", + wallet: "Funding & Commerce", + cart: "Funding & Commerce", + providers: "Funding & Commerce", + books: "Sharing & Media", + files: "Sharing & Media", + tube: "Sharing & Media", + data: "Sharing & Media", + maps: "Sharing & Media", + network: "Sharing & Media", +}; + +const CATEGORY_ORDER = [ + "Creating", + "Planning", + "Discussing & Deciding", + "Funding & Commerce", + "Sharing & Media", +]; + export class RStackAppSwitcher extends HTMLElement { #shadow: ShadowRoot; #modules: AppSwitcherModule[] = []; @@ -46,6 +80,52 @@ export class RStackAppSwitcher extends HTMLElement { this.#render(); } + #renderGroupedModules(current: string): string { + // Group modules by category + const groups = new Map(); + const uncategorized: AppSwitcherModule[] = []; + + for (const m of this.#modules) { + const cat = MODULE_CATEGORIES[m.id]; + if (cat) { + if (!groups.has(cat)) groups.set(cat, []); + groups.get(cat)!.push(m); + } else { + uncategorized.push(m); + } + } + + let html = ""; + for (const cat of CATEGORY_ORDER) { + const items = groups.get(cat); + if (!items || items.length === 0) continue; + html += `
${cat}
`; + html += items.map((m) => this.#renderItem(m, current)).join(""); + } + if (uncategorized.length > 0) { + html += `
Other
`; + html += uncategorized.map((m) => this.#renderItem(m, current)).join(""); + } + return html; + } + + #renderItem(m: AppSwitcherModule, current: string): string { + return ` +
+ + ${m.icon} +
+ ${m.name} + ${m.description} +
+
+ ${m.standaloneDomain ? `` : ""} +
+ `; + } + #render() { const current = this.current; const currentMod = this.#modules.find((m) => m.id === current); @@ -56,24 +136,7 @@ export class RStackAppSwitcher extends HTMLElement {
`; @@ -134,6 +197,7 @@ const STYLES = ` .menu { position: absolute; top: 100%; left: 0; margin-top: 6px; min-width: 260px; border-radius: 12px; overflow: hidden; + overflow-y: auto; max-height: 70vh; box-shadow: 0 8px 30px rgba(0,0,0,0.25); display: none; z-index: 200; } .menu.open { display: block; } @@ -176,4 +240,14 @@ const STYLES = ` .item-text { display: flex; flex-direction: column; min-width: 0; } .item-name { font-size: 0.875rem; font-weight: 600; } .item-desc { font-size: 0.75rem; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + +.category-header { + padding: 8px 14px 4px; font-size: 0.7rem; font-weight: 700; + text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.5; + user-select: none; +} +.category-header:not(:first-child) { + border-top: 1px solid rgba(128,128,128,0.15); + margin-top: 4px; padding-top: 10px; +} `; diff --git a/website/canvas.html b/website/canvas.html index 0b5fcff..e1d4888 100644 --- a/website/canvas.html +++ b/website/canvas.html @@ -487,8 +487,8 @@ - - + + @@ -1022,14 +1022,76 @@ // Reverse the canvas transform to get canvas coordinates const canvasX = (viewCenterX - panX) / scale; const canvasY = (viewCenterY - panY) / scale; - // Add jitter so shapes don't stack perfectly + return { x: canvasX, y: canvasY }; + } + + // Check if two rectangles overlap (with padding gap) + function rectsOverlap(a, b, gap = 20) { + return !(a.x + a.width + gap <= b.x || + b.x + b.width + gap <= a.x || + a.y + a.height + gap <= b.y || + b.y + b.height + gap <= a.y); + } + + // Collect bounding boxes of all visible shapes on the canvas + function getExistingShapeRects() { + return [...canvasContent.children] + .filter(el => el.tagName && el.tagName.includes('-') && + !el.tagName.toLowerCase().includes('arrow') && + typeof el.x === 'number' && typeof el.width === 'number' && + el.width > 0) + .map(el => ({ x: el.x, y: el.y, width: el.width, height: el.height })); + } + + // Find a free position near the viewport center that doesn't overlap existing shapes + function findFreePosition(width, height) { + const center = getViewportCenter(); + const candidateX = center.x - width / 2; + const candidateY = center.y - height / 2; + const gap = 20; + const existing = getExistingShapeRects(); + + if (existing.length === 0) { + return { x: candidateX, y: candidateY }; + } + + const candidate = { x: candidateX, y: candidateY, width, height }; + const hasOverlap = (rect) => existing.some(e => rectsOverlap(rect, e, gap)); + + if (!hasOverlap(candidate)) { + return { x: candidateX, y: candidateY }; + } + + // Spiral search: try right, below, left, above with increasing steps + const stepX = width + gap; + const stepY = height + gap; + for (let ring = 1; ring <= 20; ring++) { + const offsets = [ + { x: ring * stepX, y: 0 }, // right + { x: 0, y: ring * stepY }, // below + { x: -ring * stepX, y: 0 }, // left + { x: 0, y: -ring * stepY }, // above + { x: ring * stepX, y: ring * stepY }, // bottom-right + { x: -ring * stepX, y: ring * stepY }, // bottom-left + { x: ring * stepX, y: -ring * stepY }, // top-right + { x: -ring * stepX, y: -ring * stepY }, // top-left + ]; + for (const off of offsets) { + const test = { x: candidateX + off.x, y: candidateY + off.y, width, height }; + if (!hasOverlap(test)) { + return { x: test.x, y: test.y }; + } + } + } + + // Fallback: place with jitter if everything is occupied return { - x: canvasX + (Math.random() - 0.5) * 40, - y: canvasY + (Math.random() - 0.5) * 40 + x: candidateX + (Math.random() - 0.5) * 100, + y: candidateY + (Math.random() - 0.5) * 100 }; } - // Create a shape, position it at viewport center, add to canvas, and register for sync + // Create a shape, position it without overlapping others, add to canvas, and register for sync function newShape(tagName, props = {}) { const id = `shape-${Date.now()}-${++shapeCounter}`; const defaults = SHAPE_DEFAULTS[tagName] || { width: 300, height: 200 }; @@ -1037,9 +1099,9 @@ const shape = document.createElement(tagName); shape.id = id; - const center = getViewportCenter(); - shape.x = center.x - defaults.width / 2; - shape.y = center.y - defaults.height / 2; + const pos = findFreePosition(defaults.width, defaults.height); + shape.x = pos.x; + shape.y = pos.y; shape.width = defaults.width; shape.height = defaults.height;