1614 lines
54 KiB
TypeScript
1614 lines
54 KiB
TypeScript
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||
import { open, save } from "@tauri-apps/plugin-dialog";
|
||
import { open as openExternal } from "@tauri-apps/plugin-shell";
|
||
import { writeText as writeClipboardText } from "@tauri-apps/plugin-clipboard-manager";
|
||
import { readTextFile, writeTextFile } from "@tauri-apps/plugin-fs";
|
||
import { Menu } from "@tauri-apps/api/menu";
|
||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||
import { appDataDir } from "@tauri-apps/api/path";
|
||
import { listen } from "@tauri-apps/api/event";
|
||
import { invoke } from "@tauri-apps/api/core";
|
||
import historyIcon from "./assets/history.png";
|
||
import historyIconBright from "./assets/history_b.png";
|
||
import { markdownToHTML } from "./markdown/markdown";
|
||
import "./markdown/markdown-render.css";
|
||
import {
|
||
createFolder,
|
||
createText,
|
||
deleteText,
|
||
deleteTextVersion,
|
||
discardDraft,
|
||
getDraft,
|
||
getLatestManualVersion,
|
||
getText,
|
||
listFolders,
|
||
listTexts,
|
||
listVersions,
|
||
moveFolder,
|
||
moveTextToFolder,
|
||
saveManualVersion,
|
||
searchTexts,
|
||
setFolderOrder,
|
||
setTextOrder,
|
||
updateFolderName,
|
||
updateTextTitle,
|
||
upsertDraft,
|
||
type Folder,
|
||
type Text,
|
||
} from "./lib/db";
|
||
|
||
const formatDate = (timestamp: number) => {
|
||
if (!timestamp) return "";
|
||
return new Date(timestamp).toLocaleString();
|
||
};
|
||
|
||
type HistorySnapshot = {
|
||
body: string;
|
||
lastPersistedBody: string;
|
||
lastPersistedTitle: string;
|
||
hasDraft: boolean;
|
||
restoredDraft: boolean;
|
||
draftBaseVersionId: string | null;
|
||
latestManualVersionId: string | null;
|
||
};
|
||
|
||
type ConfirmState = {
|
||
title: string;
|
||
message: string;
|
||
actionLabel?: string;
|
||
onConfirm: () => Promise<void> | void;
|
||
};
|
||
|
||
type HistoryEntry = {
|
||
id: string;
|
||
created_at: number;
|
||
kind: "manual" | "draft";
|
||
body: string;
|
||
baseVersionId?: string | null;
|
||
};
|
||
|
||
const DEFAULT_TITLE = "Untitled Text";
|
||
|
||
export default function App() {
|
||
const [texts, setTexts] = useState<Text[]>([]);
|
||
const [selectedTextId, setSelectedTextId] = useState<string | null>(null);
|
||
const [search, setSearch] = useState("");
|
||
const [loadingTexts, setLoadingTexts] = useState(true);
|
||
const [folders, setFolders] = useState<Folder[]>([]);
|
||
const [loadingFolders, setLoadingFolders] = useState(true);
|
||
|
||
const [title, setTitle] = useState("");
|
||
const [lastPersistedTitle, setLastPersistedTitle] = useState("");
|
||
const [body, setBody] = useState("");
|
||
const [lastPersistedBody, setLastPersistedBody] = useState("");
|
||
const [hasDraft, setHasDraft] = useState(false);
|
||
const [restoredDraft, setRestoredDraft] = useState(false);
|
||
const [latestManualVersionId, setLatestManualVersionId] = useState<string | null>(null);
|
||
const [draftBaseVersionId, setDraftBaseVersionId] = useState<string | null>(null);
|
||
|
||
const [historyOpen, setHistoryOpen] = useState(false);
|
||
const [historyItems, setHistoryItems] = useState<HistoryEntry[]>([]);
|
||
const [viewingVersion, setViewingVersion] = useState<HistoryEntry | null>(null);
|
||
const [selectedHistoryId, setSelectedHistoryId] = useState<string | null>(null);
|
||
const [confirmState, setConfirmState] = useState<ConfirmState | null>(null);
|
||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||
const [markdownPreview, setMarkdownPreview] = useState(false);
|
||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(() => {
|
||
const stored = localStorage.getItem("textdb.expandedFolders");
|
||
if (!stored) return new Set();
|
||
try {
|
||
const parsed = JSON.parse(stored);
|
||
if (Array.isArray(parsed)) {
|
||
return new Set(parsed.filter((value) => typeof value === "string"));
|
||
}
|
||
} catch {
|
||
return new Set();
|
||
}
|
||
return new Set();
|
||
});
|
||
const [theme, setTheme] = useState<"default" | "light">(() => {
|
||
const storedTheme = localStorage.getItem("textdb.theme");
|
||
return storedTheme === "light" ? "light" : "default";
|
||
});
|
||
const [textSize, setTextSize] = useState(() => {
|
||
const storedSize = Number(localStorage.getItem("textdb.textSize"));
|
||
if (!Number.isNaN(storedSize) && storedSize >= 12 && storedSize <= 18) {
|
||
return storedSize;
|
||
}
|
||
return 16;
|
||
});
|
||
const [showLineNumbers, setShowLineNumbers] = useState(() => {
|
||
return localStorage.getItem("textdb.lineNumbers") === "true";
|
||
});
|
||
const [lineHeights, setLineHeights] = useState<number[]>([]);
|
||
const [measureTick, setMeasureTick] = useState(0);
|
||
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
|
||
return localStorage.getItem("textdb.sidebarCollapsed") === "true";
|
||
});
|
||
|
||
const bodyRef = useRef(body);
|
||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||
const lineNumbersRef = useRef<HTMLDivElement | null>(null);
|
||
const measureRef = useRef<HTMLDivElement | null>(null);
|
||
const historySnapshotRef = useRef<HistorySnapshot | null>(null);
|
||
const recentOpenRef = useRef(new Map<string, number>());
|
||
const dragItemRef = useRef<{ type: "text" | "folder"; id: string; parentId: string | null } | null>(
|
||
null
|
||
);
|
||
|
||
|
||
useEffect(() => {
|
||
bodyRef.current = body;
|
||
}, [body]);
|
||
|
||
|
||
useEffect(() => {
|
||
document.body.dataset.theme = theme;
|
||
localStorage.setItem("textdb.theme", theme);
|
||
}, [theme]);
|
||
|
||
useEffect(() => {
|
||
document.documentElement.style.setProperty("--base-font-size", `${textSize}px`);
|
||
localStorage.setItem("textdb.textSize", String(textSize));
|
||
}, [textSize]);
|
||
|
||
useEffect(() => {
|
||
localStorage.setItem("textdb.sidebarCollapsed", String(sidebarCollapsed));
|
||
}, [sidebarCollapsed]);
|
||
|
||
useEffect(() => {
|
||
localStorage.setItem("textdb.lineNumbers", String(showLineNumbers));
|
||
}, [showLineNumbers]);
|
||
|
||
useEffect(() => {
|
||
localStorage.setItem(
|
||
"textdb.expandedFolders",
|
||
JSON.stringify(Array.from(expandedFolders))
|
||
);
|
||
}, [expandedFolders]);
|
||
|
||
useEffect(() => {
|
||
if (selectedTextId) {
|
||
localStorage.setItem("textdb.selectedTextId", selectedTextId);
|
||
}
|
||
}, [selectedTextId]);
|
||
|
||
const isViewingHistory = viewingVersion !== null;
|
||
const isDirty = !isViewingHistory && body !== lastPersistedBody;
|
||
const hasText = body.trim().length > 0;
|
||
const showLineNumbersActive = showLineNumbers && !markdownPreview;
|
||
const hasSearch = search.trim().length > 0;
|
||
|
||
const sortByOrderThenUpdated = useCallback(
|
||
<T extends { sort_order: number | null; updated_at: number }>(a: T, b: T) => {
|
||
const aHasOrder = a.sort_order !== null && a.sort_order !== undefined;
|
||
const bHasOrder = b.sort_order !== null && b.sort_order !== undefined;
|
||
if (aHasOrder && bHasOrder) {
|
||
return (a.sort_order ?? 0) - (b.sort_order ?? 0);
|
||
}
|
||
if (aHasOrder !== bHasOrder) {
|
||
return aHasOrder ? -1 : 1;
|
||
}
|
||
return b.updated_at - a.updated_at;
|
||
},
|
||
[]
|
||
);
|
||
|
||
const folderById = useMemo(() => {
|
||
const map = new Map<string, Folder>();
|
||
for (const folder of folders) {
|
||
map.set(folder.id, folder);
|
||
}
|
||
return map;
|
||
}, [folders]);
|
||
|
||
const foldersByParent = useMemo(() => {
|
||
const map = new Map<string | null, Folder[]>();
|
||
for (const folder of folders) {
|
||
const key = folder.parent_id ?? null;
|
||
const list = map.get(key);
|
||
if (list) {
|
||
list.push(folder);
|
||
} else {
|
||
map.set(key, [folder]);
|
||
}
|
||
}
|
||
for (const [key, list] of map.entries()) {
|
||
map.set(key, [...list].sort(sortByOrderThenUpdated));
|
||
}
|
||
return map;
|
||
}, [folders, sortByOrderThenUpdated]);
|
||
|
||
const textsByFolder = useMemo(() => {
|
||
const map = new Map<string | null, Text[]>();
|
||
for (const text of texts) {
|
||
const key = text.folder_id ?? null;
|
||
const list = map.get(key);
|
||
if (list) {
|
||
list.push(text);
|
||
} else {
|
||
map.set(key, [text]);
|
||
}
|
||
}
|
||
for (const [key, list] of map.entries()) {
|
||
map.set(key, [...list].sort(sortByOrderThenUpdated));
|
||
}
|
||
return map;
|
||
}, [texts, sortByOrderThenUpdated]);
|
||
|
||
const visibleFolderIds = useMemo(() => {
|
||
if (!hasSearch) return null;
|
||
const visible = new Set<string>();
|
||
for (const text of texts) {
|
||
let current = text.folder_id ?? null;
|
||
while (current) {
|
||
if (visible.has(current)) break;
|
||
visible.add(current);
|
||
current = folderById.get(current)?.parent_id ?? null;
|
||
}
|
||
}
|
||
return visible;
|
||
}, [folderById, hasSearch, texts]);
|
||
|
||
const handleMarkdownPreviewClick = useCallback(
|
||
(event: React.MouseEvent<HTMLDivElement>) => {
|
||
const target = event.target as HTMLElement | null;
|
||
const copyButton = target?.closest?.(".md-codeblock__copy") as HTMLElement | null;
|
||
if (copyButton) {
|
||
event.preventDefault();
|
||
const encoded = copyButton.getAttribute("data-copy-code") ?? "";
|
||
const text = decodeURIComponent(encoded);
|
||
if (!text) return;
|
||
writeClipboardText(text).catch(() => {
|
||
if (navigator?.clipboard?.writeText) {
|
||
navigator.clipboard.writeText(text);
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
const link = target?.closest?.("a");
|
||
if (!link) return;
|
||
const href = link.getAttribute("href");
|
||
if (!href || href.startsWith("#") || href.startsWith("/")) return;
|
||
event.preventDefault();
|
||
openExternal(href);
|
||
},
|
||
[]
|
||
);
|
||
|
||
const handlePrintMarkdown = useCallback(() => {
|
||
if (!markdownPreview) return;
|
||
document.body.classList.add("print-markdown");
|
||
const cleanup = () => {
|
||
document.body.classList.remove("print-markdown");
|
||
window.removeEventListener("afterprint", cleanup);
|
||
};
|
||
window.addEventListener("afterprint", cleanup);
|
||
requestAnimationFrame(() => {
|
||
window.print();
|
||
});
|
||
}, [markdownPreview]);
|
||
|
||
const statusKey = useMemo(() => {
|
||
if (isViewingHistory) return "history";
|
||
if (isDirty) return "unsaved";
|
||
if (hasDraft) return "draft";
|
||
return "saved";
|
||
}, [hasDraft, isDirty, isViewingHistory]);
|
||
|
||
const canSave = !isViewingHistory && (isDirty || hasDraft);
|
||
|
||
const statusLabel = useMemo(() => {
|
||
switch (statusKey) {
|
||
case "history":
|
||
return "Viewing history";
|
||
case "unsaved":
|
||
return "Unsaved";
|
||
case "draft":
|
||
return "Draft autosaved";
|
||
default:
|
||
return "Saved";
|
||
}
|
||
}, [statusKey]);
|
||
|
||
const historyIconSrc = theme === "light" ? historyIconBright : historyIcon;
|
||
|
||
const lines = useMemo(() => body.split("\n"), [body]);
|
||
const lineNumbers = useMemo(() => lines.map((_, index) => index + 1), [lines]);
|
||
|
||
const handleTextareaScroll = useCallback((event: React.UIEvent<HTMLTextAreaElement>) => {
|
||
if (!showLineNumbersActive) return;
|
||
if (lineNumbersRef.current) {
|
||
lineNumbersRef.current.scrollTop = event.currentTarget.scrollTop;
|
||
}
|
||
}, [showLineNumbersActive]);
|
||
|
||
useEffect(() => {
|
||
if (!showLineNumbersActive) return;
|
||
const textarea = textareaRef.current;
|
||
if (!textarea || typeof ResizeObserver === "undefined") return;
|
||
const observer = new ResizeObserver(() => {
|
||
setMeasureTick((tick) => tick + 1);
|
||
});
|
||
observer.observe(textarea);
|
||
return () => observer.disconnect();
|
||
}, [showLineNumbersActive]);
|
||
|
||
useEffect(() => {
|
||
if (!showLineNumbersActive) return;
|
||
let raf = 0;
|
||
const handleResize = () => {
|
||
if (raf) cancelAnimationFrame(raf);
|
||
raf = requestAnimationFrame(() => {
|
||
setMeasureTick((tick) => tick + 1);
|
||
});
|
||
};
|
||
window.addEventListener("resize", handleResize);
|
||
return () => {
|
||
window.removeEventListener("resize", handleResize);
|
||
if (raf) cancelAnimationFrame(raf);
|
||
};
|
||
}, [showLineNumbersActive]);
|
||
|
||
useLayoutEffect(() => {
|
||
if (!showLineNumbersActive) return;
|
||
const textarea = textareaRef.current;
|
||
const measure = measureRef.current;
|
||
if (!textarea || !measure) return;
|
||
measure.style.width = `${textarea.clientWidth}px`;
|
||
const heights = Array.from(measure.children).map((child) =>
|
||
Math.ceil((child as HTMLElement).getBoundingClientRect().height)
|
||
);
|
||
setLineHeights(heights);
|
||
if (lineNumbersRef.current) {
|
||
lineNumbersRef.current.scrollTop = textarea.scrollTop;
|
||
}
|
||
}, [lines, showLineNumbersActive, textSize, measureTick, sidebarCollapsed, historyOpen]);
|
||
|
||
useEffect(() => {
|
||
if (showLineNumbersActive && textareaRef.current && lineNumbersRef.current) {
|
||
lineNumbersRef.current.scrollTop = textareaRef.current.scrollTop;
|
||
}
|
||
}, [showLineNumbersActive, body]);
|
||
|
||
|
||
const refreshTexts = useCallback(async () => {
|
||
setLoadingTexts(true);
|
||
try {
|
||
const trimmed = search.trim();
|
||
const rows = trimmed ? await searchTexts(trimmed) : await listTexts();
|
||
setTexts(rows);
|
||
} finally {
|
||
setLoadingTexts(false);
|
||
}
|
||
}, [search]);
|
||
|
||
const refreshFolders = useCallback(async () => {
|
||
setLoadingFolders(true);
|
||
try {
|
||
const rows = await listFolders();
|
||
setFolders(rows);
|
||
} finally {
|
||
setLoadingFolders(false);
|
||
}
|
||
}, []);
|
||
|
||
const getNextTextSortOrder = useCallback(
|
||
(folderId: string | null) => {
|
||
const list = textsByFolder.get(folderId ?? null) ?? [];
|
||
const hasManualOrder = list.some((text) => text.sort_order !== null);
|
||
if (!hasManualOrder) return null;
|
||
return list.length;
|
||
},
|
||
[textsByFolder]
|
||
);
|
||
|
||
const getNextFolderSortOrder = useCallback(
|
||
(parentId: string | null) => {
|
||
const list = foldersByParent.get(parentId ?? null) ?? [];
|
||
const hasManualOrder = list.some((folder) => folder.sort_order !== null);
|
||
if (!hasManualOrder) return null;
|
||
return list.length;
|
||
},
|
||
[foldersByParent]
|
||
);
|
||
|
||
const isFolderExpanded = useCallback(
|
||
(folderId: string) => {
|
||
if (hasSearch) {
|
||
return visibleFolderIds?.has(folderId) ?? false;
|
||
}
|
||
return expandedFolders.has(folderId);
|
||
},
|
||
[expandedFolders, hasSearch, visibleFolderIds]
|
||
);
|
||
|
||
const toggleFolderExpanded = useCallback((folderId: string) => {
|
||
setExpandedFolders((prev) => {
|
||
const next = new Set(prev);
|
||
if (next.has(folderId)) {
|
||
next.delete(folderId);
|
||
} else {
|
||
next.add(folderId);
|
||
}
|
||
return next;
|
||
});
|
||
}, []);
|
||
|
||
const refreshVersions = useCallback(async () => {
|
||
if (!selectedTextId || !historyOpen) return;
|
||
const [manualRows, draft] = await Promise.all([
|
||
listVersions(selectedTextId),
|
||
getDraft(selectedTextId)
|
||
]);
|
||
const manualItems: HistoryEntry[] = manualRows.map((row) => ({
|
||
id: row.id,
|
||
created_at: row.created_at,
|
||
kind: "manual",
|
||
body: row.body
|
||
}));
|
||
const draftItem: HistoryEntry[] = draft
|
||
? [
|
||
{
|
||
id: `draft:${selectedTextId}`,
|
||
created_at: draft.updated_at,
|
||
kind: "draft",
|
||
body: draft.body,
|
||
baseVersionId: draft.base_version_id ?? null
|
||
}
|
||
]
|
||
: [];
|
||
const combined = [...draftItem, ...manualItems].sort(
|
||
(a, b) => b.created_at - a.created_at
|
||
);
|
||
setHistoryItems(combined);
|
||
}, [historyOpen, selectedTextId]);
|
||
|
||
useEffect(() => {
|
||
refreshTexts().catch((error) => {
|
||
console.error("Failed to load texts", error);
|
||
});
|
||
}, [refreshTexts]);
|
||
|
||
useEffect(() => {
|
||
refreshFolders().catch((error) => {
|
||
console.error("Failed to load folders", error);
|
||
});
|
||
}, [refreshFolders]);
|
||
|
||
useEffect(() => {
|
||
if (selectedTextId || texts.length === 0) return;
|
||
const storedId = localStorage.getItem("textdb.selectedTextId");
|
||
const fallback = texts[0].id;
|
||
const resolved = storedId && texts.some((text) => text.id === storedId)
|
||
? storedId
|
||
: fallback;
|
||
setSelectedTextId(resolved);
|
||
}, [selectedTextId, texts]);
|
||
|
||
useEffect(() => {
|
||
if (!historyOpen) {
|
||
setHistoryItems([]);
|
||
return;
|
||
}
|
||
refreshVersions().catch((error) => {
|
||
console.error("Failed to load versions", error);
|
||
});
|
||
}, [historyOpen, refreshVersions]);
|
||
|
||
useEffect(() => {
|
||
if (!selectedTextId) {
|
||
setTitle("");
|
||
setLastPersistedTitle("");
|
||
setBody("");
|
||
setLastPersistedBody("");
|
||
setHasDraft(false);
|
||
setRestoredDraft(false);
|
||
setLatestManualVersionId(null);
|
||
setDraftBaseVersionId(null);
|
||
setViewingVersion(null);
|
||
setSelectedHistoryId(null);
|
||
setMarkdownPreview(false);
|
||
historySnapshotRef.current = null;
|
||
return;
|
||
}
|
||
|
||
let cancelled = false;
|
||
const loadText = async () => {
|
||
const [text, manualVersion, draft] = await Promise.all([
|
||
getText(selectedTextId),
|
||
getLatestManualVersion(selectedTextId),
|
||
getDraft(selectedTextId)
|
||
]);
|
||
|
||
if (cancelled) return;
|
||
|
||
if (!text) {
|
||
setSelectedTextId(null);
|
||
localStorage.removeItem("textdb.selectedTextId");
|
||
return;
|
||
}
|
||
|
||
const resolvedTitle = text.title || DEFAULT_TITLE;
|
||
const resolvedBody = draft?.body ?? manualVersion?.body ?? "";
|
||
const baseVersionId = draft?.base_version_id ?? manualVersion?.id ?? null;
|
||
|
||
setTitle(resolvedTitle);
|
||
setLastPersistedTitle(resolvedTitle);
|
||
setBody(resolvedBody);
|
||
setLastPersistedBody(resolvedBody);
|
||
setHasDraft(Boolean(draft));
|
||
setRestoredDraft(Boolean(draft));
|
||
setLatestManualVersionId(manualVersion?.id ?? null);
|
||
setDraftBaseVersionId(baseVersionId);
|
||
setViewingVersion(null);
|
||
setSelectedHistoryId(
|
||
draft ? `draft:${selectedTextId}` : manualVersion?.id ?? null
|
||
);
|
||
setMarkdownPreview(false);
|
||
historySnapshotRef.current = null;
|
||
};
|
||
|
||
loadText().catch((error) => {
|
||
console.error("Failed to load text", error);
|
||
});
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [selectedTextId]);
|
||
|
||
useEffect(() => {
|
||
if (!selectedTextId || !isDirty || isViewingHistory) return;
|
||
|
||
const currentBody = body;
|
||
const handle = window.setTimeout(() => {
|
||
upsertDraft(selectedTextId, currentBody, draftBaseVersionId)
|
||
.then(() => {
|
||
if (bodyRef.current !== currentBody) return;
|
||
setHasDraft(true);
|
||
setLastPersistedBody(currentBody);
|
||
setSelectedHistoryId(`draft:${selectedTextId}`);
|
||
if (historyOpen) {
|
||
refreshVersions().catch((error) => {
|
||
console.error("Failed to refresh history", error);
|
||
});
|
||
}
|
||
})
|
||
.catch((error) => {
|
||
console.error("Failed to autosave draft", error);
|
||
});
|
||
}, 600);
|
||
|
||
return () => window.clearTimeout(handle);
|
||
}, [
|
||
body,
|
||
draftBaseVersionId,
|
||
historyOpen,
|
||
isDirty,
|
||
isViewingHistory,
|
||
refreshVersions,
|
||
selectedTextId
|
||
]);
|
||
|
||
const handleNewText = useCallback(async () => {
|
||
const sortOrder = getNextTextSortOrder(null);
|
||
const { textId } = await createText(DEFAULT_TITLE, "", null, sortOrder);
|
||
await refreshTexts();
|
||
setSelectedTextId(textId);
|
||
}, [getNextTextSortOrder, refreshTexts]);
|
||
|
||
const handleNewFolder = useCallback(async () => {
|
||
const name = window.prompt("Folder name");
|
||
const trimmed = name?.trim();
|
||
if (!trimmed) return;
|
||
const sortOrder = getNextFolderSortOrder(null);
|
||
const { folderId } = await createFolder(trimmed, null, sortOrder);
|
||
await refreshFolders();
|
||
setExpandedFolders((prev) => {
|
||
const next = new Set(prev);
|
||
next.add(folderId);
|
||
return next;
|
||
});
|
||
}, [getNextFolderSortOrder, refreshFolders]);
|
||
|
||
const buildFolderPath = useCallback(
|
||
(folderId: string) => {
|
||
const names: string[] = [];
|
||
let current: string | null = folderId;
|
||
const seen = new Set<string>();
|
||
while (current) {
|
||
if (seen.has(current)) break;
|
||
seen.add(current);
|
||
const folder = folderById.get(current);
|
||
if (!folder) break;
|
||
names.unshift(folder.name);
|
||
current = folder.parent_id ?? null;
|
||
}
|
||
return names.join(" / ");
|
||
},
|
||
[folderById]
|
||
);
|
||
|
||
const folderPathList = useMemo(() => {
|
||
return folders
|
||
.map((folder) => ({
|
||
id: folder.id,
|
||
label: buildFolderPath(folder.id)
|
||
}))
|
||
.sort((a, b) => a.label.localeCompare(b.label));
|
||
}, [buildFolderPath, folders]);
|
||
|
||
const handleMoveTextToFolder = useCallback(
|
||
async (textId: string, folderId: string | null) => {
|
||
await moveTextToFolder(textId, folderId, null);
|
||
await refreshTexts();
|
||
},
|
||
[refreshTexts]
|
||
);
|
||
|
||
const handleTextContextMenu = useCallback(
|
||
async (event: React.MouseEvent, textId: string) => {
|
||
event.preventDefault();
|
||
const items = [
|
||
{
|
||
text: "Top level",
|
||
action: () => {
|
||
handleMoveTextToFolder(textId, null).catch((error) => {
|
||
console.error("Failed to move text", error);
|
||
});
|
||
}
|
||
},
|
||
...folderPathList.map((folder) => ({
|
||
text: folder.label,
|
||
action: () => {
|
||
handleMoveTextToFolder(textId, folder.id).catch((error) => {
|
||
console.error("Failed to move text", error);
|
||
});
|
||
}
|
||
}))
|
||
];
|
||
|
||
const menu = await Menu.new({
|
||
items: [
|
||
{
|
||
text: "Move to folder",
|
||
items
|
||
}
|
||
]
|
||
});
|
||
await menu.popup(undefined, getCurrentWindow());
|
||
},
|
||
[folderPathList, handleMoveTextToFolder]
|
||
);
|
||
|
||
const reorderIds = useCallback((ids: string[], draggedId: string, targetId: string) => {
|
||
const next = ids.filter((id) => id !== draggedId);
|
||
const targetIndex = next.indexOf(targetId);
|
||
const insertIndex = targetIndex === -1 ? next.length : targetIndex;
|
||
next.splice(insertIndex, 0, draggedId);
|
||
return next;
|
||
}, []);
|
||
|
||
const isDescendantFolder = useCallback(
|
||
(folderId: string, potentialAncestorId: string) => {
|
||
let current: string | null = folderId;
|
||
while (current) {
|
||
if (current === potentialAncestorId) return true;
|
||
current = folderById.get(current)?.parent_id ?? null;
|
||
}
|
||
return false;
|
||
},
|
||
[folderById]
|
||
);
|
||
|
||
const handleTextDrop = useCallback(
|
||
async (event: React.DragEvent, targetTextId: string, targetFolderId: string | null) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
const dragItem = dragItemRef.current;
|
||
if (!dragItem || dragItem.type !== "text") return;
|
||
const currentList = textsByFolder.get(targetFolderId ?? null) ?? [];
|
||
let ids = currentList.map((text) => text.id);
|
||
if (!ids.includes(dragItem.id)) {
|
||
const targetIndex = ids.indexOf(targetTextId);
|
||
ids.splice(targetIndex === -1 ? ids.length : targetIndex, 0, dragItem.id);
|
||
} else {
|
||
ids = reorderIds(ids, dragItem.id, targetTextId);
|
||
}
|
||
await moveTextToFolder(dragItem.id, targetFolderId, 0);
|
||
await setTextOrder(ids);
|
||
await refreshTexts();
|
||
dragItemRef.current = null;
|
||
},
|
||
[refreshTexts, reorderIds, textsByFolder]
|
||
);
|
||
|
||
const handleFolderDrop = useCallback(
|
||
async (event: React.DragEvent, targetFolder: Folder) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
const dragItem = dragItemRef.current;
|
||
if (!dragItem) return;
|
||
|
||
if (dragItem.type === "text") {
|
||
const list = textsByFolder.get(targetFolder.id) ?? [];
|
||
const ids = list.map((text) => text.id);
|
||
if (!ids.includes(dragItem.id)) {
|
||
ids.push(dragItem.id);
|
||
}
|
||
await moveTextToFolder(dragItem.id, targetFolder.id, 0);
|
||
await setTextOrder(ids);
|
||
await refreshTexts();
|
||
dragItemRef.current = null;
|
||
return;
|
||
}
|
||
|
||
if (dragItem.type === "folder") {
|
||
if (dragItem.id === targetFolder.id) return;
|
||
if (isDescendantFolder(targetFolder.id, dragItem.id)) return;
|
||
const sameParent = dragItem.parentId === targetFolder.parent_id;
|
||
if (sameParent) {
|
||
const siblings = foldersByParent.get(targetFolder.parent_id ?? null) ?? [];
|
||
const ids = siblings.map((folder) => folder.id);
|
||
const next = reorderIds(ids, dragItem.id, targetFolder.id);
|
||
await setFolderOrder(next);
|
||
await refreshFolders();
|
||
} else {
|
||
const children = foldersByParent.get(targetFolder.id) ?? [];
|
||
const ids = children.map((folder) => folder.id);
|
||
ids.push(dragItem.id);
|
||
await moveFolder(dragItem.id, targetFolder.id, 0);
|
||
await setFolderOrder(ids);
|
||
await refreshFolders();
|
||
}
|
||
dragItemRef.current = null;
|
||
}
|
||
},
|
||
[foldersByParent, isDescendantFolder, refreshFolders, refreshTexts, reorderIds, textsByFolder]
|
||
);
|
||
|
||
const handleRootDrop = useCallback(
|
||
async (event: React.DragEvent) => {
|
||
event.preventDefault();
|
||
const dragItem = dragItemRef.current;
|
||
if (!dragItem) return;
|
||
if (dragItem.type === "text") {
|
||
const list = textsByFolder.get(null) ?? [];
|
||
const ids = list.map((text) => text.id);
|
||
if (!ids.includes(dragItem.id)) {
|
||
ids.push(dragItem.id);
|
||
}
|
||
await moveTextToFolder(dragItem.id, null, 0);
|
||
await setTextOrder(ids);
|
||
await refreshTexts();
|
||
} else if (dragItem.type === "folder") {
|
||
const list = foldersByParent.get(null) ?? [];
|
||
const ids = list.map((folder) => folder.id);
|
||
if (!ids.includes(dragItem.id)) {
|
||
ids.push(dragItem.id);
|
||
}
|
||
await moveFolder(dragItem.id, null, 0);
|
||
await setFolderOrder(ids);
|
||
await refreshFolders();
|
||
}
|
||
dragItemRef.current = null;
|
||
},
|
||
[foldersByParent, refreshFolders, refreshTexts, textsByFolder]
|
||
);
|
||
|
||
const handleDragStartText = useCallback((event: React.DragEvent, text: Text) => {
|
||
dragItemRef.current = {
|
||
type: "text",
|
||
id: text.id,
|
||
parentId: text.folder_id ?? null
|
||
};
|
||
event.dataTransfer.effectAllowed = "move";
|
||
event.dataTransfer.setData("text/plain", text.id);
|
||
}, []);
|
||
|
||
const handleDragStartFolder = useCallback(
|
||
(event: React.DragEvent, folder: Folder) => {
|
||
dragItemRef.current = {
|
||
type: "folder",
|
||
id: folder.id,
|
||
parentId: folder.parent_id ?? null
|
||
};
|
||
event.dataTransfer.effectAllowed = "move";
|
||
event.dataTransfer.setData("text/plain", folder.id);
|
||
},
|
||
[]
|
||
);
|
||
|
||
const handleDragEnd = useCallback(() => {
|
||
dragItemRef.current = null;
|
||
}, []);
|
||
|
||
const createTextFromFile = useCallback(
|
||
async (filePath: string) => {
|
||
try {
|
||
const filename = filePath.split(/[\/]/).pop() || DEFAULT_TITLE;
|
||
const title = filename.replace(/\.(txt|md)$/i, "") || DEFAULT_TITLE;
|
||
const contents = await readTextFile(filePath);
|
||
const sortOrder = getNextTextSortOrder(null);
|
||
const { textId } = await createText(title, contents, null, sortOrder);
|
||
await refreshTexts();
|
||
setSelectedTextId(textId);
|
||
} catch (error) {
|
||
console.error("Failed to open text file", error);
|
||
}
|
||
},
|
||
[getNextTextSortOrder, refreshTexts]
|
||
);
|
||
|
||
const handleFilePaths = useCallback(
|
||
async (paths: string[]) => {
|
||
const now = Date.now();
|
||
const txtPaths = paths.filter((path) => {
|
||
const lower = path.toLowerCase();
|
||
return lower.endsWith(".txt") || lower.endsWith(".md");
|
||
});
|
||
const recent = recentOpenRef.current;
|
||
for (const path of txtPaths) {
|
||
const key = path.toLowerCase();
|
||
const last = recent.get(key);
|
||
if (last && now - last < 1000) continue;
|
||
recent.set(key, now);
|
||
await createTextFromFile(path);
|
||
}
|
||
for (const [key, timestamp] of recent.entries()) {
|
||
if (now - timestamp > 2000) recent.delete(key);
|
||
}
|
||
},
|
||
[createTextFromFile]
|
||
);
|
||
|
||
useEffect(() => {
|
||
let unlisten: (() => void) | null = null;
|
||
getCurrentWindow()
|
||
.onDragDropEvent(async (event) => {
|
||
if (event.payload.type !== "drop") return;
|
||
await handleFilePaths(event.payload.paths ?? []);
|
||
})
|
||
.then((cleanup) => {
|
||
unlisten = cleanup;
|
||
})
|
||
.catch((error) => {
|
||
console.error("Failed to register drag/drop handler", error);
|
||
});
|
||
return () => {
|
||
if (unlisten) unlisten();
|
||
};
|
||
}, [handleFilePaths]);
|
||
|
||
useEffect(() => {
|
||
let unlisten: (() => void) | null = null;
|
||
listen<string[]>("file-opened", async (event) => {
|
||
await handleFilePaths(event.payload ?? []);
|
||
})
|
||
.then((cleanup) => {
|
||
unlisten = cleanup;
|
||
})
|
||
.catch((error) => {
|
||
console.error("Failed to register file-open listener", error);
|
||
});
|
||
|
||
invoke<string[]>("take_pending_opens")
|
||
.then((paths) => handleFilePaths(paths ?? []))
|
||
.catch((error) => {
|
||
console.error("Failed to load pending file opens", error);
|
||
});
|
||
|
||
return () => {
|
||
if (unlisten) unlisten();
|
||
};
|
||
}, [handleFilePaths]);
|
||
|
||
const handleOpenText = useCallback(async () => {
|
||
const baseDir = await appDataDir();
|
||
const path = await open({
|
||
multiple: false,
|
||
directory: false,
|
||
filters: [{ name: "Text/Markdown", extensions: ["txt", "md"] }],
|
||
defaultPath: baseDir
|
||
});
|
||
if (!path || Array.isArray(path)) return;
|
||
await createTextFromFile(path);
|
||
}, [createTextFromFile]);
|
||
|
||
const handleDeleteText = useCallback(
|
||
async (promptId: string) => {
|
||
await deleteText(promptId);
|
||
await refreshTexts();
|
||
if (selectedTextId === promptId) {
|
||
setSelectedTextId(null);
|
||
localStorage.removeItem("textdb.selectedTextId");
|
||
}
|
||
},
|
||
[refreshTexts, selectedTextId]
|
||
);
|
||
|
||
const handleSaveVersion = useCallback(async () => {
|
||
if (!selectedTextId || !canSave) return;
|
||
const normalizedTitle = title.trim() || DEFAULT_TITLE;
|
||
if (normalizedTitle !== title) {
|
||
setTitle(normalizedTitle);
|
||
}
|
||
const result = await saveManualVersion(selectedTextId, normalizedTitle, body);
|
||
setLastPersistedBody(body);
|
||
setLastPersistedTitle(normalizedTitle);
|
||
setHasDraft(false);
|
||
setRestoredDraft(false);
|
||
setLatestManualVersionId(result.versionId);
|
||
setDraftBaseVersionId(result.versionId);
|
||
setSelectedHistoryId(result.versionId);
|
||
await refreshTexts();
|
||
await refreshVersions();
|
||
}, [body, canSave, refreshTexts, refreshVersions, selectedTextId, title]);
|
||
|
||
const applyTitleUpdate = useCallback((promptId: string, nextTitle: string) => {
|
||
const now = Date.now();
|
||
setTexts((prev) => {
|
||
let updated: Text | null = null;
|
||
const remaining: Text[] = [];
|
||
for (const text of prev) {
|
||
if (text.id === promptId) {
|
||
updated = { ...text, title: nextTitle, updated_at: now };
|
||
} else {
|
||
remaining.push(text);
|
||
}
|
||
}
|
||
if (!updated) return prev;
|
||
return [updated, ...remaining];
|
||
});
|
||
}, []);
|
||
|
||
const handleTitleChange = useCallback(
|
||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||
const nextTitle = event.target.value;
|
||
setTitle(nextTitle);
|
||
if (!selectedTextId || isViewingHistory) return;
|
||
applyTitleUpdate(selectedTextId, nextTitle);
|
||
setLastPersistedTitle(nextTitle);
|
||
updateTextTitle(selectedTextId, nextTitle).catch((error) => {
|
||
console.error("Failed to update title", error);
|
||
});
|
||
},
|
||
[applyTitleUpdate, isViewingHistory, selectedTextId]
|
||
);
|
||
|
||
const handleDiscardDraft = useCallback(async () => {
|
||
if (!selectedTextId || isViewingHistory) return;
|
||
await discardDraft(selectedTextId);
|
||
const manualVersion = await getLatestManualVersion(selectedTextId);
|
||
const resolvedBody = manualVersion?.body ?? "";
|
||
setBody(resolvedBody);
|
||
setLastPersistedBody(resolvedBody);
|
||
setHasDraft(false);
|
||
setRestoredDraft(false);
|
||
setLatestManualVersionId(manualVersion?.id ?? null);
|
||
setDraftBaseVersionId(manualVersion?.id ?? null);
|
||
setSelectedHistoryId(manualVersion?.id ?? null);
|
||
await refreshVersions();
|
||
}, [isViewingHistory, refreshVersions, selectedTextId]);
|
||
|
||
const handleExportText = useCallback(async () => {
|
||
if (!hasText) return;
|
||
const filename = `${title.trim() || DEFAULT_TITLE}.txt`;
|
||
const path = await save({
|
||
defaultPath: filename,
|
||
filters: [{ name: "Text", extensions: ["txt"] }]
|
||
});
|
||
if (!path) return;
|
||
const finalPath = path.endsWith(".txt") ? path : `${path}.txt`;
|
||
await writeTextFile(finalPath, body);
|
||
}, [body, hasText, title]);
|
||
|
||
const handleExitHistory = useCallback(() => {
|
||
const snapshot = historySnapshotRef.current;
|
||
if (snapshot) {
|
||
setBody(snapshot.body);
|
||
setLastPersistedBody(snapshot.lastPersistedBody);
|
||
setLastPersistedTitle(snapshot.lastPersistedTitle);
|
||
setHasDraft(snapshot.hasDraft);
|
||
setRestoredDraft(snapshot.restoredDraft);
|
||
setDraftBaseVersionId(snapshot.draftBaseVersionId);
|
||
setLatestManualVersionId(snapshot.latestManualVersionId);
|
||
}
|
||
setViewingVersion(null);
|
||
setSelectedHistoryId(null);
|
||
historySnapshotRef.current = null;
|
||
}, []);
|
||
|
||
const applyVersionAsCurrent = useCallback(
|
||
(version: HistoryEntry) => {
|
||
const snapshot = historySnapshotRef.current;
|
||
if (snapshot) {
|
||
setLastPersistedBody(snapshot.lastPersistedBody);
|
||
setLastPersistedTitle(snapshot.lastPersistedTitle);
|
||
setHasDraft(snapshot.hasDraft);
|
||
setRestoredDraft(snapshot.restoredDraft);
|
||
setLatestManualVersionId(snapshot.latestManualVersionId);
|
||
}
|
||
setDraftBaseVersionId(
|
||
version.kind === "manual" ? version.id : version.baseVersionId ?? null
|
||
);
|
||
setBody(version.body);
|
||
setLastPersistedBody(version.body);
|
||
setViewingVersion(null);
|
||
setSelectedHistoryId(version.id);
|
||
historySnapshotRef.current = null;
|
||
},
|
||
[]
|
||
);
|
||
|
||
const handleDeleteVersion = useCallback(
|
||
async (version: HistoryEntry) => {
|
||
if (!selectedTextId) return;
|
||
|
||
const currentItems = historyItems;
|
||
const index = currentItems.findIndex((item) => item.id === version.id);
|
||
const olderCandidate =
|
||
index >= 0 ? currentItems[index + 1] ?? null : null;
|
||
const fallbackCandidate =
|
||
index > 0 ? currentItems[index - 1] ?? null : null;
|
||
const shouldAutoSelect =
|
||
historyOpen && (!viewingVersion || viewingVersion.id === version.id);
|
||
|
||
if (version.kind === "draft") {
|
||
await discardDraft(selectedTextId);
|
||
setHasDraft(false);
|
||
setRestoredDraft(false);
|
||
setDraftBaseVersionId(latestManualVersionId ?? null);
|
||
if (historySnapshotRef.current) {
|
||
historySnapshotRef.current.hasDraft = false;
|
||
historySnapshotRef.current.restoredDraft = false;
|
||
historySnapshotRef.current.draftBaseVersionId =
|
||
latestManualVersionId ?? null;
|
||
}
|
||
} else {
|
||
await deleteTextVersion(selectedTextId, version.id);
|
||
}
|
||
await refreshVersions();
|
||
|
||
if (version.kind === "manual" && latestManualVersionId === version.id) {
|
||
const manualVersion = await getLatestManualVersion(selectedTextId);
|
||
const resolvedBody = manualVersion?.body ?? "";
|
||
const nextManualId = manualVersion?.id ?? null;
|
||
setLatestManualVersionId(nextManualId);
|
||
setDraftBaseVersionId(nextManualId);
|
||
if (historySnapshotRef.current) {
|
||
historySnapshotRef.current.latestManualVersionId = nextManualId;
|
||
historySnapshotRef.current.draftBaseVersionId = nextManualId;
|
||
}
|
||
if (!hasDraft && !isViewingHistory) {
|
||
setBody(resolvedBody);
|
||
setLastPersistedBody(resolvedBody);
|
||
}
|
||
}
|
||
|
||
if (shouldAutoSelect) {
|
||
if (olderCandidate) {
|
||
applyVersionAsCurrent(olderCandidate);
|
||
} else if (fallbackCandidate) {
|
||
applyVersionAsCurrent(fallbackCandidate);
|
||
} else {
|
||
handleExitHistory();
|
||
}
|
||
}
|
||
},
|
||
[
|
||
applyVersionAsCurrent,
|
||
handleExitHistory,
|
||
hasDraft,
|
||
isViewingHistory,
|
||
historyOpen,
|
||
latestManualVersionId,
|
||
refreshVersions,
|
||
selectedTextId,
|
||
historyItems,
|
||
viewingVersion
|
||
]
|
||
);
|
||
|
||
const handleConfirm = useCallback(async () => {
|
||
if (!confirmState) return;
|
||
const action = confirmState.onConfirm;
|
||
setConfirmState(null);
|
||
try {
|
||
await action();
|
||
} catch (error) {
|
||
console.error("Delete failed", error);
|
||
}
|
||
}, [confirmState]);
|
||
|
||
const handleTitleBlur = useCallback(async () => {
|
||
if (!selectedTextId || isViewingHistory) return;
|
||
const normalizedTitle = title.trim() || DEFAULT_TITLE;
|
||
if (normalizedTitle !== title) {
|
||
setTitle(normalizedTitle);
|
||
applyTitleUpdate(selectedTextId, normalizedTitle);
|
||
}
|
||
if (normalizedTitle === lastPersistedTitle) return;
|
||
await updateTextTitle(selectedTextId, normalizedTitle);
|
||
setLastPersistedTitle(normalizedTitle);
|
||
await refreshTexts();
|
||
}, [
|
||
applyTitleUpdate,
|
||
isViewingHistory,
|
||
lastPersistedTitle,
|
||
refreshTexts,
|
||
selectedTextId,
|
||
title
|
||
]);
|
||
|
||
const handleToggleHistory = useCallback(() => {
|
||
if (historyOpen && viewingVersion) {
|
||
const snapshot = historySnapshotRef.current;
|
||
if (snapshot) {
|
||
setBody(snapshot.body);
|
||
setLastPersistedBody(snapshot.lastPersistedBody);
|
||
setLastPersistedTitle(snapshot.lastPersistedTitle);
|
||
setHasDraft(snapshot.hasDraft);
|
||
setRestoredDraft(snapshot.restoredDraft);
|
||
setDraftBaseVersionId(snapshot.draftBaseVersionId);
|
||
setLatestManualVersionId(snapshot.latestManualVersionId);
|
||
}
|
||
setViewingVersion(null);
|
||
historySnapshotRef.current = null;
|
||
}
|
||
setHistoryOpen((prev) => !prev);
|
||
}, [historyOpen, viewingVersion]);
|
||
|
||
useEffect(() => {
|
||
const handleKeyDown = (event: KeyboardEvent) => {
|
||
const isSave =
|
||
(event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "s";
|
||
if (isSave) {
|
||
event.preventDefault();
|
||
handleSaveVersion().catch((error) => {
|
||
console.error("Failed to save version", error);
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (
|
||
event.key === "Tab" &&
|
||
!event.metaKey &&
|
||
!event.ctrlKey &&
|
||
!event.altKey &&
|
||
selectedTextId &&
|
||
!settingsOpen &&
|
||
!confirmState
|
||
) {
|
||
event.preventDefault();
|
||
setMarkdownPreview((value) => !value);
|
||
}
|
||
};
|
||
|
||
window.addEventListener("keydown", handleKeyDown);
|
||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||
}, [confirmState, handleSaveVersion, selectedTextId, settingsOpen]);
|
||
|
||
return (
|
||
<div className={`app app--theme-${theme}${sidebarCollapsed ? " app--sidebar-collapsed" : ""}`}>
|
||
{!sidebarCollapsed ? (
|
||
<aside className="sidebar">
|
||
<div className="sidebar__header">
|
||
<div className="sidebar__title-row">
|
||
<div className="app-title">TextDB</div>
|
||
<div className="sidebar__actions">
|
||
<button
|
||
className="icon-button icon-button--ghost"
|
||
onClick={() => setSettingsOpen(true)}
|
||
aria-label="Open settings"
|
||
title="Settings"
|
||
type="button"
|
||
>
|
||
<span className="icon-button__glyph icon-button__glyph--large" aria-hidden="true">⚙</span>
|
||
</button>
|
||
<button
|
||
className="icon-button icon-button--ghost"
|
||
onClick={() => setSidebarCollapsed(true)}
|
||
aria-label="Collapse sidebar"
|
||
title="Collapse sidebar"
|
||
type="button"
|
||
>
|
||
<span aria-hidden="true"><</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<input
|
||
className="search"
|
||
placeholder="Search texts"
|
||
value={search}
|
||
onChange={(event) => setSearch(event.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="prompt-list">
|
||
<div className="prompt-list__inner">
|
||
{loadingTexts ? (
|
||
<div className="empty">Loading texts…</div>
|
||
) : texts.length === 0 ? (
|
||
<div className="empty">No texts yet.</div>
|
||
) : (
|
||
texts.map((text) => (
|
||
<div
|
||
key={text.id}
|
||
className={`prompt-item${
|
||
text.id === selectedTextId ? " is-active" : ""
|
||
}`}
|
||
onClick={() => setSelectedTextId(text.id)}
|
||
role="button"
|
||
tabIndex={0}
|
||
onKeyDown={(event) => {
|
||
if (event.key === "Enter" || event.key === " ") {
|
||
event.preventDefault();
|
||
setSelectedTextId(text.id);
|
||
}
|
||
}}
|
||
>
|
||
<div className="prompt-item__content">
|
||
<div className="prompt-item__title">{text.title}</div>
|
||
<div className="prompt-item__meta">
|
||
Updated {formatDate(text.updated_at)}
|
||
</div>
|
||
</div>
|
||
<button
|
||
className="prompt-item__delete"
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
setConfirmState({
|
||
title: "Delete text",
|
||
message: `Delete \"${text.title}\"? This removes all versions and drafts.`,
|
||
actionLabel: "Delete text",
|
||
onConfirm: () => handleDeleteText(text.id)
|
||
});
|
||
}}
|
||
aria-label="Delete text"
|
||
title="Delete text"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div className="sidebar__footer">
|
||
<button className="button button--primary" onClick={handleNewText}>
|
||
New Text
|
||
</button>
|
||
<button className="button" onClick={handleOpenText}>
|
||
Open Text
|
||
</button>
|
||
</div>
|
||
</aside>
|
||
) : null}
|
||
|
||
<main className="workspace">
|
||
{sidebarCollapsed ? (
|
||
<div className="workspace__topbar">
|
||
<button
|
||
className="icon-button icon-button--ghost"
|
||
onClick={() => setSidebarCollapsed(false)}
|
||
aria-label="Expand sidebar"
|
||
title="Expand sidebar"
|
||
type="button"
|
||
>
|
||
<span aria-hidden="true">></span>
|
||
</button>
|
||
</div>
|
||
) : null}
|
||
{!selectedTextId ? (
|
||
<div className="empty-state">
|
||
<div className="empty-state__title">Create your first text</div>
|
||
<div className="empty-state__subtitle">
|
||
Everything stays offline in a single SQLite database.
|
||
</div>
|
||
<button className="button button--primary" onClick={handleNewText}>
|
||
New Text
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div
|
||
className={`workspace__content${
|
||
historyOpen ? " workspace__content--history" : ""
|
||
}`}
|
||
>
|
||
<section className="editor">
|
||
<div className="editor__header">
|
||
<div className="editor__title-row">
|
||
<input
|
||
className="title-input"
|
||
value={title}
|
||
onChange={handleTitleChange}
|
||
onBlur={handleTitleBlur}
|
||
placeholder="Text title"
|
||
disabled={isViewingHistory}
|
||
/>
|
||
<button
|
||
className={`icon-button${historyOpen ? " is-active" : ""}`}
|
||
onClick={handleToggleHistory}
|
||
aria-label={historyOpen ? "Close history" : "Open history"}
|
||
title={historyOpen ? "Close history" : "Open history"}
|
||
type="button"
|
||
>
|
||
<img src={historyIconSrc} alt="" className="icon-button__img" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
className={`editor__textarea-wrap${
|
||
markdownPreview ? " editor__textarea-wrap--preview" : ""
|
||
}`}
|
||
>
|
||
{showLineNumbersActive ? (
|
||
<div className="line-measure" ref={measureRef} aria-hidden="true">
|
||
{lines.map((line, index) => (
|
||
<div key={index} className="line-measure__line">
|
||
{line.length > 0 ? line : " "}
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
{showLineNumbersActive ? (
|
||
<div className="line-numbers" ref={lineNumbersRef}>
|
||
{lineNumbers.map((line, index) => (
|
||
<div
|
||
key={line}
|
||
className="line-numbers__line"
|
||
style={{ height: lineHeights[index] ? `${lineHeights[index]}px` : undefined }}
|
||
>
|
||
{line}
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
{markdownPreview ? (
|
||
<div
|
||
className="markdown-preview md-root"
|
||
dangerouslySetInnerHTML={{ __html: markdownToHTML(body) }}
|
||
onClick={handleMarkdownPreviewClick}
|
||
/>
|
||
) : (
|
||
<textarea
|
||
ref={textareaRef}
|
||
className="editor__textarea"
|
||
value={body}
|
||
onChange={(event) => setBody(event.target.value)}
|
||
onScroll={handleTextareaScroll}
|
||
placeholder="Write your text here…"
|
||
readOnly={isViewingHistory}
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
<div className="editor__footer">
|
||
{hasText ? (
|
||
<>
|
||
<button
|
||
className="button"
|
||
type="button"
|
||
onClick={() => setMarkdownPreview((value) => !value)}
|
||
>
|
||
{markdownPreview ? "Edit" : "Preview Markdown"}
|
||
</button>
|
||
<button className="button" onClick={handleExportText}>
|
||
Export Text
|
||
</button>
|
||
{markdownPreview ? (
|
||
<>
|
||
<button className="button" type="button" onClick={handlePrintMarkdown}>
|
||
Print
|
||
</button>
|
||
</>
|
||
) : null}
|
||
</>
|
||
) : null}
|
||
{hasDraft && !isViewingHistory ? (
|
||
<button
|
||
className="button"
|
||
onClick={() =>
|
||
setConfirmState({
|
||
title: "Discard draft",
|
||
message: "Discard this draft? This cannot be undone.",
|
||
actionLabel: "Discard draft",
|
||
onConfirm: handleDiscardDraft
|
||
})
|
||
}
|
||
>
|
||
Discard Draft
|
||
</button>
|
||
) : null}
|
||
<div className="editor__footer-status">
|
||
<div className="status-line">
|
||
<span className={`status status--${statusKey}`}></span>
|
||
{statusLabel}
|
||
</div>
|
||
</div>
|
||
<button
|
||
className="button button--primary button--save"
|
||
onClick={handleSaveVersion}
|
||
disabled={!canSave}
|
||
>
|
||
Save Version (⌘S)
|
||
</button>
|
||
</div>
|
||
</section>
|
||
|
||
{historyOpen ? (
|
||
<aside className="history">
|
||
<div className="history__header">
|
||
<span>History</span>
|
||
<button
|
||
className="history__close"
|
||
onClick={handleToggleHistory}
|
||
aria-label="Close history"
|
||
title="Close history"
|
||
type="button"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
<div className="history__list">
|
||
{historyItems.length === 0 ? (
|
||
<div className="empty">No versions yet.</div>
|
||
) : (
|
||
historyItems.map((version) => (
|
||
<div
|
||
key={version.id}
|
||
className={`history__item${
|
||
selectedHistoryId === version.id ? " is-active" : ""
|
||
}`}
|
||
onClick={() => applyVersionAsCurrent(version)}
|
||
role="button"
|
||
tabIndex={0}
|
||
onKeyDown={(event) => {
|
||
if (event.key === "Enter" || event.key === " ") {
|
||
event.preventDefault();
|
||
applyVersionAsCurrent(version);
|
||
}
|
||
}}
|
||
>
|
||
<div className="history__item-content">
|
||
<div className="history__item-title">
|
||
{formatDate(version.created_at)}
|
||
</div>
|
||
<div className="history__item-meta">
|
||
{version.kind === "draft" ? "Draft" : "Manual save"}
|
||
</div>
|
||
</div>
|
||
<button
|
||
className="history__item-delete"
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
setConfirmState({
|
||
title:
|
||
version.kind === "draft"
|
||
? "Discard draft"
|
||
: "Delete version",
|
||
message:
|
||
version.kind === "draft"
|
||
? "Discard this draft? This cannot be undone."
|
||
: "Delete this version? This cannot be undone.",
|
||
actionLabel:
|
||
version.kind === "draft"
|
||
? "Discard draft"
|
||
: "Delete version",
|
||
onConfirm: () => handleDeleteVersion(version)
|
||
});
|
||
}}
|
||
aria-label={
|
||
version.kind === "draft"
|
||
? "Discard draft"
|
||
: "Delete version"
|
||
}
|
||
title={
|
||
version.kind === "draft"
|
||
? "Discard draft"
|
||
: "Delete version"
|
||
}
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
</aside>
|
||
) : null}
|
||
</div>
|
||
)}
|
||
</main>
|
||
|
||
{settingsOpen ? (
|
||
<div className="settings-overlay">
|
||
<div
|
||
className="settings-overlay__backdrop"
|
||
onClick={() => setSettingsOpen(false)}
|
||
/>
|
||
<div className="settings-panel" role="dialog" aria-modal="true">
|
||
<div className="settings-panel__header">
|
||
<div className="settings-panel__title">Settings</div>
|
||
<button
|
||
className="icon-button icon-button--ghost"
|
||
onClick={() => setSettingsOpen(false)}
|
||
aria-label="Close settings"
|
||
title="Close settings"
|
||
type="button"
|
||
>
|
||
<span aria-hidden="true">×</span>
|
||
</button>
|
||
</div>
|
||
<div className="settings-panel__section">
|
||
<label className="settings-panel__label" htmlFor="theme-select">
|
||
Theme
|
||
</label>
|
||
<select
|
||
id="theme-select"
|
||
className="settings-panel__select"
|
||
value={theme}
|
||
onChange={(event) =>
|
||
setTheme(event.target.value as "default" | "light")
|
||
}
|
||
>
|
||
<option value="default">Default</option>
|
||
<option value="light">Bright</option>
|
||
</select>
|
||
</div>
|
||
<div className="settings-panel__section settings-panel__section--row">
|
||
<label className="settings-panel__label" htmlFor="line-numbers-toggle">
|
||
Line numbers
|
||
</label>
|
||
<input
|
||
id="line-numbers-toggle"
|
||
type="checkbox"
|
||
checked={showLineNumbers}
|
||
onChange={(event) => setShowLineNumbers(event.target.checked)}
|
||
/>
|
||
</div>
|
||
<div className="settings-panel__section">
|
||
<label className="settings-panel__label" htmlFor="text-size">
|
||
Text size
|
||
</label>
|
||
<div className="settings-panel__slider-row">
|
||
<input
|
||
id="text-size"
|
||
className="settings-panel__range"
|
||
type="range"
|
||
min={12}
|
||
max={18}
|
||
step={1}
|
||
value={textSize}
|
||
onChange={(event) => setTextSize(Number(event.target.value))}
|
||
/>
|
||
<div className="settings-panel__value">{textSize}px</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{confirmState ? (
|
||
<div className="modal">
|
||
<div className="modal__overlay" onClick={() => setConfirmState(null)} />
|
||
<div className="modal__card" role="dialog" aria-modal="true">
|
||
<div className="modal__title">{confirmState.title}</div>
|
||
<div className="modal__message">{confirmState.message}</div>
|
||
<div className="modal__actions">
|
||
<button className="button" onClick={() => setConfirmState(null)}>
|
||
Cancel
|
||
</button>
|
||
<button className="button button--danger" onClick={handleConfirm}>
|
||
{confirmState.actionLabel ?? "Delete"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|