feat: Front-matter properties editor and inline math bug fix

This commit is contained in:
Your Name committed 2026-10-09 22:39:37 +02:00
commit 2952e539aa
4 files changed
+1640

No files matched your search

+760
View File
@@ -0,0 +1,760 @@
/* Two floating islands (sidebar + page) on a neutral, finely grained ground. Dark first; light is its daytime twin.
Fonts are bundled (@fontsource), so the app never touches the network. */
:root{
--mark:rgba(250,204,21,.26); /* ==highlight== */
--fs:16px; /* page text size (Settings) */
--page-w:740px; /* page width (Settings) */
--side:clamp(20px,3.5vw,48px); /* page edge padding for full-width pages */
--ground:#0e0e0f; --island:#161617; --island-2:#1c1c1e; --raise:#222224;
--line:rgba(255,255,255,.07); --line-2:rgba(255,255,255,.13);
--hover:rgba(255,255,255,.055); --press:rgba(255,255,255,.095);
--ink:#ececed; --ink-2:#a1a1a6; --ink-3:#6c6c71;
--blush:#e4ada5; --blush-ink:#2a1714; --blush-soft:rgba(228,173,165,.13); --blush-line:rgba(228,173,165,.4);
--ok:#93b98d; --danger:#ea9188; --scrim:rgba(0,0,0,.5);
--code-bg:#111112; --tk:#e4ada5; --ts:#c5cf98; --tc:#6c6c71; --tn:#e8bb83; --tf:#b7c3e2;
--shadow:0 24px 60px -16px rgba(0,0,0,.65),0 2px 10px rgba(0,0,0,.35);
--grain:.05;
--f-ui:'Inter Variable','Inter',ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif;
--f-mono:'JetBrains Mono',ui-monospace,'SF Mono',Menlo,Consolas,monospace;
--ease:cubic-bezier(.2,.8,.2,1); --spring:cubic-bezier(.34,1.45,.64,1);
color-scheme:dark;
}
:root[data-theme="light"]{
--mark:rgba(250,204,21,.42);
--ground:#e7e7e9; --island:#fbfbfb; --island-2:#f3f3f4; --raise:#ffffff;
--line:rgba(0,0,0,.075); --line-2:rgba(0,0,0,.14);
--hover:rgba(0,0,0,.045); --press:rgba(0,0,0,.085);
--ink:#1c1c1e; --ink-2:#5c5c61; --ink-3:#9a9a9f;
--blush:#b8695f; --blush-ink:#fff8f5; --blush-soft:rgba(184,105,95,.11); --blush-line:rgba(184,105,95,.4);
--ok:#5f8f58; --danger:#b4493e; --scrim:rgba(20,20,22,.22);
--code-bg:#f2f2f3; --tk:#a8564c; --ts:#5c7440; --tc:#9a9a9f; --tn:#a4681d; --tf:#46609b;
--shadow:0 24px 60px -18px rgba(0,0,0,.25),0 2px 10px rgba(0,0,0,.07);
--grain:.07;
color-scheme:light;
}
*{box-sizing:border-box}
/* Scrollbars: thin, no arrows, and only visible while the scrolling area is hovered. */
::-webkit-scrollbar{width:10px;height:10px}
::-webkit-scrollbar-track,::-webkit-scrollbar-corner{background:transparent}
::-webkit-scrollbar-button{display:none}
::-webkit-scrollbar-thumb{border-radius:99px;border:3px solid transparent;background:transparent padding-box;transition:background-color .2s}
:hover::-webkit-scrollbar-thumb{background-color:var(--line-2)}
::-webkit-scrollbar-thumb:hover{background-color:var(--ink-3)}
::-webkit-scrollbar-thumb:active{background-color:var(--ink-2)}
html,body{height:100%}
body{margin:0;background:var(--ground);color:var(--ink);font:13.5px/1.5 var(--f-ui);-webkit-font-smoothing:antialiased;overflow:hidden;user-select:none}
.c,.c2,.title,input,pre,.exp-out,.pv{user-select:text}
body::after{content:"";position:fixed;inset:0;pointer-events:none;z-index:200;opacity:var(--grain);background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 .5 0 0 0 0 .5 0 0 0 0 .5 0 0 0 .9 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E")}
::selection{background:rgba(228,173,165,.32)}
button{font:inherit;color:inherit;background:none;border:0;padding:0;cursor:pointer}
:focus-visible{outline:2px solid var(--blush-line);outline-offset:2px;border-radius:5px}
.i{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round;flex:none}
.i.fat{stroke-width:3.2}
kbd{font:500 10.5px/1 var(--f-mono);padding:3px 5px;border-radius:4px;background:var(--hover);box-shadow:inset 0 -1px 0 var(--line-2);color:var(--ink-2)}
/* 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}
.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}
.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)}
.btn-icon:active{transform:scale(.9)}
.ws{display:flex;align-items:center;gap:10px;padding:6px 4px 10px 6px}
.ws-mark{width:30px;height:30px;border-radius:7px;background:var(--blush);color:var(--blush-ink);display:grid;place-items:center;font-weight:700;font-size:14px;flex:none;transition:transform .4s var(--spring)}
.ws:hover .ws-mark{transform:rotate(-8deg)}
.ws-txt{display:flex;flex-direction:column;min-width:0;flex:1;line-height:1.25}
.ws-txt strong{font-weight:600;font-size:14px}
.ws-txt span{font-size:11.5px;color:var(--ink-3);font-family:var(--f-mono)}
.qa{display:flex;align-items:center;gap:10px;height:32px;padding:0 9px;border-radius:6px;color:var(--ink-2);transition:background .15s,color .15s,transform .15s var(--ease);text-align:left}
.qa:hover{background:var(--hover);color:var(--ink)}
.qa:active{transform:scale(.98)}
.qa kbd{margin-left:auto}
.sec{display:flex;align-items:center;justify-content:space-between;padding:16px 8px 6px 9px;font-size:11px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--ink-3)}
.sec .btn-icon{width:22px;height:22px;border-radius:5px}
.sec .btn-icon .i{width:14px;height:14px}
.tree{flex:1;overflow:auto;min-height:60px;margin:0 -4px;padding:0 4px}
.row{display:flex;align-items:center;gap:6px;height:31px;padding:0 5px 0 calc(5px + var(--d,0)*14px);border-radius:6px;color:var(--ink-2);cursor:pointer;transition:background .18s,color .18s,transform .18s var(--ease);user-select:none}
.row:hover{background:var(--hover);color:var(--ink)}
.row:active{transform:scale(.985)}
.row.active{background:var(--blush-soft);color:var(--ink)}
.row.active .row-ico{color:var(--blush)}
.row .name{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.row-ico{display:grid;place-items:center;color:var(--ink-3);transition:color .2s}
.chev{width:20px;height:20px;display:grid;place-items:center;border-radius:5px;color:var(--ink-3);transition:transform .32s var(--spring),background .15s}
.chev:hover{background:var(--press)}
.chev .i{width:12px;height:12px;stroke-width:2.2}
.chev.open{transform:rotate(90deg)}
.chev.leaf{visibility:hidden}
.row-add{width:22px;height:22px;display:grid;place-items:center;border-radius:5px;color:var(--ink-3);opacity:0;transform:scale(.7);transition:opacity .15s,transform .2s var(--spring),background .15s}
.row-add .i{width:14px;height:14px}
.row:hover .row-add{opacity:1;transform:none}
.row-add:hover{background:var(--press);color:var(--ink)}
.kids{display:grid;grid-template-rows:0fr;transition:grid-template-rows .38s var(--ease)}
.kids.open{grid-template-rows:1fr}
.kids-in{overflow:hidden;min-height:0}
.kids-in>.node{opacity:0;transform:translateY(-5px);transition:opacity .25s,transform .35s var(--ease)}
.kids.open>.kids-in>.node{opacity:1;transform:none}
.kids.open>.kids-in>.node:nth-child(2){transition-delay:.04s}
.kids.open>.kids-in>.node:nth-child(3){transition-delay:.08s}
.kids.open>.kids-in>.node:nth-child(n+4){transition-delay:.12s}
.row.fresh{animation:fresh .7s var(--ease)}
.row.gone{opacity:0;transform:translateX(-12px);transition:opacity .2s,transform .22s var(--ease)}
.row.flat{animation:rise .38s var(--ease) both;animation-delay:calc(var(--i,0)*40ms)}
.filter-head{display:flex;align-items:center;justify-content:space-between;padding:4px 6px 8px 9px;color:var(--ink-2);font-size:12.5px;animation:rise .3s var(--ease)}
.filter-head b{color:var(--blush);font-weight:500}
.filter-head button{color:var(--ink-3);font-size:12px;padding:2px 6px;border-radius:5px}
.filter-head button:hover{background:var(--hover);color:var(--ink)}
.tags{display:flex;flex-wrap:wrap;gap:5px;padding:2px 6px 8px}
.tag{display:inline-flex;align-items:center;gap:3px;height:26px;padding:0 9px;border-radius:99px;background:var(--hover);color:var(--ink-2);font-size:12.5px;transition:background .18s,color .18s,transform .25s var(--spring)}
.tag span{color:var(--ink-3)}
.tag em{font-style:normal;font-size:11px;color:var(--ink-3);margin-left:3px;font-variant-numeric:tabular-nums}
.tag:hover{background:var(--press);color:var(--ink);transform:translateY(-1px)}
.tag.on{background:var(--blush);color:var(--blush-ink)}
.tag.on span,.tag.on em{color:inherit;opacity:.7}
.sb-foot{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:10px 6px 4px 9px;margin-top:6px;border-top:1px solid var(--line);color:var(--ink-3);font-size:12px}
.sb-foot span{display:flex;align-items:center;gap:7px}
.sb-foot .dot{width:6px;height:6px;border-radius:50%;background:var(--ok)}
.knob{position:absolute;top:3px;left:3px;width:20px;height:20px;border-radius:50%;background:var(--blush);color:var(--blush-ink);display:grid;place-items:center;transition:transform .5s var(--spring)}
.knob .i{width:12px;height:12px;stroke-width:2.2;position:absolute;transition:opacity .3s,transform .5s var(--spring)}
.knob .sun{opacity:0;transform:rotate(-90deg) scale(.5)}
/* main island */
.main{display:flex;flex-direction:column;overflow:hidden;position:relative}
.topbar{display:flex;align-items:center;gap:6px;height:50px;padding:0 10px 0 12px;flex:none}
.menu-btn{display:none}
.app.collapsed .menu-btn{display:grid;animation:pop .3s var(--spring)}
.crumbs{display:flex;align-items:center;gap:1px;min-width:0;flex:1;overflow:hidden;color:var(--ink-2);font-size:13px}
.crumb{display:flex;align-items:center;gap:6px;padding:4px 7px;border-radius:5px;white-space:nowrap;min-width:0;transition:background .15s,color .15s}
.crumb span{overflow:hidden;text-overflow:ellipsis}
.crumb:hover{background:var(--hover);color:var(--ink)}
.crumb:last-child{color:var(--ink)}
.crumb .i{width:14px;height:14px;color:var(--ink-3)}
.crumbs .sep{color:var(--ink-3);padding:0 1px}
.status{display:flex;align-items:center;gap:7px;color:var(--ink-3);font-size:12.5px;padding:0 6px;white-space:nowrap;opacity:0;transition:opacity 0.5s}
.status.visible{opacity:1;transition:opacity 0.2s}
.status .dot{width:6px;height:6px;border-radius:50%;background:var(--ok);transition:background .3s}
.status.saving .dot{background:var(--blush);animation:pulse 1s ease-in-out infinite}
.wc{color:var(--ink-3);font-size:12.5px;padding:0 6px;white-space:nowrap;font-variant-numeric:tabular-nums}
.scroller{flex:1;overflow:auto;overflow-x:hidden}
.cover{position:relative;height:clamp(110px,21vh,190px);margin:0 10px;border-radius:10px;overflow:hidden;background:
radial-gradient(110% 150% at 12% 0%,color-mix(in oklab,var(--c1) 62%,transparent),transparent 62%),
radial-gradient(90% 130% at 92% 110%,color-mix(in oklab,var(--c2) 55%,transparent),transparent 66%),
var(--island-2)}
.cover::after{content:"";position:absolute;inset:0;background:inherit;filter:blur(30px);opacity:.5;animation:drift 18s ease-in-out infinite alternate;pointer-events:none}
.cover.enter{animation:coverIn .7s var(--ease)}
.cover-btn{position:absolute;right:12px;bottom:12px;z-index:1;display:flex;align-items:center;gap:6px;height:28px;padding:0 10px;border-radius:6px;font-size:12px;color:var(--ink);background:color-mix(in oklab,var(--island) 72%,transparent);backdrop-filter:blur(8px);opacity:0;transform:translateY(4px);transition:opacity .2s,transform .25s var(--ease),background .15s}
.cover:hover .cover-btn,.cover-btn:focus-visible{opacity:1;transform:none}
.cover-btn .i{width:14px;height:14px}
.page{max-width:var(--page-w);margin:0 auto;padding:0 clamp(20px,6vw,72px)}
.page.enter .head{animation:rise .5s var(--ease) both}
.p-icon{position:relative;z-index:2;width:76px;height:76px;margin-top:-42px;border-radius:14px;background:var(--island);display:grid;place-items:center;color:var(--blush);box-shadow:0 0 0 1px var(--line-2),var(--shadow);transition:transform .45s var(--spring)}
.p-icon:hover{transform:rotate(-7deg) scale(1.06)}
.p-icon[aria-expanded="true"]{transform:none;box-shadow:0 0 0 1.5px var(--blush-line),var(--shadow)}
.p-icon.spin{animation:spin .6s var(--spring)}
.p-icon .i{width:34px;height:34px;stroke-width:1.35}
.i.emo{display:inline-grid;place-items:center;font-size:13px;line-height:1;stroke:none;font-family:'Noto Color Emoji','Apple Color Emoji','Segoe UI Emoji',sans-serif}
.p-icon .i.emo{font-size:38px}
.pop.ipick{width:340px;padding:8px;border-radius:10px;z-index:75;transform-origin:top left}
.ip-top{display:flex;align-items:center;gap:2px;margin-bottom:8px}
.ip-tabs{display:flex;gap:2px;flex:1;padding:3px;border-radius:7px;background:var(--hover)}
.ip-tab{flex:1;height:26px;border-radius:5px;font-size:12.5px;font-weight:550;color:var(--ink-3);transition:background .15s,color .15s}
.ip-tab.on{background:var(--raise);color:var(--ink);box-shadow:0 1px 3px rgba(0,0,0,.18)}
.ip-tab:not(.on):hover{color:var(--ink)}
.ip-top .btn-icon{width:30px;height:30px}
.ip-search{display:flex;align-items:center;gap:8px;height:34px;padding:0 10px;margin-bottom:8px;border-radius:7px;background:var(--hover);color:var(--ink-3)}
.ip-search input{flex:1;min-width:0;border:0;outline:0;background:none;color:var(--ink);font:13.5px var(--f-ui)}
.ip-grid{display:grid;grid-template-columns:repeat(8,1fr);gap:2px;max-height:252px;overflow:auto;outline:none}
.ip-cell{aspect-ratio:1;display:grid;place-items:center;border-radius:6px;color:var(--ink-2);transition:background .12s,color .12s,transform .15s var(--spring)}
.ip-cell:hover,.ip-cell:focus-visible{background:var(--hover);color:var(--ink);transform:scale(1.08)}
.ip-cell.on{background:var(--blush-soft);color:var(--blush)}
.ip-cell .i{width:19px;height:19px}
.ip-emo{font-size:21px;line-height:1;font-family:'Noto Color Emoji','Apple Color Emoji','Segoe UI Emoji',sans-serif}
.ip-none{grid-column:1/-1;margin:0;padding:18px 6px;text-align:center;color:var(--ink-3);font-size:13px}
.title{font-size:clamp(calc(var(--fs) * 1.9),4.6vw,calc(var(--fs) * 2.6));font-weight:700;letter-spacing:-.028em;line-height:1.15;margin:20px 0 14px;outline:none;text-wrap:balance;word-break:break-word}
.title:empty::before{content:attr(data-ph);color:var(--ink-3)}
.props{display:grid;gap:2px;padding-bottom:16px;margin-bottom:10px;border-bottom:1px solid var(--line)}
.prop{display:grid;grid-template-columns:120px minmax(0,1fr);align-items:center;min-height:32px;gap:8px}
.pk{display:flex;align-items:center;gap:8px;color:var(--ink-3);font-size:13px}
.pk .i{width:15px;height:15px}
.pv{color:var(--ink-2);font-size:13px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.pv.mono{font-family:var(--f-mono);font-size:12px}
.tagv{display:flex;flex-wrap:wrap;gap:5px;white-space:normal;overflow:visible}
.chip{display:inline-flex;align-items:center;gap:2px;height:24px;padding:0 4px 0 9px;border-radius:99px;background:var(--blush-soft);color:var(--ink);font-size:12.5px;transition:opacity .18s,transform .2s var(--ease)}
.chip.in{animation:chipIn .4s var(--spring)}
.chip.out{opacity:0;transform:scale(.7)}
.chip-x{width:18px;height:18px;display:grid;place-items:center;border-radius:50%;color:var(--ink-3);opacity:.6;transition:.15s}
.chip-x:hover{opacity:1;background:var(--hover);color:var(--ink)}
.chip-x .i{width:11px;height:11px;stroke-width:2.2}
.chip-add{display:inline-flex;align-items:center;gap:4px;height:24px;padding:0 9px 0 6px;border-radius:99px;color:var(--ink-3);font-size:12.5px;transition:.15s}
.chip-add:hover{background:var(--hover);color:var(--ink)}
.chip-add .i{width:13px;height:13px}
.chip-in{height:24px;width:110px;border:0;outline:0;border-radius:99px;padding:0 10px;background:var(--hover);box-shadow:inset 0 0 0 1px var(--blush-line);color:var(--ink);font:12.5px var(--f-ui);animation:chipIn .3s var(--spring)}
/* blocks */
.blocks{position:relative;padding-top:4px}
.block{position:relative;display:flex;align-items:flex-start;gap:6px;margin:1px 0;border-radius:6px}
.block.stagger{animation:rise .5s var(--ease) both;animation-delay:calc(var(--i,0)*26ms + 90ms)}
.block.enter-b{animation:blockIn .3s var(--ease)}
.block.morph{animation:morph .36s var(--spring)}
.block.leaving{opacity:0;transform:translateX(-8px);transition:opacity .16s,transform .18s var(--ease)}
.block.dragging{opacity:.3}
.block.drag-ghost{position:fixed;z-index:80;margin:0;pointer-events:none;background:var(--raise);padding:2px 8px;translate:-8px 0;border-radius:7px;box-shadow:0 0 0 1px var(--line-2),var(--shadow);opacity:.92;rotate:-.6deg}
.drag-ghost>.gutter{display:none}
body.block-dragging,body.block-dragging *{cursor:grabbing!important;user-select:none!important}
.gutter{position:absolute;left:-54px;top:var(--gt,4px);display:flex;opacity:0;transform:translateX(4px);transition:opacity .18s,transform .22s var(--ease)}
.block:hover>.gutter{opacity:1;transform:none}
.gutter button{width:24px;height:24px;display:grid;place-items:center;border-radius:5px;color:var(--ink-3);transition:background .15s,color .15s}
.gutter button:hover{background:var(--hover);color:var(--ink)}
.g-drag{cursor:grab}
.g-drag:active{cursor:grabbing}
.gutter .i{width:15px;height:15px}
.c,.c2{flex:1;min-width:0;outline:none;font-size:var(--fs);line-height:1.7;padding:1px 2px;white-space:pre-wrap;word-wrap:break-word;caret-color:var(--blush)}
.c:empty::before,.c2:empty::before{content:attr(data-ph);color:var(--ink-3);pointer-events:none}
.b-p .c:empty:not(:focus)::before{content:""}
.c code,.c2 code{font:500 .84em var(--f-mono);background:var(--hover);color:var(--blush);padding:.12em .38em;border-radius:4px}
.c kbd,.c2 kbd{font-size:.72em;vertical-align:.12em}
.c b,.c strong{font-weight:600}
.b-h1{margin-top:24px}.b-h1 .c{font-size:calc(var(--fs) * 1.75);font-weight:700;letter-spacing:-.022em;line-height:1.3}.b-h1{--gt:8px}
.b-h2{margin-top:18px}.b-h2 .c{font-size:calc(var(--fs) * 1.34);font-weight:650;letter-spacing:-.015em;line-height:1.38}.b-h2{--gt:5px}
.b-h3{margin-top:10px}.b-h3 .c{font-size:calc(var(--fs) * 1.09);font-weight:600;line-height:1.5}
.bul{width:22px;height:28px;flex:none;display:grid;place-items:center}
.bul::before{content:"";width:6px;height:6px;border-radius:50%;background:var(--ink-2)}
.morph .bul::before{animation:chipIn .4s var(--spring)}
.check{width:18px;height:18px;margin:5px 2px 0;flex:none;border-radius:5px;box-shadow:inset 0 0 0 1.6px var(--ink-3);display:grid;place-items:center;color:var(--blush-ink);transition:background .2s,box-shadow .2s}
.check:hover{box-shadow:inset 0 0 0 1.6px var(--ink-2)}
.check .i{width:13px;height:13px;stroke-width:2.6;stroke-dasharray:22;stroke-dashoffset:22;transition:stroke-dashoffset .3s var(--ease) .05s}
.check.on{background:var(--blush);box-shadow:inset 0 0 0 1.6px var(--blush)}
.check.on .i{stroke-dashoffset:0}
.check.bump{animation:bump .4s var(--spring)}
.b-todo .c{text-decoration:line-through;text-decoration-color:transparent;text-decoration-thickness:1.5px;transition:color .3s,text-decoration-color .3s}
.b-todo.done .c{color:var(--ink-3);text-decoration-color:var(--ink-3)}
.b-quote .c{border-left:2px solid var(--blush-line);border-radius:0;padding-left:16px;font-size:calc(var(--fs) * 1.06);color:var(--ink-2);margin:4px 0}
.callout{flex:1;min-width:0;display:flex;gap:12px;padding:14px 16px;border-radius:9px;background:var(--blush-soft);margin:4px 0}
.callout-ico{color:var(--blush);padding-top:4px;display:grid;cursor:pointer}
.callout-ico .i{width:18px;height:18px}
.callout[data-kind="tip"] { background: rgba(147, 185, 141, 0.15); }
:root[data-theme="light"] .callout[data-kind="tip"] { background: rgba(95, 143, 88, 0.13); }
.callout[data-kind="tip"] .callout-ico { color: var(--ok); }
.callout[data-kind="warning"] { background: rgba(232, 187, 131, 0.15); }
:root[data-theme="light"] .callout[data-kind="warning"] { background: rgba(164, 104, 29, 0.13); }
.callout[data-kind="warning"] .callout-ico { color: var(--tn); }
.callout[data-kind="caution"] { background: rgba(234, 145, 136, 0.15); }
:root[data-theme="light"] .callout[data-kind="caution"] { background: rgba(180, 73, 62, 0.13); }
.callout[data-kind="caution"] .callout-ico { color: var(--danger); }
.b-callout{--gt:18px}
.hr{flex:1;height:1px;background:var(--line-2);margin:14px 2px;transform-origin:left}
.morph .hr,.enter-b .hr{animation:draw .5s var(--ease)}
.code{flex:1;min-width:0;background:var(--code-bg);border-radius:9px;box-shadow:0 0 0 1px var(--line);margin:6px 0;overflow:hidden}
.b-code{--gt:12px}
.code-bar{display:flex;align-items:center;justify-content:space-between;padding:8px 10px 0 12px}
.code-bar button{font:500 11px var(--f-mono);color:var(--ink-3);padding:3px 7px;border-radius:5px;transition:background .15s,color .15s,opacity .2s}
.code-bar button:hover{background:var(--hover);color:var(--ink)}
.code-bar .copy{opacity:0}
.code:hover .copy{opacity:1}
pre.c{margin:0;padding:6px 18px 16px;font:calc(var(--fs) * .84)/1.7 var(--f-mono);white-space:pre;overflow-x:auto;tab-size:2}
.tk{color:var(--tk)}.ts{color:var(--ts)}.tc{color:var(--tc);font-style:italic}.tn{color:var(--tn)}.tf{color:var(--tf)}
.tg{flex:1;min-width:0}
.tg-head{display:flex;align-items:flex-start;gap:2px}
.tg-chev{width:24px;height:24px;margin-top:2px;display:grid;place-items:center;border-radius:5px;color:var(--ink-2);flex:none;transition:transform .35s var(--spring),background .15s}
.tg-chev:hover{background:var(--hover)}
.tg-chev .i{width:13px;height:13px;stroke-width:2.2}
.tg.open .tg-chev{transform:rotate(90deg)}
.tg-body{display:grid;grid-template-rows:0fr;transition:grid-template-rows .4s var(--ease)}
.tg.open .tg-body{grid-template-rows:1fr}
.tg-in{overflow:hidden;min-height:0;padding-left:26px}
.tg-in .c2{color:var(--ink-2);opacity:0;transform:translateY(-4px);transition:opacity .3s,transform .35s var(--ease);padding-bottom:4px}
.tg.open .tg-in .c2{opacity:1;transform:none}
.num{min-width:22px;height:28px;flex:none;display:flex;align-items:center;justify-content:flex-end;padding-right:4px;color:var(--ink-2);font-variant-numeric:tabular-nums;font-size:15px}
.num::before{content:counter(num) "."}
.b-number{counter-increment:num}
.b-number:not(.b-number + .b-number){counter-reset:num}
.morph .num::before{animation:chipIn .4s var(--spring)}
.c s,.c2 s,.c strike,.c2 strike{text-decoration-thickness:1.5px;color:var(--ink-2)}
.pop.fmt{display:flex;gap:2px;padding:4px;border-radius:7px;z-index:75}
.fmt[hidden]{display:none}
.fmt button{min-width:30px;height:30px;padding:0 7px;display:grid;place-items:center;border-radius:6px;color:var(--ink-2);font-size:14px;transition:background .12s,color .12s}
.fmt button:hover{background:var(--hover);color:var(--ink)}
.fmt button.on{background:var(--blush-soft);color:var(--blush)}
.fmt button b{font-weight:700}
.fmt button i{font-family:Georgia,serif;font-size:15px}
.fmt .i{width:15px;height:15px}
.pop.bmenu{width:224px;max-height:min(520px,calc(100vh - 16px));overflow:auto;transform-origin:top left;z-index:75}
.bmenu .mi{height:32px}
.bmenu .glyph{width:22px;height:22px;display:grid;place-items:center;border-radius:5px;background:var(--hover);font-size:11px;font-weight:700;color:var(--ink-2);flex:none}
.bmenu .glyph .i{width:13px;height:13px}
.bmenu .mi.cur{color:var(--ink)}
.bmenu .mi-on{margin-left:auto;color:var(--blush)}
.bmenu kbd{margin-left:auto}
.block.menu-open>.gutter{opacity:1;transform:none}
.block.menu-open>.gutter .g-drag{background:var(--hover);color:var(--ink)}
::highlight(find){background:color-mix(in oklab,var(--blush) 30%,transparent);color:inherit}
::highlight(find-current){background:var(--blush);color:var(--blush-ink)}
.findbar.pop{position:absolute;top:54px;right:14px;z-index:40;display:flex;align-items:center;gap:4px;padding:5px 5px 5px 12px;border-radius:8px;color:var(--ink-3);animation:pop .22s var(--spring)}
.findbar input{width:200px;height:28px;border:0;outline:0;background:none;color:var(--ink);font:13.5px var(--f-ui)}
.find-n{min-width:64px;text-align:right;font-size:12px;color:var(--ink-3);font-variant-numeric:tabular-nums;padding-right:4px;white-space:nowrap}
.findbar .btn-icon{width:28px;height:28px}
.findbar .up{rotate:-90deg}
.findbar .down{rotate:90deg}
/* kanban board: boards use the full width, with the page head lined up on the same left edge */
.page.wide{max-width:none;padding:0 var(--side)}
.board{overflow-x:auto;overflow-y:hidden;padding:6px var(--side) 16px;margin-top:10px;animation:rise .5s var(--ease) both .08s}
.kcols{position:relative;display:flex;align-items:flex-start;gap:12px;width:max-content}
.kcol{width:276px;flex:none;display:flex;flex-direction:column;background:var(--island-2);border-radius:10px;box-shadow:0 0 0 1px var(--line);padding:6px 6px 8px;transition:opacity .2s}
.kcol.lifted{opacity:.35}
.khead{display:flex;align-items:center;gap:2px;padding:4px 4px 6px 6px;cursor:grab;min-height:38px}
.kh-title{flex:1;min-width:0;text-align:left;font-weight:650;font-size:13.5px;padding:4px 6px;border-radius:5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:text;transition:background .15s}
.kh-title:hover{background:var(--hover)}
.kh-input{flex:1;min-width:0;height:28px;border:0;outline:0;border-radius:5px;padding:0 6px;background:var(--island);box-shadow:0 0 0 1.5px var(--blush-line);color:var(--ink);font:650 13.5px var(--f-ui)}
.kh-n{font-size:12px;color:var(--ink-3);padding:0 6px;font-variant-numeric:tabular-nums}
.kh-btn{width:26px;height:26px;opacity:0;transition:opacity .15s,background .15s}
.kh-btn .i{width:14px;height:14px}
.kcol:hover .kh-btn,.kh-btn:focus-visible{opacity:1}
.kcards{position:relative;display:flex;flex-direction:column;gap:6px;min-height:8px}
.kcard{position:relative;display:flex;align-items:flex-start;gap:8px;padding:9px 8px 9px 10px;border-radius:7px;background:var(--raise);box-shadow:0 0 0 1px var(--line),0 1px 2px rgba(0,0,0,.08);cursor:grab;transition:box-shadow .15s,opacity .2s,transform .2s var(--ease)}
.kcard:hover{box-shadow:0 0 0 1px var(--line-2),0 4px 14px -6px rgba(0,0,0,.3)}
.kcard.editing{cursor:text;box-shadow:0 0 0 1.5px var(--blush-line)}
.kcard.lifted{opacity:.3}
.kcheck{width:16px;height:16px;margin-top:2px;flex:none;border-radius:4px;box-shadow:inset 0 0 0 1.5px var(--ink-3);display:grid;place-items:center;color:var(--blush-ink);transition:background .2s,box-shadow .2s}
.kcheck .i{width:11px;height:11px;stroke-width:2.8;opacity:0;transition:opacity .2s}
.kcheck.on{background:var(--blush);box-shadow:inset 0 0 0 1.5px var(--blush)}
.kcheck.on .i{opacity:1}
.kc-text{flex:1;min-width:0;font-size:calc(var(--fs) * .84);line-height:1.5;word-wrap:break-word;user-select:text;cursor:inherit}
.kcard.done .kc-text{color:var(--ink-3);text-decoration:line-through;text-decoration-color:var(--ink-3)}
.kc-text code{font:500 .86em var(--f-mono);background:var(--hover);color:var(--blush);padding:.1em .35em;border-radius:4px}
.kc-text b{font-weight:600}
.kedit{flex:1;min-width:0;resize:none;border:0;outline:0;background:none;color:var(--ink);font:calc(var(--fs) * .84)/1.5 var(--f-ui);padding:0;overflow:hidden}
.kmore{position:absolute;top:4px;right:4px;width:24px;height:24px;background:var(--raise);opacity:0;transition:opacity .15s}
.kmore .i{width:13px;height:13px}
.kcard:hover .kmore,.kmore:focus-visible{opacity:1}
.kadd{display:flex;align-items:center;gap:7px;margin-top:6px;padding:7px 8px;border-radius:6px;color:var(--ink-3);font-size:13px;text-align:left;transition:background .15s,color .15s}
.kadd:hover{background:var(--hover);color:var(--ink)}
.kadd .i{width:14px;height:14px}
.kaddcol{width:236px;flex:none;display:flex;align-items:center;gap:8px;height:44px;padding:0 14px;border-radius:9px;color:var(--ink-3);box-shadow:inset 0 0 0 1.5px var(--line-2);font-size:13.5px;transition:background .15s,color .15s}
.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}
.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}
/* right-click menu for text */
.pop.tmenu{width:248px;transform-origin:top left;z-index:78;-webkit-app-region:no-drag}
.tmenu .mi{height:32px}
.tmenu .mi.act{background:var(--hover);color:var(--ink)}
.tmenu .mi:disabled{opacity:.45;cursor:default;background:none}
.tmenu .mi.strong{font-weight:600;color:var(--ink)}
.tmenu .mi-pad{width:16px;flex:none}
.tmenu .mi-label{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.tmenu kbd{margin-left:8px}
.tm-fmt{display:flex;gap:2px;padding:2px}
.tm-fmt button{flex:1;height:30px;display:grid;place-items:center;border-radius:6px;color:var(--ink-2);font-size:14px;transition:background .12s,color .12s}
.tm-fmt button:hover{background:var(--hover);color:var(--ink)}
.tm-fmt button.on{background:var(--blush-soft);color:var(--blush)}
.tm-fmt button i{font-family:Georgia,serif;font-size:15px}
.tm-fmt .i{width:15px;height:15px}
/* ==highlight== */
mark{background:var(--mark);color:inherit;border-radius:3px;padding:0 .12em}
.fmt-mark{background:var(--mark);padding:0 5px;border-radius:4px;font-weight:600}
/* footnotes: [^id] / ^[inline] references, and "[^id]: text" blocks */
sup.fn{font-size:.7em;line-height:0;vertical-align:super;padding:0 .08em 0 .1em;margin:0;border-radius:4px;color:var(--blush);font-weight:650;font-variant-numeric:tabular-nums;cursor:pointer;user-select:none;transition:background .15s}
sup.fn:hover{background:var(--blush-soft)}
sup.fn.missing{color:var(--ink-3);text-decoration:underline dotted;text-underline-offset:2px}
.b-footnote .c{font-size:calc(var(--fs) * .88);color:var(--ink-2)}
.fn-num{min-width:22px;height:22px;margin-top:2px;padding:0 6px;flex:none;display:grid;place-items:center;border-radius:6px;font-size:11.5px;font-weight:650;font-variant-numeric:tabular-nums;color:var(--blush);background:var(--blush-soft);transition:filter .15s}
button.fn-num:hover{filter:brightness(1.15)}
.b-footnote.orphan .fn-num{color:var(--ink-3);background:var(--hover)}
.block:not(.b-footnote)+.b-footnote,.eb:not(.b-footnote)+.b-footnote{margin-top:18px;padding-top:12px;border-top:1px solid var(--line);--gt:14px;border-radius:0}
.pop.fn-tip{max-width:340px;padding:9px 12px;border-radius:9px;font-size:13px;line-height:1.55;color:var(--ink-2);z-index:76;pointer-events:none}
.fn-tip[hidden]{display:none}
.fn-tip-n{display:inline-block;margin-right:7px;font-weight:650;color:var(--blush);font-variant-numeric:tabular-nums}
/* embeds: ![[Page]] shows a note inline, read-only */
.b-embed{--gt:10px}
.emb{flex:1;min-width:0;margin:6px 0;border-radius:10px;background:var(--island-2);box-shadow:inset 3px 0 0 var(--blush-line),0 0 0 1px var(--line);overflow:hidden}
.emb.missing{box-shadow:inset 3px 0 0 var(--line-2),0 0 0 1px var(--line)}
.emb-head{display:flex;align-items:center;padding:8px 10px 0 10px}
.emb-title{display:flex;align-items:center;gap:7px;min-width:0;max-width:100%;padding:3px 7px;border-radius:6px;color:var(--ink-3);font-size:12px;font-weight:600;transition:background .15s,color .15s}
.emb-title:hover{background:var(--hover);color:var(--ink)}
.emb-title .i{width:13px;height:13px;flex:none}
.emb-title span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.emb-body{position:relative;padding:2px 16px 12px 16px}
.emb-body>.eb:first-child{margin-top:0}
.emb:not(.open) .emb-body.long{max-height:320px;overflow:hidden}
.emb:not(.open) .emb-body.long::after{content:"";position:absolute;left:0;right:0;bottom:0;height:72px;background:linear-gradient(transparent,var(--island-2));pointer-events:none}
.emb-more{display:block;width:100%;padding:7px;border-top:1px solid var(--line);color:var(--ink-3);font-size:12px;transition:background .15s,color .15s}
.emb-more:hover{background:var(--hover);color:var(--ink)}
.emb-note{margin:8px 2px 2px;color:var(--ink-3);font-size:13.5px}
.emb-nested{display:flex;align-items:center;gap:7px;color:var(--ink-3);font-size:13.5px}
.emb-nested .i{width:14px;height:14px}
.eb{display:flex;align-items:flex-start;gap:6px;margin:1px 0}
.emb .tbl{padding:0;margin:4px 0}
.ecell{padding:7px 11px;font-size:calc(var(--fs) * .92);line-height:1.55;min-height:1.55em;word-wrap:break-word;text-align:inherit}
.tbl th .ecell{font-weight:600}
.emb .lang{font:500 11px var(--f-mono);color:var(--ink-3);padding:3px 7px}
.etg{flex:1;min-width:0}
.etg>summary{cursor:pointer;font-weight:550}
.etg-body{color:var(--ink-2);padding:2px 0 4px 20px}
/* outline: docked to the right of the page */
.pagewrap{flex:1;min-height:0;display:flex}
.pagewrap .scroller{min-width:0}
.outline{width:236px;flex:none;min-height:0;display:flex;flex-direction:column;border-left:1px solid var(--line);animation:fade .25s ease}
.ol-head{display:flex;align-items:center;gap:8px;padding:10px 8px 6px 16px;font-size:11px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--ink-3)}
.ol-head span{flex:1}
.ol-head em{font-style:normal;font-weight:500;letter-spacing:0;font-variant-numeric:tabular-nums}
.ol-head .btn-icon{width:26px;height:26px}
.ol-list{flex:1;min-height:0;overflow:auto;padding:2px 8px 24px;display:flex;flex-direction:column;gap:1px}
.ol-item{display:block;width:100%;text-align:left;white-space:normal;padding:5px 8px 5px calc(10px + var(--lv,0) * 14px);border-radius:7px;color:var(--ink-2);font-size:13px;line-height:1.4;transition:background .15s,color .15s;position:relative}
.ol-item span{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;word-break:break-word}
.ol-item.top{color:var(--ink);font-weight:550}
.ol-item:hover{background:var(--hover);color:var(--ink)}
.ol-item.on{background:var(--blush-soft);color:var(--ink)}
.ol-item.on::before{content:"";position:absolute;left:0;top:6px;bottom:6px;width:2px;border-radius:2px;background:var(--blush)}
.ol-empty{margin:6px 10px;color:var(--ink-3);font-size:12.5px;line-height:1.7}
.block.flash{animation:flash 1.3s var(--ease)}
@keyframes flash{0%,35%{background:var(--blush-soft)}100%{background:transparent}}
.main:has(.outline) .findbar.pop{right:250px}
.btn-icon.on{color:var(--blush)}
@media (max-width:760px){
.outline{position:absolute;top:50px;right:0;bottom:0;z-index:20;background:var(--island);box-shadow:-12px 0 30px -12px rgba(0,0,0,.4);border-radius:0 0 12px 0}
}
/* graph view */
.graph-view{flex:1;min-height:0;display:flex;flex-direction:column}
.graph-back{color:var(--ink-2)}
.graph-back .back{rotate:180deg;width:14px;height:14px}
.graph-title{flex:1;display:flex;align-items:center;gap:8px;font-weight:600;font-size:13px;color:var(--ink);padding-left:4px}
.graph-top .btn-icon.on{color:var(--blush)}
.graph-canvas{position:relative;flex:1;min-height:0;overflow:hidden;border-radius:0 0 12px 12px}
.graph-canvas canvas{display:block;cursor:grab;touch-action:none}
.graph-empty{position:absolute;inset:0;display:grid;place-items:center;color:var(--ink-3);pointer-events:none}
.pop.graph-panel{position:absolute;top:10px;right:10px;width:250px;padding:10px;border-radius:10px;z-index:5;display:flex;flex-direction:column;gap:8px;animation:pop .22s var(--spring)}
.gp-search{display:flex;align-items:center;gap:8px;height:32px;padding:0 10px;border-radius:7px;background:var(--hover);color:var(--ink-3)}
.gp-search input{flex:1;min-width:0;border:0;outline:0;background:none;color:var(--ink);font:13px var(--f-ui)}
.gp-seg{display:flex}
.gp-seg button{flex:1;justify-content:center}
.gp-row{display:flex;align-items:center;justify-content:space-between;gap:10px;font-size:12.5px;color:var(--ink-2);min-height:28px}
.gp-row input[type=range]{width:110px;accent-color:var(--blush)}
.gp-row .seg3 button{padding:0 10px}
.qa.on{background:var(--blush-soft);color:var(--ink)}
/* web links */
a.xl{color:var(--blush);text-decoration:underline;text-decoration-color:var(--blush-line);text-decoration-thickness:1.5px;text-underline-offset:3px;transition:text-decoration-color .15s}
a.xl:hover{text-decoration-color:var(--blush)}
.kc-text a.xl{cursor:pointer}
.pop.lbub{display:flex;align-items:center;gap:2px;padding:4px 4px 4px 10px;border-radius:7px;z-index:76;max-width:min(440px,calc(100vw - 16px))}
.lbub[hidden]{display:none}
.lb-url{display:flex;align-items:center;gap:7px;min-width:0;margin-right:6px;color:var(--ink-2);font-size:12.5px}
.lb-url span{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:240px}
.lb-url .i{width:14px;height:14px;color:var(--ink-3)}
.lbub button{height:28px;min-width:28px;padding:0 8px;display:grid;place-items:center;border-radius:6px;color:var(--ink-2);font-size:12.5px;font-weight:550;transition:background .12s,color .12s}
.lbub button:hover{background:var(--hover);color:var(--ink)}
.lbub button[data-b="open"]{color:var(--blush)}
.lbub button[data-b="unlink"]:hover{color:var(--danger)}
.lbub .i{width:14px;height:14px}
.pop.fmt.url{align-items:center;gap:6px;padding:4px 4px 4px 10px}
.fmt-ico{display:grid;color:var(--ink-3)}
.fmt-url{width:260px;height:30px;border:0;outline:0;background:none;color:var(--ink);font:13.5px var(--f-ui)}
.fmt-url.bad{color:var(--danger);animation:shake .3s}
@keyframes shake{25%{transform:translateX(-3px)}75%{transform:translateX(3px)}}
/* tables */
.tbl{position:relative;flex:1;min-width:0;margin:8px 0;padding:0 18px 18px 0}
.tbl-scroll{overflow-x:auto;border-radius:7px;box-shadow:0 0 0 1px var(--line-2)}
.tbl table{border-collapse:collapse;min-width:100%}
.tbl th,.tbl td{border:1px solid var(--line-2);padding:0;vertical-align:top;min-width:110px;text-align:left}
.tbl tr:first-child th{border-top:0}
.tbl tr:last-child td{border-bottom:0}
.tbl th:first-child,.tbl td:first-child{border-left:0}
.tbl th:last-child,.tbl td:last-child{border-right:0}
.tbl th{background:var(--island-2)}
.tcell{outline:none;padding:7px 11px;font-size:calc(var(--fs) * .92);line-height:1.55;min-height:1.55em;white-space:pre-wrap;word-wrap:break-word;caret-color:var(--blush);text-align:inherit}
.tbl th .tcell{font-weight:600}
.tcell:focus{box-shadow:inset 0 0 0 1.5px var(--blush-line);border-radius:2px}
.tcell:empty::before{content:attr(data-ph);color:var(--ink-3);pointer-events:none}
.tcell code{font:500 .86em var(--f-mono);background:var(--hover);color:var(--blush);padding:.1em .35em;border-radius:4px}
.tbl-add{position:absolute;display:grid;place-items:center;border-radius:5px;color:var(--ink-3);background:var(--hover);opacity:0;transition:opacity .15s,background .15s,color .15s}
.tbl-add .i{width:13px;height:13px}
.tbl-add:hover{background:var(--press);color:var(--ink)}
.tbl-add-col{top:0;right:0;bottom:18px;width:14px}
.tbl-add-row{left:0;right:18px;bottom:0;height:14px}
.block:hover .tbl-add{opacity:1}
.b-table{--gt:10px}
.wl{color:var(--blush);font-weight:500;text-decoration:underline;text-decoration-color:var(--blush-line);text-decoration-thickness:1.5px;text-underline-offset:3px;cursor:pointer;border-radius:4px;padding:0 1px;transition:background .15s,text-decoration-color .15s}
.wl:hover{background:var(--blush-soft);text-decoration-color:var(--blush)}
.wl.missing{color:var(--ink-3);text-decoration-style:dashed;text-decoration-color:var(--line-2)}
.img{flex:1;min-width:0;margin:8px 0;position:relative;border-radius:9px}
.img img{display:block;max-width:100%;max-height:70vh;border-radius:9px;box-shadow:0 0 0 1px var(--line)}
.img-x{position:absolute;top:8px;right:8px;width:28px;height:28px;display:grid;place-items:center;border-radius:6px;background:var(--raise);color:var(--ink-2);box-shadow:0 0 0 1px var(--line-2);opacity:0;transition:opacity .15s,color .15s}
.img:hover .img-x{opacity:1}
.img-x:hover{color:var(--danger)}
.img-miss{display:none;align-items:center;gap:8px;padding:14px 16px;border-radius:9px;background:var(--hover);color:var(--ink-3);font-size:13px;font-family:var(--f-mono);word-break:break-all}
.img.broken img{display:none}
.img.broken .img-miss{display:flex}
.img.broken .img-x{opacity:1;top:50%;translate:0 -50%}
.b-image{--gt:12px}
.backlinks{margin:8px 0 48px;padding-top:16px;border-top:1px solid var(--line);display:flex;flex-direction:column;gap:2px}
.backlinks h2{display:flex;align-items:center;gap:8px;margin:0 0 6px;font-size:12px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--ink-3)}
.bl{display:flex;align-items:flex-start;gap:10px;padding:8px 10px;border-radius:7px;text-align:left;color:var(--ink-2);transition:background .15s,color .15s}
.bl:hover{background:var(--hover);color:var(--ink)}
.bl .row-ico{padding-top:2px}
.bl-t{display:flex;flex-direction:column;min-width:0}
.bl-t b{font-weight:600}
.bl-t small{color:var(--ink-3);font-size:12.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.page-ghost{position:fixed;left:0;top:0;z-index:90;pointer-events:none;padding:5px 11px;border-radius:6px;background:var(--raise);color:var(--ink);font-size:13px;box-shadow:0 0 0 1px var(--line-2),var(--shadow);white-space:nowrap}
body.page-dragging,body.page-dragging *{cursor:grabbing!important;user-select:none!important}
.row.lifted{opacity:.4}
.favs{display:flex;flex-direction:column;margin:0 -4px;padding:0 4px}
.row.fav{padding-left:10px}
.foot-btn{width:28px;height:28px}
.foot-btn .i{width:15px;height:15px;transition:transform .5s var(--spring)}
.foot-trash{margin-left:auto}
.foot-btn[aria-label="Settings"]:hover .i{transform:rotate(60deg)}
/* settings */
.panel.settings{width:min(600px,100%);max-height:calc(100vh - 12vh - 32px);display:flex;flex-direction:column}
.set-top{display:flex;align-items:center;gap:10px;padding:14px 14px 10px 18px;border-bottom:1px solid var(--line)}
.set-top h2{margin:0;font-size:15px;font-weight:650;flex:1}
.set-body{overflow:auto;padding:4px 18px 18px}
.set-body h3{margin:18px 0 6px;font-size:11px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--ink-3)}
.set-row{display:flex;align-items:center;justify-content:space-between;gap:18px;padding:9px 0;border-bottom:1px solid var(--line)}
.set-t{display:flex;flex-direction:column;min-width:0;gap:2px}
.set-t b{font-weight:550}
.set-t small{color:var(--ink-3);font-size:12px;line-height:1.4}
.set-t small.mono{font-family:var(--f-mono);font-size:11.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:300px}
.set-btns{display:flex;gap:6px;flex:none}
.set-btns .btn{height:30px;padding:0 12px}
.seg3{display:flex;gap:2px;padding:3px;border-radius:7px;background:var(--hover);flex:none}
.seg3 button{display:flex;align-items:center;gap:6px;height:28px;padding:0 11px;border-radius:5px;font-size:12.5px;font-weight:550;color:var(--ink-3);transition:background .15s,color .15s}
.seg3 button .i{width:14px;height:14px}
.seg3 button.on{background:var(--raise);color:var(--ink);box-shadow:0 1px 3px rgba(0,0,0,.18)}
.seg3 button:not(.on):hover{color:var(--ink)}
.set-size{display:flex;align-items:center;gap:9px;flex:none}
.set-size .a-small{font-size:11px;color:var(--ink-3)}
.set-size .a-big{font-size:17px;color:var(--ink-3)}
.set-size input[type=range]{width:150px;accent-color:var(--blush)}
.set-val{min-width:46px;height:26px;padding:0 6px;border-radius:5px;font:500 12px var(--f-mono);color:var(--ink-2);background:var(--hover)}
.set-val:disabled{opacity:1;cursor:default}
.set-val:not(:disabled):hover{color:var(--blush)}
.set-preview{margin:2px 0 4px;padding:10px 14px;border-radius:7px;background:var(--island-2);line-height:1.6;color:var(--ink-2);user-select:none}
.set-note{margin:8px 0 0;color:var(--ink-3);font-size:12px}
.settings select{min-width:200px;max-width:240px;height:32px;padding:0 10px;border-radius:6px;border:0;background:var(--hover);color:var(--ink);font:13px var(--f-ui);box-shadow:inset 0 0 0 1px var(--line);cursor:pointer}
.settings select:focus-visible{outline:2px solid var(--blush-line)}
.tgl{position:relative;width:40px;height:23px;border-radius:99px;background:var(--hover);box-shadow:inset 0 0 0 1px var(--line-2);flex:none;transition:background .2s}
.tgl span{position:absolute;top:3px;left:3px;width:17px;height:17px;border-radius:50%;background:var(--ink-3);transition:transform .3s var(--spring),background .2s}
.tgl.on{background:var(--blush)}
.tgl.on span{transform:translateX(17px);background:var(--blush-ink)}
.star-btn.on{color:var(--blush)}
.star-btn.on .i{fill:currentColor}
.trash-list{flex:1;overflow:auto;padding:6px;min-height:120px;max-height:min(460px,60vh)}
.tr{display:flex;align-items:center;gap:10px;padding:8px 10px;border-radius:7px;transition:background .15s}
.tr:hover{background:var(--hover)}
.tr-t{flex:1;min-width:0;display:flex;flex-direction:column}
.tr-t b{font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.tr-t small{color:var(--ink-3);font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.tr .btn{height:30px;padding:0 11px;font-size:12.5px}
.tr .btn.danger.armed,.trash-foot .btn.danger.armed{color:var(--danger);background:color-mix(in oklab,var(--danger) 14%,transparent)}
.trash-top{display:flex;align-items:center;gap:10px;padding:14px 14px 10px 18px;border-bottom:1px solid var(--line)}
.trash-top h2{margin:0;font-size:15px;font-weight:650;flex:1}
.trash-foot{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:10px 14px;border-top:1px solid var(--line);color:var(--ink-3);font-size:12px}
.trash-empty{display:flex;flex-direction:column;align-items:center;gap:8px;padding:40px 20px;color:var(--ink-3)}
.row.drop-in{background:var(--blush-soft);box-shadow:inset 0 0 0 1.5px var(--blush-line);color:var(--ink)}
.tree.drop-root,.sec.drop-root{background:var(--blush-soft);border-radius:7px}
.drop{position:absolute;left:0;right:0;top:0;height:3px;border-radius:3px;background:var(--blush);opacity:0;pointer-events:none;transition:top .12s var(--ease),opacity .12s}
.drop.show{opacity:1}
.tail{height:32vh;cursor:text}
/* floating things */
.pop{position:fixed;z-index:70;background:var(--raise);border-radius:9px;box-shadow:0 0 0 1px var(--line-2),var(--shadow);padding:6px}
.pop:not([hidden]){animation:pop .22s var(--spring)}
.pop-label{font-size:11px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--ink-3);padding:6px 8px 4px}
.slash{width:300px;max-height:340px;overflow:auto;transform-origin:top left}
.si{display:flex;align-items:center;gap:11px;width:100%;padding:6px 8px;border-radius:6px;text-align:left;transition:background .12s}
.si.on{background:var(--hover)}
.glyph{width:38px;height:38px;flex:none;border-radius:7px;background:var(--island);box-shadow:0 0 0 1px var(--line-2);display:grid;place-items:center;font-weight:600;font-size:13px;color:var(--ink-2);transition:transform .25s var(--spring),color .2s}
.si.on .glyph{transform:scale(1.06);color:var(--blush)}
.glyph .i{width:17px;height:17px}
.si-t{display:flex;flex-direction:column;min-width:0;flex:1;line-height:1.3}
.si-t b{font-weight:500;font-size:13.5px}
.si-t small{color:var(--ink-3);font-size:12px}
.menu{width:236px;transform-origin:top right}
.mi{display:flex;align-items:center;gap:10px;width:100%;height:34px;padding:0 10px;border-radius:6px;color:var(--ink-2);text-align:left;transition:background .12s,color .12s}
.mi:hover{background:var(--hover);color:var(--ink)}
.mi.danger:hover,.mi.armed{color:var(--danger);background:color-mix(in oklab,var(--danger) 12%,transparent)}
.sepline{height:1px;background:var(--line);margin:5px 6px}
.overlay{position:fixed;inset:0;z-index:80;background:var(--scrim);backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px);display:flex;justify-content:center;align-items:flex-start;padding:12vh 16px 16px}
.overlay:not([hidden]){animation:fade .22s ease}
.overlay:not([hidden]) .panel{animation:panelIn .38s var(--spring)}
.overlay.closing{opacity:0;transition:opacity .16s}
.overlay.closing .panel{transform:scale(.97) translateY(6px);transition:transform .16s}
.panel{width:min(620px,100%);background:var(--raise);border-radius:12px;box-shadow:0 0 0 1px var(--line-2),var(--shadow);overflow:hidden}
.pal-top{display:flex;align-items:center;gap:12px;padding:16px 18px;border-bottom:1px solid var(--line);color:var(--ink-3)}
.pal-top .i{width:19px;height:19px}
.pal-top input{flex:1;min-width:0;border:0;outline:0;background:none;color:var(--ink);font:16.5px var(--f-ui)}
.pal-top input::placeholder{color:var(--ink-3)}
.pal-list{max-height:min(52vh,430px);overflow:auto;padding:6px}
.pi{display:flex;align-items:flex-start;gap:12px;width:100%;padding:9px 10px;border-radius:7px;text-align:left;transition:background .12s}
.pi.on{background:var(--hover)}
.pi .row-ico{padding-top:2px}
.pi.on .row-ico{color:var(--blush)}
.pi-t{display:flex;flex-direction:column;flex:1;min-width:0;gap:1px}
.pi-t b{font-weight:500;font-size:14px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.pi-t small{color:var(--ink-3);font-size:12.5px;line-height:1.45;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.pi-path{font:11px var(--f-mono);color:var(--ink-3);padding-top:3px;white-space:nowrap}
.pi mark{background:var(--blush-soft);color:var(--ink);border-radius:3px;padding:0 1px}
.pi-enter{opacity:0;color:var(--ink-3);padding-top:2px;transition:opacity .15s}
.pi.on .pi-enter{opacity:1}
.pal-empty{padding:28px 12px;text-align:center;color:var(--ink-3)}
.pal-foot{display:flex;gap:18px;padding:10px 18px;border-top:1px solid var(--line);color:var(--ink-3);font-size:12px}
.pal-foot span{display:flex;align-items:center;gap:5px}
.exp-top{display:flex;align-items:center;gap:12px;padding:12px 12px 12px 14px;border-bottom:1px solid var(--line)}
.seg{position:relative;display:flex;background:var(--island-2);border-radius:7px;padding:3px;flex:none}
.seg button{position:relative;z-index:1;height:28px;padding:0 14px;border-radius:6px;font-size:13px;color:var(--ink-2);transition:color .25s;min-width:96px}
.seg button.on{color:var(--ink)}
.seg-pill{position:absolute;top:3px;left:3px;height:28px;width:96px;border-radius:6px;background:var(--raise);box-shadow:0 0 0 1px var(--line-2),0 2px 6px rgba(0,0,0,.12);transition:transform .4s var(--spring)}
.seg.md .seg-pill{transform:translateX(0)}
.seg.html .seg-pill{transform:translateX(96px)}
.seg.pdf .seg-pill{transform:translateX(192px)}
.exp-name{flex:1;min-width:0;font:12px var(--f-mono);color:var(--ink-3);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.exp-out{margin:0;padding:16px 18px;max-height:min(50vh,420px);overflow:auto;font:12.5px/1.7 var(--f-mono);color:var(--ink-2);white-space:pre-wrap;word-break:break-word;background:var(--code-bg)}
.exp-out.swap{animation:fade .3s ease}
.exp-foot{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px 14px 12px 18px;border-top:1px solid var(--line);color:var(--ink-3);font-size:12.5px}
.btn{display:inline-flex;align-items:center;gap:7px;height:32px;padding:0 14px;border-radius:6px;font-weight:500;font-size:13px;transition:transform .2s var(--spring),filter .15s;flex:none}
.btn.primary{background:var(--blush);color:var(--blush-ink)}
.btn.primary:hover{filter:brightness(1.06)}
.btn:active{transform:scale(.95)}
.btn .i{width:14px;height:14px}
.toast{position:fixed;left:50%;bottom:24px;z-index:90;display:flex;align-items:center;gap:8px;padding:10px 16px;border-radius:8px;background:var(--ink);color:var(--island);font-size:13px;font-weight:500;box-shadow:var(--shadow);opacity:0;transform:translate(-50%,16px) scale(.96);pointer-events:none;transition:opacity .2s,transform .4s var(--spring)}
.toast.show{opacity:1;transform:translate(-50%,0)}
.toast .i{width:15px;height:15px;color:var(--blush)}
.toast.act.show{pointer-events:auto;padding-right:6px}
.toast-btn{margin-left:6px;padding:5px 10px;border-radius:6px;color:var(--blush);font-weight:600;transition:background .15s}
.toast-btn:hover{background:color-mix(in oklab,var(--island) 14%,transparent)}
.scrim{display:none}
@keyframes rise{from{opacity:0;transform:translateY(9px)}to{opacity:1;transform:none}}
@keyframes blockIn{from{opacity:0;transform:translateY(-6px)}}
@keyframes morph{from{opacity:.35;transform:scale(.98)}}
@keyframes fresh{0%{opacity:0;transform:translateX(-10px)}40%{opacity:1;transform:none;background:var(--blush-soft)}100%{background:transparent}}
@keyframes pop{from{opacity:0;transform:translateY(-4px) scale(.96)}}
@keyframes fade{from{opacity:0}}
@keyframes panelIn{from{opacity:0;transform:translateY(14px) scale(.96)}}
@keyframes pulse{50%{opacity:.35;transform:scale(.6)}}
@keyframes bump{35%{transform:scale(.8)}70%{transform:scale(1.12)}}
@keyframes chipIn{from{opacity:0;transform:scale(.6)}}
@keyframes draw{from{transform:scaleX(0)}}
@keyframes spin{from{transform:rotate(-180deg) scale(.6)}}
@keyframes coverIn{from{opacity:0;transform:scale(1.03)}}
@keyframes drift{from{transform:translate(-4%,-3%) scale(1.1)}to{transform:translate(4%,3%) scale(1.15)}}
::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}
.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}
.sb-inner{width:100%}
.app.drawer .scrim{display:block;position:fixed;inset:0;z-index:55;background:var(--scrim);animation:fade .25s}
.menu-btn,.app.collapsed .menu-btn{display:grid}
.gutter{display:none}
.wc{display:none}
.page{padding:0 18px}
.prop{grid-template-columns:96px minmax(0,1fr)}
.cover{margin:0 6px}
}
@media (max-width:460px){.status span:last-child{display:none}}
@media (prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.01ms!important;animation-iteration-count:1!important;transition-duration:.01ms!important}}
/* ---------- desktop shell ---------- */
.topbar,.ws{-webkit-app-region:drag}
.topbar button,.topbar .menu,.ws button{-webkit-app-region:no-drag}
.ctx{transform-origin:top left;-webkit-app-region:no-drag}
.row-rename{font:inherit;color:var(--ink);background:var(--island);border:0;border-radius:5px;padding:2px 6px;margin:0 -6px;outline:none;box-shadow:0 0 0 1px var(--blush-line)}
.dragstrip{position:fixed;top:0;left:0;right:0;height:10px;-webkit-app-region:drag}
.topbar{position:relative}
.topbar .menu{position:absolute;top:46px;right:10px;transform-origin:top right}
.app.mac .ws{padding-top:34px}
.app.mac.collapsed .topbar{padding-left:84px}
.btn-icon:disabled{opacity:.35;pointer-events:none}
.ws-path{font:11px var(--f-mono);color:var(--ink-3);text-align:left;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%;padding:0;transition:color .15s}
.ws-path:hover{color:var(--ink-2)}
.muted,.empty-tree{color:var(--ink-3);font-size:12.5px;padding:2px 9px;margin:0}
.row.ghost .name{color:var(--ink-3)}
.pv-link{text-align:left;padding:0;border-radius:4px;transition:color .15s}
.pv-link:hover{color:var(--ink)}
.blocks{padding-top:4px}
.empty-page{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:14px;height:70%;color:var(--ink-3)}
.btn{background:var(--hover);color:var(--ink)}
.btn:hover{background:var(--press)}
.btn.primary:hover{background:var(--blush)}
.exp-foot{justify-content:flex-end}
.welcome{height:100%;display:grid;place-items:center;padding:24px;-webkit-app-region:drag}
.welcome-card{width:min(460px,100%);padding:36px 32px 30px;display:flex;flex-direction:column;align-items:flex-start;gap:14px;animation:panelIn .6s var(--spring);-webkit-app-region:no-drag}
.welcome-card h1{margin:6px 0 0;font-size:24px;font-weight:700;letter-spacing:-.02em;text-wrap:balance}
.welcome-card p{margin:0;color:var(--ink-2);font-size:14.5px;line-height:1.6}
.welcome-actions{display:flex;flex-wrap:wrap;gap:8px;margin-top:8px}
.winctl{display:flex;align-items:center;gap:2px;margin-left:6px;padding-left:8px;border-left:1px solid var(--line);-webkit-app-region:no-drag}
.winctl .close:hover{background:var(--danger);color:var(--blush-ink)}
.welcome .winctl{position:fixed;top:10px;right:10px;border-left:0}
.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;
}
/* custom properties */
.custom-pk, .custom-pv {
background: transparent;
border: 1px solid transparent;
color: inherit;
font: inherit;
outline: none;
padding: 0 4px;
border-radius: 4px;
height: 24px;
width: 100%;
}
.custom-pk {
width: 80px;
}
.custom-pk:focus, .custom-pk:hover, .custom-pv:focus, .custom-pv:hover {
background: var(--hover);
}
.prop:hover .chip-x { opacity: 1; }
.prop .chip-x { opacity: 0; pointer-events: none; }
.prop:hover .chip-x { opacity: 0.6; pointer-events: auto; }
.prop:hover .chip-x:hover { opacity: 1; }
+279
View File
@@ -0,0 +1,279 @@
<script>
import Icon from './Icon.svelte';
import Editor from './Editor.svelte';
import CoverPicker from './CoverPicker.svelte';
import Backlinks from './Backlinks.svelte';
import Board from './Board.svelte';
import Outline from './Outline.svelte';
import { store, iconOf, coverOf } from '$lib/state.svelte.js';
import { COVERS } from '$lib/icons.js';
import IconPicker from './IconPicker.svelte';
import { api } from '$lib/api.js';
let doc = $derived(store.doc);
let page = $derived(store.current);
let icon = $derived(iconOf({ icon: doc?.meta.icon, title: doc?.title }));
let cover = $derived(coverOf({ cover: doc?.meta.cover, title: doc?.title }));
let tags = $derived(Array.isArray(doc?.meta.tags) ? doc.meta.tags : []);
let created = $derived(doc?.meta.created || page?.created || null);
let scrollerEl = $state();
let titleEl = $state();
let iconEl = $state();
let picking = $state(false);
let coverEl = $state();
let pickingCover = $state(false);
let coverBtnEl = $state();
let tagInput = $state(null);
let removing = $state(null);
// Keep the title element in sync when the page changes or a rename settles, but never while typing.
$effect(() => {
const d = store.doc;
if (!titleEl || !d) return;
if (document.activeElement === titleEl) return;
const blankNew = store.fresh === d.id && /^Untitled( \d+)?$/.test(d.title);
titleEl.textContent = blankNew ? '' : d.title;
if (blankNew) requestAnimationFrame(() => titleEl && titleEl.focus());
});
const fmtDate = (d) => {
if (!d) return '—';
const t = new Date(String(d).length === 10 ? d + 'T12:00:00' : d);
return isNaN(t) ? String(d) : t.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
};
function restart(el, cls) {
if (!el) return;
el.classList.remove(cls);
void el.offsetWidth;
el.classList.add(cls);
}
function setIcon(v) {
picking = false;
store.setMeta({ icon: v });
restart(iconEl, 'spin');
iconEl?.focus();
}
function setCover(v) {
pickingCover = false;
store.setMeta({ cover: v });
restart(coverEl, 'enter');
}
function onTitleInput() {
if (titleEl.innerHTML === '<br>') titleEl.innerHTML = '';
store.titleDraft = titleEl.textContent;
}
function onTitleKey(e) {
if (e.key === 'Enter' || (e.key === 'ArrowDown' && atEnd())) {
e.preventDefault();
titleEl.blur();
store.editor?.focusFirst();
}
if (e.key === 'Escape') titleEl.blur();
}
function atEnd() {
const s = getSelection();
if (!s.rangeCount || !s.isCollapsed) return false;
const r = document.createRange();
r.selectNodeContents(titleEl);
r.setStart(s.focusNode, s.focusOffset);
return r.toString().length === 0;
}
function onTitlePaste(e) {
e.preventDefault();
document.execCommand('insertText', false, e.clipboardData.getData('text/plain').replace(/\s*\n\s*/g, ' '));
}
function toTitleEnd() {
if (!titleEl) return;
titleEl.focus();
const r = document.createRange();
r.selectNodeContents(titleEl);
r.collapse(false);
const s = getSelection();
s.removeAllRanges();
s.addRange(r);
}
function addTag(commit) {
const v = String(tagInput || '').trim().toLowerCase().replace(/^#/, '').replace(/\s+/g, '-');
tagInput = null;
if (commit && v && !tags.includes(v)) store.setMeta({ tags: [...tags, v] });
}
function removeTag(t) {
removing = t;
setTimeout(() => {
store.setMeta({ tags: tags.filter((x) => x !== t) });
removing = null;
}, 180);
}
function focusOnMount(el) { el.focus(); }
let addingProp = $state(false);
let newPropEl = $state();
function updateMeta(k, v) {
if (Array.isArray(doc.meta[k])) {
store.setMeta({ [k]: v.split(',').map(s => s.trim()).filter(Boolean) });
} else {
store.setMeta({ [k]: v });
}
}
function removeMeta(k) {
const copy = { ...doc.meta };
delete copy[k];
store.doc = { ...doc, meta: copy };
store.setMeta({ [k]: undefined });
}
function renameMeta(oldK, newK) {
newK = newK.trim().replace(/[^a-zA-Z0-9_-]/g, '-');
if (!newK || newK === oldK || doc.meta[newK] !== undefined) return;
const v = doc.meta[oldK];
const copy = { ...doc.meta };
delete copy[oldK];
copy[newK] = v;
store.doc = { ...doc, meta: copy };
store.setMeta({ [oldK]: undefined, [newK]: v });
}
function commitNewProp(k) {
k = k.trim().replace(/[^a-zA-Z0-9_-]/g, '-');
if (k && !doc.meta[k] && !['icon', 'cover', 'tags', 'created'].includes(k)) {
store.setMeta({ [k]: '' });
}
addingProp = false;
}
</script>
<div class="pagewrap">
<div class="scroller" bind:this={scrollerEl}>
{#if doc}
<datalist id="all-tags">
{#each store.tagCounts as [tag]}
<option value={tag}></option>
{/each}
</datalist>
{#key doc.version}
<div class="cover enter" bind:this={coverEl} style="--c1:{COVERS[cover][0]};--c2:{COVERS[cover][1]}">
<button class="cover-btn" bind:this={coverBtnEl} onclick={() => (pickingCover = !pickingCover)}><Icon name="image" />Change cover</button>
</div>
<article class="page enter" class:wide={store.isBoard}>
<div class="head">
<button class="p-icon" bind:this={iconEl} aria-label="Change icon" title="Change icon" aria-expanded={picking} onclick={() => (picking = !picking)}>
<Icon name={icon} />
</button>
<h1
class="title"
bind:this={titleEl}
contenteditable="true"
spellcheck="false"
data-ph="Untitled"
oninput={onTitleInput}
onkeydown={onTitleKey}
onblur={() => store.commitTitle(titleEl.textContent)}
onpaste={onTitlePaste}
></h1>
<div class="props">
<div class="prop">
<span class="pk"><Icon name="hash" />Tags</span>
<div class="pv tagv">
{#each tags as t (t)}
<span class="chip in" class:out={removing === t}>
{t}<button class="chip-x" aria-label="Remove tag {t}" onclick={() => removeTag(t)}><Icon name="x" /></button>
</span>
{/each}
{#if tagInput !== null}
<input
class="chip-in"
list="all-tags"
placeholder="tag name"
aria-label="New tag"
bind:value={tagInput}
use:focusOnMount
onkeydown={(e) => {
if (e.key === 'Enter') { e.preventDefault(); addTag(true); }
if (e.key === 'Escape') { e.preventDefault(); addTag(false); }
}}
onblur={() => addTag(true)}
/>
{:else}
<button class="chip-add" onclick={() => (tagInput = '')}><Icon name="plus" />Add</button>
{/if}
</div>
</div>
<div class="prop">
<span class="pk"><Icon name="clock" />Created</span>
<span class="pv">{fmtDate(created)}</span>
</div>
<div class="prop">
<span class="pk"><Icon name="file" />File</span>
<button class="pv mono pv-link" title="Show in folder" onclick={() => api.reveal(doc.id)}>{store.relPath(doc.id)}</button>
</div>
{#each Object.keys(doc.meta).filter(k => doc.meta[k] !== undefined && !['icon', 'cover', 'tags', 'created'].includes(k)) as pk (pk)}
<div class="prop">
<span class="pk">
<Icon name="list" />
<input class="custom-pk" value={pk} onchange={(e) => renameMeta(pk, e.target.value)} />
</span>
<div style="display:flex; gap: 4px; align-items: center; width: 100%;">
<input
class="pv custom-pv"
value={Array.isArray(doc.meta[pk]) ? doc.meta[pk].join(', ') : doc.meta[pk]}
onchange={(e) => updateMeta(pk, e.target.value)}
placeholder="Empty"
/>
<button class="chip-x" aria-label="Remove property" onclick={() => removeMeta(pk)}><Icon name="x" /></button>
</div>
</div>
{/each}
{#if addingProp}
<div class="prop">
<span class="pk"><Icon name="list" /><input class="custom-pk" bind:this={newPropEl} placeholder="Key" onkeydown={(e) => { if (e.key === 'Enter') commitNewProp(e.target.value); if (e.key === 'Escape') addingProp = false; }} onblur={(e) => commitNewProp(e.target.value)} /></span>
<span class="pv custom-pv"></span>
</div>
{/if}
<div class="prop">
<span class="pk"></span>
<div class="pv"><button class="chip-add" onclick={() => { addingProp = true; setTimeout(() => newPropEl?.focus(), 0); }}><Icon name="plus" />Add property</button></div>
</div>
</div>
</div>
{#if !store.isBoard}
<Editor blocks={doc.blocks} onexittop={toTitleEnd} />
<Backlinks id={doc.id} />
{/if}
</article>
{#if pickingCover}
<CoverPicker
anchor={coverBtnEl}
current={cover}
onpick={setCover}
onclose={() => { pickingCover = false; coverBtnEl?.focus(); }}
/>
{/if}
{#if picking}
<IconPicker
anchor={iconEl}
current={doc.meta.icon || ''}
onpick={setIcon}
onremove={() => setIcon('')}
onclose={() => { picking = false; iconEl?.focus(); }}
/>
{/if}
{#if store.isBoard}
<Board body={doc.body} />
<div class="page wide"><Backlinks id={doc.id} /></div>
{/if}
{/key}
{:else}
<div class="empty-page">
<p>This folder has no pages yet.</p>
<button class="btn primary" onclick={() => (store.templatesOpen = { parent: null })}><Icon name="plus" />New page</button>
</div>
{/if}
</div>
{#if store.outlineOpen && doc && !store.isBoard}<Outline scroller={scrollerEl} />{/if}
</div>
+148
View File
@@ -0,0 +1,148 @@
// Stroke icons on a 24px grid. Multiple paths are separated by "|".
export const PATHS = {
plus: 'M12 5v14|M5 12h14',
chev: 'M9 6l6 6-6 6',
search: 'M4 11a7 7 0 1 0 14 0a7 7 0 1 0-14 0|M16.5 16.5L21 21',
panel: 'M4 5h16v14H4z|M9.5 5v14',
dots: 'M6 12h.01|M12 12h.01|M18 12h.01',
grip: 'M9 6h.01|M15 6h.01|M9 12h.01|M15 12h.01|M9 18h.01|M15 18h.01',
x: 'M6 6l12 12|M18 6L6 18',
minimize: 'M6 12h12',
maximize: 'M6 6h12v12H6z',
restore: 'M8 9h10v10H8z|M6 14V5h11',
board: 'M4 4h4.5v16H4z|M9.75 4h4.5v11h-4.5z|M15.5 4H20v7h-4.5z',
star: 'M12 3.8l2.5 5.2 5.7.8-4.1 4 1 5.6L12 16.7l-5.1 2.7 1-5.6-4.1-4 5.7-.8z',
undo: 'M9 14L4 9l5-5|M4 9h10.5a5.5 5.5 0 0 1 0 11H11',
hash: 'M5 9h14|M5 15h14|M10 4L8 20|M16 4l-2 16',
list: 'M4 6h16|M4 12h16|M4 18h16',
clock: 'M3 12a9 9 0 1 0 18 0a9 9 0 1 0-18 0|M12 7.5V12l3 2',
file: 'M7 3h7l5 5v13H7z|M14 3v5h5',
folder: 'M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z',
export: 'M12 15V3|M7 8l5-5 5 5|M5 14v6h14v-6',
copy: 'M9 9h11v11H9z|M5 15V4h11',
trash: 'M4 7h16|M10 11v6|M14 11v6|M6 7l1 13h10l1-13|M9 7V4h6v3',
tick: 'M5 12.5l4.5 4.5L19 7.5',
check: 'M5 12.5l4.5 4.5L19 7.5',
image: 'M4 5h16v14H4z|M4 16l5-5 4 4 3-3 4 4|M15 9h.01',
sun: 'M12 8a4 4 0 1 0 0 8a4 4 0 1 0 0-8|M12 2v2|M12 20v2|M4.9 4.9l1.4 1.4|M17.7 17.7l1.4 1.4|M2 12h2|M20 12h2|M4.9 19.1l1.4-1.4|M17.7 6.3l1.4-1.4',
moon: 'M20 14.5A8 8 0 1 1 9.5 4a6.5 6.5 0 0 0 10.5 10.5z',
enter: 'M9 10l-5 5 5 5|M20 4v7a4 4 0 0 1-4 4H4',
save: 'M5 4h11l3 3v13H5z|M8 4v5h7V4|M8 20v-6h8v6',
sparkle: 'M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8z|M19 16l.7 1.8 1.8.7-1.8.7L19 21l-.7-1.8-1.8-.7 1.8-.7z',
pen: 'M4 20l4-1 11-11-3-3L5 16z|M14 6l3 3',
leaf: 'M5 19c0-8 5-13 14-14-1 9-6 14-14 14z|M5 19l7-7',
book: 'M5 4h11a3 3 0 0 1 3 3v13H8a3 3 0 0 1-3-3z|M5 17a3 3 0 0 1 3-3h11',
code: 'M8 8l-4 4 4 4|M16 8l4 4-4 4|M13.5 5l-3 14',
compass: 'M3 12a9 9 0 1 0 18 0a9 9 0 1 0-18 0|M15.5 8.5l-2 5-5 2 2-5z',
cup: 'M5 9h12v5a5 5 0 0 1-5 5h-2a5 5 0 0 1-5-5z|M17 10h1.5a2.5 2.5 0 0 1 0 5H17|M9 3.5v2.5|M13 3.5v2.5',
outline: 'M4 6h16|M8 12h12|M12 18h8',
graph: 'M4 7a2.5 2.5 0 1 0 5 0a2.5 2.5 0 1 0-5 0|M15 5a2.5 2.5 0 1 0 5 0a2.5 2.5 0 1 0-5 0|M11 17.5a2.5 2.5 0 1 0 5 0a2.5 2.5 0 1 0-5 0|M8.9 6.6l6.2-1.2|M7.6 9.1l4.4 6.3|M16.9 7.4l-2.3 7.8',
cut: 'M3 6a3 3 0 1 0 6 0a3 3 0 1 0-6 0|M3 18a3 3 0 1 0 6 0a3 3 0 1 0-6 0|M20 4L8.1 15.9|M14.5 14.5L20 20|M8.1 8.1L12 12',
paste: 'M9 3h6v3H9z|M9 4.5H6V21h12V4.5h-3',
selectAll: 'M4 8V4h4|M16 4h4v4|M20 16v4h-4|M8 20H4v-4|M10.5 4h3|M10.5 20h3|M4 10.5v3|M20 10.5v3',
table: 'M4 5h16v14H4z|M4 10h16|M4 14.5h16|M10 5v14',
link: 'M10 14a4 4 0 0 0 5.66 0l3-3a4 4 0 0 0-5.66-5.66l-1 1|M14 10a4 4 0 0 0-5.66 0l-3 3a4 4 0 0 0 5.66 5.66l1-1',
unlink: 'M10 14a4 4 0 0 0 5.66 0l3-3a4 4 0 0 0-5.66-5.66l-1 1|M14 10a4 4 0 0 0-5.66 0l-3 3a4 4 0 0 0 5.66 5.66l1-1|M4 4l16 16',
alignLeft: 'M4 6h16|M4 10h10|M4 14h16|M4 18h10',
alignCenter: 'M4 6h16|M7 10h10|M4 14h16|M7 18h10',
alignRight: 'M4 6h16|M10 10h10|M4 14h16|M10 18h10',
minus: 'M5 12h14',
monitor: 'M3 5h18v12H3z|M8 21h8|M12 17v4',
gear: 'M9 12a3 3 0 1 0 6 0a3 3 0 1 0-6 0|M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z',
shuffle: 'M3 7h3c4 0 6 10 10 10h5|M18 14l3 3-3 3|M3 17h3c1.5 0 2.7-1.4 3.8-3.2|M14.2 10.2C15.3 8.4 16.5 7 18 7h3|M18 4l3 3-3 3',
// page icons offered in the picker
home: 'M4 11l8-7 8 7|M6 9.5V20h12V9.5|M10 20v-6h4v6',
heart: 'M12 20s-7.5-4.6-7.5-10.2A4.1 4.1 0 0 1 12 7.4a4.1 4.1 0 0 1 7.5 2.4C19.5 15.4 12 20 12 20z',
bolt: 'M13 3L5 14h6l-1 7 8-11h-6z',
flag: 'M5 21V4|M5 4h12l-2.5 4L17 12H5',
bell: 'M6 16v-5a6 6 0 0 1 12 0v5l1.5 2h-15z|M10 20.5a2 2 0 0 0 4 0',
bookmark: 'M6 3h12v18l-6-4-6 4z',
calendar: 'M4 6h16v14H4z|M4 10h16|M8 3v5|M16 3v5',
camera: 'M4 8h4l2-3h4l2 3h4v11H4z|M9 13.5a3 3 0 1 0 6 0a3 3 0 1 0-6 0',
music: 'M9 17V5l10-2v12|M4 17a2.5 2.5 0 1 0 5 0a2.5 2.5 0 1 0-5 0|M14 15a2.5 2.5 0 1 0 5 0a2.5 2.5 0 1 0-5 0',
globe: 'M3 12a9 9 0 1 0 18 0a9 9 0 1 0-18 0|M3 12h18|M12 3a14 14 0 0 1 0 18|M12 3a14 14 0 0 0 0 18',
map: 'M9 4L3 6v14l6-2 6 2 6-2V4l-6 2z|M9 4v14|M15 6v14',
target: 'M3 12a9 9 0 1 0 18 0a9 9 0 1 0-18 0|M7 12a5 5 0 1 0 10 0a5 5 0 1 0-10 0|M11 12a1 1 0 1 0 2 0a1 1 0 1 0-2 0',
bulb: 'M9 18h6|M10 21h4|M12 3a6 6 0 0 0-4 10.5c.7.7 1 1.5 1 2.5h6c0-1 .3-1.8 1-2.5A6 6 0 0 0 12 3z',
rocket: 'M12 3c3 2 5 6 4 11l-4 3-4-3c-1-5 1-9 4-11z|M12 9.5h.01|M8 14l-3 3 1 3 3-2|M16 14l3 3-1 3-3-2',
terminal: 'M4 5h16v14H4z|M8 10l3 2-3 2|M13 15h3',
database: 'M5 6c0-1.7 3.1-3 7-3s7 1.3 7 3-3.1 3-7 3-7-1.3-7-3z|M5 6v12c0 1.7 3.1 3 7 3s7-1.3 7-3V6|M5 12c0 1.7 3.1 3 7 3s7-1.3 7-3',
cpu: 'M7 7h10v10H7z|M10 10h4v4h-4z|M9 3v4|M15 3v4|M9 17v4|M15 17v4|M3 9h4|M3 15h4|M17 9h4|M17 15h4',
gamepad: 'M6.5 8h11a4 4 0 0 1 3.9 4.9l-.9 3.6a2.4 2.4 0 0 1-4.2 1L15 16H9l-1.3 1.5a2.4 2.4 0 0 1-4.2-1l-.9-3.6A4 4 0 0 1 6.5 8z|M8 11v3|M6.5 12.5h3|M15 11.5h.01|M17 13.5h.01',
palette: 'M12 3a9 9 0 0 0 0 18c1.1 0 2-.9 2-2 0-.5-.2-1-.5-1.3-.3-.4-.5-.8-.5-1.2 0-1.1.9-2 2-2h2.5A4.5 4.5 0 0 0 21 10c0-3.9-4-7-9-7z|M7.5 11h.01|M10 7.5h.01|M14.5 7.5h.01',
chart: 'M4 4v16h16|M8 16v-4|M12 16V8|M16 16v-6',
wallet: 'M4 7h15a1 1 0 0 1 1 1v11H5a1 1 0 0 1-1-1z|M4 7l11-3v3|M16 13h.01',
briefcase: 'M4 8h16v11H4z|M9 8V5h6v3|M4 13h16',
school: 'M2 9l10-5 10 5-10 5z|M6 11v5c0 1.5 2.7 3 6 3s6-1.5 6-3v-5|M22 9v6',
users: 'M5.5 7.5a3.5 3.5 0 1 0 7 0a3.5 3.5 0 1 0-7 0|M3 20c0-3.3 2.7-6 6-6s6 2.7 6 6|M16 4.5a3.5 3.5 0 0 1 0 6.5|M18 14c1.8.9 3 2.8 3 5',
user: 'M8 8a4 4 0 1 0 8 0a4 4 0 1 0-8 0|M4 21c0-4.4 3.6-8 8-8s8 3.6 8 8',
mail: 'M3 6h18v12H3z|M3 7l9 6 9-6',
chat: 'M4 5h16v11H9l-5 4z',
lock: 'M6 11h12v9H6z|M8.5 11V8a3.5 3.5 0 0 1 7 0v3',
key: 'M4 11a4 4 0 1 0 8 0a4 4 0 1 0-8 0|M12 11h9|M18 11v3|M15 11v2',
tree: 'M12 3l6 8h-3l4 6H5l4-6H6z|M12 17v4',
send: 'M21 3L3 10.5l7 2.5 2.5 7z|M10 13L21 3',
pulse: 'M3 12h4l3-7 4 14 3-7h4',
food: 'M7 3v8|M5 3v5a2 2 0 0 0 4 0V3|M7 11v10|M17 3c-2 1.5-3 4-3 7h3v11',
cart: 'M3 4h2.5l2.2 11h10.6L20.5 7H7|M9 19.5h.01|M17 19.5h.01',
gift: 'M4 9h16v4H4z|M5 13h14v8H5z|M12 9v12|M12 9C10.5 5 6 5 6 7.5S10 9 12 9|M12 9c1.5-4 6-4 6-1.5S14 9 12 9',
tag: 'M3 12V4h8l10 10-8 8z|M7.5 8h.01',
inbox: 'M3 13l3-8h12l3 8v6H3z|M3 13h5l1 2h6l1-2h5',
layers: 'M12 3l9 5-9 5-9-5z|M3 13l9 5 9-5',
check2: 'M3 12a9 9 0 1 0 18 0a9 9 0 1 0-18 0|M8 12.5l2.5 2.5L16 9.5'
};
// The stroke icons offered in the icon picker, with words to find them by.
export const PICKER_ICONS = [
['sparkle', 'star new magic'], ['pen', 'write edit note'], ['leaf', 'nature plant green'], ['book', 'read study learn'],
['code', 'dev programming'], ['moon', 'night dark sleep'], ['compass', 'explore direction travel'], ['cup', 'coffee tea break'],
['home', 'house'], ['heart', 'love health favorite'], ['star', 'favorite important'], ['bolt', 'energy fast idea'],
['bulb', 'idea light think'], ['rocket', 'launch project start'], ['target', 'goal aim focus'], ['flag', 'milestone goal'],
['calendar', 'date plan schedule'], ['clock', 'time'], ['bell', 'reminder notify'], ['bookmark', 'save later'],
['board', 'kanban columns'], ['check2', 'done todo task'], ['inbox', 'capture tasks'], ['layers', 'stack design'],
['file', 'document page'], ['folder', 'files directory'], ['tag', 'label'], ['hash', 'channel topic'],
['terminal', 'shell console dev'], ['database', 'data storage sql'], ['cpu', 'chip hardware computer'], ['gamepad', 'game play controller'],
['palette', 'art design color'], ['camera', 'photo picture'], ['image', 'picture photo'], ['music', 'song audio'],
['chart', 'stats graph data'], ['wallet', 'money finance budget'], ['briefcase', 'work job business'], ['school', 'study university education'],
['user', 'person me profile'], ['users', 'people team group'], ['mail', 'email letter'], ['chat', 'message talk'],
['globe', 'world web internet'], ['map', 'travel place'], ['send', 'share plane'], ['tree', 'nature forest'],
['sun', 'day light summer'], ['pulse', 'health fitness activity'], ['food', 'recipe cooking eat'], ['cart', 'shopping buy'],
['gift', 'present birthday'], ['lock', 'private secure'], ['key', 'password access'], ['trash', 'delete bin']
];
// Emoji offered in the picker, as "emoji words" pairs.
export const PICKER_EMOJI = [
'📝 note memo write', '📒 notebook ledger', '📚 books library study', '📖 book read', '🗂️ folders files', '📁 folder', '📌 pin', '📎 clip attach',
'✅ done check', '☑️ checkbox todo', '🗒️ notepad', '📅 calendar date', '🗓️ calendar plan', '⏰ alarm time', '⌛ hourglass time', '🔖 bookmark',
'💡 idea bulb', '🎯 goal target', '🚀 rocket launch', '⭐ star', '✨ sparkles new', '🔥 fire hot', '⚡ lightning fast', '🏁 finish flag',
'💻 laptop computer code', '🖥️ desktop computer', '⌨️ keyboard', '🖱️ mouse', '🧠 brain think', '🤖 robot ai', '🧪 test experiment', '🔬 science research',
'🎮 game controller', '🕹️ joystick game', '👾 alien game', '🧩 puzzle', '🎲 dice game', '♟️ chess strategy', '🎨 art paint', '🎵 music note',
'🎧 headphones music', '🎬 film movie', '📷 camera photo', '🎤 microphone podcast', '🎸 guitar', '📺 tv', '📻 radio', '🎭 theater',
'🏠 home house', '🏢 office building work', '🏫 school', '🏥 hospital health', '🌍 world earth globe', '🗺️ map travel', '✈️ plane travel flight', '🚗 car',
'🚲 bike', '⛰️ mountain', '🏖️ beach holiday', '🏕️ camping', '🌲 tree forest', '🌱 seedling grow', '🌸 flower blossom', '🍀 clover luck',
'☀️ sun', '🌙 moon night', '⛅ weather cloud', '🌧️ rain', '❄️ snow winter', '🌈 rainbow', '🌊 wave ocean', '🌋 volcano',
'☕ coffee', '🍵 tea', '🍕 pizza food', '🍔 burger food', '🥗 salad healthy', '🍎 apple fruit', '🍰 cake dessert', '🍳 cooking recipe',
'💰 money', '💳 card payment', '📈 chart growth up', '📉 chart down', '📊 bar chart stats', '🧾 receipt bill', '🏦 bank', '💼 briefcase work',
'👤 person user', '👥 people team', '👋 wave hello', '🤝 handshake deal', '💬 chat message', '📧 email mail', '📞 phone call', '📣 announcement',
'❤️ heart love', '💪 strong fitness', '🏃 run exercise', '🧘 yoga calm', '😴 sleep', '🩺 doctor health', '💊 pill medicine', '🏋️ gym',
'🔒 lock private', '🔑 key password', '🛠️ tools build', '🔧 wrench fix', '⚙️ gear settings', '🧰 toolbox', '🐛 bug', '🧹 cleanup',
'📦 package box', '🎁 gift', '🛒 shopping cart', '🏷️ tag label', '🔔 bell reminder', '❓ question', '❗ important', '⚠️ warning',
'🐶 dog', '🐱 cat', '🦊 fox', '🐼 panda', '🦉 owl', '🐢 turtle', '🦋 butterfly', '🐙 octopus'
].map((s) => { const i = s.indexOf(' '); return [s.slice(0, i), s.slice(i + 1)]; });
export const isEmoji = (s) => typeof s === 'string' && /\p{Extended_Pictographic}/u.test(s) && s.length <= 16;
export const paths = (name) => (PATHS[name] || PATHS.pen).split('|');
// String form for the vanilla editor engine.
export const ic = (name, cls = '') =>
`<svg class="i ${cls}" viewBox="0 0 24 24" aria-hidden="true">${paths(name).map((d) => `<path d="${d}"/>`).join('')}</svg>`;
export const PAGE_ICONS = ['sparkle', 'pen', 'leaf', 'book', 'code', 'moon', 'compass', 'cup'];
export const COVERS = {
blush: ['#d99a91', '#caa07c'],
sage: ['#97b096', '#d1b48e'],
sand: ['#d9b886', '#c9958c'],
dusk: ['#a596c6', '#d59b9b']
};
+453
View File
@@ -0,0 +1,453 @@
// Markdown <-> editor blocks.
//
// file body ──parseMarkdown──> [{type, html, ...}] ──editor──> blocks ──toMarkdown──> file body
//
// Block types: p, h1, h2, h3, bullet, number, todo, quote, callout, toggle, code, divider, image, table.
// Inline formatting kept: **bold**, *italic*, ~~strike~~, ==highlight==, `code`, <kbd>, [[page links]], [web](links),
// bare URLs, and footnote references [^id] / ^[inline note]. Footnote texts ("[^id]: …" lines) are blocks.
import { esc, unesc } from './text.js';
import { linkLabel, IMAGE_FILE, OTHER_FILE } from './links.js';
let seq = 0;
export const blockId = () => 'b' + (++seq).toString(36) + Math.random().toString(36).slice(2, 6);
export const block = (type, html = '', extra = {}) => ({ id: blockId(), type, html, ...extra });
/* ---------- inline ---------- */
// A page link in the editor: one atomic chip. `ref` and `label` are plain text.
export const linkChip = (ref, label) =>
`<span class="wl" contenteditable="false" data-ref="${esc(ref)}">${esc(label || linkLabel(ref))}</span>`;
// Web links. Only these schemes become clickable; anything else stays as written.
export const isWebUrl = (u) => /^(https?:\/\/[^\s]+|mailto:[^\s]+)$/i.test(String(u || ''));
// A web link in the editor. `url` and `title` are plain text, `label` is HTML.
// `auto` marks a bare URL in the file, so it is written back bare.
export const webLink = (url, label, { auto = false, title = '' } = {}) =>
`<a class="xl" href="${esc(url)}" spellcheck="false"${auto ? ' data-auto="1"' : ''}${title ? ` data-title="${esc(title)}"` : ''}>${label}</a>`;
// Trailing punctuation is usually the sentence, not the URL; a ")" only belongs to it when it opened one.
function trimUrl(u) {
let url = u;
for (;;) {
const m = url.match(/(?:[.,;:!?'"*_]|&quot;|&#39;)$/);
if (m) { url = url.slice(0, -m[0].length); continue; }
if (url.endsWith(')') && (url.match(/\(/g) || []).length < (url.match(/\)/g) || []).length) { url = url.slice(0, -1); continue; }
return url;
}
}
// A footnote reference in the editor: an atomic superscript. The number is filled in by the editor (it depends on
// the order of references on the page); the file only ever stores [^id] or ^[text].
export const footnoteRef = (id) => `<sup class="fn" contenteditable="false" data-fn="${esc(id)}">${esc(id)}</sup>`;
export const inlineNote = (text) => `<sup class="fn inline" contenteditable="false" data-inline="${esc(text)}">*</sup>`;
export function mdToHtml(md) {
const held = [];
const hold = (html) => `\u0000${held.push(html) - 1}\u0000`;
let s = esc(md)
.replace(/`([^`\n]+)`/g, (_, c) => hold(`<code>${c}</code>`))
.replace(/\$\$([^$\n]+)\$\$/g, (_, c) => hold(`<code class="language-math">${c}</code>`))
.replace(/\$([^$\n]+)\$/g, (_, c) => hold(`<code class="language-math">${c}</code>`))
.replace(/(?<!!)\[\[([^\[\]\n|]+)(?:\|([^\[\]\n]*))?\]\]/g, (_, ref, alias) => hold(linkChip(unesc(ref), alias ? unesc(alias) : '')))
// footnotes: [^id] points at a "[^id]: text" line; ^[text] carries its text inline
.replace(/\[\^([^\]\s^]+)\]/g, (_, id) => hold(footnoteRef(unesc(id))))
.replace(/\^\[([^\]\n]+)\]/g, (_, t) => hold(inlineNote(unesc(t))))
// [label](url "title") and [label](<url with spaces>)
.replace(/(?<!!)\[([^\[\]\n]+)\]\(\s*(&lt;[^\n]*?&gt;|[^)\s]+)(?:\s+&quot;([^\n]*?)&quot;)?\s*\)/g, (m, label, href, title) => {
const url = unesc(href.replace(/^&lt;|&gt;$/g, ''));
return isWebUrl(url) ? hold(webLink(url, mdToHtml(unesc(label)), { title: title ? unesc(title) : '' })) : m;
})
// bare URLs
.replace(/(^|[\s(\u0000])((?:https?:\/\/)[^\s<\u0000]+)/g, (m, pre, raw) => {
const url = trimUrl(raw);
if (url.length < 9) return m;
return pre + hold(webLink(unesc(url), url, { auto: true })) + raw.slice(url.length);
});
s = s
.replace(/&lt;kbd&gt;(.*?)&lt;\/kbd&gt;/g, '<kbd>$1</kbd>')
.replace(/\*\*(?=\S)([\s\S]*?\S)\*\*/g, '<b>$1</b>')
.replace(/~~(?=\S)([\s\S]*?\S)~~/g, '<s>$1</s>')
.replace(/(?<!=)==(?=[^\s=])([\s\S]*?[^\s=])==(?!=)/g, '<mark>$1</mark>')
.replace(/(^|[^*\w])\*(?=[^\s*])([^*\n]*?[^\s*])\*(?!\*)/g, '$1<i>$2</i>')
.replace(/(^|[^_\w])_(?=\S)([^_\n]*?\S)_(?!\w)/g, '$1<i>$2</i>')
.replace(/\u0000(\d+)\u0000/g, (_, i) => held[+i]);
return s;
}
export function htmlToMd(html) {
if (!html) return '';
const s = String(html)
.replace(/(<\/(?:span|sup)>)(?:&nbsp;|\u00a0)$/, '$1') // the spacer typed after a link or footnote at the end of a line
.replace(/<sup\b([^>]*)>([\s\S]*?)<\/sup>/gi, (_, attrs, inner) => {
const fn = attrs.match(/\bdata-fn="([^"]*)"/);
if (fn) return esc(`[^${unesc(fn[1])}]`);
const note = attrs.match(/\bdata-inline="([^"]*)"/);
return note ? esc(`^[${unesc(note[1])}]`) : inner;
})
.replace(/<span\b([^>]*\bdata-ref="([^"]*)"[^>]*)>([\s\S]*?)<\/span>/gi, (_, _a, ref, label) => {
const r = unesc(ref), l = unesc(label.replace(/<[^>]+>/g, ''));
return esc(l && l !== linkLabel(r) ? `[[${r}|${l}]]` : `[[${r}]]`);
})
.replace(/<a\b([^>]*)>([\s\S]*?)<\/a>/gi, (m, attrs, inner) => {
const attr = (name) => { const x = attrs.match(new RegExp(`\\b${name}="([^"]*)"`, 'i')); return x ? unesc(x[1]) : null; };
const href = attr('href');
const text = htmlToMd(inner);
if (!href || !isWebUrl(href)) return esc(text);
if (attr('data-auto') && text === href) return esc(href);
const title = attr('data-title');
const target = /[\s()<>]/.test(href) ? `<${href}>` : href;
return esc(`[${text || href}](${target}${title ? ` "${title}"` : ''})`);
})
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<(div|p)(\s[^>]*)?>/gi, '\n')
.replace(/<\/(div|p)>/gi, '')
.replace(/<kbd[^>]*>([\s\S]*?)<\/kbd>/gi, '\u0001$1\u0002')
.replace(/<code\b([^>]*)>([\s\S]*?)<\/code>/gi, (_, attrs, x) => {
const text = x.replace(/<[^>]+>/g, '');
if (/class=["']?[^"']*\blanguage-math\b[^"']*/i.test(attrs)) return '$' + text + '$';
return '`' + text + '`';
})
.replace(/<(b|strong)(\s[^>]*)?>([\s\S]*?)<\/\1>/gi, (_, _t, _a, x) => (x.trim() ? `**${x}**` : x))
.replace(/<(i|em)(\s[^>]*)?>([\s\S]*?)<\/\1>/gi, (_, _t, _a, x) => (x.trim() ? `*${x}*` : x))
.replace(/<(s|strike|del)(\s[^>]*)?>([\s\S]*?)<\/\1>/gi, (_, _t, _a, x) => (x.trim() ? `~~${x}~~` : x))
.replace(/<mark(\s[^>]*)?>([\s\S]*?)<\/mark>/gi, (_, _a, x) => (x.trim() ? `==${x}==` : x))
.replace(/<[^>]+>/g, '');
return unesc(s)
.replace(/\u0001/g, '<kbd>')
.replace(/\u0002/g, '</kbd>')
.replace(/^\n+/, '');
}
/* ---------- blocks ---------- */
export function parseMarkdown(src) {
const lines = String(src || '').replace(/\r\n?/g, '\n').split('\n');
const out = [];
let para = [];
const flush = () => {
if (para.length) out.push(block('p', mdToHtml(para.join('\n'))));
para = [];
};
let i = 0;
while (i < lines.length) {
const line = lines[i];
let m;
if ((m = line.match(/^\$\$\s*$/))) {
flush();
const buf = [];
i++;
while (i < lines.length && !/^\$\$\s*$/.test(lines[i])) buf.push(lines[i++]);
i++;
out.push(block('math', '', { text: buf.join('\n') }));
continue;
}
if ((m = line.match(/^\`\`\`\s*mermaid\s*$/))) {
flush();
const buf = [];
i++;
while (i < lines.length && !/^\`\`\`\s*$/.test(lines[i])) buf.push(lines[i++]);
i++;
out.push(block('mermaid', '', { text: buf.join('\n') }));
continue;
}
if ((m = line.match(/^```\s*([\w+#.-]*)\s*$/))) {
flush();
const buf = [];
i++;
while (i < lines.length && !/^```\s*$/.test(lines[i])) buf.push(lines[i++]);
i++;
out.push(block('code', '', { lang: m[1] || 'txt', text: buf.join('\n') }));
continue;
}
if (!line.trim()) { flush(); i++; continue; }
if ((m = line.match(/^\[\^([^\]\s^]+)\]:[ \t]?(.*)$/))) {
flush();
const text = [m[2]];
i++;
while (i < lines.length && /^( {4}|\t)\S/.test(lines[i])) text.push(lines[i++].replace(/^( {4}|\t)/, ''));
out.push(block('footnote', mdToHtml(text.join('\n')), { fid: m[1] }));
continue;
}
if ((m = line.match(/^\s*!\[([^\]]*)\]\(\s*(?:<([^>]+)>|([^)\s]+))(?:\s+"[^"]*")?\s*\)\s*$/))) {
flush();
const raw = m[2] || m[3];
let src = raw;
try { src = decodeURI(raw); } catch { /* keep as written */ }
out.push(block('image', '', { src, alt: m[1] }));
i++;
continue;
}
if ((m = line.match(/^\s*!\[\[([^\]|]+)(?:\|([^\]]*))?\]\]\s*$/))) {
const target = m[1].trim();
// ![[photo.png]] is a picture; ![[Page]] / ![[Page#Heading]] shows that note inline;
// other files (pdf, audio...) aren't supported, so they stay as plain text.
if (IMAGE_FILE.test(target)) {
flush();
out.push(block('image', '', { src: target, alt: m[2] || '', wiki: true }));
i++;
continue;
}
if (!OTHER_FILE.test(target)) {
flush();
out.push(block('embed', '', { ref: target, alias: m[2] || '' }));
i++;
continue;
}
}
if (/^(\*\*\*|---|___)\s*$/.test(line)) { flush(); out.push(block('divider')); i++; continue; }
if ((m = line.match(/^(#{1,6})\s+(.*?)\s*#*\s*$/))) {
flush();
out.push(block('h' + Math.min(3, m[1].length), mdToHtml(m[2])));
i++;
continue;
}
if ((m = line.match(/^([ \t]*)[-*+]\s+\[([ xX])\]\s?(.*)$/))) {
flush();
out.push(block('todo', mdToHtml(m[3]), { checked: m[2] !== ' ', indent: Math.floor(m[1].replace(/\t/g, ' ').length / 2) }));
i++;
continue;
}
if ((m = line.match(/^([ \t]*)[-*+]\s+(.*)$/))) {
flush();
out.push(block('bullet', mdToHtml(m[2]), { indent: Math.floor(m[1].replace(/\t/g, ' ').length / 2) }));
i++;
continue;
}
if ((m = line.match(/^([ \t]*)\d{1,9}[.)]\s+(.*)$/))) {
flush();
out.push(block('number', mdToHtml(m[2]), { indent: Math.floor(m[1].replace(/\t/g, ' ').length / 2) }));
i++;
continue;
}
const cm = line.match(/^>\s?\[!(\w+)\]/);
if (cm) {
flush();
const buf = [];
const first = line.replace(/^>\s?\[!\w+\][-+]?\s*/, '');
if (first) buf.push(first);
i++;
while (i < lines.length && /^>/.test(lines[i])) buf.push(lines[i++].replace(/^>\s?/, ''));
out.push(block('callout', mdToHtml(buf.join('\n').trim()), { kind: cm[1].toLowerCase() }));
continue;
}
if (/^>/.test(line)) {
flush();
const buf = [];
while (i < lines.length && /^>/.test(lines[i])) buf.push(lines[i++].replace(/^>\s?/, ''));
out.push(block('quote', mdToHtml(buf.join('\n'))));
continue;
}
if (/^<details(\s[^>]*)?>\s*$/i.test(line.trim())) {
flush();
i++;
let summary = '';
const sm = i < lines.length && lines[i].match(/^\s*<summary>(.*)<\/summary>\s*$/i);
if (sm) { summary = sm[1]; i++; }
const body = [];
while (i < lines.length && !/^\s*<\/details>\s*$/i.test(lines[i])) body.push(lines[i++]);
i++;
out.push(block('toggle', mdToHtml(summary), { body: mdToHtml(body.join('\n').trim()), open: false }));
continue;
}
if (line.includes('|') && i + 1 < lines.length && isDelimRow(lines[i + 1]) && splitRow(line).length === splitRow(lines[i + 1]).length) {
flush();
const head = splitRow(line);
const align = splitRow(lines[i + 1]).map((d) => (d.startsWith(':') && d.endsWith(':') ? 'center' : d.endsWith(':') ? 'right' : d.startsWith(':') ? 'left' : null));
const src = [line, lines[i + 1]];
const body = [];
i += 2;
while (i < lines.length && lines[i].includes('|') && lines[i].trim()) {
const cells = splitRow(lines[i]);
body.push(head.map((_, c) => cells[c] ?? ''));
src.push(lines[i++]);
}
const cell = (t) => mdToHtml(t.replace(/\\\|/g, '|'));
out.push(block('table', '', { rows: [head, ...body].map((r) => r.map(cell)), align, src: src.join('\n') }));
continue;
}
para.push(line);
i++;
}
flush();
if (!out.length) out.push(block('p'));
return out;
}
// Spaces and brackets would end the link early; everything else stays readable.
const imagePath = (src) => String(src || '').replace(/[ ()<>]/g, encodeURIComponent);
const LISTS = new Set(['bullet', 'number', 'todo']);
/* tables (GitHub style): | a | b |, then | --- | :-: |, then rows */
const isDelimRow = (l) => /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/.test(l) && l.includes('-');
// Cells of one table row. Pipes inside `code` or escaped as \| don't split.
function splitRow(line) {
let s = line.trim();
if (s.startsWith('|')) s = s.slice(1);
if (s.endsWith('|') && !s.endsWith('\\|')) s = s.slice(0, -1);
const cells = [];
let cur = '', code = false;
for (let i = 0; i < s.length; i++) {
const ch = s[i];
if (ch === '\\' && s[i + 1] === '|') { cur += '\\|'; i++; continue; }
if (ch === '`') code = !code;
if (ch === '|' && !code) { cells.push(cur.trim()); cur = ''; continue; }
cur += ch;
}
cells.push(cur.trim());
return cells;
}
// An edited table is written back with its columns padded to line up.
function tableToMd(b) {
const rows = (b.rows || [[]]).map((r) => r.map((h) => htmlToMd(h).replace(/\n/g, ' ').replace(/\|/g, '\\|').trim()));
const cols = Math.max(1, ...rows.map((r) => r.length));
const width = Array.from({ length: cols }, (_, c) => Math.max(3, ...rows.map((r) => (r[c] || '').length)));
const line = (r) => '| ' + width.map((w, c) => (r[c] || '').padEnd(w)).join(' | ') + ' |';
const align = b.align || [];
const delim = '| ' + width.map((w, c) => {
const a = align[c];
return a === 'center' ? ':' + '-'.repeat(w - 2) + ':' : a === 'right' ? '-'.repeat(w - 1) + ':' : a === 'left' ? ':' + '-'.repeat(w - 1) : '-'.repeat(w);
}).join(' | ') + ' |';
return [line(rows[0] || []), delim, ...rows.slice(1).map(line)].join('\n');
}
const quoteLines = (t, prefix = '> ') => t.split('\n').map((l) => (l ? prefix + l : prefix.trimEnd())).join('\n');
export function blockToMd(b, n = 1) {
const t = htmlToMd(b.html);
switch (b.type) {
case 'h1': return '# ' + t;
case 'h2': return '## ' + t;
case 'h3': return '### ' + t;
case 'bullet': return '- ' + t;
case 'number': return `${n}. ` + t;
case 'todo': return `- [${b.checked ? 'x' : ' '}] ` + t;
case 'quote': return quoteLines(t);
case 'callout': return `> [!${b.kind || 'note'}]\n` + quoteLines(t);
case 'divider': return '---';
case 'code': return '```' + (b.lang || '') + '\n' + (b.text || '') + '\n```';
case 'math': return '$$\n' + (b.text || '') + '\n$$';
case 'mermaid': return '```mermaid\n' + (b.text || '') + '\n```';
case 'table': return b.src ?? tableToMd(b);
case 'footnote': return `[^${b.fid}]: ` + t.replace(/\n/g, '\n ');
case 'embed': return `![[${b.ref}${b.alias ? '|' + b.alias : ''}]]`;
case 'image': return b.wiki ? `![[${b.src}${b.alt ? '|' + b.alt : ''}]]` : `![${b.alt || ''}](${imagePath(b.src)})`;
case 'toggle': return `<details>\n<summary>${t}</summary>\n\n${htmlToMd(b.body)}\n\n</details>`;
default: return t;
}
}
export function toMarkdown(blocks) {
let out = '';
let prevList = false;
let prevType = null;
let n = 0; // position in a run of numbered items
for (const b of blocks) {
if (b.type === 'p' && !htmlToMd(b.html).trim()) continue;
const isList = LISTS.has(b.type);
n = b.type === 'number' ? n + 1 : 0;
let md = blockToMd(b, n);
if (isList && b.indent) {
const sp = ' '.repeat(b.indent);
md = sp + md.replace(/\n/g, '\n' + sp);
}
const tight = (isList && prevList) || (b.type === 'footnote' && prevType === 'footnote');
out += out ? (tight ? '\n' : '\n\n') : '';
out += md;
prevList = isList;
prevType = b.type;
}
return out ? out + '\n' : '';
}
export const blockText = (b) =>
b.type === 'embed' ? '' : b.type === 'table' ? (b.rows || []).flat().map(htmlToMd).join(' ') : b.type === 'code' || b.type === 'math' || b.type === 'mermaid' ? b.text || '' : b.type === 'divider' || b.type === 'image' ? '' :b.type === 'toggle' ? htmlToMd(b.html) + ' ' + htmlToMd(b.body) : htmlToMd(b.html);
/* ---------- HTML export ---------- */
// Footnote numbers in reading order: each [^id] gets a number the first time it's referenced,
// each ^[inline note] its own. Returns { refs: Map(id -> n), html(s) } where html() rewrites references for export.
export function numberFootnotes(blocks) {
const refs = new Map();
let n = 0;
const html = (s) => String(s || '').replace(/<sup\b([^>]*)>[\s\S]*?<\/sup>/gi, (m, attrs) => {
const fn = attrs.match(/\bdata-fn="([^"]*)"/);
if (fn) {
if (!refs.has(fn[1])) refs.set(fn[1], ++n);
return `<sup class="fn"><a href="#fn-${fn[1]}">${refs.get(fn[1])}</a></sup>`;
}
const note = attrs.match(/\bdata-inline="([^"]*)"/);
return note ? `<sup class="fn" title="${note[1]}">${++n}</sup>` : m;
});
return { refs, html };
}
export function toHtmlDocument(title, raw) {
const fns = numberFootnotes(raw);
const blocks = raw.map((b) => ({ ...b, html: fns.html(b.html), body: b.body && fns.html(b.body), rows: b.rows && b.rows.map((r) => r.map(fns.html)) }));
const body = [];
let list = null; // 'ul' or 'ol' while inside a list
for (const b of blocks) {
const want = b.type === 'number' ? 'ol' : LISTS.has(b.type) ? 'ul' : null;
if (list && list !== want) { body.push(`</${list}>`); list = null; }
if (want && !list) { body.push(`<${want}>`); list = want; }
switch (b.type) {
case 'h1': case 'h2': case 'h3': body.push(`<${b.type}>${b.html}</${b.type}>`); break;
case 'bullet': case 'number': body.push(` <li>${b.html}</li>`); break;
case 'todo': body.push(` <li class="todo"><input type="checkbox" disabled${b.checked ? ' checked' : ''}> ${b.html}</li>`); break;
case 'quote': body.push(`<blockquote>${b.html}</blockquote>`); break;
case 'callout': body.push(`<aside class="callout" data-kind="${b.kind || 'note'}">${b.html}</aside>`); break;
case 'divider': body.push('<hr>'); break;
case 'table': {
const align = (c) => (b.align?.[c] ? ` style="text-align:${b.align[c]}"` : '');
const [head = [], ...rest] = b.rows || [];
body.push(`<table><thead><tr>${head.map((h, c) => `<th${align(c)}>${h}</th>`).join('')}</tr></thead><tbody>${rest.map((r) => `<tr>${r.map((h, c) => `<td${align(c)}>${h}</td>`).join('')}</tr>`).join('')}</tbody></table>`);
break;
}
case 'footnote': body.push(`<p class="footnote" id="fn-${esc(b.fid)}"><sup>${fns.refs.get(esc(b.fid)) ?? esc(b.fid)}</sup> ${b.html}</p>`); break;
case 'embed': body.push(`<blockquote class="embed">Embedded: ${esc(b.ref)}</blockquote>`); break;
case 'image': body.push(`<img src="${esc(b.src)}" alt="${esc(b.alt || '')}">`); break;
case 'code': body.push(`<pre><code class="language-${esc(b.lang || '')}">${esc(b.text || '')}</code></pre>`); break;
case 'math': body.push(`<pre><code class="language-math">${esc(b.text || '')}</code></pre>`); break;
case 'mermaid': body.push(`<pre><code class="language-mermaid">${esc(b.text || '')}</code></pre>`); break;
case 'toggle': body.push(`<details><summary>${b.html}</summary><p>${b.body || ''}</p></details>`); break;
default: if (b.html) body.push(`<p>${b.html}</p>`);
}
}
if (list) body.push(`</${list}>`);
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>${esc(title)}</title>
<style>
body { max-width: 680px; margin: 48px auto; padding: 0 20px; font: 16px/1.7 system-ui, sans-serif; color: #1c1c1e; background: #fbfbfb; }
h1, h2, h3 { line-height: 1.25; letter-spacing: -0.01em; }
code { font: 0.86em ui-monospace, monospace; background: #f2f2f3; padding: 0.1em 0.35em; border-radius: 4px; }
pre { background: #f2f2f3; padding: 14px 18px; border-radius: 12px; overflow-x: auto; }
pre code { background: none; padding: 0; }
blockquote { margin: 0; padding-left: 16px; border-left: 2px solid #d9a59e; color: #5c5c61; }
.callout { background: #f6e8e4; padding: 14px 16px; border-radius: 12px; }
mark { background: #f7e3a3; color: inherit; border-radius: 3px; padding: 0 2px; }
.footnote { font-size: .9em; color: #5c5c61; margin: 4px 0; } sup.fn a { text-decoration: none; }
ul, ol { padding-left: 1.2em; } li.todo { list-style: none; margin-left: -1.2em; }
hr { border: 0; border-top: 1px solid #e3e3e6; margin: 24px 0; }
img { max-width: 100%; border-radius: 12px; }
table { border-collapse: collapse; margin: 12px 0; } th, td { border: 1px solid #e3e3e6; padding: 6px 10px; } th { background: #f3f3f4; text-align: left; }
a { color: #a8564c; }
.wl { color: #a8564c; text-decoration: underline; text-decoration-color: #e3c2bb; }
</style>
</head>
<body>
<article>
<h1>${esc(title)}</h1>
${body.join('\n')}
</article>
</body>
</html>
`;
}