/* eslint-disable @typescript-eslint/no-explicit-any */ /** * "Try FineNotes in your browser": FineNotes' own notebook view (InkView: * toolbar, pens, shapes, lasso, text, tables, page sidebar, paper picker) on * the welcome tour notebook, hosted in a plain page instead of Obsidian. * * Nothing leaves the browser. The notebook the visitor writes in is kept in * this browser's localStorage, when it allows that, until they press Reset. * * Original code for this site. GPL-3.0-or-later. */ import { Notice, Platform, getIcon, missingIconNames, pushScope, setShimErrorHandler, type TFile } from "obsidian"; import { InkView } from "@finenotes/view/ink-view"; import { DEFAULT_SETTINGS } from "@finenotes/settings-data"; import { buildInkFile } from "@finenotes/model/serialize"; import { WELCOME_TITLE, buildWelcomeNotebook, welcomeStickerFiles } from "@finenotes/model/welcome"; import { PLUGIN_URL, PlaygroundApp, inThePlugin, playgroundPlugin } from "./host"; import { installFingerDraw } from "./finger-draw"; import { watchSectionTabs } from "./section-tabs"; const KEEP_KEY = "finenotes-playground:notebook"; /** localStorage holds about 5 MB per site; stay well inside it. */ const KEEP_LIMIT = 4_000_000; const FOLDER = WELCOME_TITLE; const NOTEBOOK = `${FOLDER}/${WELCOME_TITLE}.notebook.md`; // --- Failures become a notice, never a broken page ---------------------------------------- let lastFailure = 0; function failed(error: unknown): void { console.warn("FineNotes playground:", error); if (Date.now() - lastFailure < 4000) return; lastFailure = Date.now(); inThePlugin("That part"); } setShimErrorHandler(failed); window.addEventListener("unhandledrejection", (event) => { event.preventDefault(); failed(event.reason); }); window.addEventListener("error", (event) => { if (!event.error) return; // a resource that did not load event.preventDefault(); failed(event.error); }); // --- Keeping the visitor's notebook ----------------------------------------------------------- interface Kept { notebook: string; /** Pictures the visitor added: path → base64. */ pictures: Record; } function encode64(data: ArrayBuffer): string { const bytes = new Uint8Array(data); const chunks: string[] = []; for (let at = 0; at < bytes.length; at += 32768) chunks.push(String.fromCharCode.apply(null, Array.from(bytes.subarray(at, at + 32768)))); return btoa(chunks.join("")); } function decode64(text: string): Uint8Array { const raw = atob(text); const out = new Uint8Array(raw.length); for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i); return out; } function readKept(): Kept | null { try { const kept = JSON.parse(localStorage.getItem(KEEP_KEY) ?? "null") as Kept | null; return kept && typeof kept.notebook === "string" ? kept : null; } catch { return null; } } let keeping = true; function keep(app: PlaygroundApp, notebook: string): void { if (!keeping) return; const kept: Kept = { notebook, pictures: {} }; let size = notebook.length; for (const [path, data] of app.vault.entries()) { if (path === NOTEBOOK || stickerPaths.has(path)) continue; size += Math.ceil(data.byteLength / 3) * 4; if (size > KEEP_LIMIT) break; kept.pictures[path] = encode64(data); } try { localStorage.setItem(KEEP_KEY, JSON.stringify(kept)); } catch { // Private browsing, or full: this visit is simply not kept. } } function forgetKept(): void { keeping = false; try { for (let i = localStorage.length - 1; i >= 0; i--) { const key = localStorage.key(i); if (key?.startsWith("finenotes-playground:")) localStorage.removeItem(key); } } catch { // Nothing was kept. } } // --- The notebook ------------------------------------------------------------------------------- const stickerPaths = new Set(); /** The tour's stickers and toolbar picture, written beside the notebook as the plugin does. */ function writeStickers(app: PlaygroundApp): Record { const where: Record = {}; const iconMarkup = (name: string): string | null => getIcon(name)?.innerHTML ?? null; for (const sticker of welcomeStickerFiles(iconMarkup)) { const path = `${FOLDER}/${sticker.name}.svg`; app.vault.store(path, new TextEncoder().encode(sticker.svg)); stickerPaths.add(path); where[sticker.key] = path; } return where; } function bodyClasses(): void { const classes = ["theme-light"]; if (Platform.isMobile) classes.push("is-mobile", Platform.isPhone ? "is-phone" : "is-tablet"); document.body.classList.add(...classes); } async function open(): Promise { bodyClasses(); const app = new PlaygroundApp(); pushScope(app.scope); const stickers = writeStickers(app); const kept = readKept(); for (const [path, data] of Object.entries(kept?.pictures ?? {})) { try { app.vault.store(path, decode64(data)); } catch { // A picture that did not survive is shown as missing. } } const text = kept?.notebook ?? buildInkFile("", buildWelcomeNotebook(WELCOME_TITLE, undefined, stickers)); const file: TFile = app.vault.store(NOTEBOOK, new TextEncoder().encode(text)); app.activeFile = file; const settings = { ...DEFAULT_SETTINGS, // The on-device handwriting reader is left out of the playground. handwritingOnDevice: false, autoRecognize: false, scribbleNoticeShown: true, }; const plugin = playgroundPlugin(app, settings); const view: any = new InkView({ app } as never, plugin); app.activeView = view; // What needs Obsidian, an account or the network says so instead. view.openAiMenu = () => inThePlugin("AI (transcription, Ask AI, pictures)"); view.exportPdf = () => inThePlugin("Export as PDF"); view.copyHomeLink = () => inThePlugin("Home screen links"); const imageMenu = view.openImageMenu.bind(view); view.openImageMenu = (anchor: HTMLElement, pdf?: boolean) => pdf ? inThePlugin("Importing PDFs") : imageMenu(anchor, pdf); // Every save the view makes lands in the vault; keep it. let keepTimer = 0; app.vault.on("modify", (changed: TFile) => { if (changed !== file) return; window.clearTimeout(keepTimer); keepTimer = window.setTimeout(() => void app.vault.read(file).then((t) => keep(app, t)), 300); }); app.vault.on("create", () => { window.clearTimeout(keepTimer); keepTimer = window.setTimeout(() => void app.vault.read(file).then((t) => keep(app, t)), 300); }); // Leaving the page: write now, synchronously, as Obsidian does on quit. const writeNow = (): void => { try { const now = view.getViewData() as string; app.vault.store(NOTEBOOK, new TextEncoder().encode(now)); keep(app, now); } catch { // Not kept. } }; window.addEventListener("pagehide", writeNow); document.addEventListener("visibilitychange", () => { if (document.visibilityState === "hidden") writeNow(); }); const root = document.getElementById("play-root")!; const pane = view.containerEl as HTMLElement; pane.classList.add("play-pane"); root.appendChild(pane); view.file = file; await view.onOpen(); view.load(); view.data = text; view.setViewData(text, true); if (view.scope) pushScope(view.scope); if (view.audio) { view.audio.toggleRecording = () => inThePlugin("Recording audio"); view.audio.transcribeFromMenu = () => inThePlugin("Transcription"); } watchSectionTabs(view, root); installFingerDraw(); wireBar(); (window as any).__playView = view; (window as any).__playMissingIcons = missingIconNames; document.body.classList.add("play-ready"); } function wireBar(): void { document.getElementById("play-reset")?.addEventListener("click", () => { forgetKept(); location.reload(); }); const get = document.getElementById("play-get") as HTMLAnchorElement | null; if (get) get.href = PLUGIN_URL; } open().catch((error) => { console.error(error); new Notice("The FineNotes playground could not start in this browser.", 0); });