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;