-
-
Notifications
You must be signed in to change notification settings - Fork 28
Open
Labels
bugSomething isn't workingSomething isn't workingenhancementNew feature or requestNew feature or requesthelp wantedExtra attention is neededExtra attention is needed
Description
I am trying to implement an auto-save feature for carta-md editor. In my implementation there's a noticeable lag in large documents because of how reactivity work.. I'm not sure if it's something I'm doing or something on carta-md side. Any help would be appreciated. The lag occurs only when editing long documents. For short ones it works fine. Really struggling to optimize this code and I'm still not able to fix it after several attempts. Even backspacing stuff lags behind.. typing also lags..
Below is my +page.svelte
<script lang="ts">
import { Carta, MarkdownEditor, Markdown } from "carta-md";
import "carta-md/default.css";
import { getCartaInstance } from "./getCarta";
import "../app.css";
import "./tw.css";
// Removed invoke - now handled by store
import { onMount, onDestroy } from "svelte";
import HoverToolbar from "./HoverToolbar.svelte";
import FilePicker from "./FilePicker.svelte";
import JournalPicker from "./JournalPicker.svelte";
// Import the store and the READ-ONLY derived stores
import {
noteStore,
isLoading,
isSaving,
isDirty,
errorMessage,
value as noteValue,
} from "$lib/noteStore";
import { listen, type Event as TauriEvent } from "@tauri-apps/api/event";
import { getCurrentWindow } from "@tauri-apps/api/window"; // Use correct v2 import
// Note: Renamed imported 'value' to 'noteValue' to avoid conflict if needed,
// or just use $noteStore.value directly in template
const carta = getCartaInstance("light");
// --- Component Local State ---
let viewMode = $state<"edit" | "render">("edit");
let filePickerInstance: FilePicker | null = $state(null); // Keep instance ref
let journalPickerInstance: JournalPicker | null = $state(null);
// --- Input Handler (Calls Store Update Method) ---
function handleEditorInput(event: Event) {
// Log to verify if this handler is actually being called
// console.log("--- handleEditorInput Fired ---", event);
const target = event.target as HTMLTextAreaElement | HTMLInputElement;
if (!target || typeof target.value === "undefined") {
console.warn(
"handleEditorInput: event target not found or has no value.",
event.target,
);
return;
}
// Call the store's update method, which handles debounce and save
noteStore.updateValue(target.value);
}
// --- Event Handlers (Call Store Methods) ---
function handleKeyDown(event: KeyboardEvent) {
const isModifier = event.metaKey || event.ctrlKey;
if (isModifier) {
if (event.metaKey && (event.key === "e" || event.key === "E")) {
event.preventDefault();
viewMode = viewMode === "edit" ? "render" : "edit";
} else if (event.metaKey && (event.key === "n" || event.key === "N")) {
event.preventDefault();
noteStore.createNewNote();
} // Call store method
else if (event.key === "o" || event.key === "O") {
event.preventDefault();
filePickerInstance?.openDialog();
} else if (event.key === "s" || event.key === "S") {
event.preventDefault();
noteStore.manualSave();
} // Call store method
else if (event.key === "t" || event.key === "T") {
event.preventDefault();
noteStore.loadJournal();
} else if (event.key === "j" || event.key === "J") {
// <-- Add shortcut for JournalPicker
event.preventDefault();
console.log("Cmd+J detected, opening JournalPicker...");
journalPickerInstance?.openDialog();
}
} /* else if (event.key === "Escape" && viewMode === "render") {
viewMode = "edit";
} */
}
function handleNoteSelected(event: CustomEvent<string>) {
noteStore.loadNoteById(event.detail); // Call store method
}
// --- Lifecycle ---
let unlistenJournal: (() => void) | null = null;
let unlistenNewNote: (() => void) | null = null;
onMount(() => {
noteStore.loadJournal(); // Initial load via store
window.addEventListener("keydown", handleKeyDown);
// --- ADD BACK Backend Event Listeners ---
listen<null>("global-shortcut-journal", async (event) => {
console.log("Received global-shortcut-journal event", event);
const currentWindow = getCurrentWindow();
await currentWindow.setFocus();
await noteStore.loadJournal(); // Call store method
}).then((unlistener) => {
unlistenJournal = unlistener;
});
listen<null>("global-shortcut-new", async (event) => {
console.log("Received global-shortcut-new event", event);
const currentWindow = getCurrentWindow();
await currentWindow.setFocus();
await noteStore.createNewNote(); // Call store method
}).then((unlistener) => {
unlistenNewNote = unlistener;
});
// --- End Listeners ---
});
onDestroy(() => {
window.removeEventListener("keydown", handleKeyDown);
if (unlistenJournal) unlistenJournal();
if (unlistenNewNote) unlistenNewNote();
// Optional: Add noteStore.destroy() if you implement timeout clearing there
});
// --- NO $effect for saving needed here! ---
</script>
{#if $isLoading}
<p>Loading...</p>
{:else if $errorMessage}
<p class="text-red-600 ...">Error: {$errorMessage}</p>
{/if}
<FilePicker bind:this={filePickerInstance} on:selectnote={handleNoteSelected} />
<JournalPicker bind:this={journalPickerInstance} />
<div
class="relative max-w-[1000px] mx-auto p-16 h-screen flex flex-col overflow-hidden"
>
<HoverToolbar on:newnote={noteStore.createNewNote} />
{#if $isSaving}
<div
title="Saving..."
class="absolute top-2 right-10 ... animate-spin"
></div>
{:else if $isDirty}
<div
title="Unsaved changes"
class="absolute top-2 right-10 w-3 h-3 bg-fuchsia-400 rounded-full z-10 animate-pulse"
></div>
{/if}
<div
class="max-w-none h-full w-[800px] mx-auto prose flex-grow font-[Noto_Sans] overflow-auto content-scroll-area"
>
{#if !$isLoading}
{#if viewMode === "edit"}
<MarkdownEditor
{carta}
value={$noteValue}
disableToolbar={true}
theme="tw"
scroll="async"
mode="tabs"
textarea={{
// Attempt to pass the oninput handler
oninput: handleEditorInput,
}}
/>
{:else}
<div class="carta-prose h-full overflow-y-auto content-scroll-area">
<Markdown {carta} value={$noteValue} />
</div>
{/if}
{/if}
</div>
</div>
<style>
/* Keep existing styles */
@import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;1,100;1,200;1,300;1,400;1,500;1,600;1,700&family=IBM+Plex+Serif:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;1,100;1,200;1,300;1,400;1,500;1,600;1,700&family=Noto+Sans+Mono:[email protected]&family=Noto+Sans:ital,wght@0,100..900;1,100..900&family=Noto+Serif:ital,wght@0,100..900;1,100..900&family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap");
:global(.carta-font-code) {
font-family: "IBM Plex Mono", monospace;
line-height: 1.5rem;
letter-spacing: normal;
}
:global(.carta-input *) {
margin: 0;
padding: 0;
}
:global(.carta-editor),
:global(.carta-editor > textarea) {
height: 100% !important; /* Use !important cautiously */
min-height: 100%;
/* Add box-sizing if needed */
box-sizing: border-box;
}
.content-scroll-area {
scrollbar-width: none;
-ms-overflow-style: none;
overflow-y: auto;
}
.content-scroll-area::-webkit-scrollbar {
width: 0 !important;
display: none;
}
.content-scroll-area::-webkit-scrollbar-track {
background: transparent;
}
.content-scroll-area::-webkit-scrollbar-thumb {
background: transparent;
}
</style>
and below is noteStore.ts:
// src/lib/noteStore.ts
import { writable, derived, get } from "svelte/store";
import { invoke } from "@tauri-apps/api/core";
// --- Helper functions (keep as is) ---
function getTodayDateString(): string {
const today = new Date();
const year = today.getFullYear();
const month = String(today.getMonth() + 1).padStart(2, "0");
const day = String(today.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
// --- Store State Definition ---
type NoteState = {
value: string;
originalContent: string; // Keep for potential revert/cancellation logic in _canLoad
isLoading: boolean;
isSaving: boolean;
isDirty: boolean; // Explicit boolean flag
currentNoteId: string | null;
currentNoteType: "journal" | "note";
errorMessage: string | null;
};
const initialState: NoteState = {
value: "",
originalContent: "",
isLoading: true,
isSaving: false,
isDirty: false,
currentNoteId: null,
currentNoteType: "journal",
errorMessage: null,
};
// --- Create Writable Store ---
const store = writable<NoteState>(initialState);
// --- Derived Stores (Read-only access for UI) ---
export const isLoading = derived(store, ($store) => $store.isLoading);
export const isSaving = derived(store, ($store) => $store.isSaving);
export const isDirty = derived(store, ($store) => $store.isDirty); // Directly from state
export const currentNoteId = derived(store, ($store) => $store.currentNoteId);
export const currentNoteType = derived(
store,
($store) => $store.currentNoteType,
);
export const errorMessage = derived(store, ($store) => $store.errorMessage);
export const value = derived(store, ($store) => $store.value);
// --- Internal Store Logic ---
let saveTimeout: number | null = null;
const debounceTime = 1500; // ms
async function _saveCurrentNote() {
const currentState = get(store); // Get current state snapshot
// **Removed internal isDirty check**: We now rely on the debounce mechanism
// only calling this if isDirty was true when the timeout was set.
// We still check if we are already saving or have no ID.
if (currentState.isSaving || !currentState.currentNoteId) {
// console.log("Save skipped: Already saving or no ID");
saveTimeout = null; // Ensure timeout is cleared if skipped
return;
}
store.update((s) => ({ ...s, isSaving: true, errorMessage: null }));
try {
let savedContent = currentState.value; // Capture content being saved
if (currentState.currentNoteType === "journal") {
// Keep the debug log and format check from previous step
console.log(
`Store: Invoking save_journal with date: '${currentState.currentNoteId}' (Type: ${typeof currentState.currentNoteId})`,
);
if (!/^\d{4}-\d{2}-\d{2}$/.test(currentState.currentNoteId)) {
console.error(
"Store: Invalid date format detected before invoke:",
currentState.currentNoteId,
);
throw new Error(
`Invalid date format before saving journal: ${currentState.currentNoteId}`,
);
}
await invoke("save_journal", {
date: currentState.currentNoteId,
content: savedContent,
});
} else {
// Assuming type 'note'
await invoke("save_note", {
noteId: currentState.currentNoteId,
content: savedContent,
});
}
console.log("Store: Successfully saved");
// Update originalContent to the saved value and reset isDirty
// Only reset isDirty if the content hasn't changed *again* since save started
store.update((s) => {
// If the value hasn't changed while saving was in progress,
// then the current state is no longer dirty relative to the saved state.
const stillDirty = s.value !== savedContent;
return {
...s,
originalContent: savedContent, // Update baseline to what was saved
isDirty: stillDirty, // Reset flag only if no new changes occurred during save
errorMessage: null,
};
});
} catch (error) {
console.error("Store: Error saving:", error);
const errorString =
typeof error === "string"
? error
: error instanceof Error
? error.message
: "Unknown save error";
store.update((s) => ({
...s,
errorMessage: `Failed to save: ${errorString}`,
}));
} finally {
store.update((s) => ({ ...s, isSaving: false }));
saveTimeout = null; // Clear timeout variable once done (success or fail)
}
}
// --- Actions Exposed by the Store ---
// Action called by the input handler - Updates value, sets dirty, triggers debounce
function updateValueAndDebounceSave(newValue: string) {
// Update value and immediately set isDirty to true
store.update((s) => {
// Avoid update if value is identical (minor optimization)
if (s.value === newValue) {
return s;
}
return { ...s, value: newValue, isDirty: true }; // Set dirty unconditionally
});
// Always clear existing timeout and set a new one if not currently saving
if (saveTimeout) clearTimeout(saveTimeout);
const isCurrentlySaving = get(isSaving); // Check if a save is already in progress
if (!isCurrentlySaving) {
saveTimeout = setTimeout(() => {
_saveCurrentNote();
}, debounceTime);
} else {
// If currently saving, don't set a new timeout yet.
// The check within the save function's success handler
// will determine if isDirty should remain true, triggering a new save later if needed.
saveTimeout = null;
}
}
// Checks for unsaved changes before performing a loading action
function _canLoad(): boolean {
const currentState = get(store);
if (currentState.isSaving) {
console.warn("Store: Action aborted, currently saving.");
return false; // Don't proceed if saving
}
if (currentState.isDirty) {
// Check the explicit flag
const confirmed = confirm(
"Unsaved changes will be lost. Load new content anyway?",
);
if (!confirmed) {
return false; // User cancelled
}
// If confirmed, reset the dirty flag as we are discarding changes
store.update((s) => ({ ...s, isDirty: false }));
}
// Clear pending save if proceeding
if (saveTimeout) clearTimeout(saveTimeout);
saveTimeout = null;
return true; // Ok to proceed
}
// --- Loading Actions (ensure they reset isDirty and update originalContent) ---
async function loadJournal() {
if (!_canLoad()) return;
store.update((s) => ({ ...s, isLoading: true, errorMessage: null }));
const todayDate = getTodayDateString();
try {
const content = await invoke<string>("get_todays_journal");
store.set({
// Use store.set to completely replace state
value: content,
originalContent: content, // Update baseline
isLoading: false,
isSaving: false,
isDirty: false, // Reset dirty flag
currentNoteId: todayDate,
currentNoteType: "journal",
errorMessage: null,
});
} catch (e) {
const errorString =
typeof e === "string"
? e
: e instanceof Error
? e.message
: "Unknown error";
store.update((s) => ({
...s,
isLoading: false,
errorMessage: `Failed to load journal: ${errorString}`,
}));
}
}
async function loadNoteById(noteId: string) {
if (!noteId || !_canLoad()) return;
store.update((s) => ({ ...s, isLoading: true, errorMessage: null }));
try {
const content = await invoke<string>("get_note_content", {
noteId: noteId,
});
store.set({
// Use store.set
value: content,
originalContent: content, // Update baseline
isLoading: false,
isSaving: false,
isDirty: false, // Reset dirty flag
currentNoteId: noteId,
currentNoteType: "note",
errorMessage: null,
});
} catch (e) {
const errorString =
typeof e === "string"
? e
: e instanceof Error
? e.message
: "Unknown error";
store.update((s) => ({
...s,
isLoading: false,
errorMessage: `Failed to load note ${noteId}: ${errorString}`,
}));
}
}
async function createNewNote() {
if (!_canLoad()) return;
store.update((s) => ({ ...s, isLoading: true, errorMessage: null }));
try {
const [newNoteId, initialContent] =
await invoke<[string, string]>("create_new_note");
store.set({
// Use store.set
value: initialContent,
originalContent: initialContent, // Update baseline
isLoading: false,
isSaving: false,
isDirty: false, // Reset dirty flag
currentNoteId: newNoteId,
currentNoteType: "note",
errorMessage: null,
});
} catch (e) {
const errorString =
typeof e === "string"
? e
: e instanceof Error
? e.message
: "Unknown error";
store.update((s) => ({
...s,
isLoading: false,
errorMessage: `Failed to create note: ${errorString}`,
}));
}
}
async function loadOrCreateJournal(dateString: string) {
if (!dateString || !_canLoad()) return;
store.update((s) => ({ ...s, isLoading: true, errorMessage: null }));
console.log(`Store: Loading or creating journal for date: ${dateString}`);
try {
const content = await invoke<string>("get_or_create_journal_for_date", {
date: dateString,
});
store.set({
// Use store.set
value: content,
originalContent: content, // Update baseline
isLoading: false,
isSaving: false,
isDirty: false, // Reset dirty flag
currentNoteId: dateString,
currentNoteType: "journal",
errorMessage: null,
});
console.log(`Store: Successfully loaded/created journal for ${dateString}`);
} catch (e) {
const errorString =
typeof e === "string"
? e
: e instanceof Error
? e.message
: "Unknown error";
console.error(
`Store: Error loading/creating journal for ${dateString}:`,
errorString,
);
store.update((s) => ({
...s,
isLoading: false,
errorMessage: `Failed to load/create journal for ${dateString}: ${errorString}`,
}));
}
}
// Manual save clears any pending auto-save and triggers immediately
async function manualSave() {
if (saveTimeout) clearTimeout(saveTimeout);
saveTimeout = null;
// Check if dirty before attempting manual save
if (get(isDirty)) {
await _saveCurrentNote();
} else {
console.log("Manual save skipped: No changes detected.");
}
}
// Export the store interface (remains the same structure)
export const noteStore = {
isLoading: { subscribe: isLoading.subscribe },
isSaving: { subscribe: isSaving.subscribe },
isDirty: { subscribe: isDirty.subscribe },
currentNoteId: { subscribe: currentNoteId.subscribe },
currentNoteType: { subscribe: currentNoteType.subscribe },
errorMessage: { subscribe: errorMessage.subscribe },
value: { subscribe: value.subscribe },
loadJournal,
loadNoteById,
createNewNote,
manualSave,
updateValue: updateValueAndDebounceSave,
loadOrCreateJournal,
};Metadata
Metadata
Assignees
Labels
bugSomething isn't workingSomething isn't workingenhancementNew feature or requestNew feature or requesthelp wantedExtra attention is neededExtra attention is needed