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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-29 12:43:40 -07:00
parent 5852b91f4c
commit 9266a6155f
2 changed files with 180 additions and 19 deletions

View File

@ -140,6 +140,7 @@ class FolkNotesApp extends HTMLElement {
private sidebarOpen = true; private sidebarOpen = true;
private mobileEditing = false; private mobileEditing = false;
private _resizeHandler: (() => void) | null = null; private _resizeHandler: (() => void) | null = null;
private _suggestionSyncTimer: any = null;
// Zone-based rendering // Zone-based rendering
private navZone!: HTMLDivElement; private navZone!: HTMLDivElement;
@ -884,6 +885,92 @@ Gear: EUR 400 (10%)</code></pre><p><em>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 ── // ── Note summarization ──
private async summarizeNote(btn: HTMLElement) { private async summarizeNote(btn: HTMLElement) {
@ -2418,7 +2505,7 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
if (this.editor) { if (this.editor) {
acceptSuggestion(this.editor, suggestionId); acceptSuggestion(this.editor, suggestionId);
this.updateSuggestionReviewBar(); this.updateSuggestionReviewBar();
this.syncSuggestionsToPanel(); this.syncSuggestionsToPanel(true);
} }
pop.remove(); pop.remove();
}); });
@ -2426,7 +2513,7 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
if (this.editor) { if (this.editor) {
rejectSuggestion(this.editor, suggestionId); rejectSuggestion(this.editor, suggestionId);
this.updateSuggestionReviewBar(); this.updateSuggestionReviewBar();
this.syncSuggestionsToPanel(); this.syncSuggestionsToPanel(true);
} }
pop.remove(); pop.remove();
}); });
@ -2471,16 +2558,23 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
return Array.from(map.values()); return Array.from(map.values());
} }
/** Push current suggestions to the comment panel and ensure sidebar is visible. */ /** Push current suggestions to the comment panel (debounced to avoid letter-by-letter flicker). */
private syncSuggestionsToPanel() { private syncSuggestionsToPanel(immediate = false) {
const panel = this.shadow.querySelector('notes-comment-panel') as any; clearTimeout(this._suggestionSyncTimer);
if (!panel) return; const flush = () => {
const suggestions = this.collectSuggestions(); const panel = this.shadow.querySelector('notes-comment-panel') as any;
panel.suggestions = suggestions; if (!panel) return;
// Show sidebar if there are suggestions or comments const suggestions = this.collectSuggestions();
const sidebar = this.shadow.getElementById('comment-sidebar'); panel.suggestions = suggestions;
if (sidebar && suggestions.length > 0) { const sidebar = this.shadow.getElementById('comment-sidebar');
sidebar.classList.add('has-comments'); 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%)</code></pre><p><em>Maya is tracking expenses in rF
if (this.editor && e.detail?.suggestionId) { if (this.editor && e.detail?.suggestionId) {
acceptSuggestion(this.editor, e.detail.suggestionId); acceptSuggestion(this.editor, e.detail.suggestionId);
this.updateSuggestionReviewBar(); this.updateSuggestionReviewBar();
this.syncSuggestionsToPanel(); this.syncSuggestionsToPanel(true);
} }
}); });
panel.addEventListener('suggestion-reject', (e: CustomEvent) => { panel.addEventListener('suggestion-reject', (e: CustomEvent) => {
if (this.editor && e.detail?.suggestionId) { if (this.editor && e.detail?.suggestionId) {
rejectSuggestion(this.editor, e.detail.suggestionId); rejectSuggestion(this.editor, e.detail.suggestionId);
this.updateSuggestionReviewBar(); this.updateSuggestionReviewBar();
this.syncSuggestionsToPanel(); this.syncSuggestionsToPanel(true);
} }
}); });
} }
@ -2554,10 +2648,10 @@ Gear: EUR 400 (10%)</code></pre><p><em>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.editor.on('update', () => {
this.updateSuggestionReviewBar(); this.updateSuggestionReviewBar();
this.syncSuggestionsToPanel(); this.syncSuggestionsToPanel(); // debounced — avoids letter-by-letter flicker
}); });
// Direct click on comment highlight or suggestion marks in the DOM // Direct click on comment highlight or suggestion marks in the DOM
@ -3070,12 +3164,47 @@ Gear: EUR 400 (10%)</code></pre><p><em>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) => { makeDraggableAll(this.shadow, ".sbt-note[data-note]", (el) => {
const title = el.querySelector(".sbt-note-title")?.textContent || ""; const title = el.querySelector(".sbt-note-title")?.textContent || "";
const id = el.dataset.note || ""; const id = el.dataset.note || "";
return title ? { title, module: "rnotes", entityId: id, label: "Note", color: "#f59e0b" } : null; 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) { private demoUpdateNoteField(noteId: string, field: string, value: string) {
@ -3250,6 +3379,7 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
transition: background 0.1s; font-size: 13px; transition: background 0.1s; font-size: 13px;
} }
.sbt-notebook-header:hover { background: var(--rs-bg-hover); } .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 { .sbt-toggle {
width: 16px; text-align: center; font-size: 10px; width: 16px; text-align: center; font-size: 10px;
color: var(--rs-text-muted); flex-shrink: 0; color: var(--rs-text-muted); flex-shrink: 0;

View File

@ -23,6 +23,30 @@ function makeSuggestionId(): string {
return `s_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; 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. * Create the suggestion mode ProseMirror plugin.
* @param getSuggesting - callback that returns current suggesting mode state * @param getSuggesting - callback that returns current suggesting mode state
@ -42,7 +66,10 @@ export function createSuggestionPlugin(
const { state } = view; const { state } = view;
const { authorId, authorName } = getAuthor(); 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; const tr = state.tr;
// If there's a selection (replacement), mark the selected text as deleted // If there's a selection (replacement), mark the selected text as deleted
@ -76,9 +103,11 @@ export function createSuggestionPlugin(
tr.setMeta('suggestion-applied', true); tr.setMeta('suggestion-applied', true);
// Place cursor after the inserted text // 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); view.dispatch(tr);
advanceSession(suggestionId, newCursorPos);
return true; return true;
}, },
@ -86,6 +115,7 @@ export function createSuggestionPlugin(
handleKeyDown(view: EditorView, event: KeyboardEvent): boolean { handleKeyDown(view: EditorView, event: KeyboardEvent): boolean {
if (!getSuggesting()) return false; if (!getSuggesting()) return false;
if (event.key !== 'Backspace' && event.key !== 'Delete') return false; if (event.key !== 'Backspace' && event.key !== 'Delete') return false;
resetSession(); // break typing session on delete actions
const { state } = view; const { state } = view;
const { from, to, empty } = state.selection; const { from, to, empty } = state.selection;
@ -152,6 +182,7 @@ export function createSuggestionPlugin(
/** Intercept paste — insert pasted text as a suggestion. */ /** Intercept paste — insert pasted text as a suggestion. */
handlePaste(view: EditorView, _event: ClipboardEvent, slice: Slice): boolean { handlePaste(view: EditorView, _event: ClipboardEvent, slice: Slice): boolean {
if (!getSuggesting()) return false; if (!getSuggesting()) return false;
resetSession(); // paste is a discrete action, break typing session
const { state } = view; const { state } = view;
const { from, to } = state.selection; const { from, to } = state.selection;