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:
parent
5852b91f4c
commit
9266a6155f
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue