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;