diff --git a/website/canvas.html b/website/canvas.html index 5070920..8d77b4f 100644 --- a/website/canvas.html +++ b/website/canvas.html @@ -527,10 +527,7 @@ text-align: center; } - /* Feed mode: hide bottom toolbar */ - #canvas.feed-mode ~ #bottom-toolbar { - display: none; - } + /* Feed mode: hide bottom toolbar (overridden by comprehensive rule below) */ #community-info { display: none; @@ -1397,34 +1394,252 @@ overflow-y: auto; overflow-x: hidden; touch-action: pan-y; + scroll-behavior: smooth; + background: var(--rs-bg-page); } #canvas.feed-mode #canvas-content { transform: none !important; display: flex; flex-direction: column; align-items: center; - gap: 16px; - padding: 16px 8px 80px; + gap: 0; + padding: 100px 8px 80px; width: 100%; min-height: 100%; position: relative; } + /* Hide canvas-only UI in feed mode */ + body:has(#canvas.feed-mode) #toolbar, + body:has(#canvas.feed-mode) #bottom-toolbar { + display: none !important; + } + /* Keep corner tools but hide zoom, show only feed toggle */ + body:has(#canvas.feed-mode) #canvas-corner-tools #zoom-in, + body:has(#canvas.feed-mode) #canvas-corner-tools #zoom-out, + body:has(#canvas.feed-mode) #canvas-corner-tools #reset-view, + body:has(#canvas.feed-mode) #canvas-corner-tools .corner-sep { + display: none !important; + } + /* Target ALL shape types in feed mode */ #canvas.feed-mode folk-shape, - #canvas.feed-mode [is="folk-shape"] { + #canvas.feed-mode [is="folk-shape"], + #canvas.feed-mode folk-markdown, + #canvas.feed-mode folk-wrapper, + #canvas.feed-mode folk-slide, + #canvas.feed-mode folk-chat, + #canvas.feed-mode folk-obs-note, + #canvas.feed-mode folk-rapp, + #canvas.feed-mode folk-embed, + #canvas.feed-mode folk-drawfast, + #canvas.feed-mode folk-prompt, + #canvas.feed-mode folk-zine-gen, + #canvas.feed-mode folk-workflow-block, + #canvas.feed-mode folk-choice-vote, + #canvas.feed-mode folk-choice-rank, + #canvas.feed-mode folk-choice-spider, + #canvas.feed-mode folk-spider-3d, + #canvas.feed-mode folk-choice-conviction, + #canvas.feed-mode folk-token, + #canvas.feed-mode folk-token-mint, + #canvas.feed-mode folk-token-ledger, + #canvas.feed-mode folk-google-item, + #canvas.feed-mode folk-social-post, + #canvas.feed-mode folk-calendar, + #canvas.feed-mode folk-map, + #canvas.feed-mode folk-piano, + #canvas.feed-mode folk-splat, + #canvas.feed-mode folk-video-chat, + #canvas.feed-mode folk-transcription, + #canvas.feed-mode folk-image-gen, + #canvas.feed-mode folk-video-gen, + #canvas.feed-mode folk-blender, + #canvas.feed-mode folk-freecad, + #canvas.feed-mode folk-kicad, + #canvas.feed-mode folk-itinerary, + #canvas.feed-mode folk-destination, + #canvas.feed-mode folk-budget, + #canvas.feed-mode folk-packing-list, + #canvas.feed-mode folk-booking, + #canvas.feed-mode folk-feed { position: relative !important; transform: none !important; - width: min(100%, 480px) !important; + width: 100% !important; height: auto !important; - min-height: 200px; + min-height: 120px; flex-shrink: 0; } - /* Hide arrows/connections in feed mode */ + /* Wrapper/canvas shapes get taller min-height */ + #canvas.feed-mode folk-wrapper, + #canvas.feed-mode folk-slide { + min-height: 300px; + } + /* Hide arrows in feed mode */ #canvas.feed-mode folk-arrow { display: none !important; } - /* Hide grid background in feed mode */ - #canvas.feed-mode { - background: var(--rs-bg-page); + + /* ── Feed card wrappers ── */ + .feed-card { + width: min(100%, 600px); + margin: 8px auto; + border: 1px solid var(--rs-border, #e2e8f0); + border-radius: 12px; + background: var(--rs-bg-card, #fff); + box-shadow: 0 1px 4px rgba(0,0,0,0.06); + overflow: hidden; + flex-shrink: 0; + } + body[data-theme="dark"] .feed-card { + background: #1e293b; + border-color: #334155; + } + .feed-card-header { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-bottom: 1px solid var(--rs-border, #e2e8f0); + background: var(--rs-bg-subtle, #f8fafc); + font-size: 13px; + min-height: 36px; + } + body[data-theme="dark"] .feed-card-header { + background: #0f172a; + border-color: #334155; + } + .feed-card-icon { + font-size: 16px; + flex-shrink: 0; + width: 20px; + text-align: center; + } + .feed-card-title { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 500; + color: var(--rs-text, #1e293b); + } + body[data-theme="dark"] .feed-card-title { + color: #e2e8f0; + } + .feed-card-type { + font-size: 11px; + padding: 2px 6px; + border-radius: 4px; + background: var(--rs-accent, #6366f1); + color: white; + flex-shrink: 0; + text-transform: capitalize; + } + .feed-card-body { + position: relative; + padding: 0; + overflow: hidden; + } + + /* ── Feed section headers ── */ + .feed-section-header { + width: min(100%, 600px); + margin: 20px auto 4px; + padding: 8px 4px; + font-size: 14px; + font-weight: 700; + color: var(--rs-text-muted, #64748b); + text-transform: uppercase; + letter-spacing: 0.05em; + border-bottom: 2px solid var(--rs-accent, #6366f1); + flex-shrink: 0; + } + body[data-theme="dark"] .feed-section-header { + color: #94a3b8; + border-color: #6366f1; + } + .feed-section-header:first-child { + margin-top: 0; + } + + /* ── Feed scroll summary bar ── */ + #feed-scroll-summary { + position: fixed; + top: 148px; + left: 50%; + transform: translateX(-50%); + display: none; + align-items: center; + gap: 12px; + padding: 6px 14px; + background: white; + border-radius: 10px; + box-shadow: 0 2px 12px rgba(0,0,0,0.12); + z-index: 1002; + font-size: 13px; + max-width: 90vw; + } + body[data-theme="dark"] #feed-scroll-summary { + background: #1e293b; + color: #e2e8f0; + } + #feed-scroll-summary.visible { + display: flex; + } + .feed-summary-section { + font-weight: 600; + white-space: nowrap; + color: var(--rs-text, #1e293b); + } + body[data-theme="dark"] .feed-summary-section { + color: #e2e8f0; + } + .feed-summary-counter { + color: var(--rs-text-muted, #64748b); + white-space: nowrap; + font-variant-numeric: tabular-nums; + } + .feed-summary-chips { + display: flex; + gap: 4px; + overflow-x: auto; + scrollbar-width: none; + } + .feed-summary-chips::-webkit-scrollbar { display: none; } + .feed-chip { + padding: 2px 8px; + border-radius: 6px; + background: var(--rs-bg-subtle, #f1f5f9); + border: 1px solid var(--rs-border, #e2e8f0); + font-size: 12px; + cursor: pointer; + white-space: nowrap; + transition: background 0.15s, border-color 0.15s; + } + body[data-theme="dark"] .feed-chip { + background: #334155; + border-color: #475569; + color: #e2e8f0; + } + .feed-chip:hover { + background: var(--rs-accent, #6366f1); + color: white; + border-color: var(--rs-accent, #6366f1); + } + .feed-chip.active { + background: var(--rs-accent, #6366f1); + color: white; + border-color: var(--rs-accent, #6366f1); + } + + @media (max-width: 768px) { + #feed-scroll-summary { + top: 100px; + left: 8px; + right: 8px; + transform: none; + } + #canvas.feed-mode #canvas-content { + padding-top: 120px; + } } #canvas-content.hide-forgotten :state(forgotten) { @@ -1933,6 +2148,12 @@ +
+ + +
+
+
@@ -5702,43 +5923,41 @@ }); // ── Feed Mode ── + const FEED_SHAPE_SELECTOR = 'folk-shape, folk-markdown, folk-wrapper, folk-slide, folk-chat, folk-obs-note, folk-rapp, folk-embed, folk-drawfast, folk-prompt, folk-zine-gen, folk-workflow-block, folk-choice-vote, folk-choice-rank, folk-choice-spider, folk-spider-3d, folk-choice-conviction, folk-token, folk-token-mint, folk-token-ledger, folk-google-item, folk-social-post, folk-calendar, folk-map, folk-piano, folk-splat, folk-video-chat, folk-transcription, folk-image-gen, folk-video-gen, folk-blender, folk-freecad, folk-kicad, folk-itinerary, folk-destination, folk-budget, folk-packing-list, folk-booking, folk-feed'; + let feedMode = false; let feedSortKey = 'y'; let feedOriginalOrder = []; + let feedScrollObserver = null; const feedToggleBtn = document.getElementById('feed-toggle'); const feedSortBar = document.getElementById('feed-sort-bar'); const feedSortSelect = document.getElementById('feed-sort'); + const feedScrollSummary = document.getElementById('feed-scroll-summary'); + const feedSummarySection = feedScrollSummary.querySelector('.feed-summary-section'); + const feedSummaryCounter = feedScrollSummary.querySelector('.feed-summary-counter'); + const feedSummaryChips = feedScrollSummary.querySelector('.feed-summary-chips'); - function toggleFeedMode() { - feedMode = !feedMode; - canvas.classList.toggle('feed-mode', feedMode); - feedToggleBtn.classList.toggle('active', feedMode); - feedSortBar.classList.toggle('hidden', !feedMode); - if (feedMode) { - // Save original DOM order before reordering - feedOriginalOrder = [...canvasContent.children]; - sortFeedShapes(feedSortKey); - } else { - // Restore original DOM order - for (const el of feedOriginalOrder) canvasContent.appendChild(el); - feedOriginalOrder = []; - } + // Human-readable type name from tag + function feedTypeName(tag) { + return (tag || '').replace('folk-', '').replace(/-/g, ' '); } - function sortFeedShapes(key) { - const shapes = [...canvasContent.querySelectorAll( - 'folk-shape, folk-markdown, folk-wrapper, folk-slide, folk-chat, folk-obs-note, folk-rapp, folk-embed, folk-drawfast, folk-prompt, folk-zine-gen, folk-workflow-block, folk-choice-vote, folk-choice-rank, folk-choice-spider, folk-spider-3d, folk-choice-conviction, folk-token, folk-google-item, folk-social-post, folk-calendar, folk-map, folk-piano, folk-splat, folk-video-chat, folk-transcription, folk-image-gen, folk-video-gen, folk-zine-gen, folk-blender, folk-freecad, folk-kicad, folk-itinerary, folk-destination, folk-budget, folk-packing-list, folk-booking' - )]; + // Get label for a shape element + function feedShapeLabel(el) { + const d = sync.doc?.shapes?.[el.id] || {}; + return d.title || d.content?.slice(0, 40) || d.tokenName || d.label || + el.getAttribute('title') || el.textContent?.trim().slice(0, 40) || feedTypeName(el.tagName.toLowerCase()); + } - shapes.sort((a, b) => { - // Read from Automerge doc when available + // Sort shapes array by key, return sorted array + function feedSortArray(shapes, key) { + return shapes.sort((a, b) => { const da = (sync.doc?.shapes?.[a.id]) || {}; const db = (sync.doc?.shapes?.[b.id]) || {}; switch (key) { case 'y': return (parseFloat(da.y ?? a.y ?? 0)) - (parseFloat(db.y ?? b.y ?? 0)); case 'created': { - // Shape IDs encode timestamp: shape-- const ta = parseInt(a.id?.split('-')[1]) || 0; const tb = parseInt(b.id?.split('-')[1]) || 0; return ta - tb; @@ -5750,12 +5969,229 @@ const nameB = db.title || db.content || b.getAttribute('title') || b.textContent?.slice(0, 50) || ''; return nameA.localeCompare(nameB); } - default: - return 0; + default: return 0; } }); + } - for (const s of shapes) canvasContent.appendChild(s); + // Compute section groupings for sorted shapes + function feedComputeSections(shapes, key) { + const sections = []; // [{name, items: [el, ...]}] + for (const el of shapes) { + const d = sync.doc?.shapes?.[el.id] || {}; + let sectionName; + switch (key) { + case 'type': { + const tag = el.tagName.toLowerCase(); + const icon = SHAPE_ICONS[tag] || ''; + sectionName = icon + ' ' + feedTypeName(tag); + break; + } + case 'created': { + const ts = parseInt(el.id?.split('-')[1]) || 0; + if (!ts) { sectionName = 'Unknown'; break; } + const date = new Date(ts); + const now = new Date(); + const dayDiff = Math.floor((now - date) / 86400000); + if (dayDiff === 0) sectionName = 'Today'; + else if (dayDiff <= 7) sectionName = 'This Week'; + else if (dayDiff <= 30) sectionName = 'This Month'; + else sectionName = 'Older'; + break; + } + case 'alpha': { + const label = (d.title || d.content || el.getAttribute('title') || el.textContent?.slice(0, 1) || '').trim(); + const first = (label[0] || '#').toUpperCase(); + sectionName = /[A-Z]/.test(first) ? first : '#'; + break; + } + case 'y': { + const y = parseFloat(d.y ?? el.y ?? 0); + // Compute range from all shapes + if (!feedComputeSections._yRange) { + const allY = shapes.map(s => parseFloat((sync.doc?.shapes?.[s.id] || {}).y ?? s.y ?? 0)); + const minY = Math.min(...allY); + const maxY = Math.max(...allY); + const range = maxY - minY || 1; + feedComputeSections._yRange = { minY, range }; + } + const { minY, range } = feedComputeSections._yRange; + const pct = (y - minY) / range; + if (pct < 0.33) sectionName = 'Top'; + else if (pct < 0.66) sectionName = 'Middle'; + else sectionName = 'Bottom'; + break; + } + default: sectionName = 'All'; + } + const last = sections[sections.length - 1]; + if (last && last.name === sectionName) { + last.items.push(el); + } else { + sections.push({ name: sectionName, items: [el] }); + } + } + feedComputeSections._yRange = null; // reset + return sections; + } + + // Build feed DOM: section headers + feed cards + function feedBuildDOM(sections) { + // Remove any existing feed elements + canvasContent.querySelectorAll('.feed-card, .feed-section-header').forEach(el => el.remove()); + + let itemIndex = 0; + for (const section of sections) { + // Section header + const header = document.createElement('div'); + header.className = 'feed-section-header'; + header.dataset.section = section.name; + header.textContent = section.name + ' (' + section.items.length + ')'; + canvasContent.appendChild(header); + + for (const shape of section.items) { + itemIndex++; + const card = document.createElement('div'); + card.className = 'feed-card'; + card.dataset.feedIndex = itemIndex; + card.dataset.section = section.name; + + const cardHeader = document.createElement('div'); + cardHeader.className = 'feed-card-header'; + + const tag = shape.tagName.toLowerCase(); + const icon = document.createElement('span'); + icon.className = 'feed-card-icon'; + icon.textContent = SHAPE_ICONS[tag] || '📄'; + + const title = document.createElement('span'); + title.className = 'feed-card-title'; + title.textContent = feedShapeLabel(shape); + + const typeBadge = document.createElement('span'); + typeBadge.className = 'feed-card-type'; + typeBadge.textContent = feedTypeName(tag); + + cardHeader.append(icon, title, typeBadge); + + const cardBody = document.createElement('div'); + cardBody.className = 'feed-card-body'; + cardBody.appendChild(shape); + + card.append(cardHeader, cardBody); + canvasContent.appendChild(card); + } + } + } + + // Populate section chips in scroll summary + function feedPopulateChips(sections) { + feedSummaryChips.innerHTML = ''; + for (const section of sections) { + const chip = document.createElement('span'); + chip.className = 'feed-chip'; + chip.textContent = section.name; + chip.dataset.section = section.name; + chip.addEventListener('click', () => scrollToSection(section.name)); + feedSummaryChips.appendChild(chip); + } + } + + // Track scroll position to update summary bar + function feedSetupScrollTracking() { + feedTeardownScrollTracking(); + const headers = canvasContent.querySelectorAll('.feed-section-header'); + const cards = canvasContent.querySelectorAll('.feed-card'); + const totalItems = cards.length; + + if (!headers.length) return; + + // Use scroll event on the canvas element (it's the scrollable container in feed mode) + const scrollHandler = () => { + const scrollTop = canvas.scrollTop; + const viewportMid = scrollTop + 140; // account for fixed bars + + // Find current section + let currentSection = ''; + for (const h of headers) { + if (h.offsetTop <= viewportMid) { + currentSection = h.dataset.section; + } + } + + // Count current item index + let currentIndex = 0; + for (const c of cards) { + if (c.offsetTop <= viewportMid) currentIndex = parseInt(c.dataset.feedIndex); + } + + feedSummarySection.textContent = currentSection; + feedSummaryCounter.textContent = currentIndex + ' of ' + totalItems; + + // Update active chip + feedSummaryChips.querySelectorAll('.feed-chip').forEach(ch => { + ch.classList.toggle('active', ch.dataset.section === currentSection); + }); + }; + + canvas.addEventListener('scroll', scrollHandler, { passive: true }); + canvas._feedScrollHandler = scrollHandler; + // Initial update + scrollHandler(); + } + + function feedTeardownScrollTracking() { + if (canvas._feedScrollHandler) { + canvas.removeEventListener('scroll', canvas._feedScrollHandler); + canvas._feedScrollHandler = null; + } + } + + function scrollToSection(sectionName) { + const header = canvasContent.querySelector('.feed-section-header[data-section="' + CSS.escape(sectionName) + '"]'); + if (header) { + canvas.scrollTo({ top: header.offsetTop - 130, behavior: 'smooth' }); + } + } + + function toggleFeedMode() { + feedMode = !feedMode; + canvas.classList.toggle('feed-mode', feedMode); + feedToggleBtn.classList.toggle('active', feedMode); + feedSortBar.classList.toggle('hidden', !feedMode); + feedScrollSummary.classList.toggle('visible', feedMode); + if (feedMode) { + feedOriginalOrder = [...canvasContent.children]; + sortFeedShapes(feedSortKey); + } else { + // Teardown scroll tracking + feedTeardownScrollTracking(); + // Unwrap shapes from feed cards back into canvas-content + canvasContent.querySelectorAll('.feed-card-body').forEach(body => { + while (body.firstChild) canvasContent.appendChild(body.firstChild); + }); + // Remove feed cards and section headers + canvasContent.querySelectorAll('.feed-card, .feed-section-header').forEach(el => el.remove()); + // Restore original DOM order + for (const el of feedOriginalOrder) canvasContent.appendChild(el); + feedOriginalOrder = []; + feedScrollSummary.classList.remove('visible'); + } + } + + function sortFeedShapes(key) { + // If currently wrapped in cards, unwrap first + canvasContent.querySelectorAll('.feed-card-body').forEach(body => { + while (body.firstChild) canvasContent.appendChild(body.firstChild); + }); + canvasContent.querySelectorAll('.feed-card, .feed-section-header').forEach(el => el.remove()); + + const shapes = [...canvasContent.querySelectorAll(FEED_SHAPE_SELECTOR)]; + feedSortArray(shapes, key); + const sections = feedComputeSections(shapes, key); + feedBuildDOM(sections); + feedPopulateChips(sections); + feedSetupScrollTracking(); } feedToggleBtn.addEventListener('click', toggleFeedMode);