feat: Add Focus Mode toggle for distraction-free writing
This commit is contained in:
1 parent
ac23f67e8a
commit
437887207c
4 files changed
+996
-41
No files matched your search
+41
-41
@@ -55,9 +55,34 @@ kbd{font:500 10.5px/1 var(--f-mono);padding:3px 5px;border-radius:4px;background
|
||||
/* shell */
|
||||
.app{height:100%;display:grid;grid-template-columns:268px minmax(0,1fr);gap:10px;padding:10px;transition:grid-template-columns .5s var(--ease),gap .5s var(--ease)}
|
||||
.app.collapsed{grid-template-columns:0px minmax(0,1fr);gap:0}
|
||||
.app.focus-mode{grid-template-columns:0px minmax(0,1fr);gap:0}
|
||||
.island{background:var(--island);border-radius:12px;box-shadow:0 0 0 1px var(--line);min-width:0;min-height:0}
|
||||
.sidebar{overflow:hidden;transition:opacity .35s var(--ease),transform .45s var(--ease)}
|
||||
.app.collapsed .sidebar{opacity:0;pointer-events:none}
|
||||
.app.focus-mode .sidebar{opacity:0;pointer-events:none}
|
||||
.app.focus-mode .topbar {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.4s ease;
|
||||
}
|
||||
.app.focus-mode .topbar:hover, .app.focus-mode .topbar:focus-within {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.app.focus-mode .main {
|
||||
background: var(--island);
|
||||
}
|
||||
.app.focus-mode .scroller {
|
||||
padding-top: 10px;
|
||||
}
|
||||
.app.focus-mode .topbar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.sb-inner{width:268px;height:100%;display:flex;flex-direction:column;padding:10px;gap:2px}
|
||||
.btn-icon{width:30px;height:30px;display:grid;place-items:center;border-radius:6px;color:var(--ink-2);transition:background .15s,color .15s,transform .2s var(--spring);flex:none}
|
||||
.btn-icon:hover{background:var(--hover);color:var(--ink)}
|
||||
@@ -336,7 +361,9 @@ pre.c{margin:0;padding:6px 18px 16px;font:calc(var(--fs) * .84)/1.7 var(--f-mono
|
||||
.kaddcol:hover{background:var(--hover);color:var(--ink)}
|
||||
.kdrop{position:absolute;left:4px;right:4px;height:3px;border-radius:3px;background:var(--blush);pointer-events:none}
|
||||
.kdrop-col{position:absolute;top:0;bottom:0;width:3px;border-radius:3px;background:var(--blush);pointer-events:none}
|
||||
.kghost{position:fixed;left:0;top:0;z-index:90;pointer-events:none;opacity:.95;box-shadow:0 0 0 1px var(--line-2),var(--shadow)!important;overflow:hidden}
|
||||
/* The floating copy is a clone of a card or column, which carry hover and move transitions: none of those may apply,
|
||||
or it would slide in from the corner and trail behind the pointer. */
|
||||
.kghost{position:fixed;left:0;top:0;z-index:90;pointer-events:none;opacity:.95;box-shadow:0 0 0 1px var(--line-2),var(--shadow)!important;overflow:hidden;transition:none!important;animation:none!important;will-change:transform}
|
||||
.kghost .kmore,.kghost .kh-btn{display:none}
|
||||
body.board-dragging,body.board-dragging *{cursor:grabbing!important;user-select:none!important}
|
||||
.pop.kmenu{width:220px;transform-origin:top left;z-index:75}
|
||||
@@ -663,7 +690,7 @@ body.page-dragging,body.page-dragging *{cursor:grabbing!important;user-select:no
|
||||
::view-transition-old(root),::view-transition-new(root){animation:none;mix-blend-mode:normal}
|
||||
|
||||
@media (max-width:760px){
|
||||
.app,.app.collapsed{grid-template-columns:minmax(0,1fr);gap:0;padding:8px}
|
||||
.app,.app.collapsed,.app.focus-mode{grid-template-columns:minmax(0,1fr);gap:0;padding:8px}
|
||||
.sidebar{position:fixed;top:8px;bottom:8px;left:8px;z-index:60;width:min(284px,calc(100% - 56px));transform:translateX(calc(-100% - 20px));box-shadow:0 0 0 1px var(--line-2),var(--shadow)}
|
||||
.app.collapsed .sidebar{opacity:1;pointer-events:auto}
|
||||
.app.drawer .sidebar{transform:none}
|
||||
@@ -713,45 +740,18 @@ body.page-dragging,body.page-dragging *{cursor:grabbing!important;user-select:no
|
||||
.welcome-logo{width:72px;height:72px;margin:-8px 0 -4px -8px}
|
||||
.ws-mark.big{width:44px;height:44px;border-radius:9px;font-size:19px}
|
||||
|
||||
/* Math and Mermaid */
|
||||
.math, .mermaid-wrap {
|
||||
position: relative;
|
||||
margin: 12px 0;
|
||||
border-radius: 12px;
|
||||
background: var(--ink-bg-2);
|
||||
border: 1px solid var(--ink-border);
|
||||
overflow: hidden;
|
||||
}
|
||||
.math-preview, .mermaid-preview {
|
||||
padding: 16px;
|
||||
background: var(--ink-bg);
|
||||
border-bottom: 1px solid var(--ink-border);
|
||||
text-align: center;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.mermaid-preview svg {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
.math .code-bar, .mermaid-wrap .code-bar {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
transform: translateY(-100%);
|
||||
display: flex;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.math:focus-within .code-bar, .mermaid-wrap:focus-within .code-bar {
|
||||
opacity: 1;
|
||||
}
|
||||
.math .code-c, .mermaid-wrap .code-c {
|
||||
padding: 12px 16px;
|
||||
background: var(--ink-bg-2);
|
||||
font: 0.85em var(--font-mono);
|
||||
outline: none;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
/* Math and Mermaid: the rendered result on top, its source underneath */
|
||||
.math,.mermaid-wrap{flex:1;min-width:0;margin:6px 0;border-radius:9px;background:var(--code-bg);box-shadow:0 0 0 1px var(--line);overflow:hidden}
|
||||
.b-math,.b-mermaid{--gt:14px}
|
||||
.math-preview,.mermaid-preview{padding:18px 16px;background:var(--island-2);text-align:center;overflow-x:auto;min-height:60px}
|
||||
.math-preview .katex-display{margin:0}
|
||||
.mermaid-preview svg{max-width:100%;height:auto}
|
||||
.preview-note{color:var(--ink-3);font-size:13px}
|
||||
.preview-error{color:var(--danger);font:12.5px/1.5 var(--f-mono);text-align:left;white-space:pre-wrap;word-break:break-word}
|
||||
.math .code-bar,.mermaid-wrap .code-bar{justify-content:flex-start;border-top:1px solid var(--line);padding:8px 14px 0}
|
||||
.math .lang,.mermaid-wrap .lang{font:500 11px var(--f-mono);color:var(--ink-3)}
|
||||
.math pre.c,.mermaid-wrap pre.c{font-variant-ligatures:none} /* "-->" must stay what you typed */
|
||||
.math pre.c:empty::before,.mermaid-wrap pre.c:empty::before{content:attr(data-ph);color:var(--ink-3)}
|
||||
|
||||
/* custom properties */
|
||||
.custom-pk, .custom-pv {
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
<script>
|
||||
import Icon from './Icon.svelte';
|
||||
import WindowControls from './WindowControls.svelte';
|
||||
import { store, iconOf } from '$lib/state.svelte.js';
|
||||
import { api, isDesktop, platform } from '$lib/api.js';
|
||||
import { toast } from '$lib/toast.svelte.js';
|
||||
|
||||
let menuOpen = $state(false);
|
||||
let armed = $state(false);
|
||||
let menuEl = $state();
|
||||
let btnEl = $state();
|
||||
|
||||
let trail = $derived(store.currentId ? [...store.ancestors(store.currentId), store.current].filter(Boolean) : []);
|
||||
|
||||
function toggleMenu() {
|
||||
menuOpen = !menuOpen;
|
||||
armed = false;
|
||||
}
|
||||
function run(kind) {
|
||||
if (kind === 'delete') {
|
||||
if (!armed) { armed = true; return; }
|
||||
menuOpen = false;
|
||||
store.removePage();
|
||||
return;
|
||||
}
|
||||
menuOpen = false;
|
||||
if (kind === 'md' || kind === 'html' || kind === 'pdf') store.exportFormat = kind;
|
||||
if (kind === 'board') store.setBoard(!store.isBoard);
|
||||
if (kind === 'reveal') api.reveal(store.currentId);
|
||||
if (kind === 'history') { store.historyOpen = store.currentId; }
|
||||
if (kind === 'path') {
|
||||
api.filePath(store.currentId).then((p) =>
|
||||
navigator.clipboard.writeText(p).then(() => toast('File path copied', 'copy'), () => toast(p, 'file'))
|
||||
);
|
||||
}
|
||||
}
|
||||
function onWindowClick(e) {
|
||||
if (menuOpen && menuEl && !menuEl.contains(e.target) && !btnEl.contains(e.target)) menuOpen = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onclick={onWindowClick} onkeydown={(e) => e.key === 'Escape' && (menuOpen = false)} />
|
||||
|
||||
<header class="topbar">
|
||||
<button class="btn-icon menu-btn" aria-label="Show sidebar" onclick={() => store.toggleSidebar()}><Icon name="panel" /></button>
|
||||
<nav class="crumbs" aria-label="Breadcrumb">
|
||||
{#each trail as p, i (p.id)}
|
||||
{#if i > 0}<span class="sep">/</span>{/if}
|
||||
<button class="crumb" onclick={() => p.id !== store.currentId && store.openPage(p.id)}>
|
||||
<Icon name={iconOf(p)} /><span>{p.id === store.currentId && store.titleDraft !== null ? store.titleDraft || 'Untitled' : p.title}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</nav>
|
||||
{#if store.saving || store.justSaved}
|
||||
<div class="status" class:saving={store.saving} class:visible={store.saving || store.justSaved}>
|
||||
<i class="dot"></i><span>{store.saving ? 'Saving…' : 'Saved to disk'}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<span class="wc">{store.words} {store.words === 1 ? 'word' : 'words'}</span>
|
||||
<button
|
||||
class="btn-icon"
|
||||
class:on={store.outlineOpen && !store.isBoard}
|
||||
aria-label="Outline"
|
||||
aria-pressed={store.outlineOpen && !store.isBoard}
|
||||
title={store.isBoard ? 'Boards have no outline' : `Outline (${platform === 'darwin' ? '⌘⇧O' : 'Ctrl+Shift+O'})`}
|
||||
disabled={!store.doc || store.isBoard}
|
||||
onclick={() => store.toggleOutline()}
|
||||
><Icon name="outline" /></button>
|
||||
<button
|
||||
class="btn-icon"
|
||||
class:on={store.focusMode}
|
||||
aria-label="Focus mode"
|
||||
aria-pressed={store.focusMode}
|
||||
title="Focus mode"
|
||||
onclick={() => store.toggleFocusMode()}
|
||||
><Icon name="maximize" /></button>
|
||||
<button
|
||||
class="btn-icon star-btn"
|
||||
class:on={store.currentId && store.isPinned(store.currentId)}
|
||||
aria-label={store.currentId && store.isPinned(store.currentId) ? 'Remove from favorites' : 'Add to favorites'}
|
||||
title={store.currentId && store.isPinned(store.currentId) ? 'Remove from favorites' : 'Add to favorites'}
|
||||
disabled={!store.doc}
|
||||
onclick={() => store.togglePin()}
|
||||
><Icon name="star" /></button>
|
||||
<button class="btn-icon" bind:this={btnEl} aria-label="Page options" aria-expanded={menuOpen} onclick={toggleMenu} disabled={!store.doc}>
|
||||
<Icon name="dots" class="fat" />
|
||||
</button>
|
||||
<WindowControls />
|
||||
{#if menuOpen}
|
||||
<div class="pop menu" bind:this={menuEl} role="menu">
|
||||
<button class="mi" role="menuitem" onclick={() => run('board')}>
|
||||
<Icon name={store.isBoard ? 'file' : 'board'} />{store.isBoard ? 'Turn into document' : 'Turn into board'}
|
||||
</button>
|
||||
<div class="sepline"></div>
|
||||
<button class="mi" role="menuitem" onclick={() => run('md')}><Icon name="export" />Export as Markdown</button>
|
||||
<button class="mi" role="menuitem" onclick={() => run('html')}><Icon name="export" />Export as HTML</button>
|
||||
<button class="mi" role="menuitem" onclick={() => run('pdf')}><Icon name="export" />Export as PDF</button>
|
||||
|
||||
{#if isDesktop}
|
||||
<button class="mi" role="menuitem" onclick={() => run('reveal')}><Icon name="folder" />Show in folder</button>
|
||||
{/if}
|
||||
<button class="mi" role="menuitem" onclick={() => run('path')}><Icon name="copy" />Copy file path</button>
|
||||
<button class="mi" role="menuitem" onclick={() => run('history')}><Icon name="clock" />Version history</button>
|
||||
<div class="sepline"></div>
|
||||
<button class="mi danger" class:armed role="menuitem" onclick={() => run('delete')}>
|
||||
<Icon name="trash" /><span>{armed ? 'Click again to move to trash' : 'Delete page'}</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
@@ -0,0 +1,767 @@
|
||||
// App state. One store, shared by every component.
|
||||
//
|
||||
// api.read(id) ─> parseMarkdown ─> doc.blocks ─> <Editor> ─> edit(blocks) ─> (600ms) ─> toMarkdown ─> api.write(id)
|
||||
//
|
||||
import { api, errorText } from './api.js';
|
||||
import { parseMarkdown, toMarkdown, blockText, toHtmlDocument } from './markdown.js';
|
||||
import { PAGE_ICONS, COVERS, PATHS, isEmoji } from './icons.js';
|
||||
import { countWords, htmlText } from './text.js';
|
||||
import { toast } from './toast.svelte.js';
|
||||
import { resolveLink, refFor, refPage, headingOf, normHeading, matchesHeading } from './links.js';
|
||||
import { sectionOf } from './embed.js';
|
||||
import { scrollToBlock } from './scroll.js';
|
||||
import { BOARD_KEY, isBoard, parseBoard, boardToMd, defaultBoard, boardFromMarkdown } from './kanban.js';
|
||||
|
||||
const hash = (s) => { let h = 7; for (const ch of String(s)) h = (h * 31 + ch.charCodeAt(0)) | 0; return Math.abs(h); };
|
||||
const byTitle = (a, b) => a.title.localeCompare(b.title, undefined, { numeric: true, sensitivity: 'base' });
|
||||
const lastSegment = (id) => id.slice(id.lastIndexOf('/') + 1);
|
||||
|
||||
// A page's icon: a stroke icon name or an emoji from its front matter, else one picked from its title.
|
||||
export const iconOf = (p) => (p && p.icon && (PATHS[p.icon] || isEmoji(p.icon)) ? p.icon : PAGE_ICONS[hash(p ? p.title : '') % PAGE_ICONS.length]);
|
||||
export const coverOf = (p) => (p && COVERS[p.cover] ? p.cover : Object.keys(COVERS)[hash(p ? p.title : '') % 4]);
|
||||
export const isMobile = () => typeof window !== 'undefined' && window.innerWidth <= 760;
|
||||
|
||||
class Store {
|
||||
vault = $state(null);
|
||||
pages = $state([]);
|
||||
ready = $state(false);
|
||||
currentId = $state(null);
|
||||
doc = $state.raw(null); // { id, title, meta, blocks, exists, version }
|
||||
open = $state({});
|
||||
filterTag = $state(null);
|
||||
theme = $state('dark'); // what's showing: 'dark' | 'light'
|
||||
themePref = $state('dark'); // what was chosen: 'dark' | 'light' | 'system'
|
||||
fontSize = $state(16); // page text, px
|
||||
pageWidth = $state('default'); // 'narrow' | 'default' | 'wide' | 'full'
|
||||
settingsOpen = $state(false);
|
||||
view = $state('page'); // 'page' | 'graph'
|
||||
graph = $state({ local: false, depth: 1, orphans: true, ghosts: false, folders: false, spacing: 1 });
|
||||
collapsed = $state(false);
|
||||
focusMode = $state(false);
|
||||
drawer = $state(false);
|
||||
saving = $state(false);
|
||||
justSaved = $state(false);
|
||||
#justSavedTimer = null;
|
||||
words = $state(0);
|
||||
titleDraft = $state(null);
|
||||
fresh = $state(null);
|
||||
paletteOpen = $state(false);
|
||||
exportFormat = $state(null);
|
||||
menu = $state(null); // right-click menu: { x, y, id } (id null = empty sidebar space)
|
||||
textMenu = $state(null); // right-click menu for text: what Electron reported, plus what was clicked (TextMenu.svelte)
|
||||
paletteQuery = ''; // text to start the search palette with
|
||||
renaming = $state(null); // page id being renamed inline in the sidebar
|
||||
pins = $state([]); // favorite page ids, in the order they were added
|
||||
recent = $state([]); // recently opened page ids, newest first
|
||||
trashOpen = $state(false);
|
||||
historyOpen = $state(null);
|
||||
templatesOpen = $state(null); // null, or { parent }
|
||||
findOpen = $state(0); // find-in-page bar: 0 closed, otherwise bumped on every Ctrl+F to refocus it
|
||||
|
||||
editor = null; // set by <Editor>, not reactive
|
||||
#vaults = {}; // per notes folder: { [path]: { pins, recent } }, kept in settings
|
||||
#blocks = [];
|
||||
headings = $state.raw([]); // outline of the open page: [{ id (block id), level 1-3, text }]
|
||||
#headingBase = []; // headings as the rest of the vault knows them: [{ id, text }] (what [[Page#Heading]] links use)
|
||||
#headingTimer = null;
|
||||
#headingChain = Promise.resolve();
|
||||
outlineOpen = $state(false);
|
||||
#raw = null; // a board's Markdown body; null for ordinary pages, which save from #blocks
|
||||
#dirty = false;
|
||||
#timer = null;
|
||||
#chain = Promise.resolve();
|
||||
#version = 0;
|
||||
|
||||
get current() { return this.pages.find((p) => p.id === this.currentId) || null; }
|
||||
children(parent) { return this.pages.filter((p) => p.parent === parent).sort(byTitle); }
|
||||
ancestors(id) {
|
||||
const out = [];
|
||||
let p = this.pages.find((x) => x.id === id);
|
||||
let parent = p && p.parent;
|
||||
while (parent) {
|
||||
const a = this.pages.find((x) => x.id === parent);
|
||||
if (!a) break;
|
||||
out.unshift(a);
|
||||
parent = a.parent;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
get tagCounts() {
|
||||
const n = {};
|
||||
for (const p of this.pages) for (const t of p.tags || []) n[t] = (n[t] || 0) + 1;
|
||||
return Object.entries(n).sort((a, b) => a[0].localeCompare(b[0]));
|
||||
}
|
||||
relPath(id) { return `${this.vault ? this.vault.name : 'Notes'}/${id}.md`; }
|
||||
|
||||
/* lifecycle */
|
||||
async init() {
|
||||
try {
|
||||
const s = (await api.getSettings()) || {};
|
||||
this.themePref = ['dark', 'light', 'system'].includes(s.theme) ? s.theme : 'dark';
|
||||
this.applyTheme(this.#resolve(this.themePref), false);
|
||||
this.#systemDark.addEventListener('change', () => { if (this.themePref === 'system') this.#transition(this.#resolve('system')); });
|
||||
this.setFontSize(s.fontSize, false);
|
||||
this.setPageWidth(s.pageWidth, false);
|
||||
if (s.graph && typeof s.graph === 'object') this.graph = { ...this.graph, ...s.graph };
|
||||
this.collapsed = !!s.sidebarCollapsed;
|
||||
this.focusMode = !!s.focusMode;
|
||||
this.outlineOpen = !!s.outline;
|
||||
this.open = s.open && typeof s.open === 'object' ? s.open : {};
|
||||
this.#vaults = s.vaults && typeof s.vaults === 'object' ? s.vaults : {};
|
||||
const v = await api.get();
|
||||
if (v) await this.#load(v, s.lastPage);
|
||||
} catch (err) {
|
||||
toast(errorText(err), 'x');
|
||||
}
|
||||
this.ready = true;
|
||||
api.onChanged((ids) => this.#external(ids));
|
||||
api.onFlush(() => this.settle());
|
||||
window.addEventListener('blur', () => this.settle());
|
||||
}
|
||||
|
||||
async #load(v, last) {
|
||||
this.vault = { path: v.path, name: v.name };
|
||||
this.pages = v.pages;
|
||||
const saved = this.#vaults[v.path] || {};
|
||||
this.pins = Array.isArray(saved.pins) ? saved.pins : [];
|
||||
this.recent = Array.isArray(saved.recent) ? saved.recent : [];
|
||||
const first = this.pages.find((p) => p.id === last) || this.children(null).find((p) => p.exists) || this.pages[0];
|
||||
if (first) await this.openPage(first.id);
|
||||
else { this.doc = null; this.currentId = null; }
|
||||
}
|
||||
|
||||
async pick(kind) {
|
||||
try {
|
||||
const v = await api.pick(kind);
|
||||
if (v) await this.#load(v, null);
|
||||
} catch (err) {
|
||||
toast(errorText(err), 'x');
|
||||
}
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
const v = await api.list();
|
||||
if (v) this.pages = v.pages;
|
||||
}
|
||||
|
||||
/* pages */
|
||||
async openPage(id) {
|
||||
this.view = 'page';
|
||||
await this.settle();
|
||||
let r;
|
||||
try { r = await api.read(id); } catch (err) { toast(errorText(err), 'x'); return; }
|
||||
for (const a of this.ancestors(id)) this.open[a.id] = true;
|
||||
this.currentId = id;
|
||||
this.titleDraft = null;
|
||||
this.#blocks = parseMarkdown(r.body);
|
||||
this.#raw = isBoard(r.meta) ? r.body : null;
|
||||
this.#dirty = false;
|
||||
this.doc = { id, title: r.title, meta: r.meta || {}, blocks: this.#blocks, body: r.body, exists: r.exists, version: ++this.#version };
|
||||
this.#derive(true);
|
||||
this.drawer = false;
|
||||
this.recent = [id, ...this.recent.filter((x) => x !== id)].slice(0, 30);
|
||||
this.#persist({ lastPage: id, open: $state.snapshot(this.open) });
|
||||
this.#saveLists();
|
||||
}
|
||||
|
||||
async newPage(parent = null, templateName = null) {
|
||||
await this.flush();
|
||||
const covers = Object.keys(COVERS);
|
||||
try {
|
||||
let meta = {
|
||||
icon: PAGE_ICONS[Math.floor(Math.random() * PAGE_ICONS.length)],
|
||||
cover: covers[Math.floor(Math.random() * covers.length)]
|
||||
};
|
||||
let body = '';
|
||||
let title = 'Untitled';
|
||||
|
||||
if (templateName) {
|
||||
const tpl = await api.getTemplate(templateName);
|
||||
if (tpl) {
|
||||
meta = { ...meta, ...tpl.meta };
|
||||
body = tpl.body;
|
||||
if (templateName !== 'Default') title = templateName;
|
||||
}
|
||||
}
|
||||
|
||||
const id = await api.create(parent, title, meta, body);
|
||||
if (parent) this.open[parent] = true;
|
||||
this.filterTag = null;
|
||||
await this.refresh();
|
||||
this.fresh = id;
|
||||
await this.openPage(id);
|
||||
setTimeout(() => { if (this.fresh === id) this.fresh = null; }, 1500);
|
||||
} catch (err) {
|
||||
toast(errorText(err), 'x');
|
||||
}
|
||||
}
|
||||
|
||||
async removePage(id = this.currentId) {
|
||||
if (!id) return;
|
||||
const page = this.pages.find((p) => p.id === id);
|
||||
const parent = page ? page.parent : null;
|
||||
const within = (x) => x === id || x.startsWith(id + '/');
|
||||
const count = this.pages.filter((p) => within(p.id)).length;
|
||||
const hitsCurrent = !!this.currentId && within(this.currentId);
|
||||
if (hitsCurrent) {
|
||||
clearTimeout(this.#timer);
|
||||
this.#dirty = false;
|
||||
} else {
|
||||
await this.flush();
|
||||
}
|
||||
let entry;
|
||||
try { entry = await api.remove(id); } catch (err) { toast(errorText(err), 'x'); return; }
|
||||
await this.refresh();
|
||||
const undo = entry ? { label: 'Undo', run: () => this.restorePage(entry) } : null;
|
||||
toast(count > 1 ? `Moved ${count} pages to the trash` : 'Moved to the trash', 'trash', undo);
|
||||
if (!hitsCurrent) return;
|
||||
const next = (parent && this.pages.find((p) => p.id === parent)) || this.children(null)[0];
|
||||
if (next) await this.openPage(next.id);
|
||||
else { this.doc = null; this.currentId = null; }
|
||||
}
|
||||
|
||||
async renameTag(oldTag, newTag) {
|
||||
if (!oldTag || !newTag || oldTag === newTag) return;
|
||||
await this.flush();
|
||||
let updated = 0;
|
||||
try {
|
||||
for (const page of this.pages) {
|
||||
if (page.tags && page.tags.includes(oldTag)) {
|
||||
const r = await api.read(page.id);
|
||||
const meta = r.meta || {};
|
||||
const tags = meta.tags || [];
|
||||
const idx = tags.indexOf(oldTag);
|
||||
if (idx !== -1) {
|
||||
tags[idx] = newTag;
|
||||
meta.tags = tags;
|
||||
await api.write(page.id, meta, r.body);
|
||||
updated++;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (updated > 0) {
|
||||
if (this.filterTag === oldTag) this.filterTag = newTag;
|
||||
await this.refresh();
|
||||
if (this.currentId) {
|
||||
const r = await api.read(this.currentId);
|
||||
this.doc = { ...this.doc, meta: r.meta || {}, version: ++this.#version };
|
||||
}
|
||||
toast(`Renamed tag across ${updated} page${updated === 1 ? '' : 's'}`);
|
||||
}
|
||||
} catch (err) {
|
||||
toast('Could not rename tag: ' + errorText(err), 'x');
|
||||
}
|
||||
}
|
||||
|
||||
async renamePage(id, text) {
|
||||
const page = this.pages.find((p) => p.id === id);
|
||||
if (!page) return;
|
||||
const title = String(text || '').replace(/\s+/g, ' ').trim() || 'Untitled';
|
||||
if (title === page.title) return;
|
||||
await this.settle();
|
||||
try {
|
||||
await this.#relocated(id, await api.rename(id, title));
|
||||
} catch (err) {
|
||||
toast('Could not rename: ' + errorText(err), 'x');
|
||||
}
|
||||
}
|
||||
|
||||
// Bring a page back from the trash and open it.
|
||||
async restorePage(entry) {
|
||||
try {
|
||||
const id = await api.restore(entry);
|
||||
await this.refresh();
|
||||
for (const a of this.ancestors(id)) this.open[a.id] = true;
|
||||
await this.openPage(id);
|
||||
toast('Restored', 'check');
|
||||
return id;
|
||||
} catch (err) {
|
||||
toast('Could not restore: ' + errorText(err), 'x');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/* favorites */
|
||||
isPinned(id) { return this.pins.includes(id); }
|
||||
togglePin(id = this.currentId) {
|
||||
if (!id) return;
|
||||
const on = !this.isPinned(id);
|
||||
this.pins = on ? [...this.pins, id] : this.pins.filter((x) => x !== id);
|
||||
this.#saveLists();
|
||||
toast(on ? 'Added to favorites' : 'Removed from favorites', 'star');
|
||||
}
|
||||
get pinned() { return this.pins.map((id) => this.pages.find((p) => p.id === id)).filter(Boolean); }
|
||||
get recentPages() { return this.recent.map((id) => this.pages.find((p) => p.id === id && p.exists)).filter(Boolean); }
|
||||
|
||||
#saveLists() {
|
||||
if (!this.vault) return;
|
||||
this.#vaults = { ...this.#vaults, [this.vault.path]: { pins: $state.snapshot(this.pins), recent: $state.snapshot(this.recent) } };
|
||||
this.#persist({ vaults: this.#vaults });
|
||||
}
|
||||
|
||||
/* daily note: Daily/YYYY-MM-DD */
|
||||
async openDaily() {
|
||||
const d = new Date();
|
||||
const z = (n) => String(n).padStart(2, '0');
|
||||
const id = `Daily/${d.getFullYear()}-${z(d.getMonth() + 1)}-${z(d.getDate())}`;
|
||||
try {
|
||||
await this.flush();
|
||||
await this.refresh();
|
||||
if (!this.pages.some((p) => p.id === 'Daily' && p.exists)) {
|
||||
await api.write('Daily', { icon: 'book', cover: 'sand', created: id.slice(6) }, '');
|
||||
}
|
||||
const fresh = !this.pages.some((p) => p.id === id && p.exists);
|
||||
if (fresh) {
|
||||
const covers = Object.keys(COVERS);
|
||||
await api.write(id, { icon: 'cup', cover: covers[Math.floor(Math.random() * covers.length)], created: id.slice(6) }, '');
|
||||
await this.refresh();
|
||||
}
|
||||
this.open.Daily = true;
|
||||
this.filterTag = null;
|
||||
await this.openPage(id);
|
||||
if (fresh) requestAnimationFrame(() => this.editor?.focusFirst());
|
||||
} catch (err) {
|
||||
toast(errorText(err), 'x');
|
||||
}
|
||||
}
|
||||
|
||||
// Move a page under `parent` (null = top level).
|
||||
async movePage(id, parent) {
|
||||
parent = parent || null;
|
||||
const page = this.pages.find((p) => p.id === id);
|
||||
if (!page || page.parent === parent || parent === id || (parent && parent.startsWith(id + '/'))) return;
|
||||
await this.settle();
|
||||
try {
|
||||
const next = await api.move(id, parent);
|
||||
if (parent) this.open[parent] = true;
|
||||
await this.#relocated(id, next);
|
||||
this.fresh = next;
|
||||
setTimeout(() => { if (this.fresh === next) this.fresh = null; }, 1500);
|
||||
const to = parent && this.pages.find((p) => p.id === parent);
|
||||
toast(to ? `Moved into ${to.title}` : 'Moved to the top level', 'folder');
|
||||
} catch (err) {
|
||||
toast('Could not move: ' + errorText(err), 'x');
|
||||
}
|
||||
}
|
||||
|
||||
// Bookkeeping after page `id` became `next`, together with everything under it.
|
||||
async #relocated(id, next) {
|
||||
if (next === id) return;
|
||||
const move = (k) => (k === id ? next : k.startsWith(id + '/') ? next + k.slice(id.length) : k);
|
||||
const open = {};
|
||||
for (const [k, v] of Object.entries(this.open)) open[move(k)] = v;
|
||||
this.open = open;
|
||||
this.pins = this.pins.map(move);
|
||||
this.recent = this.recent.map(move);
|
||||
this.#saveLists();
|
||||
await this.refresh();
|
||||
this.#persist({ open: $state.snapshot(this.open) });
|
||||
const cur = this.currentId;
|
||||
if (!cur || move(cur) === cur) return;
|
||||
const parentOf = (x) => x.slice(0, Math.max(0, x.lastIndexOf('/')));
|
||||
if (parentOf(move(cur)) !== parentOf(cur)) {
|
||||
// Its folder changed, so image paths resolve differently: load it fresh.
|
||||
this.currentId = null;
|
||||
await this.openPage(move(cur));
|
||||
return;
|
||||
}
|
||||
this.currentId = move(cur);
|
||||
this.doc = { ...this.doc, id: this.currentId, title: lastSegment(this.currentId) };
|
||||
this.#persist({ lastPage: this.currentId });
|
||||
}
|
||||
|
||||
/* links */
|
||||
// [[#Heading]] has no page part: it points into the page you're on.
|
||||
resolveLink(ref) { return refPage(ref) ? resolveLink(ref, this.pages) : this.currentId; }
|
||||
refFor(id) { return refFor(id, this.pages); }
|
||||
|
||||
// Open what a [[link]] points at: its page (made first if it doesn't exist yet), then its heading if it names one.
|
||||
async openLink(ref) {
|
||||
let id = this.resolveLink(ref);
|
||||
if (!id && !refPage(ref)) return; // [[#Heading]] with no page open
|
||||
if (!id) {
|
||||
id = await this.createLinked(refPage(ref).split('/').pop());
|
||||
if (!id) return;
|
||||
}
|
||||
if (id !== this.currentId || this.view !== 'page') await this.openPage(id);
|
||||
const heading = headingOf(ref);
|
||||
if (heading) await this.revealHeading(heading);
|
||||
}
|
||||
|
||||
// Scroll the open page to the heading with this text (matched ignoring case and spacing).
|
||||
async revealHeading(text) {
|
||||
const h = this.headings.find((x) => matchesHeading(text, x.text));
|
||||
if (!h) { toast(`No heading “${text}” on this page`, 'hash'); return false; }
|
||||
// The page may still be drawing after a page switch: wait for the heading's block to exist.
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const scroller = document.querySelector('.main .scroller');
|
||||
const el = scroller && scroller.querySelector(`.block[data-id="${h.id}"]`);
|
||||
if (el) {
|
||||
await new Promise((r) => requestAnimationFrame(() => r()));
|
||||
scrollToBlock(scroller, el);
|
||||
return true;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 40));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// What an ![[embed]] shows. state: 'ok' (blocks, imageUrl) | 'missing' | 'self' | 'board' | 'noheading'.
|
||||
async loadEmbed(ref) {
|
||||
const name = refPage(ref), heading = headingOf(ref);
|
||||
const id = name ? resolveLink(ref, this.pages) : this.currentId;
|
||||
if (!id) return { state: 'missing', name };
|
||||
const page = this.pages.find((p) => p.id === id);
|
||||
const title = page ? page.title : id.slice(id.lastIndexOf('/') + 1);
|
||||
if (id === this.currentId) return { state: 'self', id, title };
|
||||
const r = await api.read(id);
|
||||
if (!r.exists) return { state: 'missing', name: title };
|
||||
if (isBoard(r.meta)) return { state: 'board', id, title };
|
||||
let blocks = parseMarkdown(r.body);
|
||||
if (heading) {
|
||||
const section = sectionOf(blocks, heading);
|
||||
if (!section) return { state: 'noheading', id, title, heading };
|
||||
blocks = section;
|
||||
}
|
||||
return { state: 'ok', id, title, heading, blocks, imageUrl: (src) => api.mediaUrl(id, src) };
|
||||
}
|
||||
|
||||
// Make a top-level page for a link to something that doesn't exist yet. Returns its id.
|
||||
async createLinked(title) {
|
||||
const covers = Object.keys(COVERS);
|
||||
try {
|
||||
const id = await api.create(null, title || 'Untitled', {
|
||||
icon: PAGE_ICONS[Math.floor(Math.random() * PAGE_ICONS.length)],
|
||||
cover: covers[Math.floor(Math.random() * covers.length)]
|
||||
});
|
||||
await this.refresh();
|
||||
return id;
|
||||
} catch (err) {
|
||||
toast(errorText(err), 'x');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async commitTitle(text) {
|
||||
const d = this.doc;
|
||||
this.titleDraft = null;
|
||||
if (!d) return;
|
||||
await this.renamePage(d.id, text);
|
||||
}
|
||||
|
||||
/* boards */
|
||||
get isBoard() { return isBoard(this.doc?.meta); }
|
||||
|
||||
// The board changed: save its Markdown.
|
||||
editBoard(body) {
|
||||
this.#raw = body;
|
||||
this.#blocks = parseMarkdown(body);
|
||||
this.#dirty = true;
|
||||
this.#derive();
|
||||
this.#schedule();
|
||||
}
|
||||
|
||||
// Show the open page as a board (columns from its headings) or back as a document.
|
||||
async setBoard(on) {
|
||||
const d = this.doc;
|
||||
if (!d || on === this.isBoard) return;
|
||||
await this.flush();
|
||||
const meta = { ...d.meta };
|
||||
let body;
|
||||
if (on) {
|
||||
meta[BOARD_KEY] = 'basic';
|
||||
body = boardToMd(boardFromMarkdown(toMarkdown(this.#blocks)));
|
||||
this.#raw = body;
|
||||
} else {
|
||||
delete meta[BOARD_KEY];
|
||||
body = this.#raw ?? toMarkdown(this.#blocks);
|
||||
this.#raw = null;
|
||||
}
|
||||
this.#blocks = parseMarkdown(body);
|
||||
this.#derive(true);
|
||||
this.doc = { ...d, meta, blocks: this.#blocks, body, version: ++this.#version };
|
||||
this.#dirty = true;
|
||||
await this.flush();
|
||||
toast(on ? 'Showing as a board' : 'Showing as a document', on ? 'board' : 'file');
|
||||
}
|
||||
|
||||
async newBoard(parent = null) {
|
||||
await this.flush();
|
||||
try {
|
||||
const id = await api.create(parent, 'Board', { icon: 'board', cover: Object.keys(COVERS)[Math.floor(Math.random() * 4)], [BOARD_KEY]: 'basic' });
|
||||
const meta = (await api.read(id)).meta;
|
||||
await api.write(id, meta, boardToMd(defaultBoard()));
|
||||
if (parent) this.open[parent] = true;
|
||||
this.filterTag = null;
|
||||
await this.refresh();
|
||||
this.fresh = id;
|
||||
await this.openPage(id);
|
||||
setTimeout(() => { if (this.fresh === id) this.fresh = null; }, 1500);
|
||||
} catch (err) {
|
||||
toast(errorText(err), 'x');
|
||||
}
|
||||
}
|
||||
|
||||
/* editing */
|
||||
edit(blocks) {
|
||||
this.#blocks = blocks;
|
||||
this.#dirty = true;
|
||||
this.#derive();
|
||||
this.#schedule();
|
||||
}
|
||||
|
||||
// Word count and heading outline, both read from the blocks.
|
||||
// fresh: the page was just loaded (or replaced from disk), so these headings are the ones links already use.
|
||||
#derive(fresh = false) {
|
||||
this.words = countWords(this.#blocks.map(blockText).join(' '));
|
||||
const next = this.#raw !== null ? [] : this.#blocks
|
||||
.filter((b) => b.type === 'h1' || b.type === 'h2' || b.type === 'h3')
|
||||
.map((b) => ({ id: b.id, level: +b.type[1], text: htmlText(b.html).replace(/\s+/g, ' ').trim() }))
|
||||
.filter((h) => h.text);
|
||||
// Typing inside a heading changes its text on every key, but most keystrokes change nothing here.
|
||||
const changed = JSON.stringify(next) !== JSON.stringify(this.headings);
|
||||
if (changed) this.headings = next;
|
||||
if (fresh) {
|
||||
clearTimeout(this.#headingTimer);
|
||||
this.#headingBase = next.map(({ id, text }) => ({ id, text }));
|
||||
} else if (changed) {
|
||||
// A renamed heading updates the links to it once you stop typing (not after every keystroke).
|
||||
clearTimeout(this.#headingTimer);
|
||||
this.#headingTimer = setTimeout(() => this.#commitHeadingRenames(), 2000);
|
||||
}
|
||||
}
|
||||
|
||||
// Save, and bring links to renamed headings up to date. Used when you leave the page or the window.
|
||||
async settle() {
|
||||
await this.flush();
|
||||
await this.#commitHeadingRenames();
|
||||
}
|
||||
#commitHeadingRenames() {
|
||||
clearTimeout(this.#headingTimer);
|
||||
this.#headingChain = this.#headingChain.then(() => this.#applyHeadingRenames()).catch((err) => toast(errorText(err), 'x'));
|
||||
return this.#headingChain;
|
||||
}
|
||||
// Compare the headings now with the ones links know about; for each heading whose text changed, re-point the
|
||||
// links to it: in the open page (through the editor), and in every other note (on disk).
|
||||
async #applyHeadingRenames() {
|
||||
const id = this.currentId;
|
||||
if (!id || this.#raw !== null) return;
|
||||
const alive = new Set(this.#blocks.map((b) => b.id));
|
||||
const base = this.#headingBase.filter((b) => alive.has(b.id));
|
||||
const was = base.map((b) => b.text);
|
||||
const now = new Map(this.headings.map((h) => [h.id, h]));
|
||||
const renames = [];
|
||||
base.forEach((b, i) => {
|
||||
const h = now.get(b.id);
|
||||
if (!h || h.text === b.text) return;
|
||||
// Links point at the first heading of a name, and a change of case alone leaves them working.
|
||||
const first = !was.slice(0, i).some((t) => normHeading(t) === normHeading(b.text));
|
||||
if (first && normHeading(h.text) !== normHeading(b.text)) renames.push({ from: b.text, to: h.text });
|
||||
b.text = h.text;
|
||||
});
|
||||
const known = new Set(base.map((b) => b.id));
|
||||
for (const h of this.headings) if (!known.has(h.id)) base.push({ id: h.id, text: h.text }); // tracked from now on
|
||||
this.#headingBase = base;
|
||||
if (!renames.length) return;
|
||||
|
||||
// The open page first, before anything else can change it.
|
||||
let links = 0, notes = new Set();
|
||||
for (const r of renames) {
|
||||
const n = this.editor ? this.editor.renameHeadingRefs(r.from, r.to) : 0;
|
||||
if (n) { links += n; notes.add(id); }
|
||||
}
|
||||
for (const r of renames) {
|
||||
try {
|
||||
const out = await api.renameHeading(id, r.from, r.to);
|
||||
links += out.count;
|
||||
out.pages.forEach((p) => notes.add(p));
|
||||
} catch (err) {
|
||||
toast('Could not update links to the renamed heading: ' + errorText(err), 'x');
|
||||
}
|
||||
}
|
||||
if (links) {
|
||||
const to = renames.length === 1 ? `“${renames[0].to}”` : 'the renamed headings';
|
||||
toast(`Updated ${links} link${links === 1 ? '' : 's'} to ${to} in ${notes.size} note${notes.size === 1 ? '' : 's'}`, 'link');
|
||||
}
|
||||
}
|
||||
|
||||
toggleFocusMode() {
|
||||
this.focusMode = !this.focusMode;
|
||||
this.#persist({ focusMode: this.focusMode });
|
||||
}
|
||||
|
||||
toggleOutline(on = !this.outlineOpen) {
|
||||
this.outlineOpen = on;
|
||||
this.#persist({ outline: on });
|
||||
}
|
||||
|
||||
setMeta(patch) {
|
||||
const d = this.doc;
|
||||
if (!d) return;
|
||||
this.doc = { ...d, meta: { ...d.meta, ...patch } };
|
||||
const p = this.current;
|
||||
if (p) for (const k of ['icon', 'cover', 'tags']) if (k in patch) p[k] = patch[k];
|
||||
this.#dirty = true;
|
||||
this.#schedule();
|
||||
}
|
||||
|
||||
#schedule() {
|
||||
this.saving = true;
|
||||
clearTimeout(this.#timer);
|
||||
this.#timer = setTimeout(() => this.flush(), 600);
|
||||
}
|
||||
|
||||
flush() {
|
||||
clearTimeout(this.#timer);
|
||||
this.#chain = this.#chain.then(() => this.#save());
|
||||
return this.#chain;
|
||||
}
|
||||
|
||||
async #save() {
|
||||
const d = this.doc;
|
||||
if (!d || !this.#dirty) { this.saving = false; return; }
|
||||
this.#dirty = false;
|
||||
const body = this.#raw ?? toMarkdown(this.#blocks);
|
||||
try {
|
||||
await api.write(d.id, { ...d.meta }, body);
|
||||
const p = this.pages.find((x) => x.id === d.id);
|
||||
if (p && !p.exists) p.exists = true;
|
||||
this.justSaved = true;
|
||||
clearTimeout(this.#justSavedTimer);
|
||||
this.#justSavedTimer = setTimeout(() => { this.justSaved = false; }, 3000);
|
||||
} catch (err) {
|
||||
this.#dirty = true;
|
||||
toast('Could not save: ' + errorText(err), 'x');
|
||||
}
|
||||
this.saving = this.#dirty;
|
||||
}
|
||||
|
||||
async #external(ids) {
|
||||
await this.refresh();
|
||||
const d = this.doc;
|
||||
if (!d || !ids.includes(d.id) || this.#dirty) return; // a pending local edit wins and overwrites on save
|
||||
const r = await api.read(d.id);
|
||||
if (!r.exists) {
|
||||
const first = this.children(null)[0];
|
||||
if (first) await this.openPage(first.id);
|
||||
else { this.doc = null; this.currentId = null; }
|
||||
return;
|
||||
}
|
||||
if (r.body === (this.#raw ?? toMarkdown(this.#blocks)) && isBoard(r.meta) === this.isBoard) return;
|
||||
this.#blocks = parseMarkdown(r.body);
|
||||
this.#raw = isBoard(r.meta) ? r.body : null;
|
||||
this.#derive(true);
|
||||
this.doc = { ...d, meta: r.meta || {}, blocks: this.#blocks, body: r.body, version: ++this.#version };
|
||||
toast('Updated from disk', 'file');
|
||||
}
|
||||
|
||||
exportText(format) {
|
||||
const d = this.doc;
|
||||
if (!d) return '';
|
||||
return format === 'html' ? toHtmlDocument(d.title, this.#blocks) : `# ${d.title}\n\n${this.#raw ?? toMarkdown(this.#blocks)}`;
|
||||
}
|
||||
|
||||
/* ui */
|
||||
toggleFold(id) {
|
||||
this.open[id] = !this.open[id];
|
||||
this.#persist({ open: $state.snapshot(this.open) });
|
||||
}
|
||||
|
||||
toggleSidebar() {
|
||||
if (isMobile()) { this.drawer = !this.drawer; return; }
|
||||
this.collapsed = !this.collapsed;
|
||||
this.#persist({ sidebarCollapsed: this.collapsed });
|
||||
}
|
||||
|
||||
/* version history */
|
||||
// Put back an earlier version of a page and reload it, so the editor doesn't save the old text over it.
|
||||
async restoreVersion(id, ts) {
|
||||
try {
|
||||
await this.flush();
|
||||
await api.restoreHistory(id, ts);
|
||||
await this.refresh();
|
||||
if (this.currentId === id) { this.currentId = null; await this.openPage(id); }
|
||||
toast('Version restored. The one before is kept in history.', 'clock');
|
||||
} catch (err) {
|
||||
toast('Could not restore: ' + errorText(err), 'x');
|
||||
}
|
||||
}
|
||||
|
||||
/* graph view */
|
||||
openGraph() {
|
||||
this.settle();
|
||||
this.view = 'graph';
|
||||
this.paletteOpen = false;
|
||||
this.findOpen = 0;
|
||||
this.drawer = false;
|
||||
}
|
||||
closeGraph() {
|
||||
// The page editor was unmounted meanwhile: remount it from the latest content, not what was first loaded.
|
||||
if (this.doc) this.doc = { ...this.doc, blocks: this.#blocks, body: this.#raw ?? this.doc.body, version: ++this.#version };
|
||||
this.view = 'page';
|
||||
}
|
||||
setGraph(patch) {
|
||||
this.graph = { ...this.graph, ...patch };
|
||||
this.#persist({ graph: $state.snapshot(this.graph) });
|
||||
}
|
||||
|
||||
/* appearance */
|
||||
#systemDark = typeof matchMedia === 'function' ? matchMedia('(prefers-color-scheme: dark)') : { matches: true, addEventListener() {} };
|
||||
#resolve(pref) { return pref === 'system' ? (this.#systemDark.matches ? 'dark' : 'light') : pref; }
|
||||
|
||||
applyTheme(t, persist = true) {
|
||||
this.theme = t;
|
||||
document.documentElement.dataset.theme = t;
|
||||
if (persist) { this.themePref = t; this.#persist({ theme: t }); }
|
||||
}
|
||||
|
||||
// Choose 'dark', 'light' or 'system' (follows the desktop). `origin`: element the theme spreads out from.
|
||||
setThemePref(pref, origin) {
|
||||
this.themePref = pref;
|
||||
this.#persist({ theme: pref });
|
||||
this.#transition(this.#resolve(pref), origin);
|
||||
}
|
||||
|
||||
toggleTheme(origin) {
|
||||
this.setThemePref(this.theme === 'dark' ? 'light' : 'dark', origin);
|
||||
}
|
||||
|
||||
#transition(next, origin) {
|
||||
if (next === this.theme) return;
|
||||
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
if (!document.startViewTransition || reduce || !origin) return this.applyTheme(next, false);
|
||||
const r = origin.getBoundingClientRect();
|
||||
const x = r.left + r.width / 2, y = r.top + r.height / 2;
|
||||
const radius = Math.hypot(Math.max(x, innerWidth - x), Math.max(y, innerHeight - y));
|
||||
const vt = document.startViewTransition(() => this.applyTheme(next, false));
|
||||
vt.ready
|
||||
.then(() => document.documentElement.animate(
|
||||
{ clipPath: [`circle(0px at ${x}px ${y}px)`, `circle(${radius}px at ${x}px ${y}px)`] },
|
||||
{ duration: 650, easing: 'cubic-bezier(.2,.8,.2,1)', pseudoElement: '::view-transition-new(root)' }
|
||||
))
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
static FONT = { min: 13, max: 22, base: 16 };
|
||||
static WIDTHS = { narrow: '620px', default: '740px', wide: '920px', full: 'none' };
|
||||
|
||||
setFontSize(px, persist = true) {
|
||||
const { min, max, base } = Store.FONT;
|
||||
const n = Math.round(Number(px));
|
||||
this.fontSize = Number.isFinite(n) ? Math.min(max, Math.max(min, n)) : base;
|
||||
document.documentElement.style.setProperty('--fs', this.fontSize + 'px');
|
||||
if (persist) this.#persist({ fontSize: this.fontSize });
|
||||
}
|
||||
|
||||
setPageWidth(w, persist = true) {
|
||||
this.pageWidth = Store.WIDTHS[w] ? w : 'default';
|
||||
document.documentElement.style.setProperty('--page-w', Store.WIDTHS[this.pageWidth]);
|
||||
if (persist) this.#persist({ pageWidth: this.pageWidth });
|
||||
}
|
||||
|
||||
#persist(patch) {
|
||||
Promise.resolve(api.setSettings(patch)).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
export const store = new Store();
|
||||
@@ -0,0 +1,78 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import Sidebar from '$lib/components/Sidebar.svelte';
|
||||
import Topbar from '$lib/components/Topbar.svelte';
|
||||
import PageView from '$lib/components/PageView.svelte';
|
||||
import Palette from '$lib/components/Palette.svelte';
|
||||
import ExportDialog from '$lib/components/ExportDialog.svelte';
|
||||
import Toast from '$lib/components/Toast.svelte';
|
||||
import ContextMenu from '$lib/components/ContextMenu.svelte';
|
||||
import TextMenu from '$lib/components/TextMenu.svelte';
|
||||
import FindBar from '$lib/components/FindBar.svelte';
|
||||
import GraphView from '$lib/components/GraphView.svelte';
|
||||
import TrashDialog from '$lib/components/TrashDialog.svelte';
|
||||
import HistoryDialog from '$lib/components/HistoryDialog.svelte';
|
||||
import TemplateDialog from '$lib/components/TemplateDialog.svelte';
|
||||
import SettingsDialog from '$lib/components/SettingsDialog.svelte';
|
||||
import Welcome from '$lib/components/Welcome.svelte';
|
||||
import { store } from '$lib/state.svelte.js';
|
||||
import { platform, api } from '$lib/api.js';
|
||||
import { toast } from '$lib/toast.svelte.js';
|
||||
|
||||
onMount(() => {
|
||||
store.init();
|
||||
// Right-click: remember what was clicked; if nothing in the page handled it, Electron reports it back and
|
||||
// the app's own menu opens (the browser preview keeps the browser's menu).
|
||||
let target = null;
|
||||
const remember = (e) => { target = e.target; };
|
||||
window.addEventListener('contextmenu', remember, true);
|
||||
const off = api.onContextMenu?.((info) => { store.textMenu = { ...info, target }; });
|
||||
return () => { window.removeEventListener('contextmenu', remember, true); off?.(); };
|
||||
});
|
||||
|
||||
function onkeydown(e) {
|
||||
const mod = e.ctrlKey || e.metaKey;
|
||||
if (!mod || !store.vault) return;
|
||||
const k = e.key.toLowerCase();
|
||||
if (k === 'k') { e.preventDefault(); store.paletteOpen = !store.paletteOpen; }
|
||||
else if (k === 'o' && e.shiftKey) { e.preventDefault(); if (store.doc && !store.isBoard && store.view === 'page') store.toggleOutline(); }
|
||||
else if (k === 'g' && !e.shiftKey) { e.preventDefault(); store.view === 'graph' ? store.closeGraph() : store.openGraph(); }
|
||||
else if (k === ',') { e.preventDefault(); store.settingsOpen = !store.settingsOpen; }
|
||||
else if (k === 'd' && e.shiftKey) { e.preventDefault(); store.openDaily(); }
|
||||
else if (k === 'n') { e.preventDefault(); store.templatesOpen = { parent: null }; }
|
||||
else if (k === '\\') { e.preventDefault(); store.toggleSidebar(); }
|
||||
else if (k === 'f' && store.doc && store.view === 'page') { e.preventDefault(); store.findOpen = (store.findOpen || 0) + 1; }
|
||||
else if (k === 's' && !e.shiftKey) { e.preventDefault(); store.flush().then(() => toast('Saved to disk')); }
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window {onkeydown} onresize={() => window.innerWidth > 760 && (store.drawer = false)} />
|
||||
|
||||
{#if store.vault}
|
||||
<div class="dragstrip"></div>
|
||||
<div class="app" class:collapsed={store.collapsed} class:focus-mode={store.focusMode} class:drawer={store.drawer} class:mac={platform === 'darwin'}>
|
||||
<Sidebar />
|
||||
<div class="scrim" role="presentation" onclick={() => (store.drawer = false)}></div>
|
||||
<main class="main island">
|
||||
{#if store.view === 'graph'}
|
||||
<GraphView />
|
||||
{:else}
|
||||
<Topbar />
|
||||
{#if store.findOpen}<FindBar />{/if}
|
||||
<PageView />
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
{:else if store.ready}
|
||||
<Welcome />
|
||||
{/if}
|
||||
|
||||
{#if store.paletteOpen}<Palette />{/if}
|
||||
{#if store.exportFormat}<ExportDialog />{/if}
|
||||
{#if store.menu}<ContextMenu />{/if}
|
||||
{#if store.textMenu}{#key store.textMenu}<TextMenu />{/key}{/if}
|
||||
{#if store.trashOpen}<TrashDialog />{/if}
|
||||
{#if store.historyOpen}<HistoryDialog />{/if}
|
||||
{#if store.templatesOpen}<TemplateDialog />{/if}
|
||||
{#if store.settingsOpen}<SettingsDialog />{/if}
|
||||
<Toast />
|
||||
Reference in new issue
Block a user