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 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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue