Skip to content

Improve editor performance #157

@rishikanthc

Description

@rishikanthc

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

No one assigned

    Labels

    bugSomething isn't workingenhancementNew feature or requesthelp wantedExtra attention is needed

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions