From 5852b91f4c0fc12a67fc4d11f8e2b3e91cadd502 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Sat, 28 Mar 2026 16:45:11 -0700 Subject: [PATCH 1/2] feat(mi): make MI aware of all 48 canvas shape types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Expand MI system prompt "Available shape types" from 20 β†’ 48 shapes, organized by category (Core, AI, Creative, Social, Decisions, Travel, Tokens, Geo, Video) - Expand Shape Mapping Rules from 12 β†’ 30 triage rules covering all shape types with prop hints - Expand KNOWN_TRIAGE_SHAPES from 15 β†’ 48 so Gemini triage no longer silently downgrades unknown shapes to folk-markdown - Add 22 missing TOOL_HINTS to mi-tool-schema.ts (travel, tokens, CAD, creative, geo, meta shapes) for keyword-based chip suggestions - Add all 48 shapes to SHAPE_ICONS in mi-triage-panel.ts (was 13) - Register folk-image-studio and folk-transaction-builder in canvas.html (were ghost shapes β€” imported but never defined/registered) - Add SHAPE_DEFAULTS for folk-social-thread/campaign/newsletter, folk-design-agent, folk-image-studio Co-Authored-By: Claude Opus 4.6 --- lib/mi-tool-schema.ts | 27 +++++++++++++++++++ lib/mi-triage-panel.ts | 53 ++++++++++++++++++++++++++++++++---- server/mi-routes.ts | 61 +++++++++++++++++++++++++++++++++++------- website/canvas.html | 12 ++++++++- 4 files changed, 138 insertions(+), 15 deletions(-) diff --git a/lib/mi-tool-schema.ts b/lib/mi-tool-schema.ts index 6fbff87..b540604 100644 --- a/lib/mi-tool-schema.ts +++ b/lib/mi-tool-schema.ts @@ -41,6 +41,33 @@ const TOOL_HINTS: ToolHint[] = [ { tagName: "folk-choice-rank", label: "Ranking", icon: "πŸ“Š", keywords: ["rank", "order", "priority", "sort"] }, { tagName: "folk-choice-spider", label: "Spider Chart", icon: "πŸ•ΈοΈ", keywords: ["spider", "radar", "criteria", "evaluate"] }, { tagName: "folk-spider-3d", label: "3D Spider", icon: "πŸ“Š", keywords: ["spider", "radar", "3d", "overlap", "membrane", "governance", "permeability"] }, + { tagName: "folk-choice-conviction", label: "Conviction Vote", icon: "πŸ”₯", keywords: ["conviction", "stake", "weight", "governance", "token vote"] }, + // Travel + { tagName: "folk-itinerary", label: "Itinerary", icon: "πŸ—“οΈ", keywords: ["itinerary", "trip", "travel", "plan", "schedule"] }, + { tagName: "folk-destination", label: "Destination", icon: "πŸ“", keywords: ["destination", "city", "place", "travel", "visit"] }, + { tagName: "folk-booking", label: "Booking", icon: "🎫", keywords: ["booking", "reservation", "flight", "hotel", "transport"] }, + { tagName: "folk-budget", label: "Budget", icon: "πŸ’°", keywords: ["budget", "expense", "cost", "money", "spending"] }, + { tagName: "folk-packing-list", label: "Packing List", icon: "πŸŽ’", keywords: ["packing", "list", "luggage", "gear", "pack"] }, + // Tokens + { tagName: "folk-token-mint", label: "Token Mint", icon: "πŸͺ™", keywords: ["token", "mint", "create token", "currency", "coin"] }, + { tagName: "folk-token-ledger", label: "Token Ledger", icon: "πŸ“’", keywords: ["ledger", "balance", "token", "transactions", "holdings"] }, + { tagName: "folk-transaction-builder", label: "Transaction", icon: "πŸ’Έ", keywords: ["transaction", "transfer", "send", "multisig", "safe"] }, + // Creative / CAD + { tagName: "folk-blender", label: "3D Scene", icon: "🎲", keywords: ["blender", "3d", "render", "scene", "model"] }, + { tagName: "folk-freecad", label: "CAD Part", icon: "πŸ”§", keywords: ["cad", "freecad", "part", "mechanical", "parametric"] }, + { tagName: "folk-kicad", label: "PCB Design", icon: "πŸ”Œ", keywords: ["pcb", "kicad", "circuit", "schematic", "electronics"] }, + { tagName: "folk-design-agent", label: "Print Design", icon: "πŸ–¨οΈ", keywords: ["design", "poster", "flyer", "brochure", "print", "layout", "scribus"] }, + // Zine + { tagName: "folk-zine-gen", label: "Zine", icon: "πŸ“°", keywords: ["zine", "magazine", "publication", "pamphlet", "print"] }, + // Geo + { tagName: "folk-holon", label: "Holon", icon: "🌐", keywords: ["holon", "h3", "hexagon", "geospatial", "region"] }, + { tagName: "folk-holon-browser", label: "Holon Browser", icon: "🌍", keywords: ["holon", "browse", "explore", "territory", "map"] }, + // Meta + { tagName: "folk-canvas", label: "Nested Canvas", icon: "πŸ”²", keywords: ["canvas", "nested", "subspace", "embed canvas"] }, + { tagName: "folk-wrapper", label: "Wrapper", icon: "πŸ“¦", keywords: ["wrapper", "container", "group", "frame"] }, + { tagName: "folk-image", label: "Image", icon: "πŸ–ΌοΈ", keywords: ["image", "photo", "picture", "png", "jpg"] }, + { tagName: "folk-bookmark", label: "Bookmark", icon: "πŸ”–", keywords: ["bookmark", "link", "save", "reference"] }, + { tagName: "folk-obs-note", label: "Obsidian Note", icon: "πŸ““", keywords: ["obsidian", "note", "vault", "knowledge"] }, // Module content hints (these create content in rApps, not canvas shapes) { tagName: "rcal-event", label: "Calendar Event", icon: "πŸ“…", keywords: ["event", "meeting", "schedule", "standup", "appointment"], moduleAction: { module: "rcal", contentType: "event" } }, { tagName: "rtasks-task", label: "Task", icon: "βœ…", keywords: ["task", "todo", "assign", "deadline", "backlog"], moduleAction: { module: "rtasks", contentType: "task" } }, diff --git a/lib/mi-triage-panel.ts b/lib/mi-triage-panel.ts index 5f983ed..35960a1 100644 --- a/lib/mi-triage-panel.ts +++ b/lib/mi-triage-panel.ts @@ -10,19 +10,62 @@ import type { TriageManager } from "./mi-content-triage"; /** Icon lookup by tagName β€” matches TOOL_HINTS from mi-tool-schema.ts */ const SHAPE_ICONS: Record = { + // Core "folk-markdown": { icon: "πŸ“", label: "Note" }, + "folk-wrapper": { icon: "πŸ“¦", label: "Wrapper" }, "folk-embed": { icon: "πŸ”—", label: "Embed" }, - "folk-calendar": { icon: "πŸ“…", label: "Calendar" }, - "folk-map": { icon: "πŸ—ΊοΈ", label: "Map" }, + "folk-image": { icon: "πŸ–ΌοΈ", label: "Image" }, + "folk-bookmark": { icon: "πŸ”–", label: "Bookmark" }, + "folk-slide": { icon: "πŸ–ΌοΈ", label: "Slide" }, + "folk-chat": { icon: "πŸ’¬", label: "Chat" }, + "folk-piano": { icon: "🎹", label: "Piano" }, + "folk-canvas": { icon: "πŸ”²", label: "Canvas" }, + "folk-rapp": { icon: "πŸ“¦", label: "rApp" }, + "folk-feed": { icon: "πŸ“‘", label: "Feed" }, + "folk-obs-note": { icon: "πŸ““", label: "Obsidian" }, "folk-workflow-block": { icon: "βš™οΈ", label: "Workflow" }, + "folk-google-item": { icon: "πŸ”", label: "Google" }, + // AI + "folk-prompt": { icon: "πŸ€–", label: "AI Chat" }, + "folk-image-gen": { icon: "🎨", label: "AI Image" }, + "folk-image-studio": { icon: "πŸ–ŒοΈ", label: "Studio" }, + "folk-video-gen": { icon: "🎬", label: "AI Video" }, + "folk-zine-gen": { icon: "πŸ“°", label: "Zine" }, + "folk-transcription": { icon: "πŸŽ™οΈ", label: "Transcribe" }, + // Creative + "folk-splat": { icon: "πŸ’Ž", label: "3D Splat" }, + "folk-drawfast": { icon: "✏️", label: "Drawing" }, + "folk-blender": { icon: "🎲", label: "3D Scene" }, + "folk-freecad": { icon: "πŸ”§", label: "CAD" }, + "folk-kicad": { icon: "πŸ”Œ", label: "PCB" }, + "folk-design-agent": { icon: "πŸ–¨οΈ", label: "Design" }, + // Social "folk-social-post": { icon: "πŸ“£", label: "Social Post" }, "folk-social-thread": { icon: "🧡", label: "Thread" }, "folk-social-campaign": { icon: "πŸ“’", label: "Campaign" }, "folk-social-newsletter": { icon: "πŸ“§", label: "Newsletter" }, + // Decisions "folk-choice-vote": { icon: "πŸ—³οΈ", label: "Vote" }, - "folk-prompt": { icon: "πŸ€–", label: "AI Chat" }, - "folk-image-gen": { icon: "🎨", label: "AI Image" }, - "folk-slide": { icon: "πŸ–ΌοΈ", label: "Slide" }, + "folk-choice-rank": { icon: "πŸ“Š", label: "Ranking" }, + "folk-choice-spider": { icon: "πŸ•ΈοΈ", label: "Spider" }, + "folk-choice-conviction": { icon: "πŸ”₯", label: "Conviction" }, + "folk-spider-3d": { icon: "πŸ“Š", label: "3D Spider" }, + // Travel + "folk-itinerary": { icon: "πŸ—“οΈ", label: "Itinerary" }, + "folk-destination": { icon: "πŸ“", label: "Destination" }, + "folk-booking": { icon: "🎫", label: "Booking" }, + "folk-budget": { icon: "πŸ’°", label: "Budget" }, + "folk-packing-list": { icon: "πŸŽ’", label: "Packing" }, + // Tokens + "folk-token-mint": { icon: "πŸͺ™", label: "Token Mint" }, + "folk-token-ledger": { icon: "πŸ“’", label: "Ledger" }, + "folk-transaction-builder": { icon: "πŸ’Έ", label: "Transaction" }, + // Geo + "folk-calendar": { icon: "πŸ“…", label: "Calendar" }, + "folk-map": { icon: "πŸ—ΊοΈ", label: "Map" }, + "folk-holon": { icon: "🌐", label: "Holon" }, + "folk-holon-browser": { icon: "🌍", label: "Holons" }, + "folk-video-chat": { icon: "πŸ“Ή", label: "Video Call" }, }; export class MiTriagePanel { diff --git a/server/mi-routes.ts b/server/mi-routes.ts index 0b11ad2..8c2c8d8 100644 --- a/server/mi-routes.ts +++ b/server/mi-routes.ts @@ -233,10 +233,17 @@ include action markers in your response. Each marker is on its own line: [MI_ACTION:{"type":"navigate","path":"/myspace/rspace"}] Use "$1", "$2", etc. as ref values when creating shapes, then reference them in subsequent connect actions. -Available shape types: folk-markdown, folk-wrapper, folk-image-gen, folk-video-gen, folk-prompt, -folk-embed, folk-calendar, folk-map, folk-chat, folk-slide, folk-obs-note, folk-workflow-block, -folk-social-post, folk-social-thread, folk-social-campaign, folk-social-newsletter, -folk-splat, folk-drawfast, folk-rapp, folk-feed. +Available shape types (grouped by category): + +Core: folk-markdown, folk-wrapper, folk-embed, folk-image, folk-bookmark, folk-slide, folk-chat, folk-piano, folk-canvas, folk-rapp, folk-feed, folk-obs-note, folk-workflow-block, folk-google-item. +AI: folk-prompt, folk-image-gen, folk-image-studio, folk-video-gen, folk-zine-gen, folk-transcription. +Creative: folk-splat, folk-drawfast, folk-blender, folk-freecad, folk-kicad, folk-design-agent. +Social: folk-social-post, folk-social-thread, folk-social-campaign, folk-social-newsletter. +Decisions: folk-choice-vote, folk-choice-rank, folk-choice-spider, folk-choice-conviction, folk-spider-3d. +Travel: folk-itinerary, folk-destination, folk-booking, folk-budget, folk-packing-list. +Tokens: folk-token-mint, folk-token-ledger, folk-transaction-builder. +Geo: folk-holon, folk-holon-browser, folk-map, folk-calendar. +Video: folk-video-chat. ## Transforms When the user asks to align, distribute, or arrange selected shapes: @@ -324,6 +331,25 @@ analyze it and classify each distinct piece into the most appropriate canvas sha - Marketing campaigns / content plans β†’ folk-social-campaign (set title, description, platforms props) - Newsletters / email campaigns β†’ folk-social-newsletter (set subject, listName props) - Decisions / polls / questions for voting β†’ folk-choice-vote (set question prop) +- Ranked choices / priority lists β†’ folk-choice-rank (set question prop) +- Multi-criteria evaluation β†’ folk-choice-spider (set question prop) +- Travel destinations β†’ folk-destination (set destName, country props) +- Trip itineraries β†’ folk-itinerary (set tripTitle, itemsJson props) +- Bookings / reservations β†’ folk-booking (set bookingType, provider props) +- Budget / expenses β†’ folk-budget (set budgetTotal props) +- 3D models / scenes β†’ folk-splat or folk-blender (set src prop) +- Circuit / PCB design β†’ folk-kicad (set brief prop) +- CAD / 3D parts β†’ folk-freecad (set brief prop) +- Print / layout design β†’ folk-design-agent (set brief prop) +- AI chat / assistant β†’ folk-prompt (start a conversation) +- Image generation requests β†’ folk-image-gen (set prompt prop) +- Video generation requests β†’ folk-video-gen (set prompt prop) +- Zine / publication content β†’ folk-zine-gen (set prompt prop) +- Audio / transcription β†’ folk-transcription +- Data feeds from modules β†’ folk-feed (set sourceModule, feedId props) +- Embed another rApp β†’ folk-rapp (set moduleId prop) +- Token minting β†’ folk-token-mint (set tokenName, symbol props) +- Token ledger / balances β†’ folk-token-ledger (set tokenId prop) - Everything else (prose, notes, transcripts, summaries) β†’ folk-markdown (set content prop in markdown format) ## Output Format @@ -342,11 +368,28 @@ Return a JSON object with: - If the content is too short or trivial for multiple shapes, still return at least one shape`; const KNOWN_TRIAGE_SHAPES = new Set([ - "folk-markdown", "folk-embed", "folk-image", "folk-bookmark", - "folk-calendar", "folk-map", - "folk-workflow-block", "folk-social-post", "folk-social-thread", - "folk-social-campaign", "folk-social-newsletter", "folk-choice-vote", - "folk-prompt", "folk-image-gen", "folk-slide", + // Core + "folk-markdown", "folk-wrapper", "folk-embed", "folk-image", "folk-bookmark", + "folk-slide", "folk-chat", "folk-piano", "folk-canvas", "folk-rapp", "folk-feed", + "folk-obs-note", "folk-workflow-block", "folk-google-item", + // AI + "folk-prompt", "folk-image-gen", "folk-image-studio", "folk-video-gen", + "folk-zine-gen", "folk-transcription", + // Creative + "folk-splat", "folk-drawfast", "folk-blender", "folk-freecad", "folk-kicad", + "folk-design-agent", + // Social + "folk-social-post", "folk-social-thread", "folk-social-campaign", "folk-social-newsletter", + // Decisions + "folk-choice-vote", "folk-choice-rank", "folk-choice-spider", + "folk-choice-conviction", "folk-spider-3d", + // Travel + "folk-itinerary", "folk-destination", "folk-booking", "folk-budget", "folk-packing-list", + // Tokens + "folk-token-mint", "folk-token-ledger", "folk-transaction-builder", + // Geo & Video + "folk-map", "folk-calendar", "folk-video-chat", + "folk-holon", "folk-holon-browser", ]); function sanitizeTriageResponse(raw: any): { shapes: any[]; connections: any[]; summary: string } { diff --git a/website/canvas.html b/website/canvas.html index 88f1b94..33d23ef 100644 --- a/website/canvas.html +++ b/website/canvas.html @@ -2433,6 +2433,7 @@ FolkCalendar, FolkMap, FolkImageGen, + FolkImageStudio, FolkVideoGen, FolkPrompt, FolkZineGen, @@ -2682,6 +2683,7 @@ FolkCalendar.define(); FolkMap.define(); FolkImageGen.define(); + FolkImageStudio.define(); FolkVideoGen.define(); FolkPrompt.define(); FolkTranscription.define(); @@ -2695,6 +2697,7 @@ FolkBooking.define(); FolkTokenMint.define(); FolkTokenLedger.define(); + FolkTransactionBuilder.define(); FolkChoiceVote.define(); FolkChoiceRank.define(); FolkChoiceSpider.define(); @@ -2731,6 +2734,7 @@ shapeRegistry.register("folk-calendar", FolkCalendar); shapeRegistry.register("folk-map", FolkMap); shapeRegistry.register("folk-image-gen", FolkImageGen); + shapeRegistry.register("folk-image-studio", FolkImageStudio); shapeRegistry.register("folk-video-gen", FolkVideoGen); shapeRegistry.register("folk-prompt", FolkPrompt); shapeRegistry.register("folk-zine-gen", FolkZineGen); @@ -2745,6 +2749,7 @@ shapeRegistry.register("folk-booking", FolkBooking); shapeRegistry.register("folk-token-mint", FolkTokenMint); shapeRegistry.register("folk-token-ledger", FolkTokenLedger); + shapeRegistry.register("folk-transaction-builder", FolkTransactionBuilder); shapeRegistry.register("folk-choice-vote", FolkChoiceVote); shapeRegistry.register("folk-choice-rank", FolkChoiceRank); shapeRegistry.register("folk-choice-spider", FolkChoiceSpider); @@ -3937,7 +3942,12 @@ Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Ask clarifying quest "folk-choice-spider": { width: 440, height: 540 }, "folk-spider-3d": { width: 440, height: 480 }, "folk-choice-conviction": { width: 380, height: 480 }, - "folk-social-post": { width: 300, height: 380 }, + "folk-social-post": { width: 300, height: 380 }, + "folk-social-thread": { width: 280, height: 320 }, + "folk-social-campaign": { width: 300, height: 380 }, + "folk-social-newsletter": { width: 280, height: 300 }, + "folk-design-agent": { width: 450, height: 550 }, + "folk-image-studio": { width: 420, height: 520 }, "folk-multisig-email": { width: 400, height: 380 }, "folk-splat": { width: 480, height: 420 }, "folk-blender": { width: 420, height: 520 }, From 9266a6155f4bbaf471869f803cf9e3e3b443049b Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Sun, 29 Mar 2026 12:43:40 -0700 Subject: [PATCH 2/2] feat(rnotes): debounce suggestion panel + drag-drop notes between notebooks Batch consecutive keystrokes into single suggestions via session tracker, debounce panel sync (400ms) to prevent letter-by-letter flicker, and add HTML5 drag-and-drop to move notes between notebooks in the sidebar. Co-Authored-By: Claude Opus 4.6 --- modules/rnotes/components/folk-notes-app.ts | 164 ++++++++++++++++-- .../rnotes/components/suggestion-plugin.ts | 35 +++- 2 files changed, 180 insertions(+), 19 deletions(-) diff --git a/modules/rnotes/components/folk-notes-app.ts b/modules/rnotes/components/folk-notes-app.ts index 991067f..e3c78fe 100644 --- a/modules/rnotes/components/folk-notes-app.ts +++ b/modules/rnotes/components/folk-notes-app.ts @@ -140,6 +140,7 @@ class FolkNotesApp extends HTMLElement { private sidebarOpen = true; private mobileEditing = false; private _resizeHandler: (() => void) | null = null; + private _suggestionSyncTimer: any = null; // Zone-based rendering private navZone!: HTMLDivElement; @@ -884,6 +885,92 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF } } + /** Move a note from one notebook to another via Automerge docs. */ + private async moveNoteToNotebook(noteId: string, sourceNotebookId: string, targetNotebookId: string) { + if (sourceNotebookId === targetNotebookId) return; + const runtime = (window as any).__rspaceOfflineRuntime; + if (!runtime?.isInitialized) return; + + const dataSpace = runtime.resolveDocSpace?.('rnotes') || this.space; + const sourceDocId = `${dataSpace}:notes:notebooks:${sourceNotebookId}` as DocumentId; + const targetDocId = `${dataSpace}:notes:notebooks:${targetNotebookId}` as DocumentId; + + // Get the note data from source + const sourceDoc = runtime.get(sourceDocId) as NotebookDoc | undefined; + if (!sourceDoc?.items?.[noteId]) return; + + // Deep-clone the note item (plain object from Automerge) + const noteItem = JSON.parse(JSON.stringify(sourceDoc.items[noteId])); + noteItem.notebookId = targetNotebookId; + noteItem.updatedAt = Date.now(); + + // Subscribe to target doc if needed, add the note, then unsubscribe + let targetDoc: NotebookDoc | undefined; + try { + targetDoc = await runtime.subscribe(targetDocId, notebookSchema); + } catch { + return; // target notebook not accessible + } + + // Add to target + runtime.change(targetDocId, `Move note ${noteId}`, (d: NotebookDoc) => { + if (!d.items) (d as any).items = {}; + d.items[noteId] = noteItem; + }); + + // Remove from source + runtime.change(sourceDocId, `Move note ${noteId} out`, (d: NotebookDoc) => { + delete d.items[noteId]; + }); + + // If we're viewing the source notebook, refresh + if (this.subscribedDocId === sourceDocId) { + this.doc = runtime.get(sourceDocId); + this.renderFromDoc(); + } + + // Update sidebar counts + const srcNb = this.notebooks.find(n => n.id === sourceNotebookId); + const tgtNb = this.notebooks.find(n => n.id === targetNotebookId); + if (srcNb) srcNb.note_count = String(Math.max(0, parseInt(srcNb.note_count) - 1)); + if (tgtNb) tgtNb.note_count = String(parseInt(tgtNb.note_count) + 1); + + // Refresh sidebar note cache for source + const srcNotes = this.notebookNotes.get(sourceNotebookId); + if (srcNotes) this.notebookNotes.set(sourceNotebookId, srcNotes.filter(n => n.id !== noteId)); + + // If target is expanded, refresh its notes + if (this.expandedNotebooks.has(targetNotebookId)) { + const tgtDoc = runtime.get(targetDocId) as NotebookDoc | undefined; + if (tgtDoc?.items) { + const notes: Note[] = Object.values(tgtDoc.items).map((item: any) => ({ + id: item.id, title: item.title || 'Untitled', content: item.content || '', + content_plain: item.contentPlain || '', type: item.type || 'NOTE', + tags: item.tags?.length ? Array.from(item.tags) : null, + is_pinned: item.isPinned || false, url: item.url || null, + language: item.language || null, fileUrl: item.fileUrl || null, + mimeType: item.mimeType || null, duration: item.duration ?? null, + created_at: item.createdAt ? new Date(item.createdAt).toISOString() : new Date().toISOString(), + updated_at: item.updatedAt ? new Date(item.updatedAt).toISOString() : new Date().toISOString(), + })); + this.notebookNotes.set(targetNotebookId, notes); + } + } + + // Unsubscribe from target if it's not the active notebook + if (this.subscribedDocId !== targetDocId) { + runtime.unsubscribe(targetDocId); + } + + // Close editor if we were editing the moved note + if (this.selectedNote?.id === noteId) { + this.selectedNote = null; + this.renderContent(); + } + + this.renderNav(); + } + // ── Note summarization ── private async summarizeNote(btn: HTMLElement) { @@ -2418,7 +2505,7 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF if (this.editor) { acceptSuggestion(this.editor, suggestionId); this.updateSuggestionReviewBar(); - this.syncSuggestionsToPanel(); + this.syncSuggestionsToPanel(true); } pop.remove(); }); @@ -2426,7 +2513,7 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF if (this.editor) { rejectSuggestion(this.editor, suggestionId); this.updateSuggestionReviewBar(); - this.syncSuggestionsToPanel(); + this.syncSuggestionsToPanel(true); } pop.remove(); }); @@ -2471,16 +2558,23 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF return Array.from(map.values()); } - /** Push current suggestions to the comment panel and ensure sidebar is visible. */ - private syncSuggestionsToPanel() { - const panel = this.shadow.querySelector('notes-comment-panel') as any; - if (!panel) return; - const suggestions = this.collectSuggestions(); - panel.suggestions = suggestions; - // Show sidebar if there are suggestions or comments - const sidebar = this.shadow.getElementById('comment-sidebar'); - if (sidebar && suggestions.length > 0) { - sidebar.classList.add('has-comments'); + /** Push current suggestions to the comment panel (debounced to avoid letter-by-letter flicker). */ + private syncSuggestionsToPanel(immediate = false) { + clearTimeout(this._suggestionSyncTimer); + const flush = () => { + const panel = this.shadow.querySelector('notes-comment-panel') as any; + if (!panel) return; + const suggestions = this.collectSuggestions(); + panel.suggestions = suggestions; + const sidebar = this.shadow.getElementById('comment-sidebar'); + if (sidebar && suggestions.length > 0) { + sidebar.classList.add('has-comments'); + } + }; + if (immediate) { + flush(); + } else { + this._suggestionSyncTimer = setTimeout(flush, 400); } } @@ -2503,14 +2597,14 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF if (this.editor && e.detail?.suggestionId) { acceptSuggestion(this.editor, e.detail.suggestionId); this.updateSuggestionReviewBar(); - this.syncSuggestionsToPanel(); + this.syncSuggestionsToPanel(true); } }); panel.addEventListener('suggestion-reject', (e: CustomEvent) => { if (this.editor && e.detail?.suggestionId) { rejectSuggestion(this.editor, e.detail.suggestionId); this.updateSuggestionReviewBar(); - this.syncSuggestionsToPanel(); + this.syncSuggestionsToPanel(true); } }); } @@ -2554,10 +2648,10 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF } }); - // On any change, update the suggestion review bar + sidebar panel + // On any change, update the suggestion review bar + sidebar panel (debounced) this.editor.on('update', () => { this.updateSuggestionReviewBar(); - this.syncSuggestionsToPanel(); + this.syncSuggestionsToPanel(); // debounced β€” avoids letter-by-letter flicker }); // Direct click on comment highlight or suggestion marks in the DOM @@ -3070,12 +3164,47 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF }); }); - // Make sidebar notes draggable + // Make sidebar notes draggable (cross-rApp + intra-sidebar) makeDraggableAll(this.shadow, ".sbt-note[data-note]", (el) => { const title = el.querySelector(".sbt-note-title")?.textContent || ""; const id = el.dataset.note || ""; return title ? { title, module: "rnotes", entityId: id, label: "Note", color: "#f59e0b" } : null; }); + + // Also set native drag data for intra-sidebar notebook moves + this.shadow.querySelectorAll(".sbt-note[data-note]").forEach(el => { + (el as HTMLElement).addEventListener("dragstart", (e) => { + const noteId = (el as HTMLElement).dataset.note!; + const nbId = (el as HTMLElement).dataset.notebook!; + e.dataTransfer?.setData("application/x-rnotes-move", JSON.stringify({ noteId, sourceNotebookId: nbId })); + }); + }); + + // Notebook headers accept dropped notes + this.shadow.querySelectorAll(".sbt-notebook-header[data-toggle-notebook]").forEach(el => { + (el as HTMLElement).addEventListener("dragover", (e) => { + if (e.dataTransfer?.types.includes("application/x-rnotes-move")) { + e.preventDefault(); + (el as HTMLElement).classList.add("drop-target"); + } + }); + (el as HTMLElement).addEventListener("dragleave", () => { + (el as HTMLElement).classList.remove("drop-target"); + }); + (el as HTMLElement).addEventListener("drop", (e) => { + e.preventDefault(); + (el as HTMLElement).classList.remove("drop-target"); + const raw = e.dataTransfer?.getData("application/x-rnotes-move"); + if (!raw) return; + try { + const { noteId, sourceNotebookId } = JSON.parse(raw); + const targetNotebookId = (el as HTMLElement).dataset.toggleNotebook!; + if (noteId && sourceNotebookId && targetNotebookId) { + this.moveNoteToNotebook(noteId, sourceNotebookId, targetNotebookId); + } + } catch {} + }); + }); } private demoUpdateNoteField(noteId: string, field: string, value: string) { @@ -3250,6 +3379,7 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF transition: background 0.1s; font-size: 13px; } .sbt-notebook-header:hover { background: var(--rs-bg-hover); } + .sbt-notebook-header.drop-target { background: rgba(99, 102, 241, 0.15); border: 1px dashed var(--rs-primary, #6366f1); border-radius: 4px; } .sbt-toggle { width: 16px; text-align: center; font-size: 10px; color: var(--rs-text-muted); flex-shrink: 0; diff --git a/modules/rnotes/components/suggestion-plugin.ts b/modules/rnotes/components/suggestion-plugin.ts index 649b455..8fa42ae 100644 --- a/modules/rnotes/components/suggestion-plugin.ts +++ b/modules/rnotes/components/suggestion-plugin.ts @@ -23,6 +23,30 @@ function makeSuggestionId(): string { return `s_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; } +// ── Typing session tracker ── +// Reuses the same suggestionId while the user types consecutively, +// so an entire typed word/phrase becomes ONE suggestion in the sidebar. +let _sessionSuggestionId: string | null = null; +let _sessionNextPos: number = -1; // the position where the next char is expected + +function getOrCreateSessionId(insertPos: number): string { + if (_sessionSuggestionId && insertPos === _sessionNextPos) { + return _sessionSuggestionId; + } + _sessionSuggestionId = makeSuggestionId(); + return _sessionSuggestionId; +} + +function advanceSession(id: string, nextPos: number): void { + _sessionSuggestionId = id; + _sessionNextPos = nextPos; +} + +function resetSession(): void { + _sessionSuggestionId = null; + _sessionNextPos = -1; +} + /** * Create the suggestion mode ProseMirror plugin. * @param getSuggesting - callback that returns current suggesting mode state @@ -42,7 +66,10 @@ export function createSuggestionPlugin( const { state } = view; const { authorId, authorName } = getAuthor(); - const suggestionId = makeSuggestionId(); + // Reuse session ID for consecutive typing at the same position + const suggestionId = (from !== to) + ? makeSuggestionId() // replacement β†’ new suggestion + : getOrCreateSessionId(from); // plain insert β†’ batch with session const tr = state.tr; // If there's a selection (replacement), mark the selected text as deleted @@ -76,9 +103,11 @@ export function createSuggestionPlugin( tr.setMeta('suggestion-applied', true); // Place cursor after the inserted text - tr.setSelection(TextSelection.create(tr.doc, insertPos + text.length)); + const newCursorPos = insertPos + text.length; + tr.setSelection(TextSelection.create(tr.doc, newCursorPos)); view.dispatch(tr); + advanceSession(suggestionId, newCursorPos); return true; }, @@ -86,6 +115,7 @@ export function createSuggestionPlugin( handleKeyDown(view: EditorView, event: KeyboardEvent): boolean { if (!getSuggesting()) return false; if (event.key !== 'Backspace' && event.key !== 'Delete') return false; + resetSession(); // break typing session on delete actions const { state } = view; const { from, to, empty } = state.selection; @@ -152,6 +182,7 @@ export function createSuggestionPlugin( /** Intercept paste β€” insert pasted text as a suggestion. */ handlePaste(view: EditorView, _event: ClipboardEvent, slice: Slice): boolean { if (!getSuggesting()) return false; + resetSession(); // paste is a discrete action, break typing session const { state } = view; const { from, to } = state.selection;