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 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%)</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 ──
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) {
acceptSuggestion(this.editor, suggestionId);
this.updateSuggestionReviewBar();
this.syncSuggestionsToPanel();
this.syncSuggestionsToPanel(true);
}
pop.remove();
});
@ -2426,7 +2513,7 @@ Gear: EUR 400 (10%)</code></pre><p><em>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%)</code></pre><p><em>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%)</code></pre><p><em>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%)</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.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%)</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) => {
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%)</code></pre><p><em>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;

View File

@ -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;