<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>toMarkdown Viewer</title>
<link rel="stylesheet" href="vendor/katex/katex.min.css">
<script src="vendor/katex/katex.min.js"></script>
<script src="vendor/mermaid.min.js"></script>
<style>
:root {
--bg: #ffffff; --fg: #1f2328; --muted: #59636e; --border: #d1d9e0;
--sidebar-bg: #f6f8fa; --accent: #0969da; --code-bg: #f6f8fa;
--hover: #eaeef2; --active: #ddf4ff;
--danger: #d1242f; --success: #1a7f37; --warning: #d29922; --question: #8250df;
--mark-bg: #ffd54a66; --shadow: 0 12px 40px rgba(31, 35, 40, .25);
}
:root[data-theme="dark"] {
--bg: #0d1117; --fg: #f0f6fc; --muted: #9198a1; --border: #3d444d;
--sidebar-bg: #010409; --accent: #4493f8; --code-bg: #2d2d2d;
--hover: #15191f; --active: #0c2d6b;
--danger: #f85149; --success: #3fb950; --warning: #d29922; --question: #a371f7;
--mark-bg: #b8860b66; --shadow: 0 12px 40px rgba(0, 0, 0, .55);
--hl-heading: #7aa2f7; --hl-code: #e0af68; --hl-link: #7aa2f7; --hl-quote: #9aa5ce;
}
:root[data-theme="sepia"] {
--bg: #f4ecd8; --fg: #433422; --muted: #8a7a5c; --border: #d8cba8;
--sidebar-bg: #ede3c8; --accent: #9a6a28; --code-bg: #ece1c4;
--hover: #e6dabc; --active: #ddcf9e;
--danger: #a83232; --success: #4f6b31; --warning: #a4762a; --question: #6d5a8f;
--mark-bg: #d8a83766; --shadow: 0 12px 40px rgba(67, 52, 34, .3);
}
@media (prefers-color-scheme: dark) {
:root[data-theme="system"] {
--bg: #0d1117; --fg: #f0f6fc; --muted: #9198a1; --border: #3d444d;
--sidebar-bg: #010409; --accent: #4493f8; --code-bg: #2d2d2d;
--hover: #15191f; --active: #0c2d6b;
--danger: #f85149; --success: #3fb950; --warning: #d29922; --question: #a371f7;
--mark-bg: #b8860b66; --shadow: 0 12px 40px rgba(0, 0, 0, .55);
}
}
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
* { box-sizing: border-box; }
body {
margin: 0; display: flex; height: 100vh; overflow: hidden;
background: var(--bg); color: var(--fg);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
}
#sidebar {
width: 280px; min-width: 180px; max-width: 50vw; resize: horizontal;
overflow: auto; border-right: 1px solid var(--border);
background: var(--sidebar-bg); padding: 8px; flex-shrink: 0;
display: flex; flex-direction: column;
}
#toolbar { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
#toolbar button, #toolbar select {
flex: 1 1 45%; padding: 6px 8px; border: 1px solid var(--border); border-radius: 6px;
background: var(--bg); color: var(--fg); cursor: pointer; font-size: 12px;
}
#toolbar button:hover { background: var(--hover); }
.section-title { font-size: 11px; text-transform: uppercase; color: var(--muted); margin: 8px 6px 2px; }
#recents { margin: 0; padding: 0; list-style: none; font-size: 12px; }
#recents li {
padding: 3px 6px; border-radius: 4px; cursor: pointer; color: var(--muted);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
#recents li:hover { background: var(--hover); color: var(--fg); }
ul.tree { list-style: none; padding-left: 14px; margin: 0; font-size: 13px; }
ul.tree.root { padding-left: 0; }
.node { padding: 2px 6px; border-radius: 4px; cursor: pointer; white-space: nowrap;
overflow: hidden; text-overflow: ellipsis; }
.node:hover { background: var(--hover); }
.node.selected { background: var(--active); }
.node.highlighted { background: color-mix(in srgb, var(--accent) 20%, transparent); border-left: 3px solid var(--accent); padding-left: 3px; }
.node.dir { font-weight: 600; }
.node.dir::before { content: "▸ "; color: var(--muted); }
.node.dir.open::before { content: "▾ "; }
li.collapsed > ul { display: none; }
#toc { margin: 0; padding: 0 0 8px; list-style: none; font-size: 12px; }
#toc li { padding: 2px 6px; border-radius: 4px; cursor: pointer; color: var(--muted);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#toc li:hover { background: var(--hover); color: var(--fg); }
#read-progress { position: absolute; top: 0; left: 0; height: 2px; width: 0;
background: var(--accent, #4a7dd0); z-index: 50; transition: width .1s linear; }
#main { flex: 1; display: flex; flex-direction: column; overflow: hidden; position: relative; }
#scroll { flex: 1; overflow-y: auto; }
#content { max-width: 860px; margin: 0 auto; padding: 32px 40px 80px; line-height: 1.6; }
#placeholder { color: var(--muted); text-align: center; margin-top: 30vh; }
#content h1, #content h2 { border-bottom: 1px solid var(--border); padding-bottom: .3em; }
#content a { color: var(--accent); }
#content pre {
background: var(--code-bg); border: 1px solid var(--border); border-radius: 6px;
padding: 12px; overflow-x: auto; font-size: 13px;
}
#content code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .95em; }
#content :not(pre) > code { background: var(--code-bg); padding: .15em .35em; border-radius: 4px; }
#content table { border-collapse: collapse; display: block; overflow-x: auto; }
#content th, #content td { border: 1px solid var(--border); padding: 5px 10px; }
#content th { background: var(--code-bg); }
#content blockquote { border-left: 4px solid var(--border); margin-left: 0; padding-left: 14px; color: var(--muted); }
#content img { max-width: 100%; }
#statusbar {
border-top: 1px solid var(--border); background: var(--sidebar-bg);
padding: 4px 14px; font-size: 12px; color: var(--muted);
display: flex; gap: 18px; flex-shrink: 0;
}
#statusbar .live { color: var(--accent); }
#stats { cursor: pointer; border-radius: 4px; padding: 0 4px; margin: 0 -4px; }
#stats:hover { background: var(--hover); color: var(--fg); }
#error {
display: none; margin: 12px; padding: 10px 14px; border: 1px solid var(--danger);
border-radius: 6px; color: var(--danger); font-size: 13px;
}
#toast-stack {
position: fixed; right: 16px; bottom: 40px; z-index: 100;
display: flex; flex-direction: column; gap: 8px; max-width: 340px;
}
.toast {
padding: 9px 14px; border-radius: 8px; font-size: 13px; cursor: pointer;
background: var(--bg); color: var(--fg); border: 1px solid var(--border);
border-left: 3px solid var(--accent); box-shadow: var(--shadow);
transition: opacity .3s;
}
.toast.success { border-left-color: var(--success); }
.toast.error { border-left-color: var(--danger); }
.toast.fade { opacity: 0; }
button.busy { opacity: .6; pointer-events: none; position: relative; }
button.busy::after {
content: ""; display: inline-block; width: 10px; height: 10px; margin-left: 6px;
border: 2px solid var(--muted); border-top-color: transparent; border-radius: 50%;
animation: spin .7s linear infinite; vertical-align: -1px;
}
@keyframes spin { to { transform: rotate(360deg); } }
.empty-state { color: var(--muted); font-size: 12px; padding: 10px 8px; text-align: center; }
#import-bar { border: 1px solid var(--border); border-radius: 6px; padding: 8px; margin-bottom: 8px; }
#import-bar input[type="text"], #import-bar #import-url {
width: 100%; padding: 6px 8px; font-size: 12px; border: 1px solid var(--border);
border-radius: 6px; background: var(--bg); color: var(--fg); margin-bottom: 6px;
}
#import-bar .import-row { display: flex; align-items: center; gap: 8px; }
#import-bar button {
padding: 5px 10px; border: 1px solid var(--border); border-radius: 6px;
background: var(--bg); color: var(--fg); cursor: pointer; font-size: 12px;
}
#import-bar button:hover { background: var(--hover); }
#import-bar .import-save-as { font-size: 11.5px; color: var(--muted); display: flex; align-items: center; gap: 4px; }
#tabs { display: flex; gap: 2px; margin-bottom: 8px; border-bottom: 1px solid var(--border); }
#tabs button {
flex: 1; padding: 5px 2px; border: none; background: none; color: var(--muted);
cursor: pointer; font-size: 12px; border-bottom: 2px solid transparent;
}
#tabs button.active { color: var(--fg); border-bottom-color: var(--accent); }
.pane { display: none; }
.pane.active { display: block; }
#search-pane input, #search-pane select {
width: 100%; margin-bottom: 6px; padding: 6px 8px; font-size: 13px;
border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--fg);
}
.result { padding: 4px 6px; border-radius: 4px; cursor: pointer; font-size: 12px; margin-bottom: 2px; }
.result:hover { background: var(--hover); }
.result .where { color: var(--muted); font-size: 11px; }
.tag-item { display: inline-block; margin: 2px; padding: 2px 8px; border-radius: 10px;
background: var(--code-bg); border: 1px solid var(--border); font-size: 12px; cursor: pointer; }
.tag-item:hover { background: var(--hover); }
.task-item { padding: 3px 6px; font-size: 12px; border-radius: 4px; cursor: pointer; }
.task-item:hover { background: var(--hover); }
.task-item .src { color: var(--muted); font-size: 11px; }
#note-panel {
border-top: 1px solid var(--border); margin-top: 8px; padding-top: 6px;
font-size: 12px; max-height: 40vh; overflow-y: auto;
}
#note-panel .kv { display: flex; gap: 6px; padding: 1px 6px; }
#note-panel .kv .k { color: var(--muted); min-width: 70px; }
#note-panel .bl { padding: 2px 6px; border-radius: 4px; cursor: pointer; }
#note-panel .bl:hover { background: var(--hover); }
#note-panel .bl .ctx { color: var(--muted); font-size: 11px; overflow: hidden;
text-overflow: ellipsis; white-space: nowrap; }
.overlay {
display: none; position: fixed; inset: 0; z-index: 50;
background: color-mix(in srgb, var(--bg) 75%, transparent);
}
.overlay.show { display: block; }
#switcher {
position: absolute; top: 15vh; left: 50%; transform: translateX(-50%);
width: 480px; background: var(--bg); border: 1px solid var(--border);
border-radius: 10px; box-shadow: var(--shadow); overflow: hidden;
}
#switcher input {
width: 100%; padding: 12px 14px; font-size: 15px; border: none; outline: none;
background: var(--bg); color: var(--fg); border-bottom: 1px solid var(--border);
}
#switcher-results { max-height: 40vh; overflow-y: auto; }
#switcher-results .result.sel { background: var(--active); }
#graph-box {
position: absolute; inset: 6vh 8vw; background: var(--bg);
border: 1px solid var(--border); border-radius: 10px; display: flex; flex-direction: column;
}
#graph-head { display: flex; gap: 8px; align-items: center; padding: 8px 12px;
border-bottom: 1px solid var(--border); font-size: 13px; }
#graph-head button { padding: 4px 10px; border: 1px solid var(--border); border-radius: 6px;
background: var(--bg); color: var(--fg); cursor: pointer; font-size: 12px; }
.graph-mode-btn {
padding: 6px 12px;
margin: 0 2px;
border: 1px solid var(--border);
background: var(--bg);
color: var(--fg);
border-radius: 4px;
cursor: pointer;
font-size: 12px;
}
.graph-mode-btn.active {
background: var(--accent);
color: white;
border-color: var(--accent);
}
#graph-header {
padding: 8px 12px;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
}
#graph-header-controls {
display: flex;
gap: 8px;
align-items: center;
}
#graph-zoom-controls button {
padding: 4px 8px;
margin-left: 4px;
font-size: 11px;
cursor: pointer;
border: 1px solid var(--border);
background: var(--bg);
border-radius: 3px;
}
#graph-refresh {
padding: 4px 8px;
font-size: 11px;
cursor: pointer;
border: 1px solid var(--border);
background: var(--bg);
border-radius: 3px;
white-space: nowrap;
}
#graph-refresh:hover {
background: var(--accent);
color: white;
border-color: var(--accent);
}
#graph-refresh:disabled {
opacity: 0.5;
cursor: not-allowed;
}
#graph-refresh-indicator {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
}
#graph-refresh-spinner {
display: inline-block;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
#graph-canvas { flex: 1; width: 100%; }
#editor-wrap { flex: 1; display: none; flex-direction: column; overflow: hidden; }
#editor-wrap.show { display: flex; }
#edit-bar {
display: flex; gap: 6px; align-items: center; padding: 6px 10px;
border-bottom: 1px solid var(--border); background: var(--sidebar-bg); flex-wrap: wrap;
}
#edit-bar input {
padding: 4px 8px; font-size: 12px; border: 1px solid var(--border);
border-radius: 6px; background: var(--bg); color: var(--fg); width: 130px;
}
#edit-bar button {
padding: 4px 10px; border: 1px solid var(--border); border-radius: 6px;
background: var(--bg); color: var(--fg); cursor: pointer; font-size: 12px;
}
#edit-bar button:hover { background: var(--hover); }
#save-state { font-size: 11px; color: var(--muted); margin-left: auto; }
#edit-split { flex: 1; display: flex; overflow: hidden; }
#editor-shell { position: relative; flex: 1; min-width: 0; display: flex; border-right: 1px solid var(--border); }
#editor-backdrop, #editor-shell #editor {
margin: 0; border: 0; overflow-y: auto; white-space: pre-wrap; overflow-wrap: anywhere;
font: 13.5px/1.55 ui-monospace, SFMono-Regular, Menlo, monospace;
padding: 16px 20px; box-sizing: border-box;
}
#editor-backdrop { position: absolute; inset: 0; pointer-events: none; color: var(--fg, inherit); }
#editor-shell #editor {
position: relative; flex: 1; min-width: 0; outline: none; resize: none;
background: transparent; color: transparent; caret-color: var(--fg, currentColor);
}
.hl-heading { font-weight: 700; color: var(--hl-heading, #2a6bcc); }
.hl-strong { font-weight: 700; }
.hl-emphasis { font-style: italic; }
.hl-code, .hl-codeblock { color: var(--hl-code, #8a5a00); }
.hl-link, .hl-wikilink { color: var(--hl-link, #2a6bcc); text-decoration: underline; }
.hl-blockquote { color: var(--hl-quote, #5a6472); font-style: italic; }
.hl-list_marker { color: var(--hl-heading, #2a6bcc); }
.hl-frontmatter { opacity: .55; }
#edit-preview { flex: 1; min-width: 0; overflow-y: auto; padding: 20px 28px; line-height: 1.6; }
#edit-preview pre { background: var(--code-bg); border: 1px solid var(--border);
border-radius: 6px; padding: 12px; overflow-x: auto; font-size: 13px; }
#edit-preview code { font-family: ui-monospace, Menlo, monospace; font-size: .95em; }
#edit-preview table { border-collapse: collapse; }
#edit-preview th, #edit-preview td { border: 1px solid var(--border); padding: 5px 10px; }
#edit-preview blockquote { border-left: 4px solid var(--border); margin-left: 0;
padding-left: 14px; color: var(--muted); }
#edit-preview a { color: var(--accent); }
#autocomplete {
position: fixed; z-index: 60; display: none; background: var(--bg);
border: 1px solid var(--border); border-radius: 8px; box-shadow: var(--shadow);
max-height: 220px; overflow-y: auto; min-width: 220px; font-size: 13px;
}
#autocomplete div { padding: 5px 10px; cursor: pointer; }
#autocomplete div.sel, #autocomplete div:hover { background: var(--active); }
#wiki-ac { position: fixed; z-index: 280; display: none; min-width: 220px; max-height: 40vh; overflow-y: auto;
background: var(--bg, #fff); border: 1px solid rgba(128,128,128,.35); border-radius: 8px;
box-shadow: 0 6px 24px rgba(0,0,0,.25); font-size: 13px; }
#wiki-ac div { padding: 5px 10px; cursor: pointer; }
#wiki-ac div.sel { background: rgba(100,140,220,.25); }
#prompt-box, .dialog-box {
position: absolute; top: 20vh; left: 50%; transform: translateX(-50%);
width: 520px; background: var(--bg); border: 1px solid var(--border);
border-radius: 10px; box-shadow: var(--shadow); padding: 14px;
}
#prompt-box h3 { margin: 0 0 10px; font-size: 14px; }
#prompt-box input, #prompt-box textarea {
width: 100%; padding: 8px 10px; font-size: 13px; border: 1px solid var(--border);
border-radius: 6px; background: var(--bg); color: var(--fg); margin-bottom: 10px;
}
#prompt-box textarea { height: 180px; font-family: ui-monospace, Menlo, monospace; resize: vertical; }
#prompt-actions { display: flex; gap: 8px; justify-content: flex-end; }
#prompt-actions button {
padding: 6px 14px; border: 1px solid var(--border); border-radius: 6px;
background: var(--bg); color: var(--fg); cursor: pointer; font-size: 13px;
}
#prompt-actions button.primary { background: var(--accent); color: #fff; border-color: var(--accent); }
#content input[type="checkbox"] { pointer-events: auto; cursor: pointer; }
.callout { border: 1px solid var(--border); border-left: 4px solid var(--accent);
border-radius: 6px; padding: 8px 12px; margin: 10px 0; background: var(--code-bg); }
.callout-title, .callout summary { font-weight: 600; margin-bottom: 4px; cursor: default; }
.callout summary { cursor: pointer; }
.callout-warning, .callout-caution, .callout-attention { border-left-color: var(--warning); }
.callout-danger, .callout-error, .callout-bug { border-left-color: var(--danger); }
.callout-tip, .callout-hint, .callout-success, .callout-check { border-left-color: var(--success); }
.callout-question, .callout-help, .callout-faq { border-left-color: var(--question); }
.transclusion { border: 1px dashed var(--border); border-radius: 8px; padding: 8px 14px; margin: 10px 0; }
.trans-title { font-size: 12px; margin-bottom: 4px; }
.trans-title a { color: var(--muted); text-decoration: none; }
.trans-title a:hover { color: var(--accent); }
mark { background: var(--mark-bg); color: inherit; border-radius: 2px; padding: 0 2px; }
.pdf-embed { width: 100%; height: 70vh; border: 1px solid var(--border); border-radius: 6px; }
pre.mermaid { background: none; border: none; text-align: center; }
.math-block { text-align: center; margin: 12px 0; }
audio, video { max-width: 100%; }
body.zen #sidebar, body.zen #statusbar, body.zen #tabbar { display: none !important; }
#scroll, #live-wrap, #sidebar, #editor, #edit-preview, #note-panel,
#switcher-results, #result-body { scrollbar-gutter: stable; }
#editor-backdrop { scrollbar-gutter: stable; }
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--muted) 45%, transparent);
border-radius: 5px; border: 2px solid transparent; background-clip: content-box;
}
::-webkit-scrollbar-thumb:hover { background: var(--muted); border: 2px solid transparent; background-clip: content-box; }
mark.kw {
background: color-mix(in srgb, var(--accent) 33%, transparent);
outline: 1px solid color-mix(in srgb, var(--accent) 66%, transparent);
}
#hover-pop {
position: fixed; z-index: 70; display: none; width: 380px; max-height: 300px;
overflow: hidden; background: var(--bg); border: 1px solid var(--border);
border-radius: 8px; box-shadow: var(--shadow); padding: 10px 14px;
font-size: 12.5px; line-height: 1.5;
}
#hover-pop .pop-path { color: var(--muted); font-size: 11px; margin-bottom: 6px; }
#tabbar { display: flex; gap: 2px; background: var(--sidebar-bg); border-bottom: 1px solid var(--border);
overflow-x: auto; flex-shrink: 0; min-height: 30px; }
#tabbar:empty { display: none; }
.tab { display: flex; align-items: center; gap: 6px; padding: 5px 10px; font-size: 12px;
border-right: 1px solid var(--border); cursor: pointer; color: var(--muted);
white-space: nowrap; max-width: 200px; }
.tab.active { background: var(--bg); color: var(--fg); box-shadow: inset 0 -2px var(--accent); }
.tab .close { border: none; background: none; color: var(--muted); cursor: pointer;
font-size: 13px; padding: 0 2px; border-radius: 3px; }
.tab .close:hover { background: var(--hover); color: var(--fg); }
.tab span.t { overflow: hidden; text-overflow: ellipsis; }
#ctx-menu { position: fixed; z-index: 80; display: none; min-width: 180px; background: var(--bg);
border: 1px solid var(--border); border-radius: 8px; padding: 4px;
box-shadow: var(--shadow); font-size: 13px; }
#ctx-menu > div { padding: 6px 12px; border-radius: 5px; cursor: pointer; display: flex;
align-items: center; justify-content: space-between; }
#ctx-menu > div:hover { background: var(--hover); }
#ctx-menu > div.danger:hover { background: color-mix(in srgb, var(--danger) 14%, transparent); color: var(--danger); }
#ctx-menu > div.has-submenu::after { content: "▸"; color: var(--muted); margin-left: 8px; font-size: 11px; }
#ctx-menu .submenu { position: fixed; z-index: 81; display: none; min-width: 160px; background: var(--bg);
border: 1px solid var(--border); border-radius: 8px; padding: 4px;
box-shadow: var(--shadow); font-size: 12px; }
#ctx-menu .submenu > div { padding: 5px 12px; border-radius: 5px; cursor: pointer; white-space: nowrap; }
#ctx-menu .submenu > div:hover { background: var(--hover); }
.tab-context-menu { position: fixed; z-index: 90; display: none; min-width: 160px; background: var(--bg);
border: 1px solid var(--border); border-radius: 8px; padding: 4px;
box-shadow: var(--shadow); font-size: 12px; }
.tab-context-item { padding: 6px 12px; border-radius: 5px; cursor: pointer; white-space: nowrap; user-select: none; }
.tab-context-item:hover { background: var(--hover); }
.tab-context-item[data-action="closeAll"]:hover { background: color-mix(in srgb, var(--danger) 14%, transparent); color: var(--danger); }
.tab span.t.dirty { color: var(--danger); font-weight: 500; }
.tab span.t { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#pins-box .section-title { margin-top: 0; }
.pin-row { display: flex; align-items: center; padding: 2px 6px; border-radius: 4px;
cursor: pointer; font-size: 12.5px; }
.pin-row:hover { background: var(--hover); }
.pin-row .t { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#graph-filter { padding: 4px 8px; font-size: 12px; border: 1px solid var(--border);
border-radius: 6px; background: var(--bg); color: var(--fg); width: 160px; }
#vault-list { margin: 4px 0 0; }
#vault-list .pin-row .t { font-size: 12px; }
#live-wrap { flex: 1; display: none; overflow-y: auto; }
#live-wrap.show { display: block; }
#live-doc { max-width: 860px; margin: 0 auto; padding: 32px 40px 40vh; line-height: 1.6; }
#live-doc .block { border-radius: 6px; padding: 2px 8px; margin: 0 -8px; cursor: text; }
#live-doc .block:hover { outline: 1px solid var(--border); }
#live-doc .block.active { outline: 1px solid var(--accent); }
#live-doc .block h1, #live-doc .block h2 { border-bottom: 1px solid var(--border); padding-bottom: .3em; }
#live-doc .block a { color: var(--accent); }
#live-doc .block pre { background: var(--code-bg); border: 1px solid var(--border);
border-radius: 6px; padding: 12px; overflow-x: auto; font-size: 13px; }
#live-doc .block code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .95em; }
#live-doc .block :not(pre) > code { background: var(--code-bg); padding: .15em .35em; border-radius: 4px; }
#live-doc .block table { border-collapse: collapse; display: block; overflow-x: auto; }
#live-doc .block th, #live-doc .block td { border: 1px solid var(--border); padding: 5px 10px; }
#live-doc .block th { background: var(--code-bg); }
#live-doc .block blockquote { border-left: 4px solid var(--border); margin-left: 0;
padding-left: 14px; color: var(--muted); }
#live-doc .block img { max-width: 100%; }
#live-doc textarea.block-edit, #live-doc .block-backdrop {
width: 100%; border: none; padding: 16px 20px; margin: 0;
font: 13.5px/1.55 ui-monospace, SFMono-Regular, Menlo, monospace;
white-space: pre-wrap; overflow-wrap: anywhere; box-sizing: border-box;
}
#live-doc textarea.block-edit { outline: none; resize: none; background: transparent;
display: block; overflow: hidden; }
#live-doc .block-backdrop { color: var(--fg, inherit); }
.block-shell { position: relative; }
.block-shell .block-backdrop { position: absolute; inset: 0; pointer-events: none; margin: 0; }
.block-shell textarea { position: relative; background: transparent; color: transparent; caret-color: var(--fg, currentColor); }
.block-shell textarea, .block-shell .block-backdrop { scrollbar-gutter: stable; vertical-align: top; font: 400 13.5px/1.55 ui-monospace, SFMono-Regular, Menlo, monospace; }
#live-doc .block-append { color: var(--muted); font-size: 13px; padding: 8px;
cursor: text; border-radius: 6px; }
#live-doc .block-append:hover { outline: 1px dashed var(--border); }
#hover-pop img { max-width: 100%; }
#result-box {
position: absolute; top: 10vh; left: 50%; transform: translateX(-50%);
width: min(720px, 86vw); max-height: 74vh; display: flex; flex-direction: column;
background: var(--bg); border: 1px solid var(--border); border-radius: 10px;
box-shadow: var(--shadow);
}
#result-head { display: flex; align-items: center; gap: 8px; padding: 10px 14px;
border-bottom: 1px solid var(--border); font-size: 14px; font-weight: 600; }
#result-head button { margin-left: auto; padding: 4px 10px; border: 1px solid var(--border);
border-radius: 6px; background: var(--bg); color: var(--fg);
cursor: pointer; font-size: 12px; }
#result-tabs { display: flex; gap: 2px; padding: 0 14px; border-bottom: 1px solid var(--border);
background: var(--sidebar-bg); flex-shrink: 0; }
#result-tabs:empty { display: none; }
#result-tabs button {
padding: 7px 12px; border: none; background: none; color: var(--muted);
cursor: pointer; font-size: 12.5px; border-bottom: 2px solid transparent;
}
#result-tabs button:hover { color: var(--fg); }
#result-tabs button.active { color: var(--fg); border-bottom-color: var(--accent); font-weight: 600; }
#result-body { overflow-y: auto; padding: 14px 18px; line-height: 1.6; font-size: 14px; }
#result-body table { border-collapse: collapse; width: 100%; }
#result-body th, #result-body td { border: 1px solid var(--border); padding: 4px 10px; font-size: 13px; }
#result-body th { background: var(--code-bg); text-align: left; position: sticky; top: -14px; }
#result-body tr:nth-child(even) td { background: color-mix(in srgb, var(--code-bg) 55%, transparent); }
#result-body pre { background: var(--code-bg); border: 1px solid var(--border);
border-radius: 6px; padding: 10px; overflow-x: auto; }
#settings-box label { display: block; font-size: 12px; color: var(--muted); margin: 10px 0 4px; }
#settings-box label[for] { font-weight: 600; color: var(--fg); margin: 12px 0 8px; }
#settings-box select, #settings-box input {
width: 100%; padding: 7px 9px; font-size: 13px; border: 1px solid var(--border);
border-radius: 6px; background: var(--bg); color: var(--fg);
}
#settings-box input[type="radio"], #settings-box input[type="checkbox"] {
width: auto; cursor: pointer;
}
#settings-box select { max-width: 200px; }
#settings-box input[type="password"], #settings-box input[type="text"], #settings-box input[type="number"] {
width: 100%; max-width: 300px;
}
#settings-box input[type="number"] { width: 80px; max-width: 80px; }
#lightbox { position: fixed; inset: 0; background: rgba(0,0,0,.85); display: none;
flex-direction: column; align-items: center; justify-content: center; z-index: 300; }
#lightbox.show { display: flex; }
#lightbox-container { display: flex; flex-direction: column; align-items: center; gap: 12px; }
#lightbox-toolbar {
display: flex; gap: 8px; align-items: center; padding: 10px 16px;
background: rgba(0, 0, 0, 0.4); border-radius: 8px; backdrop-filter: blur(4px);
color: #fff;
}
#lightbox-toolbar button, #lightbox-toolbar select {
padding: 6px 10px; border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 4px; background: rgba(255, 255, 255, 0.1);
color: #fff; cursor: pointer; font-size: 12px; transition: all 0.15s;
}
#lightbox-toolbar button:hover, #lightbox-toolbar select:hover {
background: rgba(255, 255, 255, 0.2); border-color: rgba(255, 255, 255, 0.5);
}
#lightbox-toolbar button:active { background: rgba(255, 255, 255, 0.3); }
#lightbox-zoom-display { color: #fff; font-size: 13px; min-width: 50px; text-align: center; }
#lightbox-zoom-slider {
width: 120px; cursor: pointer; accent-color: #4493f8;
}
#lightbox-image-wrapper {
position: relative; width: 100%; height: 100%; max-width: 94vw; max-height: 94vh;
display: flex; align-items: center; justify-content: center; overflow: hidden;
cursor: grab;
}
#lightbox-image-wrapper.panning { cursor: grabbing; }
#lightbox-image-wrapper img {
max-width: 100%; max-height: 100%; object-fit: contain;
border-radius: 4px; box-shadow: 0 8px 40px rgba(0,0,0,.6);
user-select: none; -webkit-user-drag: none;
}
#content img, #edit-preview img { cursor: zoom-in; }
#cheatsheet-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.45); display: none;
align-items: center; justify-content: center; z-index: 260; }
#cheatsheet-overlay.show { display: flex; }
#cheatsheet { background: var(--bg, #fff); color: inherit; border-radius: 10px; padding: 20px 26px;
max-height: 80vh; overflow-y: auto; min-width: 420px; box-shadow: 0 8px 40px rgba(0,0,0,.35); }
#cheatsheet h3 { margin: 12px 0 6px; font-size: 13px; text-transform: uppercase; opacity: .6; }
#cheatsheet div.row { display: flex; justify-content: space-between; gap: 24px; padding: 3px 0; font-size: 13.5px; }
#cheatsheet kbd { background: rgba(128,128,128,.18); border-radius: 4px; padding: 1px 7px;
font: 12px ui-monospace, monospace; }
#drop-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.45); display: none; align-items: center; justify-content: center; z-index: 270; }
#drop-overlay.show { display: flex; }
#drop-dialog { background: var(--bg); border: 1px solid var(--border); border-radius: 10px; padding: 18px 22px; min-width: 320px; display: flex; flex-direction: column; gap: 8px; }
#drop-dialog h3 { margin: 0 0 6px; font-size: 14px; }
#drop-dialog button { text-align: left; padding: 8px 12px; border-radius: 6px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); cursor: pointer; }
#drop-dialog button:hover:not(:disabled) { background: var(--hover); }
#drop-dialog button:disabled { opacity: .45; cursor: default; }
.loc-row { display: flex; justify-content: space-between; gap: 12px; align-items: center; font-size: 13px; }
#confirm-insert-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.45); display: none; align-items: center; justify-content: center; z-index: 270; }
#confirm-insert-overlay.show { display: flex; }
#confirm-insert-dialog { background: var(--bg); border: 1px solid var(--border); border-radius: 10px; padding: 18px 22px; min-width: 320px; display: flex; flex-direction: column; gap: 8px; }
#confirm-insert-dialog h3 { margin: 0 0 6px; font-size: 14px; font-weight: 400; }
#confirm-insert-dialog button { text-align: left; padding: 8px 12px; border-radius: 6px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); cursor: pointer; }
#confirm-insert-dialog button:hover { background: var(--hover); }
#image-drop-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.45); display: none; align-items: center; justify-content: center; z-index: 270; }
#image-drop-overlay.show { display: flex; }
#image-drop-dialog { background: var(--bg); border: 1px solid var(--border); border-radius: 10px; padding: 18px 22px; min-width: 320px; max-width: 480px; display: flex; flex-direction: column; gap: 8px; }
#image-drop-dialog h3 { margin: 0 0 6px; font-size: 14px; font-weight: 400; overflow-wrap: anywhere; }
#image-drop-dialog button { text-align: left; padding: 8px 12px; border-radius: 6px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); cursor: pointer; }
#image-drop-dialog button:hover:not(:disabled) { background: var(--hover); }
#image-drop-dialog button:disabled { opacity: .45; cursor: default; }
.recycle-bin-node { font-weight: 600; color: var(--danger); }
.recycle-bin-node::before { content: "🗑 "; }
.recycle-bin-badge { display: inline-block; background: var(--danger); color: white; border-radius: 10px;
padding: 1px 6px; font-size: 11px; margin-left: 4px; font-weight: 400; }
.deleted-file-item { display: flex; align-items: center; gap: 8px; padding: 4px 6px; border-radius: 4px;
font-size: 12px; cursor: pointer; }
.deleted-file-item:hover { background: var(--hover); }
.deleted-file-item .file-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.deleted-file-item .file-size { color: var(--muted); font-size: 11px; }
.deleted-file-item .delete-time { color: var(--muted); font-size: 11px; }
@media print {
#sidebar, #statusbar, #error, #tabbar, #read-progress, #toast-stack { display: none !important; }
#main, #scroll { overflow: visible !important; }
body { height: auto; overflow: visible; }
#content { max-width: none; padding: 0; font-size: 12pt; }
#content pre, #content blockquote, #content table { break-inside: avoid; }
#content h1, #content h2, #content h3 { break-after: avoid; }
#content a { color: inherit; text-decoration: underline; }
}
</style>
<style id="user-css"></style>
</head>
<body>
<div id="sidebar">
<div id="toolbar">
<button id="open-folder">Open Folder</button>
<button id="open-file">Open File</button>
<select id="theme" title="Theme">
<option value="system">System</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
<option value="sepia">Sepia</option>
</select>
<button id="custom-css" title="Load a custom CSS file">Custom CSS</button>
<button id="export-html">Export HTML</button>
<button id="print-pdf" title="Print or save as PDF">Print / PDF</button>
<button id="copy-rich" title="Copy as rich text">Copy Rich</button>
<button id="show-graph" title="Vault link graph">Graph</button>
<button id="edit-toggle" title="Toggle edit mode (Cmd+E)">Edit</button>
<button id="new-note" title="Create a note in the vault">New Note</button>
<button id="import-btn" title="Convert a file or URL to Markdown">Import…</button>
</div>
<div id="import-bar" style="display:none">
<input id="import-url" placeholder="Paste a URL and press Enter…" aria-label="URL to convert to Markdown">
<div class="import-row">
<button id="import-file">Choose file…</button>
<label class="import-save-as"><input id="import-pick" type="checkbox">Save As…</label>
</div>
</div>
<div id="tabs">
<button data-pane="files-pane" class="active">Files</button>
<button data-pane="search-pane">Search</button>
<button data-pane="tags-pane">Tags</button>
<button data-pane="tasks-pane">Tasks</button>
</div>
<div id="files-pane" class="pane active">
<div id="pins-box"></div>
<div id="recents-box"></div>
<div id="toc-box"></div>
<div id="tree-box"></div>
</div>
<div id="search-pane" class="pane">
<input id="search-input" placeholder="Search vault… (Enter)" aria-label="Search vault">
<select id="search-mode">
<option value="text">Full text</option>
<option value="tag">Tag</option>
<option value="alias">Alias</option>
<option value="field">Frontmatter field</option>
<option value="semantic">Semantic (vectors)</option>
</select>
<div id="search-results"><div class="empty-state">Type a query and press Enter to search the vault.</div></div>
</div>
<div id="tags-pane" class="pane"><div id="tags-list"><div class="empty-state">Open a vault to browse its tags.</div></div></div>
<div id="tasks-pane" class="pane"><div id="tasks-list"><div class="empty-state">Open a vault to list its tasks.</div></div></div>
<div id="note-panel"></div>
</div>
<div id="main">
<div id="read-progress"></div>
<div id="tabbar"></div>
<div id="error"></div>
<div id="scroll">
<div id="content"><p id="placeholder">Open a folder or file — or drop one here.</p></div>
</div>
<div id="editor-wrap">
<div id="edit-bar">
<input id="find-input" placeholder="Find">
<input id="replace-input" placeholder="Replace">
<button id="find-next">Next</button>
<button id="replace-one">Replace</button>
<button id="replace-all">All</button>
<button id="insert-table" title="Insert a table skeleton">Table</button>
<button id="edit-props" title="Edit YAML frontmatter">Properties</button>
<button id="rename-btn" title="Rename note, rewriting inbound links">Rename</button>
<span id="save-state"></span>
</div>
<div id="edit-split">
<div id="editor-shell"><pre id="editor-backdrop" aria-hidden="true"></pre><textarea id="editor" spellcheck="false"></textarea></div>
<div id="edit-preview"></div>
</div>
</div>
<div id="live-wrap"><div id="live-doc"></div></div>
<div id="statusbar"><span id="stats" role="button" tabindex="0" title="Click for full text analysis (tokens, frequencies)"></span><span id="sel-stats"></span><span id="live" class="live"></span></div>
</div>
<div id="autocomplete"></div>
<div id="wiki-ac" role="listbox"></div>
<div id="hover-pop"></div>
<div id="ctx-menu"></div>
<div id="prompt-overlay" class="overlay" role="dialog" aria-modal="true" aria-labelledby="prompt-title">
<div id="prompt-box">
<h3 id="prompt-title"></h3>
<input id="prompt-input" style="display:none">
<textarea id="prompt-textarea" style="display:none"></textarea>
<div id="prompt-actions">
<button id="prompt-cancel">Cancel</button>
<button id="prompt-ok" class="primary">OK</button>
</div>
</div>
</div>
<div id="switcher-overlay" class="overlay" role="dialog" aria-modal="true" aria-label="Quick switcher">
<div id="switcher">
<input id="switcher-input" placeholder="Open note by title or alias…" aria-label="Quick switcher">
<div id="switcher-results"></div>
</div>
</div>
<div id="result-overlay" class="overlay" role="dialog" aria-modal="true" aria-labelledby="result-title">
<div id="result-box">
<div id="result-head"><span id="result-title"></span><button id="result-insert" style="display:none">Insert into note</button><button id="result-copy">Copy</button><button id="result-close">Close</button></div>
<div id="result-tabs" role="tablist"></div>
<div id="result-body"></div>
</div>
</div>
<div id="binary-overlay" class="overlay" role="dialog" aria-modal="true" aria-label="Binary file format">
<div class="dialog-box" style="width: 500px;">
<h3 style="margin: 0 0 16px; font-size: 16px; color: var(--fg);">How would you like to view this binary file?</h3>
<div style="display: flex; flex-direction: column; gap: 10px;">
<button id="binary-hex" style="padding: 12px; border: 1px solid var(--border); border-radius: 6px; background: var(--hover); color: var(--fg); cursor: pointer; text-align: left; font-weight: 500;">
<div style="font-size: 14px;">🔢 Hexadecimal (Hex Dump)</div>
<div style="font-size: 12px; color: var(--muted); margin-top: 4px;">Show as offset + hex bytes + ASCII representation</div>
</button>
<button id="binary-text" style="padding: 12px; border: 1px solid var(--border); border-radius: 6px; background: var(--hover); color: var(--fg); cursor: pointer; text-align: left; font-weight: 500;">
<div style="font-size: 14px;">📝 Raw Text</div>
<div style="font-size: 12px; color: var(--muted); margin-top: 4px;">Attempt to decode as UTF-8 text (lossy conversion)</div>
</button>
<button id="binary-base64" style="padding: 12px; border: 1px solid var(--border); border-radius: 6px; background: var(--hover); color: var(--fg); cursor: pointer; text-align: left; font-weight: 500;">
<div style="font-size: 14px;">🔐 Base64</div>
<div style="font-size: 12px; color: var(--muted); margin-top: 4px;">Encode as Base64 string</div>
</button>
</div>
<div style="margin-top: 16px; text-align: right;">
<button id="binary-cancel" style="padding: 8px 16px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--fg); cursor: pointer;">Cancel</button>
</div>
</div>
</div>
<div id="settings-overlay" class="overlay" role="dialog" aria-modal="true" aria-label="Settings">
<div class="dialog-box" style="width: 600px; max-height: 85vh; display: flex; flex-direction: column;">
<div style="display: flex; justify-content: space-between; align-items: center; padding: 14px 14px 10px; border-bottom: 1px solid var(--border);">
<h3 style="margin: 0; font-size: 16px;">Settings</h3>
<button id="settings-dialog-close" style="background: none; border: none; color: var(--fg); cursor: pointer; font-size: 20px; padding: 0; width: 24px; height: 24px; display: flex; align-items: center; justify-content: center;">×</button>
</div>
<div id="settings-box" style="flex: 1; overflow-y: auto; padding: 14px;">
<div style="margin-bottom: 20px;">
<div style="font-weight: 600; font-size: 13px; color: var(--fg); margin-bottom: 8px;">Tab Mode</div>
<div style="display: flex; gap: 16px;">
<label style="display: flex; align-items: center; cursor: pointer; font-size: 13px;">
<input type="radio" name="tab-mode" value="Single" style="margin-right: 8px; cursor: pointer;">
<span>Single tab</span>
</label>
<label style="display: flex; align-items: center; cursor: pointer; font-size: 13px;">
<input type="radio" name="tab-mode" value="Multi" style="margin-right: 8px; cursor: pointer;">
<span>Multiple tabs</span>
</label>
</div>
</div>
<div style="margin-bottom: 20px;">
<div style="font-weight: 600; font-size: 13px; color: var(--fg); margin-bottom: 8px;">Theme</div>
<div style="display: flex; gap: 16px;">
<label style="display: flex; align-items: center; cursor: pointer; font-size: 13px;">
<input type="radio" name="theme-pref" value="light" style="margin-right: 8px; cursor: pointer;">
<span>Light</span>
</label>
<label style="display: flex; align-items: center; cursor: pointer; font-size: 13px;">
<input type="radio" name="theme-pref" value="dark" style="margin-right: 8px; cursor: pointer;">
<span>Dark</span>
</label>
<label style="display: flex; align-items: center; cursor: pointer; font-size: 13px;">
<input type="radio" name="theme-pref" value="sepia" style="margin-right: 8px; cursor: pointer;">
<span>Sepia</span>
</label>
</div>
</div>
<div style="margin-bottom: 20px;">
<label style="display: flex; align-items: center; cursor: pointer; font-size: 13px;">
<input type="checkbox" id="auto-save" style="margin-right: 8px; cursor: pointer;">
<span>Auto-save</span>
</label>
</div>
<div style="margin-bottom: 20px;">
<label for="recycle-retention" style="display: block; font-weight: 600; font-size: 13px; color: var(--fg); margin-bottom: 8px;">Recycle bin retention</label>
<select id="recycle-retention" style="width: 100%; max-width: 200px;">
<option value="30">30 days</option>
<option value="90">90 days</option>
<option value="180">180 days</option>
<option value="365">365 days</option>
</select>
</div>
<div style="margin-bottom: 20px;">
<label style="display: flex; align-items: center; cursor: pointer; font-size: 13px;">
<input type="checkbox" id="mouse-wheel-zoom" style="margin-right: 8px; cursor: pointer;">
<span>Mouse wheel zoom</span>
</label>
</div>
<div style="margin-bottom: 20px;">
<div style="font-weight: 600; font-size: 13px; color: var(--fg); margin-bottom: 8px;">Zoom behavior</div>
<div style="display: flex; flex-direction: column; gap: 8px;">
<label style="display: flex; align-items: center; cursor: pointer; font-size: 13px;">
<input type="radio" name="zoom-behavior" value="ResetPerImage" style="margin-right: 8px; cursor: pointer;">
<span>Reset per image</span>
</label>
<label style="display: flex; align-items: center; cursor: pointer; font-size: 13px;">
<input type="radio" name="zoom-behavior" value="RememberPerImage" style="margin-right: 8px; cursor: pointer;">
<span>Remember per image</span>
</label>
<label style="display: flex; align-items: center; cursor: pointer; font-size: 13px;">
<input type="radio" name="zoom-behavior" value="RememberGlobal" style="margin-right: 8px; cursor: pointer;">
<span>Remember globally</span>
</label>
</div>
</div>
<div style="margin-bottom: 20px;">
<label style="display: flex; align-items: center; cursor: pointer; font-size: 13px;">
<input type="checkbox" id="auto-restore-tabs" style="margin-right: 8px; cursor: pointer;">
<span>Auto-restore tabs on launch</span>
</label>
</div>
<div style="margin-top: 20px; padding-top: 20px; border-top: 1px solid var(--border);">
<label for="set-font" style="display: block; font-weight: 600; font-size: 13px; color: var(--fg); margin-bottom: 8px;">Content font size (px)</label>
<input id="set-font" type="number" min="11" max="24" step="1" style="width: 100%; max-width: 80px;">
<label for="set-lineheight" style="display: block; font-weight: 600; font-size: 13px; color: var(--fg); margin: 12px 0 8px;">Line height</label>
<select id="set-lineheight">
<option value="1.4">Compact (1.4)</option>
<option value="1.6" selected>Normal (1.6)</option>
<option value="1.8">Relaxed (1.8)</option>
<option value="2.0">Airy (2.0)</option>
</select>
<label for="set-key" style="display: block; font-weight: 600; font-size: 13px; color: var(--fg); margin: 12px 0 8px;">Anthropic API key (stored locally; enables AI actions)</label>
<input id="set-key" type="password" placeholder="sk-ant-…">
<label style="display: flex; align-items: center; cursor: pointer; font-size: 13px; margin-top: 12px;"><input id="set-reopen" type="checkbox" style="width:auto;margin-right:6px">Reopen last vault on launch</label>
<label style="display: flex; align-items: center; cursor: pointer; font-size: 13px; margin-top: 8px;"><input id="set-live" type="checkbox" style="width:auto;margin-right:6px">Open vault notes in Live editing mode</label>
<label style="display: block; font-weight: 600; font-size: 13px; color: var(--fg); margin: 12px 0 8px;">Known vaults</label>
<div id="vault-list"></div>
</div>
</div>
<div style="display: flex; gap: 8px; justify-content: flex-end; padding: 14px; border-top: 1px solid var(--border);">
<button id="settings-cancel" style="padding: 6px 14px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--fg); cursor: pointer; font-size: 13px;">Cancel</button>
<button id="settings-save" class="primary" style="padding: 6px 14px; border: 1px solid var(--accent); border-radius: 6px; background: var(--accent); color: #fff; cursor: pointer; font-size: 13px;">Save</button>
</div>
</div>
</div>
<div id="graph-overlay" class="overlay" role="dialog" aria-modal="true" aria-label="Vault graph">
<div id="graph-box">
<div id="graph-head">
<strong>Vault graph</strong>
<input id="graph-filter" placeholder="Filter notes…">
<div id="graph-toggle">
<button id="graph-global" class="graph-mode-btn">Global</button>
<button id="graph-current" class="graph-mode-btn">Current</button>
<button id="graph-word" class="graph-mode-btn">Word</button>
</div>
<span style="flex:1"></span>
<button id="graph-close">Close</button>
</div>
<div id="graph-header" style="display: none;">
<div id="graph-title">Word Relationships</div>
<div id="graph-timestamp" style="font-size: 12px; color: var(--muted);">Last updated: Never</div>
<div id="graph-header-controls">
<div id="graph-zoom-controls">
<button id="graph-zoom-in" title="Zoom in (+ key)">+</button>
<button id="graph-zoom-out" title="Zoom out (- key)">−</button>
<button id="graph-zoom-reset" title="Reset zoom (0 key)">Reset</button>
</div>
<button id="graph-refresh" title="Refresh index now">🔄 Refresh</button>
<div id="graph-refresh-indicator" style="display: none; font-size: 12px; color: var(--muted); margin-left: 8px;">
<span id="graph-refresh-spinner">⏳</span> Refreshing...
</div>
</div>
</div>
<canvas id="graph-canvas"></canvas>
</div>
</div>
<div id="lightbox" role="dialog" aria-label="Image preview">
<div id="lightbox-container">
<div id="lightbox-toolbar">
<button id="lightbox-zoom-out" title="Zoom out (−)">−</button>
<div id="lightbox-zoom-display">1.0x</div>
<button id="lightbox-zoom-in" title="Zoom in (+)">+</button>
<input id="lightbox-zoom-slider" type="range" min="1" max="15" step="0.1" value="1" title="Zoom slider">
<button id="lightbox-fit-window" title="Fit to window">Fit</button>
<button id="lightbox-reset-zoom" title="Reset zoom">Reset</button>
</div>
<div id="lightbox-image-wrapper"><img alt="" id="lightbox-img"></div>
</div>
</div>
<div id="cheatsheet-overlay" role="dialog" aria-label="Keyboard shortcuts"><div id="cheatsheet"></div></div>
<div id="drop-overlay" role="dialog" aria-label="Dropped file">
<div id="drop-dialog">
<h3 id="drop-title"></h3>
<button data-act="copy">Copy into vault & link</button>
<button data-act="linkorig">Link original location</button>
<button data-act="convnote">Convert to new linked note</button>
<button data-act="convinline">Convert inline</button>
<button data-act="cancel">Cancel (Esc)</button>
</div>
</div>
<div id="confirm-insert-overlay" role="dialog" aria-label="Confirm large insert">
<div id="confirm-insert-dialog">
<h3 id="confirm-insert-title"></h3>
<button data-act="insert">Insert anyway</button>
<button data-act="cancel">Cancel (Esc)</button>
</div>
</div>
<div id="image-drop-overlay" role="dialog" aria-label="Dropped image">
<div id="image-drop-dialog">
<h3 id="image-drop-title"></h3>
<button data-act="store">Save image into vault & embed</button>
<button data-act="link">Link only (keep remote URL)</button>
<button data-act="cancel">Cancel (Esc)</button>
</div>
</div>
<script>
const invoke = window.__TAURI__.core.invoke;
const listen = window.__TAURI__.event.listen;
let mermaidSeq = 0;
function effectiveDark() {
const t = document.documentElement.dataset.theme;
return t === 'dark' || (t === 'system' && matchMedia('(prefers-color-scheme: dark)').matches);
}
async function postRender(container) {
if (window.mermaid) mermaid.initialize({ startOnLoad: false, theme: effectiveDark() ? 'dark' : 'neutral' });
if (window.katex) {
for (const el of container.querySelectorAll('[data-tex]')) {
try {
katex.render(decodeURIComponent(el.dataset.tex), el,
{ displayMode: el.classList.contains('math-block'), throwOnError: false });
} catch {}
delete el.dataset.tex;
}
}
if (window.mermaid) {
const nodes = [...container.querySelectorAll('pre.mermaid:not([data-processed])')];
for (const n of nodes) n.id = 'mmd-' + (mermaidSeq++);
if (nodes.length) { try { await mermaid.run({ nodes }); } catch {} }
}
container.querySelectorAll('img').forEach(img => {
img.onclick = (e) => { e.preventDefault(); openLightbox(img.src); };
});
}
function openLightbox(src) {
if (window.initLightboxImage) {
window.initLightboxImage(src);
document.getElementById('lightbox').classList.add('show');
}
}
function closeLightbox() {
if (window.closeLightboxZoom) {
window.closeLightboxZoom();
} else {
document.getElementById('lightbox').classList.remove('show');
}
}
const SHORTCUTS = [
['General', [['Cmd/Ctrl+K', 'Command palette'], ['Cmd/Ctrl+O / Cmd/Ctrl+P', 'Quick switcher'],
['Cmd/Ctrl+,', 'Settings'], ['?', 'This cheat sheet'], ['Esc', 'Close overlay']]],
['View', [['Cmd/Ctrl+E', 'Toggle live editing'], ['Cmd/Ctrl+Shift+E', 'Toggle split source'],
['Cmd/Ctrl+=', 'Larger text'], ['Cmd/Ctrl+-', 'Smaller text'], ['Cmd/Ctrl+0', 'Reset text size']]],
['Notes', [['Cmd/Ctrl+D', 'Daily note'], ['Cmd/Ctrl+S', 'Save (editor)'], ['Cmd/Ctrl+W', 'Close tab'],
['Cmd/Ctrl+Shift+Z', 'Zen mode (toggle sidebar)'], ['Cmd/Ctrl+1-9', 'Jump to tab N']]],
['Editing', [['Cmd/Ctrl+B', 'Bold'], ['Cmd/Ctrl+I', 'Italic'], ['Cmd/Ctrl+Shift+X', 'Strikethrough'],
['Cmd/Ctrl+Shift+K', 'Insert link']]],
];
const cheatOverlay = document.getElementById('cheatsheet-overlay');
function openCheatsheet() {
const box = document.getElementById('cheatsheet');
box.innerHTML = SHORTCUTS.map(([title, rows]) =>
`<h3>${title}</h3>` + rows.map(([k, d]) =>
`<div class="row"><span>${escapeHtml(d)}</span><kbd>${escapeHtml(k)}</kbd></div>`).join('')
).join('');
cheatOverlay.classList.add('show'); trapFocus(cheatOverlay);
}
function closeCheatsheet() { cheatOverlay.classList.remove('show'); releaseFocus(cheatOverlay); }
cheatOverlay.addEventListener('click', (e) => { if (e.target === cheatOverlay) closeCheatsheet(); });
document.addEventListener('keydown', (e) => {
const typing = /^(INPUT|TEXTAREA|SELECT)$/.test(document.activeElement?.tagName) || document.activeElement?.isContentEditable;
if (e.key === '?' && !typing && !e.metaKey && !e.ctrlKey) { e.preventDefault(); openCheatsheet(); }
if (e.key === 'Escape' && cheatOverlay.classList.contains('show')) closeCheatsheet();
});
invoke('syntax_css').then(css => {
const st = document.createElement('style');
st.textContent = css;
document.head.appendChild(st);
}).catch(() => {});
const errBox = document.getElementById('error');
const scrollBox = document.getElementById('scroll');
const contentBox = document.getElementById('content');
let currentFile = null;
let currentFolder = null;
const readProgress = document.getElementById('read-progress');
scrollBox.addEventListener('scroll', () => {
const max = scrollBox.scrollHeight - scrollBox.clientHeight;
readProgress.style.width = max > 0 ? (scrollBox.scrollTop / max * 100) + '%' : '0';
}, { passive: true });
function escapeHtml(s) {
return String(s ?? '').replace(/[&<>"']/g, c =>
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
}
const toastStack = document.createElement('div');
toastStack.id = 'toast-stack';
toastStack.setAttribute('aria-live', 'polite');
document.body.appendChild(toastStack);
function toast(msg, kind = 'info', ms = 3500) {
const el = document.createElement('div');
el.className = 'toast ' + kind;
el.textContent = msg;
el.onclick = () => el.remove();
toastStack.appendChild(el);
setTimeout(() => { el.classList.add('fade'); setTimeout(() => el.remove(), 300); }, ms);
}
function friendlyError(e) {
const s = String(e);
if (/os error 2|No such file|NotFound/i.test(s)) return 'File not found — it may have been moved or deleted.';
if (/os error 13|permission denied/i.test(s)) return 'Permission denied — check the file or folder permissions.';
if (/Failed to fetch|error sending request|dns|connect/i.test(s)) return 'Could not reach the URL — check the address and your connection.';
if (/API key/i.test(s)) return 'AI action needs an Anthropic API key — add one in Settings (Cmd+,).';
if (/too large/i.test(s)) return s; return s;
}
function showError(msg) { errBox.textContent = friendlyError(msg); errBox.style.display = 'block'; }
function clearError() { errBox.style.display = 'none'; }
async function withBusy(btn, fn) {
if (btn) { btn.disabled = true; btn.classList.add('busy'); }
try { return await fn(); }
finally { if (btn) { btn.disabled = false; btn.classList.remove('busy'); } }
}
const FOCUSABLE = 'a[href], button:not([disabled]), input, textarea, select, [tabindex]:not([tabindex="-1"])';
const focusReturns = new WeakMap();
function trapFocus(overlay) {
focusReturns.set(overlay, document.activeElement);
const first = overlay.querySelector(FOCUSABLE);
if (first && !overlay.contains(document.activeElement)) first.focus();
overlay.addEventListener('keydown', (e) => {
if (e.key !== 'Tab') return;
const items = [...overlay.querySelectorAll(FOCUSABLE)].filter(el => el.offsetParent !== null);
if (!items.length) return;
const first = items[0], last = items[items.length - 1];
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
});
}
function releaseFocus(overlay) {
const prev = focusReturns.get(overlay);
if (prev && document.contains(prev)) prev.focus();
focusReturns.delete(overlay);
}
function watchOverlayFocus(overlay) {
new MutationObserver(() => {
if (overlay.classList.contains('show')) trapFocus(overlay);
else releaseFocus(overlay);
}).observe(overlay, { attributes: true, attributeFilter: ['class'] });
}
const EXPORT_CSS = {
light: { bg:'#ffffff', fg:'#1f2328', muted:'#59636e', border:'#d1d9e0', code:'#f6f8fa', accent:'#0969da' },
dark: { bg:'#0d1117', fg:'#f0f6fc', muted:'#9198a1', border:'#3d444d', code:'#151b23', accent:'#4493f8' },
sepia: { bg:'#f4ecd8', fg:'#433422', muted:'#8a7a5c', border:'#d8cba8', code:'#ece1c4', accent:'#9a6a28' },
};
function exportCss(theme) {
const t = EXPORT_CSS[theme] || EXPORT_CSS.light;
return `body{background:${t.bg};color:${t.fg};font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;line-height:1.6}
main.content{max-width:860px;margin:0 auto;padding:32px 40px}
h1,h2{border-bottom:1px solid ${t.border};padding-bottom:.3em}
a{color:${t.accent}}
pre{background:${t.code};border:1px solid ${t.border};border-radius:6px;padding:12px;overflow-x:auto;font-size:13px}
code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.95em}
:not(pre)>code{background:${t.code};padding:.15em .35em;border-radius:4px}
table{border-collapse:collapse}th,td{border:1px solid ${t.border};padding:5px 10px}th{background:${t.code}}
blockquote{border-left:4px solid ${t.border};margin-left:0;padding-left:14px;color:${t.muted}}
img{max-width:100%}`;
}
const themeSel = document.getElementById('theme');
function applyTheme(name) {
const changed = document.documentElement.dataset.theme !== name;
document.documentElement.dataset.theme = name;
localStorage.setItem('theme', name);
themeSel.value = name;
if (changed && typeof currentFile !== 'undefined' && currentFile) openFile(currentFile, false, true);
}
themeSel.onchange = () => applyTheme(themeSel.value);
applyTheme(localStorage.getItem('theme') || 'system');
async function loadUserCss(path) {
try {
const css = await invoke('read_text_file', { path });
document.getElementById('user-css').textContent = css;
localStorage.setItem('userCss', path);
} catch (e) { showError('Custom CSS: ' + e); }
}
document.getElementById('custom-css').onclick = async () => {
const f = await invoke('pick_file');
if (f) loadUserCss(f);
};
if (localStorage.getItem('userCss')) loadUserCss(localStorage.getItem('userCss'));
function getRecents() { try { return JSON.parse(localStorage.getItem('recents') || '[]'); } catch { return []; } }
function addRecent(path, kind) {
const recents = getRecents().filter(r => r.path !== path);
recents.unshift({ path, kind });
localStorage.setItem('recents', JSON.stringify(recents.slice(0, 8)));
renderRecents();
}
function renderRecents() {
const box = document.getElementById('recents-box');
const recents = getRecents();
if (!recents.length) { box.innerHTML = ''; return; }
box.innerHTML = '<div class="section-title">Recent</div>';
const ul = document.createElement('ul');
ul.id = 'recents';
for (const r of recents) {
const li = document.createElement('li');
li.textContent = r.path.split('/').pop() + (r.kind === 'dir' ? '/' : '');
li.title = r.path;
li.onclick = () => r.kind === 'dir' ? openFolder(r.path) : openFile(r.path);
ul.appendChild(li);
}
box.appendChild(ul);
}
function buildToc(container = contentBox) {
const box = document.getElementById('toc-box');
const headings = container.querySelectorAll('h1, h2, h3, h4');
if (headings.length < 2) { box.innerHTML = ''; return; }
box.innerHTML = '<div class="section-title">Contents</div>';
const ul = document.createElement('ul');
ul.id = 'toc';
headings.forEach((h, i) => {
h.id = h.id || 'h-' + i;
const li = document.createElement('li');
const level = parseInt(h.tagName[1], 10);
li.style.paddingLeft = (6 + (level - 1) * 12) + 'px';
li.textContent = h.textContent;
li.onclick = () => h.scrollIntoView({ behavior: 'smooth', block: 'start' });
ul.appendChild(li);
});
box.appendChild(ul);
}
function renderTree(nodes, parentEl) {
const ul = document.createElement('ul');
ul.className = 'tree' + (parentEl.id === 'tree-box' ? ' root' : '');
for (const n of nodes) {
const li = document.createElement('li');
const div = document.createElement('div');
div.className = 'node' + (n.is_dir ? ' dir' : '');
div.textContent = n.name;
div.title = n.path;
if (n.is_dir) {
li.className = 'collapsed';
div.onclick = () => { li.classList.toggle('collapsed'); div.classList.toggle('open'); };
} else {
div.onclick = (e) => {
document.querySelectorAll('.node.selected').forEach(el => el.classList.remove('selected'));
div.classList.add('selected');
if (e.metaKey || e.ctrlKey) openInNewTab(n.path);
else openFile(n.path, false);
};
}
li.appendChild(div);
if (n.is_dir && n.children.length) renderTree(n.children, li);
ul.appendChild(li);
}
parentEl.appendChild(ul);
}
async function openFolder(root) {
clearError();
try {
const nodes = await invoke('list_tree', { root });
const box = document.getElementById('tree-box');
box.innerHTML = '<div class="section-title">Files</div>';
renderTree(nodes, box);
currentFolder = root;
addRecent(root, 'dir');
invoke('watch_tree', { root }).catch(() => {});
window._tagCache = null;
const vaults = JSON.parse(localStorage.getItem('vaults') || '[]').filter(v => v !== root);
vaults.unshift(root);
localStorage.setItem('vaults', JSON.stringify(vaults.slice(0, 10)));
localStorage.setItem('lastVault', root);
renderPins();
loadTabs();
await renderRecycleBin();
} catch (e) { showError(String(e)); }
}
async function renderRecycleBin() {
if (!currentFolder) return;
try {
const deletedFiles = await invoke('get_deleted_files');
const box = document.getElementById('tree-box');
let recycleBinSection = box.querySelector('[data-recycle-bin-section]');
if (!recycleBinSection) {
const div = document.createElement('div');
div.setAttribute('data-recycle-bin-section', 'true');
div.style.marginTop = '8px';
box.appendChild(div);
recycleBinSection = div;
}
recycleBinSection.innerHTML = '';
if (deletedFiles.length === 0) {
const emptyDiv = document.createElement('div');
emptyDiv.className = 'recycle-bin-node empty-state';
emptyDiv.textContent = 'Recycle Bin (empty)';
emptyDiv.style.cursor = 'default';
recycleBinSection.appendChild(emptyDiv);
} else {
const folderDiv = document.createElement('div');
folderDiv.className = 'recycle-bin-node';
folderDiv.style.cursor = 'pointer';
folderDiv.style.padding = '2px 6px';
folderDiv.style.borderRadius = '4px';
folderDiv.setAttribute('data-recycle-bin', 'true');
const textSpan = document.createElement('span');
textSpan.textContent = 'Recycle Bin';
const badge = document.createElement('span');
badge.className = 'recycle-bin-badge';
badge.textContent = deletedFiles.length;
folderDiv.appendChild(textSpan);
folderDiv.appendChild(badge);
folderDiv.onclick = (e) => {
e.stopPropagation();
const list = folderDiv.nextElementSibling;
if (list && list.hasAttribute('data-recycle-list')) {
list.style.display = list.style.display === 'none' ? 'block' : 'none';
folderDiv.style.fontWeight = folderDiv.style.fontWeight === '700' ? '600' : '700';
}
};
recycleBinSection.appendChild(folderDiv);
const ul = document.createElement('ul');
ul.setAttribute('data-recycle-list', 'true');
ul.className = 'tree';
for (const file of deletedFiles) {
const li = document.createElement('li');
const fileDiv = document.createElement('div');
fileDiv.className = 'deleted-file-item';
fileDiv.setAttribute('data-file-id', file.id);
const fileName = document.createElement('span');
fileName.className = 'file-name';
fileName.textContent = file.original_path.split('/').pop();
fileName.title = file.original_path;
const fileSize = document.createElement('span');
fileSize.className = 'file-size';
fileSize.textContent = formatFileSize(file.file_size);
const deleteTime = document.createElement('span');
deleteTime.className = 'delete-time';
deleteTime.textContent = formatDeleteTime(file.deleted_at);
fileDiv.appendChild(fileName);
fileDiv.appendChild(fileSize);
fileDiv.appendChild(deleteTime);
fileDiv.onclick = (e) => {
if (e.metaKey || e.ctrlKey) return;
e.stopPropagation();
};
li.appendChild(fileDiv);
ul.appendChild(li);
}
recycleBinSection.appendChild(ul);
}
} catch (e) {
console.error('Error rendering recycle bin:', e);
}
}
function formatFileSize(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 10) / 10 + ' ' + sizes[i];
}
function formatDeleteTime(timestamp) {
const ms = timestamp * 1000;
const date = new Date(ms);
const now = new Date();
const diff = now - date;
if (diff < 60 * 1000) return 'just now';
if (diff < 60 * 60 * 1000) return Math.floor(diff / 60000) + 'm ago';
if (diff < 24 * 60 * 60 * 1000) return Math.floor(diff / 3600000) + 'h ago';
if (diff < 7 * 24 * 60 * 60 * 1000) return Math.floor(diff / (24 * 3600000)) + 'd ago';
return (date.getMonth() + 1) + '/' + date.getDate();
}
function showBinaryFormatDialog(filePath) {
return new Promise((resolve) => {
const overlay = document.getElementById('binary-overlay');
const filename = filePath.split('/').pop();
const handleChoice = (format) => {
overlay.classList.remove('show');
resolve(format);
};
document.getElementById('binary-hex').onclick = () => handleChoice('hex');
document.getElementById('binary-text').onclick = () => handleChoice('text');
document.getElementById('binary-base64').onclick = () => handleChoice('base64');
document.getElementById('binary-cancel').onclick = () => {
overlay.classList.remove('show');
resolve(null);
};
overlay.classList.add('show');
});
}
async function openFile(path, remember = true, preserveScroll = false, binaryFormat = null) {
clearError();
const ratio = preserveScroll && scrollBox.scrollHeight > 0
? scrollBox.scrollTop / scrollBox.scrollHeight : 0;
try {
let r = await invoke('open_file', { path, vaultRoot: currentFolder });
if (r.words === 0 && r.chars === 0 && !binaryFormat) {
const ext = path.split('.').pop().toLowerCase();
const imageExts = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'avif', 'tiff', 'tif'];
const isImage = imageExts.includes(ext);
const isKnownCode = /\.(js|ts|jsx|tsx|html|css|py|rs|go|java|rb|php|sh|json|yaml|yml|toml|xml|dockerfile)$/i.test(path);
if (!isImage && !isKnownCode) {
const choice = await showBinaryFormatDialog(path);
if (choice) {
r = await invoke('view_binary', { path, format: choice });
} else {
return; }
}
}
contentBox.innerHTML = r.html;
postRender(contentBox);
document.title = r.title + ' — toMarkdown Viewer';
scrollBox.scrollTop = preserveScroll ? ratio * scrollBox.scrollHeight : 0;
document.getElementById('stats').textContent =
`${r.words.toLocaleString()} words · ${r.chars.toLocaleString()} chars · ~${Math.ceil(r.chars / 4).toLocaleString()} tokens (est.) · ~${r.read_minutes} min read`;
buildToc();
currentFile = path;
if (remember) addRecent(path, 'file');
invoke('watch_file', { path }).catch(() => {});
renderNotePanel(path);
syncTabs(path);
if (/\.(md|markdown)$/.test(path) && currentFolder) {
if (viewMode === 'live') {
liveDoc = await invoke('read_source', { path });
liveDirty = false;
renderLiveDoc(false);
} else if (viewMode === 'read' && liveDefaultOn() && Date.now() - (window._noAutoLive || 0) > 600) {
setViewMode('live');
}
} else if (viewMode === 'live') {
window._noAutoLive = Date.now();
setViewMode('read');
}
} catch (e) { showError(String(e)); }
}
let tabs = [], activeTab = -1;
function tabsKey() { return 'tabs:' + (currentFolder || ''); }
function saveTabs() {
if (currentFolder) localStorage.setItem(tabsKey(), JSON.stringify({ tabs, active: activeTab }));
}
function loadTabs() {
try {
const st = JSON.parse(localStorage.getItem(tabsKey()) || 'null');
tabs = (st?.tabs || []).filter(p => typeof p === 'string');
activeTab = Math.min(st?.active ?? -1, tabs.length - 1);
} catch { tabs = []; activeTab = -1; }
renderTabs();
if (activeTab >= 0) openFile(tabs[activeTab], false);
}
function syncTabs(path) {
if (activeTab >= 0 && tabs[activeTab] === path) { renderTabs(); return; }
const existing = tabs.indexOf(path);
if (existing >= 0) {
activeTab = existing;
} else {
tabs.push(path);
activeTab = tabs.length - 1;
}
renderTabs();
saveTabs();
}
function openInNewTab(path) {
tabs.push(path);
activeTab = tabs.length - 1;
renderTabs();
openFile(path);
}
function closeTab(i) {
tabs.splice(i, 1);
if (activeTab >= tabs.length) activeTab = tabs.length - 1;
else if (i < activeTab) activeTab--;
renderTabs();
saveTabs();
if (activeTab >= 0) openFile(tabs[activeTab], false);
}
function renderTabs() {
const bar = document.getElementById('tabbar');
bar.innerHTML = '';
if (tabs.length < 2 && activeTab <= 0 && tabs.length === 0) return;
tabs.forEach((p, i) => {
const div = document.createElement('div');
div.className = 'tab' + (i === activeTab ? ' active' : '');
const t = document.createElement('span');
t.className = 't';
t.textContent = p.split('/').pop();
t.title = p;
const x = document.createElement('button');
x.className = 'close';
x.textContent = '×';
x.title = 'Close tab';
x.onclick = (e) => { e.stopPropagation(); closeTab(i); };
div.append(t, x);
div.onclick = () => { activeTab = i; renderTabs(); saveTabs(); openFile(tabs[i], false); };
div.onauxclick = (e) => { if (e.button === 1) closeTab(i); };
bar.appendChild(div);
});
}
contentBox.addEventListener('click', async (e) => {
const a = e.target.closest('a');
if (!a) return;
const href = a.getAttribute('href') || '';
if (href.startsWith('hashtag:')) {
e.preventDefault();
runSearch(decodeURIComponent(href.slice(8)), 'tag');
return;
}
if (!href.startsWith('wikilink:')) return;
e.preventDefault();
if (!currentFolder) { showError('Open the vault folder to follow wikilinks.'); return; }
const target = decodeURIComponent(href.slice('wikilink:'.length)).split('#')[0];
try {
const resolved = await invoke('resolve_wikilink',
{ root: currentFolder, target, from: currentFile || '' });
openFile(resolved);
} catch (err) { showError(String(err)); }
});
async function renderNotePanel(path) {
const panel = document.getElementById('note-panel');
if (!currentFolder || !path.endsWith('.md')) { panel.innerHTML = ''; return; }
try {
const info = await invoke('note_info', { root: currentFolder, path });
const note = info.note || {};
let html = '';
const fm = note.frontmatter || {};
const fmKeys = Object.keys(fm);
if (fmKeys.length) {
html += '<div class="section-title">Properties</div>';
for (const k of fmKeys) {
const v = Array.isArray(fm[k]) ? fm[k].join(', ') : String(fm[k] ?? '');
html += `<div class="kv"><span class="k">${escapeHtml(k)}</span><span>${escapeHtml(v)}</span></div>`;
}
}
const tags = note.tags || [];
if (tags.length) {
html += '<div class="section-title">Tags</div><div>' +
tags.map(t => `<span class="tag-item" data-tag="${escapeHtml(t)}">#${escapeHtml(t)}</span>`).join('') + '</div>';
}
const backlinks = (info.backlinks && info.backlinks.backlinks) || info.backlinks || [];
if (Array.isArray(backlinks) && backlinks.length) {
html += `<div class="section-title">Backlinks (${backlinks.length})</div>`;
for (const b of backlinks) {
html += `<div class="bl" data-path="${escapeHtml(b.from)}"><div>${escapeHtml(b.from)}</div><div class="ctx">${escapeHtml((b.context || '').trim())}</div></div>`;
}
}
const outlinks = note.links || [];
if (outlinks.length) {
html += `<div class="section-title">Outgoing links (${outlinks.length})</div>`;
for (const l of outlinks) {
html += `<div class="bl" data-target="${escapeHtml(l.target)}"><div>${l.embed ? '⧉ ' : ''}${escapeHtml(l.target)}${l.heading ? ' › ' + escapeHtml(l.heading) : ''}</div></div>`;
}
}
html += '<div class="section-title">Related</div><div id="related-box"><div class="bl" id="find-related">Find related notes…</div></div>';
html += '<div class="section-title">Unlinked mentions</div><div id="unlinked-box"><div class="bl" id="find-unlinked">Find unlinked mentions…</div></div>';
panel.innerHTML = html;
panel.querySelectorAll('.bl[data-target]').forEach(el => {
el.onclick = async () => {
try {
const resolved = await invoke('resolve_wikilink',
{ root: currentFolder, target: el.dataset.target, from: currentFile || '' });
openFile(resolved);
} catch (err) { showError(String(err)); }
};
});
document.getElementById('find-unlinked').onclick = async () => {
const box = document.getElementById('unlinked-box');
box.innerHTML = '<div class="section-title">…</div>';
try {
const hits = await invoke('unlinked_mentions', { root: currentFolder, path });
box.innerHTML = hits.length ? '' : '<div class="kv">none found</div>';
for (const h of hits) {
const div = document.createElement('div');
div.className = 'bl';
div.dataset.path = h.note;
div.innerHTML = `<div>${escapeHtml(h.note)}:${h.line}</div><div class="ctx">${escapeHtml(h.context)}</div>`;
div.onclick = () => openFile(currentFolder + '/' + h.note);
box.appendChild(div);
}
} catch (e) { box.innerHTML = ''; showError(String(e)); }
};
panel.querySelectorAll('.bl[data-path]').forEach(el =>
el.onclick = () => openFile(currentFolder + '/' + el.dataset.path));
panel.querySelectorAll('.tag-item').forEach(el =>
el.onclick = () => runSearch(el.dataset.tag, 'tag'));
document.getElementById('find-related').onclick = async () => {
const box = document.getElementById('related-box');
box.innerHTML = '<div class="section-title">…</div>';
try {
const rel = await invoke('related_notes', { root: currentFolder, path });
box.innerHTML = rel.length ? '' : '<div class="kv">none found</div>';
for (const r of rel) {
const div = document.createElement('div');
div.className = 'bl';
div.innerHTML = `<div>${escapeHtml(r.path)}</div><div class="ctx">similarity ${r.score.toFixed(3)}</div>`;
div.onclick = () => openFile(currentFolder + '/' + r.path);
box.appendChild(div);
}
} catch (e) { box.innerHTML = ''; showError(String(e)); }
};
} catch { panel.innerHTML = ''; }
}
let reloadTimer = null;
function flashLive(msg) {
const el = document.getElementById('live');
el.textContent = msg;
setTimeout(() => { if (el.textContent === msg) el.textContent = ''; }, 2000);
}
listen('file-changed', () => {
if (!currentFile) return;
if (editMode || Date.now() - lastSelfSave < 1500) return;
if (viewMode === 'live' && activeBlockEl) return;
clearTimeout(reloadTimer);
reloadTimer = setTimeout(async () => {
if (viewMode === 'live') {
try {
liveDoc = await invoke('read_source', { path: currentFile });
liveDirty = false;
renderLiveDoc();
} catch {}
} else {
openFile(currentFile, false, true);
}
flashLive('reloaded ' + new Date().toLocaleTimeString());
}, 200);
});
let treeTimer = null;
listen('tree-changed', () => {
if (!currentFolder) return;
clearTimeout(treeTimer);
treeTimer = setTimeout(async () => {
try {
const nodes = await invoke('list_tree', { root: currentFolder });
const box = document.getElementById('tree-box');
box.innerHTML = '<div class="section-title">Files</div>';
renderTree(nodes, box);
} catch {}
}, 500);
});
document.getElementById('open-folder').onclick = (e) => withBusy(e.target, async () => {
const dir = await invoke('pick_folder');
if (dir) await openFolder(dir);
});
document.getElementById('open-file').onclick = (e) => withBusy(e.target, async () => {
const f = await invoke('pick_file');
if (f) await openFile(f);
});
document.getElementById('export-html').onclick = async () => {
if (!currentFile) { showError('Open a file first.'); return; }
const theme = document.documentElement.dataset.theme;
const effective = theme === 'system'
? (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light') : theme;
try {
const saved = await invoke('export_html', {
title: document.title.replace(' — toMarkdown Viewer', ''),
bodyHtml: contentBox.innerHTML,
css: exportCss(effective) + '\n' + document.getElementById('user-css').textContent,
});
if (saved) toast('Exported ' + saved.split('/').pop(), 'success');
} catch (e) { toast('Export failed: ' + friendlyError(e), 'error'); }
};
document.getElementById('print-pdf').onclick = () => window.print();
document.getElementById('copy-rich').onclick = async () => {
try {
await navigator.clipboard.write([new ClipboardItem({
'text/html': new Blob([contentBox.innerHTML], { type: 'text/html' }),
'text/plain': new Blob([contentBox.innerText], { type: 'text/plain' }),
})]);
toast('Copied as rich text', 'success');
} catch (e) { toast('Copy failed: ' + friendlyError(e), 'error'); }
};
document.querySelectorAll('#tabs button').forEach(btn => {
btn.onclick = () => {
document.querySelectorAll('#tabs button').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.pane').forEach(p => p.classList.remove('active'));
btn.classList.add('active');
document.getElementById(btn.dataset.pane).classList.add('active');
if (btn.dataset.pane === 'tags-pane') loadTags();
if (btn.dataset.pane === 'tasks-pane') loadTasks();
};
});
function switchTab(pane) { document.querySelector(`#tabs button[data-pane="${pane}"]`).click(); }
async function runSearch(query, mode) {
if (!currentFolder) { showError('Open a vault folder first.'); return; }
switchTab('search-pane');
document.getElementById('search-input').value = query;
document.getElementById('search-mode').value = mode;
const box = document.getElementById('search-results');
box.innerHTML = '<div class="section-title">Searching…</div>';
if (mode === 'semantic') {
try {
const hits = await invoke('semantic_search', { root: currentFolder, query });
box.innerHTML = hits.length
? `<div class="section-title">${hits.length} chunk(s) by vector similarity</div>`
: '<div class="empty-state">No semantically similar chunks found.</div>';
for (const h of hits) {
const div = document.createElement('div');
div.className = 'result';
div.innerHTML = `<div>${escapeHtml(h.path.split('/').pop())}${h.heading ? ' › ' + escapeHtml(h.heading) : ''} (${h.score.toFixed(2)})</div><div class="where">${escapeHtml(h.snippet)}</div>`;
div.onclick = () => openFile(h.path);
box.appendChild(div);
}
} catch (e) { box.innerHTML = ''; showError(String(e)); }
return;
}
try {
const r = await invoke('vault_search', { root: currentFolder, query, mode });
const hits = r.hits || [];
box.innerHTML = hits.length
? `<div class="section-title">${hits.length} hit(s)</div>`
: `<div class="empty-state">No matches for “${escapeHtml(query)}”. Try another mode or query.</div>`;
for (const h of hits) {
const div = document.createElement('div');
div.className = 'result';
const where = h.context ? h.context : (h.tag ? '#' + h.tag : (h.alias || h.value || ''));
div.innerHTML = `<div>${escapeHtml(h.note)}${h.line ? ':' + h.line : ''}</div><div class="where">${escapeHtml(where)}</div>`;
div.onclick = () => openFile(currentFolder + '/' + h.note);
box.appendChild(div);
}
} catch (e) { box.innerHTML = ''; showError(String(e)); }
}
document.getElementById('search-input').addEventListener('keydown', (e) => {
if (e.key === 'Enter') runSearch(e.target.value, document.getElementById('search-mode').value);
});
async function loadTags() {
if (!currentFolder) return;
const box = document.getElementById('tags-list');
try {
const v = await invoke('vault_overview', { root: currentFolder });
const tags = (v.tags || []).sort((a, b) => b.count - a.count);
box.innerHTML = tags.length ? '' : '<div class="empty-state">No tags in this vault yet.</div>';
for (const t of tags) {
const span = document.createElement('span');
span.className = 'tag-item';
span.textContent = `#${t.tag} (${t.count})`;
span.onclick = () => runSearch(t.tag, 'tag');
box.appendChild(span);
}
} catch (e) { showError(String(e)); }
}
async function loadTasks() {
if (!currentFolder) return;
const box = document.getElementById('tasks-list');
try {
const v = await invoke('vault_tasks', { root: currentFolder });
const tasks = v.tasks || [];
box.innerHTML = tasks.length
? `<div class="section-title">${tasks.length} task(s)</div>`
: '<div class="empty-state">No open tasks — nothing to do here.</div>';
for (const t of tasks) {
const div = document.createElement('div');
div.className = 'task-item';
const due = t.task.due ? ` 📅 ${t.task.due}` : '';
div.innerHTML = `<div>[${escapeHtml(t.task.state)}] ${escapeHtml(t.task.text)}${due}</div><div class="src">${escapeHtml(t.note)}:${t.task.line}</div>`;
div.onclick = () => openFile(currentFolder + '/' + t.note);
box.appendChild(div);
}
} catch (e) { showError(String(e)); }
}
const switcherOverlay = document.getElementById('switcher-overlay');
const switcherInput = document.getElementById('switcher-input');
const switcherResults = document.getElementById('switcher-results');
let switcherItems = [], switcherSel = 0;
function fuzzyScore(hay, needle) {
hay = hay.toLowerCase();
if (hay.includes(needle)) return 1000 - hay.indexOf(needle) - hay.length * 0.01;
let i = 0;
for (const ch of hay) if (ch === needle[i]) i++;
return i === needle.length ? 500 - hay.length * 0.01 : -1;
}
function renderSwitcher() {
const q = switcherInput.value.trim().toLowerCase();
let scored = switcherItems.map(it => {
const best = Math.max(fuzzyScore(it.title, q), ...(it.aliases || []).map(a => fuzzyScore(a, q)));
return { it, score: q ? best : 0 };
}).filter(x => x.score >= 0);
scored.sort((a, b) => b.score - a.score);
scored = scored.slice(0, 12);
switcherSel = Math.min(switcherSel, Math.max(0, scored.length - 1));
switcherResults.innerHTML = '';
scored.forEach((x, i) => {
const div = document.createElement('div');
div.className = 'result' + (i === switcherSel ? ' sel' : '');
const alias = (x.it.aliases || []).length ? ` · ${x.it.aliases.join(', ')}` : '';
div.innerHTML = `<div>${escapeHtml(x.it.title)}</div><div class="where">${escapeHtml(x.it.path)}${escapeHtml(alias)}</div>`;
div.onclick = () => { closeSwitcher(); openFile(currentFolder + '/' + x.it.path); };
switcherResults.appendChild(div);
});
return scored;
}
async function openSwitcher() {
if (!currentFolder) { showError('Open a vault folder first.'); return; }
try { switcherItems = await invoke('quick_list', { root: currentFolder }); } catch { switcherItems = []; }
window._paletteMode = false;
switcherInput.placeholder = 'Open note by title or alias…';
switcherOverlay.classList.add('show');
switcherInput.value = ''; switcherSel = 0;
renderSwitcher();
switcherInput.focus();
}
function closeSwitcher() {
switcherOverlay.classList.remove('show');
if (window._paletteMode) closePalette();
}
function rerenderOverlay() { window._paletteMode ? renderPalette() : renderSwitcher(); }
switcherInput.addEventListener('input', () => { switcherSel = 0; rerenderOverlay(); });
switcherInput.addEventListener('keydown', (e) => {
const shown = switcherResults.children.length;
if (e.key === 'Escape') closeSwitcher();
else if (e.key === 'ArrowDown') { switcherSel = Math.min(switcherSel + 1, shown - 1); rerenderOverlay(); }
else if (e.key === 'ArrowUp') { switcherSel = Math.max(switcherSel - 1, 0); rerenderOverlay(); }
else if (e.key === 'Enter') {
const sel = switcherResults.children[switcherSel];
if (sel) sel.click();
}
});
switcherOverlay.addEventListener('click', (e) => { if (e.target === switcherOverlay) closeSwitcher(); });
document.addEventListener('keydown', (e) => {
if ((e.metaKey || e.ctrlKey) && (e.key === 'o' || e.key === 'p')) { e.preventDefault(); openSwitcher(); }
});
const graphOverlay = document.getElementById('graph-overlay');
const graphCanvas = document.getElementById('graph-canvas');
const graphZoom = {
global: { scale: 1, panX: 0, panY: 0, centerX: 0, centerY: 0 },
current: { scale: 1, panX: 0, panY: 0, centerX: 0, centerY: 0 },
word: { scale: 1, panX: 0, panY: 0, centerX: 0, centerY: 0 },
};
let currentGraphMode = 'global';
let graphNodes = [], graphLinks = [], graphAnim = null, dragNode = null;
let selectedWordNode = null;
let graphAnimating = true;
async function showGraph(focus) {
if (!currentFolder) { showError('Open a vault folder first.'); return; }
try {
const data = await invoke('graph_data', { root: currentFolder, focus: focus || null });
graphOverlay.classList.add('show');
const rect = graphCanvas.getBoundingClientRect();
graphCanvas.width = rect.width * devicePixelRatio;
graphCanvas.height = rect.height * devicePixelRatio;
const W = graphCanvas.width, H = graphCanvas.height;
graphNodes = data.nodes.map((n, i) => ({
...n,
x: W / 2 + Math.cos(i * 2.4) * W / 4 * Math.random(),
y: H / 2 + Math.sin(i * 2.4) * H / 4 * Math.random(),
vx: 0, vy: 0,
}));
const byId = Object.fromEntries(graphNodes.map(n => [n.id, n]));
graphLinks = data.links.map(l => ({ a: byId[l.source], b: byId[l.target] })).filter(l => l.a && l.b);
cancelAnimationFrame(graphAnim);
let ticks = 0;
const step = () => {
try {
for (const n of graphNodes) {
for (const m of graphNodes) {
if (n === m) continue;
let dx = n.x - m.x, dy = n.y - m.y;
const d2 = Math.max(dx * dx + dy * dy, 100);
const f = 60000 / d2;
const d = Math.sqrt(d2);
n.vx += (dx / d) * f; n.vy += (dy / d) * f;
}
n.vx += (W / 2 - n.x) * 0.002; n.vy += (H / 2 - n.y) * 0.002;
}
for (const { a, b } of graphLinks) {
if (!a || !b) {
console.warn('%c[GRAPH] Invalid link:', 'color: red', { a: !!a, b: !!b });
continue;
}
const dx = b.x - a.x, dy = b.y - a.y;
const d = Math.max(Math.sqrt(dx * dx + dy * dy), 1);
const f = (d - 140) * 0.01;
a.vx += (dx / d) * f; a.vy += (dy / d) * f;
b.vx -= (dx / d) * f; b.vy -= (dy / d) * f;
}
for (const n of graphNodes) {
if (n === dragNode) continue;
n.vx *= 0.85; n.vy *= 0.85;
n.x += n.vx; n.y += n.vy;
}
drawGraph();
if (++ticks < 300 && graphAnimating) graphAnim = requestAnimationFrame(step);
} catch (err) {
console.error('%c[GRAPH] Error in step():', 'color: red', err.message, err.stack);
}
};
step();
} catch (e) { showError(String(e)); }
}
graphCanvas.addEventListener('wheel', (e) => {
e.preventDefault();
const zoom = graphZoom[currentGraphMode];
const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1;
const newScale = Math.max(0.2, Math.min(5, zoom.scale * zoomFactor));
zoom.scale = newScale;
drawGraph();
});
let lastTouchDistance = 0;
graphCanvas.addEventListener('touchstart', (e) => {
if (e.touches.length === 2) {
const dx = e.touches[0].clientX - e.touches[1].clientX;
const dy = e.touches[0].clientY - e.touches[1].clientY;
lastTouchDistance = Math.sqrt(dx * dx + dy * dy);
}
});
graphCanvas.addEventListener('touchmove', (e) => {
if (e.touches.length === 2) {
e.preventDefault();
const dx = e.touches[0].clientX - e.touches[1].clientX;
const dy = e.touches[0].clientY - e.touches[1].clientY;
const distance = Math.sqrt(dx * dx + dy * dy);
const zoom = graphZoom[currentGraphMode];
if (lastTouchDistance > 0) {
const ratio = distance / lastTouchDistance;
zoom.scale = Math.max(0.2, Math.min(5, zoom.scale * ratio));
drawGraph();
}
lastTouchDistance = distance;
}
});
document.addEventListener('keydown', (e) => {
const zoom = graphZoom[currentGraphMode];
if (e.key === '+' || e.key === '=') {
zoom.scale = Math.max(0.2, Math.min(5, zoom.scale * 1.2));
drawGraph();
} else if (e.key === '-' || e.key === '_') {
zoom.scale = Math.max(0.2, Math.min(5, zoom.scale / 1.2));
drawGraph();
} else if (e.key === '0') {
zoom.scale = 1;
zoom.panX = 0;
zoom.panY = 0;
drawGraph();
}
});
function drawGraph() {
const ctx = graphCanvas.getContext('2d');
const css = getComputedStyle(document.documentElement);
ctx.clearRect(0, 0, graphCanvas.width, graphCanvas.height);
const zoom = graphZoom[currentGraphMode];
ctx.save();
ctx.scale(zoom.scale, zoom.scale);
ctx.translate(-zoom.panX, -zoom.panY);
if (currentGraphMode === 'word') {
ctx.lineWidth = devicePixelRatio;
const maxFrequency = Math.max(...graphNodes.map(n => n.frequency || 1), 1);
ctx.strokeStyle = css.getPropertyValue('--border');
for (const link of graphLinks) {
if (link.a && link.b) {
ctx.beginPath();
ctx.moveTo(link.a.x, link.a.y);
ctx.lineTo(link.b.x, link.b.y);
ctx.stroke();
}
}
ctx.font = `${12 * devicePixelRatio}px monospace`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
for (const node of graphNodes) {
const size = 5 + Math.log(Math.max(1, node.frequency)) * 3;
const hue = (node.frequency / maxFrequency) * 120;
const color = `hsl(${hue}, 70%, 50%)`;
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(node.x, node.y, size, 0, Math.PI * 2);
ctx.fill();
if (size > 10) {
ctx.fillStyle = 'white';
ctx.fillText(node.word.substring(0, 10), node.x, node.y);
}
}
} else {
ctx.strokeStyle = css.getPropertyValue('--border');
ctx.lineWidth = devicePixelRatio;
const match = (n) => !graphFilter || n.title.toLowerCase().includes(graphFilter);
for (const { a, b } of graphLinks) {
if (!match(a) || !match(b)) continue;
ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke();
}
ctx.fillStyle = css.getPropertyValue('--accent');
ctx.font = `${11 * devicePixelRatio}px -apple-system, sans-serif`;
for (const n of graphNodes.filter(match)) {
const r = (4 + Math.min(n.links, 8)) * devicePixelRatio;
ctx.beginPath(); ctx.arc(n.x, n.y, r, 0, Math.PI * 2); ctx.fill();
ctx.fillText(n.title, n.x + r + 3, n.y + 3);
ctx.fillStyle = css.getPropertyValue('--accent');
}
}
ctx.restore();
}
function graphNodeAt(e) {
const rect = graphCanvas.getBoundingClientRect();
const x = (e.clientX - rect.left) * devicePixelRatio;
const y = (e.clientY - rect.top) * devicePixelRatio;
return graphNodes.find(n => (n.x - x) ** 2 + (n.y - y) ** 2 < (14 * devicePixelRatio) ** 2);
}
function wordGraphNodeAt(e) {
const rect = graphCanvas.getBoundingClientRect();
const zoom = graphZoom[currentGraphMode];
const x = ((e.clientX - rect.left) / zoom.scale) + zoom.panX;
const y = ((e.clientY - rect.top) / zoom.scale) + zoom.panY;
const hitNode = graphNodes.find(node => {
const dx = node.x - x;
const dy = node.y - y;
return Math.sqrt(dx*dx + dy*dy) < 20;
});
return hitNode;
}
graphCanvas.addEventListener('mousedown', (e) => { dragNode = graphNodeAt(e); });
graphCanvas.addEventListener('mousemove', (e) => {
if (dragNode) {
const rect = graphCanvas.getBoundingClientRect();
dragNode.x = (e.clientX - rect.left) * devicePixelRatio;
dragNode.y = (e.clientY - rect.top) * devicePixelRatio;
drawGraph();
} else if (currentGraphMode === 'word') {
const n = wordGraphNodeAt(e);
if (n) {
showWordTooltip(n, e.clientX, e.clientY - 10);
} else {
hideWordTooltip();
}
}
}, { passive: true });
graphCanvas.addEventListener('mouseup', (e) => {
if (currentGraphMode === 'word') {
const n = wordGraphNodeAt(e);
if (n && dragNode === n) {
onWordNodeClick(n);
}
} else {
const n = graphNodeAt(e);
if (n && dragNode === n) {
graphOverlay.classList.remove('show');
openFile(currentFolder + '/' + n.id);
}
}
dragNode = null;
});
graphCanvas.addEventListener('mouseleave', hideWordTooltip);
document.getElementById('show-graph').onclick = (e) => withBusy(e.target, () => showGraph(null));
document.getElementById('graph-global').addEventListener('click', () => switchGraphMode('global'));
document.getElementById('graph-current').addEventListener('click', () => switchGraphMode('current'));
document.getElementById('graph-word').addEventListener('click', () => switchGraphMode('word'));
async function switchGraphMode(mode) {
console.log('%c[WORD_GRAPH] Switching to mode:', 'color: blue; font-weight: bold', mode);
currentGraphMode = mode;
document.querySelectorAll('.graph-mode-btn').forEach(btn => btn.classList.remove('active'));
document.getElementById('graph-' + mode).classList.add('active');
const wordHeader = document.getElementById('graph-header');
if (mode === 'word') {
wordHeader.style.display = 'block';
console.log('%c[WORD_GRAPH] Loading data for Word mode', 'color: cyan');
await loadWordGraphData();
console.log('%c[WORD_GRAPH] Data loaded, nodes:', 'color: green', graphNodes.length, 'links:', graphLinks.length);
} else {
wordHeader.style.display = 'none';
}
console.log('%c[WORD_GRAPH] Redrawing graph', 'color: orange');
redrawGraph();
}
document.getElementById('graph-zoom-in').addEventListener('click', () => {
const zoom = graphZoom[currentGraphMode];
zoom.scale = Math.min(5, zoom.scale * 1.2);
redrawGraph();
});
document.getElementById('graph-zoom-out').addEventListener('click', () => {
const zoom = graphZoom[currentGraphMode];
zoom.scale = Math.max(0.2, zoom.scale / 1.2);
redrawGraph();
});
document.getElementById('graph-zoom-reset').addEventListener('click', () => {
const zoom = graphZoom[currentGraphMode];
zoom.scale = 1;
zoom.panX = 0;
zoom.panY = 0;
redrawGraph();
});
document.getElementById('graph-refresh').addEventListener('click', async () => {
const button = document.getElementById('graph-refresh');
const indicator = document.getElementById('graph-refresh-indicator');
button.disabled = true;
button.style.display = 'none';
indicator.style.display = 'flex';
console.log('[WORD_GRAPH] Refresh started at', new Date().toISOString());
try {
console.log('[WORD_GRAPH] Invoking index_vault_words for:', currentFolder);
await invoke('index_vault_words', { root: currentFolder });
console.log('[WORD_GRAPH] Index command sent, waiting for completion...');
let attempts = 0;
const maxAttempts = 30;
while (attempts < maxAttempts) {
await new Promise(resolve => setTimeout(resolve, 1000));
attempts++;
try {
const response = await invoke('word_graph_data', { root: currentFolder });
if (response.nodes && response.nodes.length > 0) {
console.log('[WORD_GRAPH] Index complete, found', response.nodes.length, 'words');
break;
}
} catch (err) {
if (attempts % 5 === 0) {
console.log('[WORD_GRAPH] Waiting for index... (attempt', attempts, ')');
}
}
}
console.log('[WORD_GRAPH] Loading refreshed graph data...');
await loadWordGraphData();
console.log('[WORD_GRAPH] Refresh completed successfully at', new Date().toISOString());
} catch (err) {
console.error('[WORD_GRAPH] Refresh failed:', err);
toast('Refresh failed: ' + err);
} finally {
button.style.display = 'inline-block';
indicator.style.display = 'none';
button.disabled = false;
console.log('[WORD_GRAPH] Refresh operation finished');
}
});
function setIndexingStatus(isIndexing) {
const timestamp = document.getElementById('graph-timestamp');
if (isIndexing) {
timestamp.textContent = 'Indexing...';
} else {
timestamp.textContent = 'Last updated: Now';
}
}
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
graphAnimating = false;
} else {
graphAnimating = true;
}
});
async function loadWordGraphData() {
try {
console.log('%c[WORD_GRAPH] loadWordGraphData() starting', 'color: cyan');
const changedFiles = [];
if (changedFiles.length > 0) {
invoke('delta_index_vault_words', { root: currentFolder, changed_files: changedFiles }).catch(err => {
console.warn('Delta indexing failed, will use cached data:', err);
});
}
console.log('%c[WORD_GRAPH] Fetching graph data for:', 'color: cyan', currentFolder);
const response = await invoke('word_graph_data', { root: currentFolder });
console.log('%c[WORD_GRAPH] Response received:', 'color: green', { nodeCount: response.nodes.length, linkCount: response.links.length });
const rect = graphCanvas.getBoundingClientRect();
let W = rect.width * devicePixelRatio;
let H = rect.height * devicePixelRatio;
console.log('%c[WORD_GRAPH] Canvas dimensions:', 'color: cyan', { W: W.toFixed(0), H: H.toFixed(0), rectWidth: rect.width, rectHeight: rect.height });
if (W <= 0 || H <= 0) {
W = graphCanvas.parentElement?.offsetWidth || 800;
H = graphCanvas.parentElement?.offsetHeight || 600;
console.log('%c[WORD_GRAPH] Using fallback dimensions:', 'color: orange', { W, H });
}
graphNodes = response.nodes.map(node => ({
...node,
x: Math.random() * W,
y: Math.random() * H,
vx: 0,
vy: 0,
}));
console.log('%c[WORD_GRAPH] Nodes initialized:', 'color: green', graphNodes.length, 'sample:', graphNodes[0]);
const nodeMap = new Map(graphNodes.map((n, i) => [i, n]));
graphLinks = response.links.map(link => {
const a = nodeMap.get(link.source);
const b = nodeMap.get(link.target);
if (!a || !b) {
console.warn('%c[WORD_GRAPH] Invalid link:', 'color: red', { source: link.source, target: link.target, a: !!a, b: !!b });
}
return { a, b, weight: link.weight };
});
console.log('%c[WORD_GRAPH] Links initialized:', 'color: green', graphLinks.length, 'valid links:', graphLinks.filter(l => l.a && l.b).length);
if (response.last_updated) {
document.getElementById('graph-timestamp').textContent = `Last updated: ${response.last_updated}`;
}
redrawGraph();
} catch (err) {
console.error('Failed to load word graph:', err);
toast('Failed to load word graph');
}
}
function onWordNodeClick(wordNode) {
selectedWordNode = wordNode;
invoke('word_graph_notes', { root: currentFolder, word: wordNode.word })
.then(notes => {
console.log('%c[WORD_GRAPH] Clicked:', 'color: blue; font-weight: bold', wordNode.word);
console.log('%c[WORD_GRAPH] API returned notes:', 'color: green', notes);
let noteElements = document.querySelectorAll('[data-file]');
if (noteElements.length === 0) {
noteElements = document.querySelectorAll('[data-path]');
}
if (noteElements.length === 0) {
noteElements = document.querySelectorAll('.file-item, .note-item');
}
console.log('%c[WORD_GRAPH] Found sidebar elements:', 'color: orange', noteElements.length);
const sidebarPaths = Array.from(noteElements).map(el => ({
'data-file': el.getAttribute('data-file'),
'data-path': el.getAttribute('data-path'),
'title': el.getAttribute('title'),
'text': el.textContent?.trim().substring(0, 50)
}));
console.log('%c[WORD_GRAPH] Sidebar paths:', 'color: purple', sidebarPaths);
let matchCount = 0;
noteElements.forEach(noteEl => {
const fileName = noteEl.getAttribute('data-file') ||
noteEl.getAttribute('data-path') ||
noteEl.getAttribute('title') ||
noteEl.textContent?.trim();
if (!fileName) return;
const isMatch = notes.some(note =>
note.includes(fileName) || fileName.includes(note) || note === fileName
);
if (isMatch) {
noteEl.classList.add('highlighted');
matchCount++;
console.log('%c[WORD_GRAPH] ✓ Matched:', 'color: green', fileName);
} else {
noteEl.classList.remove('highlighted');
}
});
console.log('%c[WORD_GRAPH] Result: ' + matchCount + ' notes highlighted', 'color: ' + (matchCount > 0 ? 'green' : 'red') + '; font-weight: bold');
})
.catch(err => console.error('[WORD_GRAPH] Error:', err));
redrawGraph();
}
function showWordTooltip(wordNode, x, y) {
const tooltip = document.getElementById('graph-tooltip') || document.createElement('div');
tooltip.id = 'graph-tooltip';
tooltip.style.cssText = `
position: absolute;
background: var(--bg);
border: 1px solid var(--border);
padding: 8px;
border-radius: 4px;
font-size: 12px;
z-index: 1000;
left: ${x}px;
top: ${y}px;
max-width: 200px;
`;
tooltip.innerHTML = `
<strong>${wordNode.word}</strong><br>
Frequency: ${wordNode.frequency}<br>
Partners: loading...
`;
document.body.appendChild(tooltip);
}
function hideWordTooltip() {
const tooltip = document.getElementById('graph-tooltip');
if (tooltip) tooltip.remove();
}
function redrawGraph() {
drawGraph();
}
document.getElementById('graph-close').onclick = () => graphOverlay.classList.remove('show');
graphOverlay.addEventListener('click', (e) => { if (e.target === graphOverlay) graphOverlay.classList.remove('show'); });
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') graphOverlay.classList.remove('show'); });
const editorWrap = document.getElementById('editor-wrap');
const editor = document.getElementById('editor');
const editPreview = document.getElementById('edit-preview');
const saveState = document.getElementById('save-state');
let editMode = false, dirty = false, lastSelfSave = 0;
const editorBackdrop = document.getElementById('editor-backdrop');
let hlTimer = null, hlSeq = 0;
function byteToCharIndex(text) {
const enc = new TextEncoder();
const map = new Map(); let b = 0, c = 0;
for (const ch of text) { map.set(b, c); b += enc.encode(ch).length; c += ch.length; }
map.set(b, text.length);
return (off) => { while (off > 0 && !map.has(off)) off--; return map.get(off) ?? text.length; };
}
function spansToHtml(text, spans) {
let out = '', pos = 0;
for (const s of spans) {
if (s.start < pos) continue; out += escapeHtml(text.slice(pos, s.start));
out += '<span class="hl-' + s.kind + '">' + escapeHtml(text.slice(s.start, s.end)) + '</span>';
pos = s.end;
}
return out + escapeHtml(text.slice(pos));
}
async function refreshHighlight() {
const text = editor.value, seq = ++hlSeq;
try {
const raw = await invoke('highlight_markdown', { source: text });
if (seq !== hlSeq) return; const toChar = byteToCharIndex(text);
const spans = raw.map(s => ({ start: toChar(s.start), end: toChar(s.end), kind: s.kind }));
editorBackdrop.innerHTML = spansToHtml(text, spans) + '\n';
} catch { editorBackdrop.textContent = text; }
}
editor.addEventListener('input', () => { editorBackdrop.textContent = editor.value + '\n'; clearTimeout(hlTimer); hlTimer = setTimeout(refreshHighlight, 120); });
editor.addEventListener('scroll', () => { editorBackdrop.scrollTop = editor.scrollTop; }, { passive: true });
const promptOverlay = document.getElementById('prompt-overlay');
function ask(title, { multiline = false, value = '' } = {}) {
return new Promise((resolve) => {
document.getElementById('prompt-title').textContent = title;
const input = document.getElementById('prompt-input');
const ta = document.getElementById('prompt-textarea');
input.style.display = multiline ? 'none' : 'block';
ta.style.display = multiline ? 'block' : 'none';
const field = multiline ? ta : input;
field.value = value;
promptOverlay.classList.add('show');
field.focus();
const done = (val) => {
promptOverlay.classList.remove('show');
document.getElementById('prompt-ok').onclick = null;
document.getElementById('prompt-cancel').onclick = null;
input.onkeydown = null;
resolve(val);
};
document.getElementById('prompt-ok').onclick = () => done(field.value);
document.getElementById('prompt-cancel').onclick = () => done(null);
input.onkeydown = (e) => {
if (e.key === 'Enter') done(field.value);
if (e.key === 'Escape') done(null);
};
});
}
async function enterEditMode() { await setViewMode('split'); }
async function exitEditMode() { await setViewMode('read'); }
document.getElementById('edit-toggle').onclick = () => {
setViewMode(viewMode === 'read' ? 'live' : viewMode === 'live' ? 'split' : 'read');
};
document.addEventListener('keydown', (e) => {
const mod = e.metaKey || e.ctrlKey;
if (mod && !e.shiftKey && e.key === 'e') {
e.preventDefault();
setViewMode(viewMode === 'read' ? 'live' : 'read');
}
if (mod && e.shiftKey && e.key.toLowerCase() === 'e') {
e.preventDefault();
setViewMode(viewMode === 'split' ? 'read' : 'split');
}
if (mod && e.key === 's') {
e.preventDefault();
if (viewMode === 'split') saveNow();
if (viewMode === 'live') { commitActiveBlock(false).then(saveLive); }
}
});
async function saveNow(silent = false) {
if (!currentFile || !dirty) return;
try {
lastSelfSave = Date.now();
await invoke('save_file', { path: currentFile, content: editor.value, vaultRoot: currentFolder });
dirty = false;
if (!silent) saveState.textContent = 'saved ' + new Date().toLocaleTimeString();
} catch (e) { showError('Save failed: ' + e); }
}
let saveTimer = null, previewTimer = null;
async function refreshEditPreview() {
editPreview.innerHTML = await invoke('render_markdown',
{ md: editor.value, vaultRoot: currentFolder, filePath: currentFile });
postRender(editPreview);
}
editor.addEventListener('input', () => {
dirty = true;
saveState.textContent = 'editing…';
clearTimeout(saveTimer);
saveTimer = setTimeout(() => { if (viewMode === 'split') saveNow(true); }, 1200);
clearTimeout(previewTimer);
previewTimer = setTimeout(refreshEditPreview, 150);
maybeAutocomplete();
acMaybeShow(editor);
});
editor.addEventListener('blur', () => { acHide(); saveNow(); });
const findInput = document.getElementById('find-input');
const replaceInput = document.getElementById('replace-input');
function findNext() {
const q = findInput.value;
if (!q) return;
const from = editor.selectionEnd;
let i = editor.value.indexOf(q, from);
if (i < 0) i = editor.value.indexOf(q); if (i >= 0) {
editor.focus();
editor.setSelectionRange(i, i + q.length);
}
}
document.getElementById('find-next').onclick = findNext;
findInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') findNext(); });
document.getElementById('replace-one').onclick = () => {
const q = findInput.value;
if (!q) return;
const sel = editor.value.slice(editor.selectionStart, editor.selectionEnd);
if (sel === q) {
editor.setRangeText(replaceInput.value, editor.selectionStart, editor.selectionEnd, 'end');
editor.dispatchEvent(new Event('input'));
}
findNext();
};
document.getElementById('replace-all').onclick = () => {
const q = findInput.value;
if (!q) return;
editor.value = editor.value.split(q).join(replaceInput.value);
editor.dispatchEvent(new Event('input'));
};
document.getElementById('insert-table').onclick = () => {
const skeleton = '\n| Column 1 | Column 2 | Column 3 |\n| --- | --- | --- |\n| | | |\n| | | |\n';
editor.setRangeText(skeleton, editor.selectionStart, editor.selectionEnd, 'end');
editor.dispatchEvent(new Event('input'));
editor.focus();
};
const wikiAc = document.getElementById('wiki-ac');
let wacItems = [], wacSel = 0, wacTarget = null, wacStart = -1;
function acHide() { wikiAc.style.display = 'none'; wacTarget = null; }
function acApply() {
const it = wacItems[wacSel]; if (!it || !wacTarget) return;
const v = wacTarget.value, caret = wacTarget.selectionStart;
wacTarget.value = v.slice(0, wacStart) + it.label + ']]' + v.slice(caret);
const pos = wacStart + it.label.length + 2;
wacTarget.setSelectionRange(pos, pos);
wacTarget.dispatchEvent(new Event('input'));
acHide();
}
async function acMaybeShow(ta) {
const upto = ta.value.slice(0, ta.selectionStart);
const m = upto.match(/\[\[([^\]\n]*)$/);
if (!m || !currentFolder) { acHide(); return; }
wacTarget = ta; wacStart = ta.selectionStart - m[1].length;
try {
const hits = await invoke('wikilink_complete', { root: currentFolder, prefix: m[1] });
wacItems = hits; wacSel = 0;
if (!hits.length) { acHide(); return; }
wikiAc.innerHTML = hits.map((h, i) => `<div class="${i === 0 ? 'sel' : ''}">${escapeHtml(h.label)}</div>`).join('');
const r = ta.getBoundingClientRect();
wikiAc.style.left = r.left + 16 + 'px';
wikiAc.style.top = Math.min(r.top + 60, innerHeight - 200) + 'px';
wikiAc.style.display = 'block';
Array.from(wikiAc.children).forEach((el, i) => { el.onmousedown = (e) => { e.preventDefault(); wacSel = i; acApply(); }; });
} catch { acHide(); }
}
function acKeydown(e) {
if (wikiAc.style.display !== 'block') return false;
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
wacSel = (wacSel + (e.key === 'ArrowDown' ? 1 : wacItems.length - 1)) % wacItems.length;
Array.from(wikiAc.children).forEach((el, i) => el.classList.toggle('sel', i === wacSel));
e.preventDefault(); e.stopImmediatePropagation(); return true;
}
if (e.key === 'Enter' || e.key === 'Tab') { acApply(); e.preventDefault(); e.stopImmediatePropagation(); return true; }
if (e.key === 'Escape') { acHide(); e.stopPropagation(); e.stopImmediatePropagation(); return true; }
return false;
}
const acBox = document.getElementById('autocomplete');
let acItems = [], acSel = 0, acStart = -1, acKind = null;
function caretViewportPos() {
const mirror = document.createElement('div');
const style = getComputedStyle(editor);
for (const prop of ['font', 'padding', 'border', 'boxSizing', 'lineHeight', 'letterSpacing', 'whiteSpace', 'wordWrap', 'width'])
mirror.style[prop] = style[prop];
mirror.style.position = 'fixed';
mirror.style.visibility = 'hidden';
mirror.style.whiteSpace = 'pre-wrap';
mirror.style.wordWrap = 'break-word';
mirror.textContent = editor.value.slice(0, editor.selectionEnd);
const marker = document.createElement('span');
marker.textContent = '';
mirror.appendChild(marker);
document.body.appendChild(mirror);
const eRect = editor.getBoundingClientRect();
const mRect = marker.getBoundingClientRect();
const miRect = mirror.getBoundingClientRect();
const x = eRect.left + (mRect.left - miRect.left);
const y = eRect.top + (mRect.top - miRect.top) - editor.scrollTop;
document.body.removeChild(mirror);
return { x, y };
}
async function maybeAutocomplete() {
if (!currentFolder) return hideAc();
const pos = editor.selectionEnd;
const before = editor.value.slice(Math.max(0, pos - 60), pos);
const tagMatch = before.match(/(^|\s)#([A-Za-z0-9_\/-]*)$/);
if (tagMatch) {
acKind = 'tag'; acStart = pos - tagMatch[2].length;
if (!window._tagCache) { try { window._tagCache = await invoke('tag_list', { root: currentFolder }); } catch { return hideAc(); } }
const q = tagMatch[2].toLowerCase();
acItems = window._tagCache.filter(t => t.toLowerCase().startsWith(q))
.slice(0, 8).map(t => ({ label: '#' + t, insert: t }));
} else return hideAc();
if (!acItems.length) return hideAc();
acSel = 0;
renderAc();
}
function renderAc() {
acBox.innerHTML = '';
acItems.forEach((it, i) => {
const div = document.createElement('div');
div.textContent = it.label;
div.className = i === acSel ? 'sel' : '';
div.onmousedown = (e) => { e.preventDefault(); acceptAc(i); };
acBox.appendChild(div);
});
const { x, y } = caretViewportPos();
acBox.style.left = Math.min(x, innerWidth - 240) + 'px';
acBox.style.top = (y + 20) + 'px';
acBox.style.display = 'block';
}
function hideAc() { acBox.style.display = 'none'; acItems = []; }
function acceptAc(i) {
const it = acItems[i];
if (!it) return;
const end = editor.selectionEnd;
let insert = it.insert;
editor.setRangeText(insert, acStart, end, 'end');
hideAc();
editor.dispatchEvent(new Event('input'));
editor.focus();
}
editor.addEventListener('keydown', (e) => {
if (acKeydown(e)) return;
if (!acItems.length) return;
if (e.key === 'ArrowDown') { e.preventDefault(); acSel = (acSel + 1) % acItems.length; renderAc(); }
else if (e.key === 'ArrowUp') { e.preventDefault(); acSel = (acSel - 1 + acItems.length) % acItems.length; renderAc(); }
else if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); acceptAc(acSel); }
else if (e.key === 'Escape') hideAc();
});
editor.addEventListener('click', hideAc);
async function editorPaste(e, el) {
const text = e.clipboardData?.getData('text/plain') || '';
if (/^https?:\/\/\S+$/.test(text.trim()) && el.selectionStart !== el.selectionEnd) {
e.preventDefault();
const sel = el.value.slice(el.selectionStart, el.selectionEnd);
el.setRangeText(`[${sel}](${text.trim()})`, el.selectionStart, el.selectionEnd, 'end');
el.dispatchEvent(new Event('input'));
return;
}
if (!currentFolder) return;
const item = [...(e.clipboardData?.items || [])].find(i => i.type.startsWith('image/'));
if (!item) return;
e.preventDefault();
const file = item.getAsFile();
const ext = (item.type.split('/')[1] || 'png').replace('jpeg', 'jpg');
const reader = new FileReader();
reader.onload = async () => {
const base64 = String(reader.result).split(',')[1];
try {
const embed = await invoke('paste_image', { root: currentFolder, base64Data: base64, extension: ext });
el.setRangeText(embed, el.selectionStart, el.selectionEnd, 'end');
el.dispatchEvent(new Event('input'));
} catch (err) { showError(String(err)); }
};
reader.readAsDataURL(file);
}
editor.addEventListener('paste', (e) => editorPaste(e, editor));
contentBox.addEventListener('click', async (e) => {
if (e.target.matches('input[type="checkbox"]') && currentFile) {
e.preventDefault();
const boxes = [...contentBox.querySelectorAll('input[type="checkbox"]')];
const index = boxes.indexOf(e.target);
try {
lastSelfSave = Date.now();
await invoke('toggle_task', { path: currentFile, index, vaultRoot: currentFolder });
openFile(currentFile, false, true);
} catch (err) { showError(String(err)); }
}
});
document.getElementById('new-note').onclick = async () => {
if (!currentFolder) { showError('Open a vault folder first.'); return; }
const title = await ask('New note title (may include a folder, e.g. projects/Idea)');
if (!title) return;
try {
const path = await invoke('create_note', { root: currentFolder, title, daily: false });
openFolder(currentFolder);
await openFile(path);
enterEditMode();
} catch (e) { showError(String(e)); }
};
document.getElementById('rename-btn').onclick = async () => {
if (!currentFile || !currentFolder) return;
const base = currentFile.split('/').pop().replace(/\.md$/, '');
const newName = await ask('Rename note (inbound links will be rewritten)', { value: base });
if (!newName || newName === base) return;
if (dirty) await saveNow();
try {
const newPath = await invoke('rename_note', { root: currentFolder, path: currentFile, newName });
openFolder(currentFolder);
editMode = false;
editorWrap.classList.remove('show');
document.getElementById('scroll').style.display = '';
document.getElementById('edit-toggle').textContent = 'Edit';
await openFile(newPath);
flashLive('renamed, links rewritten');
} catch (e) { showError(String(e)); }
};
document.getElementById('edit-props').onclick = async () => {
if (!currentFile) return;
if (dirty) await saveNow();
const source = await invoke('read_source', { path: currentFile });
let yaml = '';
if (source.startsWith('---\n')) {
const end = source.indexOf('\n---', 4);
if (end > 0) yaml = source.slice(4, end);
}
const edited = await ask('Frontmatter properties (YAML; empty removes the block)', { multiline: true, value: yaml });
if (edited === null) return;
try {
await invoke('set_frontmatter', { path: currentFile, yaml: edited, vaultRoot: currentFolder });
editor.value = await invoke('read_source', { path: currentFile });
dirty = false;
refreshEditPreview();
refreshHighlight();
renderNotePanel(currentFile);
flashLive('properties updated');
} catch (e) { showError(String(e)); }
};
const resultOverlay = document.getElementById('result-overlay');
const resultTabs = document.getElementById('result-tabs');
let lastResultMd = '';
async function showResult(title, markdown) {
lastResultMd = markdown;
resultTabs.innerHTML = '';
document.getElementById('result-title').textContent = title;
document.getElementById('result-body').innerHTML =
await invoke('render_markdown', { md: markdown, vaultRoot: null, filePath: null });
postRender(document.getElementById('result-body'));
document.getElementById('result-insert').style.display = editMode ? '' : 'none';
resultOverlay.classList.add('show');
}
async function showTabbedResult(title, sections) {
lastResultMd = sections.map(s => `## ${s.label}\n\n${s.markdown}`).join('\n');
document.getElementById('result-title').textContent = title;
const body = document.getElementById('result-body');
const htmlCache = new Array(sections.length);
resultTabs.innerHTML = '';
async function select(i) {
[...resultTabs.children].forEach((b, j) => {
b.classList.toggle('active', j === i);
b.setAttribute('aria-selected', j === i);
});
if (htmlCache[i] === undefined) {
htmlCache[i] = await invoke('render_markdown',
{ md: sections[i].markdown, vaultRoot: null, filePath: null });
}
body.innerHTML = htmlCache[i];
body.scrollTop = 0;
postRender(body);
}
sections.forEach((s, i) => {
const btn = document.createElement('button');
btn.textContent = s.label;
btn.setAttribute('role', 'tab');
btn.onclick = () => select(i);
resultTabs.appendChild(btn);
});
resultTabs.onkeydown = (e) => {
if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return;
const btns = [...resultTabs.children];
const cur = btns.findIndex(b => b.classList.contains('active'));
const next = (cur + (e.key === 'ArrowRight' ? 1 : -1) + btns.length) % btns.length;
btns[next].focus();
select(next);
};
await select(0);
document.getElementById('result-insert').style.display = editMode ? '' : 'none';
resultOverlay.classList.add('show');
}
document.getElementById('result-close').onclick = () => resultOverlay.classList.remove('show');
document.getElementById('result-copy').onclick = async () => {
try { await navigator.clipboard.writeText(lastResultMd); flashLive('copied'); } catch {}
};
document.getElementById('result-insert').onclick = () => {
editor.setRangeText('\n' + lastResultMd + '\n', editor.selectionStart, editor.selectionEnd, 'end');
editor.dispatchEvent(new Event('input'));
resultOverlay.classList.remove('show');
};
resultOverlay.addEventListener('click', (e) => { if (e.target === resultOverlay) resultOverlay.classList.remove('show'); });
async function currentDocText() {
if (editMode) return editor.value;
if (!currentFile) return null;
return invoke('read_source', { path: currentFile }).catch(() => null);
}
async function runAi(kind, extraPromptTitle) {
const content = await currentDocText();
if (!content) { showError('Open a document first.'); return; }
let extra = null;
if (extraPromptTitle) {
extra = await ask(extraPromptTitle);
if (!extra) return;
}
toast('Asking Claude…');
try {
const out = await invoke('ai_action', { kind, content, extra });
showResult('AI: ' + kind, out);
} catch (e) { toast(friendlyError(e), 'error', 6000); }
}
const settingsOverlay = document.getElementById('settings-overlay');
const setFont = document.getElementById('set-font');
const setLineHeight_el = document.getElementById('set-lineheight');
const setKey = document.getElementById('set-key');
const tabModeRadios = document.querySelectorAll('input[name="tab-mode"]');
const themeRadios = document.querySelectorAll('input[name="theme-pref"]');
const autoSaveCheckbox = document.getElementById('auto-save');
const recycleRetentionSelect = document.getElementById('recycle-retention');
const mouseWheelZoomCheckbox = document.getElementById('mouse-wheel-zoom');
const zoomBehaviorRadios = document.querySelectorAll('input[name="zoom-behavior"]');
const autoRestoreTabsCheckbox = document.getElementById('auto-restore-tabs');
let currentPreferences = null;
async function openSettings() {
setFont.value = localStorage.getItem('fontSize') || '16';
setLineHeight_el.value = localStorage.getItem('lineHeight') || '1.6';
setKey.value = localStorage.getItem('apiKey') || '';
document.getElementById('set-reopen').checked = !!localStorage.getItem('reopenLast');
document.getElementById('set-live').checked = liveDefaultOn();
if (currentFolder) {
try {
currentPreferences = await invoke('get_preferences', { vault_root: currentFolder });
document.querySelector(`input[name="tab-mode"][value="${currentPreferences.tab_mode}"]`).checked = true;
document.querySelector(`input[name="theme-pref"][value="${currentPreferences.theme}"]`).checked = true;
autoSaveCheckbox.checked = currentPreferences.auto_save;
recycleRetentionSelect.value = currentPreferences.recycle_retention_days;
mouseWheelZoomCheckbox.checked = currentPreferences.mouse_wheel_zoom;
document.querySelector(`input[name="zoom-behavior"][value="${currentPreferences.zoom_behavior}"]`).checked = true;
autoRestoreTabsCheckbox.checked = currentPreferences.auto_restore_tabs;
} catch (e) {
console.warn('Could not load preferences:', e);
}
}
renderVaultList();
settingsOverlay.classList.add('show');
trapFocus(settingsOverlay);
}
function setFontSize(px) {
px = Math.max(11, Math.min(24, px));
localStorage.setItem('fontSize', String(px));
contentBox.style.fontSize = px + 'px';
editPreview.style.fontSize = px + 'px';
}
function setLineHeight(lh) {
lh = Math.max(1.3, Math.min(2.2, lh));
localStorage.setItem('lineHeight', String(lh));
contentBox.style.lineHeight = lh;
editPreview.style.lineHeight = lh;
}
async function saveSettings() {
applyTheme(document.querySelector('input[name="theme-pref"]:checked').value || 'light');
setFontSize(parseInt(setFont.value, 10) || 16);
setLineHeight(parseFloat(setLineHeight_el.value) || 1.6);
const key = setKey.value.trim();
localStorage.setItem('apiKey', key);
invoke('set_api_key', { key }).catch(() => {});
localStorage.setItem('reopenLast', document.getElementById('set-reopen').checked ? '1' : '');
if (currentFolder && currentPreferences) {
try {
const updatedPrefs = {
tab_mode: document.querySelector('input[name="tab-mode"]:checked').value,
theme: document.querySelector('input[name="theme-pref"]:checked').value,
auto_save: autoSaveCheckbox.checked,
recycle_retention_days: parseInt(recycleRetentionSelect.value, 10),
mouse_wheel_zoom: mouseWheelZoomCheckbox.checked,
zoom_behavior: document.querySelector('input[name="zoom-behavior"]:checked').value,
auto_restore_tabs: autoRestoreTabsCheckbox.checked,
show_toast: currentPreferences.show_toast !== undefined ? currentPreferences.show_toast : true,
};
await invoke('update_preferences', { vault_root: currentFolder, preferences: updatedPrefs });
toast('Settings saved', 'success');
} catch (e) {
toast('Failed to save preferences: ' + friendlyError(e), 'error');
return;
}
}
closeSettings();
}
function closeSettings() {
settingsOverlay.classList.remove('show');
releaseFocus(settingsOverlay);
}
document.getElementById('settings-save').onclick = saveSettings;
document.getElementById('settings-cancel').onclick = closeSettings;
document.getElementById('settings-dialog-close').onclick = closeSettings;
settingsOverlay.addEventListener('click', (e) => { if (e.target === settingsOverlay) closeSettings(); });
document.addEventListener('keydown', (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === ',') { e.preventDefault(); openSettings(); }
if (e.key === 'Escape' && settingsOverlay.classList.contains('show')) { e.stopPropagation(); closeSettings(); }
}, true);
if (localStorage.getItem('fontSize')) setFontSize(parseInt(localStorage.getItem('fontSize'), 10));
if (localStorage.getItem('lineHeight')) setLineHeight(parseFloat(localStorage.getItem('lineHeight')));
if (localStorage.getItem('apiKey')) invoke('set_api_key', { key: localStorage.getItem('apiKey') }).catch(() => {});
const COMMANDS = [
{ label: 'Open Folder…', run: () => document.getElementById('open-folder').click() },
{ label: 'Open File…', run: () => document.getElementById('open-file').click() },
{ label: 'New Note', run: () => document.getElementById('new-note').click() },
{ label: 'Import: Convert File or URL to Markdown…', run: () => document.getElementById('import-btn').click() },
{ label: 'View: Reading', run: () => setViewMode('read') },
{ label: 'View: Live Editing (Cmd+E)', run: () => setViewMode('live') },
{ label: 'View: Split Source (Cmd+Shift+E)', run: () => setViewMode('split') },
{ label: 'Insert Wikilink ([[)', run: () => { if (viewMode === 'split') { const p = editor.selectionStart; editor.setRangeText('[[', p, p, 'end'); editor.focus(); acMaybeShow(editor); } } },
{ label: 'Text: Larger (Cmd+=)', run: () => setFontSize((parseInt(localStorage.getItem('fontSize'), 10) || 16) + 1) },
{ label: 'Text: Smaller (Cmd+-)', run: () => setFontSize((parseInt(localStorage.getItem('fontSize'), 10) || 16) - 1) },
{ label: 'Quick Switcher', run: openSwitcher },
{ label: 'Graph: Global', run: () => showGraph(null) },
{ label: 'Graph: Current Note', run: () => showGraph(currentFile) },
{ label: 'Search Vault', run: () => { switchTab('search-pane'); document.getElementById('search-input').focus(); } },
{ label: 'Semantic Search', run: () => { switchTab('search-pane'); document.getElementById('search-mode').value = 'semantic'; document.getElementById('search-input').focus(); } },
{ label: 'Rename Note (rewrites links)', run: () => document.getElementById('rename-btn').click() },
{ label: 'Edit Properties (frontmatter)', run: () => document.getElementById('edit-props').click() },
{ label: 'Export HTML', run: () => document.getElementById('export-html').click() },
{ label: 'Export DOCX (Word)…', run: () => exportAs('docx') },
{ label: 'Export RTF…', run: () => exportAs('rtf') },
{ label: 'Print / Save as PDF', run: () => window.print() },
{ label: 'Copy as Rich Text', run: () => document.getElementById('copy-rich').click() },
{ label: 'AI: Summarize Document', run: () => runAi('summarize') },
{ label: 'AI: Suggest Tags', run: () => runAi('tag') },
{ label: 'AI: Translate Document…', run: () => runAi('translate', 'Translate to which language?') },
{ label: 'AI: Ask About Document…', run: () => runAi('ask', 'What do you want to know?') },
{ label: 'Daily Note (Cmd+D)', run: () => openDailyNote() },
{ label: 'New Note from Template…', run: () => newFromTemplate() },
{ label: 'Pin/Unpin Current Note', run: () => { if (currentFile) { togglePin(currentFile); } } },
{ label: 'Open in New Tab (duplicate)', run: () => { if (currentFile) openInNewTab(currentFile); } },
{ label: 'Zen Mode (toggle sidebar)', run: toggleZen },
{ label: 'Typewriter Mode', run: () => { typewriterOn = !typewriterOn; flashLive('typewriter ' + (typewriterOn ? 'on' : 'off')); } },
{ label: 'Document Statistics', run: showDocStats },
{ label: 'Drop Diagnostics (debug)', run: showDropDiag },
{ label: 'Reveal Debug Log in Finder', run: revealDebugLog },
{ label: 'Text Analysis: Tokens & Frequencies', run: showTextMetrics },
{ label: 'Keyword Highlights (toggle)', run: toggleKeywords },
{ label: 'Theme: System', run: () => applyTheme('system') },
{ label: 'Theme: Light', run: () => applyTheme('light') },
{ label: 'Theme: Dark', run: () => applyTheme('dark') },
{ label: 'Theme: Sepia', run: () => applyTheme('sepia') },
{ label: 'Settings…', run: openSettings },
{ label: 'Keyboard Shortcuts (?)', run: openCheatsheet },
{ label: 'Localize External Links…', run: localizeAllDialog },
];
function openPalette() {
switcherOverlay.classList.add('show');
switcherInput.value = '';
switcherInput.placeholder = 'Run a command…';
switcherSel = 0;
window._paletteMode = true;
renderPalette();
switcherInput.focus();
}
function renderPalette() {
const q = switcherInput.value.trim().toLowerCase();
const items = COMMANDS.filter(c => !q || fuzzyScore(c.label, q) >= 0)
.sort((a, b) => q ? fuzzyScore(b.label, q) - fuzzyScore(a.label, q) : 0)
.slice(0, 14);
switcherSel = Math.min(switcherSel, Math.max(0, items.length - 1));
switcherResults.innerHTML = '';
items.forEach((c, i) => {
const div = document.createElement('div');
div.className = 'result' + (i === switcherSel ? ' sel' : '');
div.innerHTML = `<div>${c.label}</div>`;
div.onclick = () => { closePalette(); c.run(); };
switcherResults.appendChild(div);
});
}
function closePalette() {
switcherOverlay.classList.remove('show');
switcherInput.placeholder = 'Open note by title or alias…';
window._paletteMode = false;
}
document.addEventListener('keydown', (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') { e.preventDefault(); openPalette(); }
});
let scrollSyncing = false;
function syncScroll(from, to) {
if (scrollSyncing) return;
scrollSyncing = true;
const ratio = from.scrollTop / Math.max(1, from.scrollHeight - from.clientHeight);
to.scrollTop = ratio * (to.scrollHeight - to.clientHeight);
requestAnimationFrame(() => { scrollSyncing = false; });
}
editor.addEventListener('scroll', () => syncScroll(editor, editPreview));
editPreview.addEventListener('scroll', () => syncScroll(editPreview, editor));
editPreview.addEventListener('click', async (e) => {
const box = e.target.closest('input[type="checkbox"]');
if (box) {
e.preventDefault();
const boxes = [...editPreview.querySelectorAll('input[type="checkbox"]')];
toggleTaskInEditor(boxes.indexOf(box));
return;
}
const a = e.target.closest('a');
if (a && (a.getAttribute('href') || '').startsWith('hashtag:')) {
e.preventDefault();
runSearch(decodeURIComponent(a.getAttribute('href').slice(8)), 'tag');
return;
}
if (a && (a.getAttribute('href') || '').startsWith('wikilink:')) {
e.preventDefault();
if (!currentFolder) return;
const target = decodeURIComponent(a.getAttribute('href').slice('wikilink:'.length)).split('#')[0];
try {
const resolved = await invoke('resolve_wikilink', { root: currentFolder, target, from: currentFile || '' });
await exitEditMode();
openFile(resolved);
} catch (err) { showError(String(err)); }
}
});
function toggleTaskInEditor(index) {
const lines = editor.value.split('\n');
let seen = 0;
for (let i = 0; i < lines.length; i++) {
const t = lines[i].trimStart();
if (/^[-*+] \[.\]/.test(t)) {
if (seen === index) {
const pos = lines[i].length - t.length + 3;
const state = lines[i][pos] === ' ' ? 'x' : ' ';
lines[i] = lines[i].slice(0, pos) + state + lines[i].slice(pos + 1);
editor.value = lines.join('\n');
editor.dispatchEvent(new Event('input'));
return;
}
seen++;
}
}
}
let typewriterOn = false;
function toggleZen() { document.body.classList.toggle('zen'); }
function centerCaret() {
if (!typewriterOn || !editMode) return;
const line = editor.value.slice(0, editor.selectionStart).split('\n').length;
const lh = parseFloat(getComputedStyle(editor).lineHeight) || 21;
editor.scrollTop = Math.max(0, line * lh - editor.clientHeight / 2);
}
editor.addEventListener('input', centerCaret);
editor.addEventListener('keyup', (e) => {
if (['ArrowUp', 'ArrowDown', 'Enter'].includes(e.key)) centerCaret();
});
function wrapSelection(before, after, el = editor) {
const { selectionStart: a, selectionEnd: b, value } = el;
const sel = value.slice(a, b);
if (value.slice(a - before.length, a) === before && value.slice(b, b + after.length) === after) {
el.setRangeText(sel, a - before.length, b + after.length, 'select');
} else {
el.setRangeText(before + sel + after, a, b, 'select');
el.setSelectionRange(a + before.length, b + before.length);
}
el.dispatchEvent(new Event('input'));
}
const LIST_RE = /^(\s*)([-*+]|\d+[.)])( \[.\])? /;
function formatKeydown(e, el) {
const mod = e.metaKey || e.ctrlKey;
if (mod && !e.shiftKey && e.key === 'b') { e.preventDefault(); wrapSelection('**', '**', el); return; }
if (mod && !e.shiftKey && e.key === 'i') { e.preventDefault(); wrapSelection('*', '*', el); return; }
if (mod && e.shiftKey && e.key.toLowerCase() === 'x') { e.preventDefault(); wrapSelection('~~', '~~', el); return; }
if (mod && e.shiftKey && e.key.toLowerCase() === 'k') {
e.preventDefault();
const sel = el.value.slice(el.selectionStart, el.selectionEnd) || 'link text';
el.setRangeText(`[${sel}](url)`, el.selectionStart, el.selectionEnd, 'end');
el.dispatchEvent(new Event('input'));
return;
}
if (e.key === 'Tab' && !acItems.length) {
const { selectionStart: a, value } = el;
const lineStart = value.lastIndexOf('\n', a - 1) + 1;
const lineEnd = value.indexOf('\n', a) < 0 ? value.length : value.indexOf('\n', a);
const line = value.slice(lineStart, lineEnd);
if (line.includes('|')) {
e.preventDefault();
if (e.shiftKey) {
const prev = value.lastIndexOf('|', Math.max(0, a - 2));
const prev2 = prev > 0 ? value.lastIndexOf('|', prev - 1) : -1;
if (prev2 >= 0) el.setSelectionRange(prev2 + 2, prev2 + 2);
} else {
const next = value.indexOf('|', a);
if (next >= 0 && next + 2 <= value.length) el.setSelectionRange(next + 2, next + 2);
}
return;
}
if (LIST_RE.test(line)) {
e.preventDefault();
if (e.shiftKey) {
if (line.startsWith(' ')) {
el.setRangeText('', lineStart, lineStart + 2, 'preserve');
}
} else {
el.setRangeText(' ', lineStart, lineStart, 'preserve');
}
el.dispatchEvent(new Event('input'));
return;
}
}
if (e.key === 'Enter' && !acItems.length) {
const { selectionStart: a, value } = el;
const lineStart = value.lastIndexOf('\n', a - 1) + 1;
const line = value.slice(lineStart, a);
const m = line.match(LIST_RE);
if (m) {
e.preventDefault();
if (line.trim() === m[0].trim()) {
el.setRangeText('', lineStart, a, 'end');
} else {
const marker = m[3] ? `${m[1]}${m[2]} [ ] ` : m[0];
el.setRangeText('\n' + marker, a, a, 'end');
}
el.dispatchEvent(new Event('input'));
}
}
}
editor.addEventListener('keydown', (e) => { if (acKeydown(e)) return; formatKeydown(e, editor); });
document.addEventListener('selectionchange', () => {
const el = document.getElementById('sel-stats');
let text = '';
if (editMode && document.activeElement === editor) {
text = editor.value.slice(editor.selectionStart, editor.selectionEnd);
} else {
text = String(window.getSelection() || '');
}
el.textContent = text.trim()
? `· selection: ${text.trim().split(/\s+/).length} words, ${text.length} chars`
: '';
});
async function showDocStats() {
const content = await currentDocText();
if (!content) { showError('Open a document first.'); return; }
const st = await invoke('doc_stats', { content });
const top = st.top_words.map(w => `| ${w.word} | ${w.count} |`).join('\n');
const md = `**Flesch reading ease:** ${st.flesch_reading_ease.toFixed(1)} — ${st.interpretation}
**Grade level:** ${st.flesch_kincaid_grade.toFixed(1)} · **Words:** ${st.words} (${st.distinct_words} distinct) · **Sentences:** ${st.sentences} · **Avg sentence:** ${st.avg_sentence_length.toFixed(1)} words
| Top word | Count |
| --- | --- |
${top}`;
showResult('Document statistics', md);
}
async function showTextMetrics() {
const content = await currentDocText();
if (!content) { showError('Open a document first.'); return; }
toast('Analyzing text…');
try {
const sections = await invoke('text_metrics', { content });
showTabbedResult('Text analysis', sections);
} catch (e) { toast(friendlyError(e), 'error'); }
}
document.getElementById('stats').addEventListener('click', () => {
if (document.getElementById('stats').textContent) showTextMetrics();
});
let kwOn = false;
async function toggleKeywords() {
if (kwOn) {
kwOn = false;
if (currentFile) openFile(currentFile, false, true);
return;
}
const content = await currentDocText();
if (!content) return;
const st = await invoke('doc_stats', { content });
const words = st.top_words.slice(0, 8).map(w => w.word);
if (!words.length) return;
const re = new RegExp('\\b(' + words.map(w => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|') + ')\\b', 'gi');
const walker = document.createTreeWalker(contentBox, NodeFilter.SHOW_TEXT);
const nodes = [];
while (walker.nextNode()) {
const n = walker.currentNode;
if (!n.parentElement.closest('pre, code, mark')) nodes.push(n);
}
for (const n of nodes) {
if (!re.test(n.textContent)) continue;
const span = document.createElement('span');
span.innerHTML = n.textContent.replace(re, '<mark class="kw">$1</mark>');
n.replaceWith(span);
}
kwOn = true;
flashLive('highlighting: ' + words.join(', '));
}
const hoverPop = document.getElementById('hover-pop');
let hoverTimer = null, hoverToken = 0, hoverHideTimer = null;
function hidePopSoon() {
clearTimeout(hoverHideTimer);
hoverHideTimer = setTimeout(() => { hoverPop.style.display = 'none'; }, 250);
}
function hidePopNow() {
hoverAnchor = null;
hoverToken++;
clearTimeout(hoverTimer);
clearTimeout(hoverHideTimer);
hoverPop.style.display = 'none';
}
hoverPop.addEventListener('mouseenter', () => clearTimeout(hoverHideTimer));
hoverPop.addEventListener('mouseleave', hidePopSoon);
let hoverAnchor = null;
function showPeekFor(el, root, target) {
clearTimeout(hoverHideTimer);
clearTimeout(hoverTimer);
const token = ++hoverToken;
hoverTimer = setTimeout(async () => {
try {
const peek = await invoke('peek_note', { root, target, from: currentFile });
if (token !== hoverToken) return; hoverPop.innerHTML = `<div class="pop-path">${escapeHtml(peek.path)}${peek.truncated ? ' · …' : ''}</div>` + peek.html;
postRender(hoverPop);
const r = el.getBoundingClientRect();
hoverPop.style.left = Math.min(r.left, innerWidth - 400) + 'px';
hoverPop.style.top = Math.min(r.bottom + 6, innerHeight - 320) + 'px';
hoverPop.style.display = 'block';
} catch {}
}, 350);
}
function hoverTargetOf(el) {
const a = el.closest('a');
if (a && (a.getAttribute('href') || '').startsWith('wikilink:')) {
try {
return decodeURIComponent(a.getAttribute('href').slice('wikilink:'.length)).split('#')[0];
} catch {
return a.getAttribute('href').slice('wikilink:'.length).split('#')[0];
}
}
const row = el.closest('[data-path]');
if (row && row.dataset.path && row.dataset.path.endsWith('.md')) return row.dataset.path;
return null;
}
function bindHoverPreview(container) {
container.addEventListener('mouseover', (e) => {
if (!currentFolder) return;
const el = e.target.closest('a, [data-path]');
const target = el && hoverTargetOf(e.target);
if (!el || !target) return;
if (el === hoverAnchor) {
clearTimeout(hoverHideTimer);
return;
}
hoverAnchor = el;
showPeekFor(el, currentFolder, target);
});
container.addEventListener('mouseout', (e) => {
const el = e.target.closest('a, [data-path]');
if (!el || el !== hoverAnchor) return;
if (e.relatedTarget && el.contains(e.relatedTarget)) return;
hoverAnchor = null;
hoverToken++;
clearTimeout(hoverTimer);
hidePopSoon();
});
container.addEventListener('scroll', hidePopNow, { passive: true });
}
document.addEventListener('click', (e) => {
if (!hoverPop.contains(e.target)) hidePopNow();
});
document.getElementById('scroll').addEventListener('scroll', hidePopNow, { passive: true });
bindHoverPreview(contentBox);
bindHoverPreview(editPreview);
bindHoverPreview(document.getElementById('note-panel'));
function pinsKey() { return 'pins:' + (currentFolder || ''); }
function getPins() { try { return JSON.parse(localStorage.getItem(pinsKey()) || '[]'); } catch { return []; } }
function togglePin(path) {
const pins = getPins();
const i = pins.indexOf(path);
if (i >= 0) pins.splice(i, 1); else pins.unshift(path);
localStorage.setItem(pinsKey(), JSON.stringify(pins));
renderPins();
}
function renderPins() {
const box = document.getElementById('pins-box');
const pins = getPins().filter(p => p);
if (!pins.length) { box.innerHTML = ''; return; }
box.innerHTML = '<div class="section-title">Pinned</div>';
for (const p of pins) {
const row = document.createElement('div');
row.className = 'pin-row';
row.innerHTML = `<span class="t">★ ${escapeHtml(p.split('/').pop())}</span>`;
row.title = p;
const x = document.createElement('button');
x.className = 'close'; x.textContent = '×'; x.title = 'Unpin';
x.onclick = (e) => { e.stopPropagation(); togglePin(p); };
row.appendChild(x);
row.onclick = () => openFile(p);
box.appendChild(row);
}
}
const ctxMenu = document.getElementById('ctx-menu');
let activeSubmenu = null;
function hideCtx() {
ctxMenu.style.display = 'none';
if (activeSubmenu) { activeSubmenu.style.display = 'none'; activeSubmenu = null; }
}
document.addEventListener('click', hideCtx);
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') hideCtx(); });
function showCtx(x, y, items) {
ctxMenu.innerHTML = '';
for (const it of items) {
const div = document.createElement('div');
div.textContent = it.label;
if (it.danger) div.className = 'danger';
if (it.submenu) {
div.className = (div.className ? div.className + ' ' : '') + 'has-submenu';
const submenu = document.createElement('div');
submenu.className = 'submenu';
submenu.style.display = 'none';
document.body.appendChild(submenu);
for (const sub of it.submenu) {
const subDiv = document.createElement('div');
subDiv.textContent = sub.label;
subDiv.onclick = () => { hideCtx(); sub.run(); };
submenu.appendChild(subDiv);
}
div.onclick = (e) => {
e.stopPropagation();
if (activeSubmenu) activeSubmenu.style.display = 'none';
const rect = div.getBoundingClientRect();
const top = rect.top;
const left = rect.right + 4;
submenu.style.top = Math.max(0, Math.min(top, innerHeight - submenu.offsetHeight)) + 'px';
submenu.style.left = Math.min(left, innerWidth - 180) + 'px';
submenu.style.display = 'block';
activeSubmenu = submenu;
};
} else {
div.onclick = () => { hideCtx(); it.run(); };
}
ctxMenu.appendChild(div);
}
ctxMenu.style.left = Math.min(x, innerWidth - 200) + 'px';
ctxMenu.style.top = Math.min(y, innerHeight - items.length * 32 - 12) + 'px';
ctxMenu.style.display = 'block';
}
async function doCopyFile(path, format) {
try {
const ext = path.split('.').pop() || '';
const fileType = ext.toLowerCase();
toast(`Copying as ${format}…`, 'info', 2000);
const result = await invoke('copy_file', { path, format, file_type: fileType });
const formatNames = {
'markdown': 'Markdown',
'base64': 'Base64',
'hex': 'Hex dump',
'sha256': 'SHA256 hash',
'md5': 'MD5 hash',
'crc': 'CRC hash'
};
toast(`Copied to clipboard as ${formatNames[format] || format}`, 'success', 3500);
} catch (e) {
toast(`Failed to copy file: ${friendlyError(e)}`, 'error', 5000);
}
}
async function refreshTree() {
if (!currentFolder) return;
try {
const nodes = await invoke('list_tree', { root: currentFolder });
const box = document.getElementById('tree-box');
box.innerHTML = '<div class="section-title">Files</div>';
renderTree(nodes, box);
await renderRecycleBin();
} catch {}
}
document.getElementById('tree-box').addEventListener('contextmenu', (e) => {
const node = e.target.closest('.node');
if (!node || !currentFolder) return;
e.preventDefault();
const path = node.title;
const isDir = node.classList.contains('dir');
const dirOf = isDir ? path : path.slice(0, path.lastIndexOf('/'));
const items = [
{ label: 'New note here', run: async () => {
const name = await ask('New note name');
if (!name) return;
try {
const rel = dirOf.slice(currentFolder.length + 1);
const title = rel ? rel + '/' + name : name;
const p = await invoke('create_note', { root: currentFolder, title, daily: false, template: null });
await refreshTree(); openFile(p); enterEditMode();
} catch (err) { showError(String(err)); }
} },
{ label: 'New folder', run: async () => {
const name = await ask('New folder name');
if (!name) return;
try { await invoke('new_folder', { path: dirOf + '/' + name }); refreshTree(); }
catch (err) { showError(String(err)); }
} },
];
if (!isDir && path.endsWith('.md')) {
items.push({ label: getPins().includes(path) ? 'Unpin' : 'Pin', run: () => togglePin(path) });
items.push({ label: 'Rename (rewrites links)', run: async () => {
const base = path.split('/').pop().replace(/\.md$/, '');
const newName = await ask('Rename note', { value: base });
if (!newName || newName === base) return;
try {
const np = await invoke('rename_note', { root: currentFolder, path, newName });
await refreshTree(); openFile(np);
} catch (err) { showError(String(err)); }
} });
}
if (!isDir) {
const copyAsSubmenu = [
{ label: 'Markdown (Cmd+C)', run: async () => { await doCopyFile(path, 'markdown'); } },
{ label: 'Base64 (⇧Cmd+C)', run: async () => { await doCopyFile(path, 'base64'); } },
{ label: 'Hex dump (⌘H)', run: async () => { await doCopyFile(path, 'hex'); } },
{ label: 'SHA256 hash', run: async () => { await doCopyFile(path, 'sha256'); } },
{ label: 'MD5 hash', run: async () => { await doCopyFile(path, 'md5'); } },
{ label: 'CRC hash', run: async () => { await doCopyFile(path, 'crc'); } },
];
items.push({ label: 'Copy as', submenu: copyAsSubmenu });
}
items.push(
{ label: 'Copy path', run: () => navigator.clipboard.writeText(path).then(() => flashLive('path copied')) },
{ label: 'Reveal in Finder', run: () => invoke('reveal_in_finder', { path }).catch(err => showError(String(err))) },
{ label: 'Delete…', danger: true, run: async () => {
const yes = await ask(`Type DELETE to remove ${path.split('/').pop()}`);
if (yes !== 'DELETE') return;
try {
await invoke('delete_path', { path, vaultRoot: currentFolder });
if (currentFile === path) { currentFile = null; contentBox.innerHTML = ''; }
refreshTree();
flashLive('deleted');
} catch (err) { showError(String(err)); }
} },
);
showCtx(e.clientX, e.clientY, items);
});
document.getElementById('tree-box').addEventListener('contextmenu', (e) => {
const recycleBinNode = e.target.closest('[data-recycle-bin]');
const deletedFileItem = e.target.closest('.deleted-file-item');
const recycleBinSection = e.target.closest('[data-recycle-bin-section]');
if (deletedFileItem && !recycleBinNode) {
e.preventDefault();
const fileId = deletedFileItem.getAttribute('data-file-id');
const items = [
{ label: 'Restore', run: async () => {
try {
await invoke('restore_file', { fileId, vaultRoot: currentFolder });
await renderRecycleBin();
await refreshTree();
toast('File restored', 'success');
} catch (err) { showError(String(err)); }
} },
{ label: 'Delete permanently…', danger: true, run: async () => {
const yes = await ask('Type DELETE to permanently remove this file');
if (yes !== 'DELETE') return;
try {
await invoke('permanently_delete', { fileId, vaultRoot: currentFolder });
await renderRecycleBin();
toast('File permanently deleted', 'success');
} catch (err) { showError(String(err)); }
} },
];
showCtx(e.clientX, e.clientY, items);
return;
}
if (recycleBinNode || (recycleBinSection && e.target.closest('.recycle-bin-node'))) {
e.preventDefault();
const deletedFiles = document.querySelectorAll('.deleted-file-item');
const items = [];
if (deletedFiles.length > 0) {
items.push({ label: 'Empty recycle bin…', danger: true, run: async () => {
const yes = await ask('Type DELETE to permanently delete all items in recycle bin');
if (yes !== 'DELETE') return;
try {
await invoke('empty_recycle_bin', { vaultRoot: currentFolder });
await renderRecycleBin();
toast('Recycle bin emptied', 'success');
} catch (err) { showError(String(err)); }
} });
} else {
items.push({ label: 'Recycle Bin is empty', run: () => {} });
}
showCtx(e.clientX, e.clientY, items);
}
});
document.getElementById('content').addEventListener('contextmenu', (e) => {
const a = e.target.closest('a[href], img[src]');
if (!a || !currentFile) return;
const target = a.tagName === 'IMG' ? a.getAttribute('src') : a.getAttribute('href');
if (!/^(https?|file):/.test(target || '')) return;
e.preventDefault();
showCtx(e.clientX, e.clientY, [
{ label: 'Store in vault', run: () => runLocalize(target, 'store') },
{ label: 'Convert to markdown note', run: () => runLocalize(target, 'convert') },
]);
});
async function runLocalize(link, action) {
try {
toast('Localizing…', 'info', 1500);
await invoke('localize_link', { root: currentFolder, notePath: currentFile, link, action });
await openFile(currentFile, false, true);
toast('Link localized', 'success');
} catch (e) { toast(friendlyError(e), 'error', 6000); }
}
async function localizeAllDialog() {
if (!currentFile) return;
if (dropDialogBusy) { toast('Finish the current drop dialog first', 'info'); return; }
const links = await invoke('scan_external_links', { root: currentFolder, notePath: currentFile });
if (!links.length) { toast('No external links in this note', 'info'); return; }
dropDialogBusy = true;
dropDialogEl.innerHTML = '<h3>Localize external links</h3>' + links.map((l, i) =>
`<div class="loc-row"><span title="${escapeHtml(l.link)}">${escapeHtml(l.link.length > 48 ? l.link.slice(0, 45) + '…' : l.link)}</span>
<select data-i="${i}">
<option value="skip"${l.kind === 'image_url' ? '' : ' selected'}>Skip</option>
<option value="store"${l.kind === 'image_url' ? ' selected' : ''}>Store</option>
<option value="convert">Convert</option>
</select></div>`).join('') +
'<div><button id="loc-apply">Apply</button> <button id="loc-cancel">Cancel</button></div>';
dropOverlay.classList.add('show'); trapFocus(dropOverlay);
const close = () => {
dropOverlay.classList.remove('show'); releaseFocus(dropOverlay);
restoreDropDialog();
dropDialogBusy = false;
localizeDialogClose = null;
};
localizeDialogClose = close;
document.getElementById('loc-cancel').onclick = close;
document.getElementById('loc-apply').onclick = async () => {
const jobs = [...dropDialogEl.querySelectorAll('select')].map(s => ({ link: links[+s.dataset.i].link, action: s.value })).filter(j => j.action !== 'skip');
close();
let ok = 0;
for (const j of jobs) {
try { await invoke('localize_link', { root: currentFolder, notePath: currentFile, link: j.link, action: j.action }); ok++; }
catch (e) { toast(`Skipped ${j.link.slice(0, 40)}: ${friendlyError(e)}`, 'error', 5000); }
}
if (ok) { await openFile(currentFile, false, true); toast(`Localized ${ok} link${ok === 1 ? '' : 's'}`, 'success'); }
};
}
let graphFilter = '';
document.getElementById('graph-filter').addEventListener('input', (e) => {
graphFilter = e.target.value.trim().toLowerCase();
drawGraph();
});
async function openDailyNote() {
if (!currentFolder) { showError('Open a vault folder first.'); return; }
try {
const p = await invoke('daily_note', { root: currentFolder });
await refreshTree();
openFile(p);
} catch (e) { showError(String(e)); }
}
async function newFromTemplate() {
if (!currentFolder) { showError('Open a vault folder first.'); return; }
const templates = await invoke('list_templates', { root: currentFolder });
if (!templates.length) { showError('No templates folder configured in this vault (.obsidian/templates.json).'); return; }
const choice = await ask('Template (' + templates.map(t => t.split('/').pop()).join(', ') + ')', { value: templates[0].split('/').pop() });
if (!choice) return;
const template = templates.find(t => t.split('/').pop().toLowerCase() === choice.trim().toLowerCase()) || templates[0];
const title = await ask('New note title');
if (!title) return;
try {
const p = await invoke('create_note', { root: currentFolder, title, daily: false, template });
await refreshTree(); openFile(p); enterEditMode();
} catch (e) { showError(String(e)); }
}
document.addEventListener('keydown', (e) => {
const mod = e.metaKey || e.ctrlKey;
if (mod && e.key === 'd') { e.preventDefault(); openDailyNote(); }
if (mod && e.shiftKey && e.key.toLowerCase() === 'z') { e.preventDefault(); toggleZen(); }
if (mod && e.key === 'w' && tabs.length) { e.preventDefault(); if (activeTab >= 0) closeTab(activeTab); }
if (mod && /^[1-9]$/.test(e.key)) {
const i = parseInt(e.key, 10) - 1;
if (tabs[i]) { e.preventDefault(); activeTab = i; renderTabs(); saveTabs(); openFile(tabs[i], false); }
}
if (mod && (e.key === '=' || e.key === '+')) { e.preventDefault(); setFontSize((parseInt(localStorage.getItem('fontSize'), 10) || 16) + 1); }
if (mod && e.key === '-') { e.preventDefault(); setFontSize((parseInt(localStorage.getItem('fontSize'), 10) || 16) - 1); }
if (mod && e.key === '0') { e.preventDefault(); setFontSize(16); }
const selectedNode = document.querySelector('.node.selected');
if (selectedNode && !document.activeElement?.matches('input, textarea')) {
const path = selectedNode.title;
if (!path.split('/').pop().includes('.')) return; if (mod && e.key === 'c' && !e.shiftKey) { e.preventDefault(); doCopyFile(path, 'markdown'); }
if (mod && e.shiftKey && e.key.toLowerCase() === 'c') { e.preventDefault(); doCopyFile(path, 'base64'); }
if (mod && e.key === 'h') { e.preventDefault(); doCopyFile(path, 'hex'); }
}
});
function renderVaultList() {
const box = document.getElementById('vault-list');
const vaults = JSON.parse(localStorage.getItem('vaults') || '[]');
box.innerHTML = vaults.length ? '' : '<div class="pin-row"><span class="t">none yet</span></div>';
for (const v of vaults) {
const row = document.createElement('div');
row.className = 'pin-row';
row.innerHTML = `<span class="t">${escapeHtml(v)}</span>`;
const x = document.createElement('button');
x.className = 'close'; x.textContent = '×'; x.title = 'Forget vault';
x.onclick = (e) => {
e.stopPropagation();
localStorage.setItem('vaults', JSON.stringify(vaults.filter(o => o !== v)));
renderVaultList();
};
row.appendChild(x);
row.onclick = () => { settingsOverlay.classList.remove('show'); openFolder(v); };
box.appendChild(row);
}
}
document.getElementById('set-reopen').addEventListener('change', (e) => {
localStorage.setItem('reopenLast', e.target.checked ? '1' : '');
});
document.getElementById('set-live').addEventListener('change', (e) => {
localStorage.setItem('liveDefault', e.target.checked ? '1' : '0');
});
if (localStorage.getItem('reopenLast') && localStorage.getItem('lastVault')) {
openFolder(localStorage.getItem('lastVault'));
}
const liveWrap = document.getElementById('live-wrap');
const liveDocEl = document.getElementById('live-doc');
let viewMode = 'read'; let liveDoc = '';
let liveBlocks = [];
let activeBlockEl = null;
let liveSaveTimer = null;
let liveDirty = false;
function liveDefaultOn() { return localStorage.getItem('liveDefault') !== '0'; }
async function saveLive() {
if (!liveDirty || !currentFile) return;
try {
lastSelfSave = Date.now();
await invoke('save_file', { path: currentFile, content: liveDoc, vaultRoot: currentFolder });
liveDirty = false;
saveState.textContent = 'saved ' + new Date().toLocaleTimeString();
flashLive('saved');
} catch (e) { showError('Save failed: ' + e); }
}
function scheduleLiveSave() {
liveDirty = true;
clearTimeout(liveSaveTimer);
liveSaveTimer = setTimeout(saveLive, 800);
}
async function renderLiveDoc(preserveScroll = true) {
const ratio = preserveScroll && liveWrap.scrollHeight > 0
? liveWrap.scrollTop / liveWrap.scrollHeight : 0;
liveBlocks = await invoke('render_blocks',
{ md: liveDoc, vaultRoot: currentFolder, filePath: currentFile });
liveDocEl.innerHTML = '';
activeBlockEl = null;
liveBlocks.forEach((b, i) => {
const div = document.createElement('div');
div.className = 'block';
div.dataset.i = i;
div.innerHTML = b.html || '<span style="color:var(--muted)">·</span>';
liveDocEl.appendChild(div);
});
const add = document.createElement('div');
add.className = 'block-append';
add.textContent = '+ Click to write…';
add.onclick = () => appendBlock();
liveDocEl.appendChild(add);
postRender(liveDocEl);
buildToc(liveDocEl);
liveWrap.scrollTop = ratio * liveWrap.scrollHeight;
}
function autosize(ta) {
ta.style.height = 'auto';
ta.style.height = ta.scrollHeight + 'px';
}
async function commitActiveBlock(rerender = true) {
if (!activeBlockEl) return;
const ta = activeBlockEl.querySelector('textarea.block-edit');
if (!ta) { activeBlockEl = null; return; }
const i = parseInt(activeBlockEl.dataset.i, 10);
const oldText = liveBlocks[i]?.text ?? '';
const newText = ta.value;
const el = activeBlockEl;
activeBlockEl = null;
if (newText === oldText) {
el.classList.remove('active');
el.innerHTML = liveBlocks[i].html;
postRender(el);
return;
}
liveBlocks[i].text = newText;
liveDoc = liveBlocks.map(b => b.text).join('');
scheduleLiveSave();
if (rerender) await renderLiveDoc();
}
let blockHlTimer = null, blockHlSeq = 0;
async function refreshBlockHighlight(ta, backdrop) {
const text = ta.value, seq = ++blockHlSeq;
try {
const raw = await invoke('highlight_markdown', { source: text });
if (seq !== blockHlSeq) return; const toChar = byteToCharIndex(text);
const spans = raw.map(s => ({ start: toChar(s.start), end: toChar(s.end), kind: s.kind }));
backdrop.innerHTML = spansToHtml(text, spans) + '\n';
} catch { backdrop.textContent = text; }
}
function activateBlock(el) {
if (activeBlockEl === el) return;
commitActiveBlock(false).then(() => {
const i = parseInt(el.dataset.i, 10);
el.classList.add('active');
el.innerHTML = '';
const shell = document.createElement('div');
shell.className = 'block-shell';
const backdrop = document.createElement('pre');
backdrop.className = 'block-backdrop';
const ta = document.createElement('textarea');
ta.className = 'block-edit';
ta.value = liveBlocks[i].text;
shell.appendChild(backdrop);
shell.appendChild(ta);
el.appendChild(shell);
activeBlockEl = el;
autosize(ta);
refreshBlockHighlight(ta, backdrop);
ta.focus();
const end = ta.value.replace(/\n+$/, '').length;
ta.setSelectionRange(end, end);
ta.addEventListener('input', () => {
autosize(ta);
backdrop.textContent = ta.value + '\n';
clearTimeout(blockHlTimer);
blockHlTimer = setTimeout(() => refreshBlockHighlight(ta, backdrop), 120);
acMaybeShow(ta);
});
ta.addEventListener('scroll', () => { backdrop.scrollTop = ta.scrollTop; }, { passive: true });
ta.addEventListener('keydown', (e) => {
if (acKeydown(e)) return;
if (e.key === 'Escape') { e.preventDefault(); commitActiveBlock(); return; }
formatKeydown(e, ta);
});
ta.addEventListener('paste', (e) => editorPaste(e, ta));
ta.addEventListener('blur', () => {
acHide();
setTimeout(() => { if (activeBlockEl === el) commitActiveBlock(); }, 120);
});
});
}
function appendBlock() {
commitActiveBlock(false).then(() => {
if (liveDoc && !liveDoc.endsWith('\n')) liveDoc += '\n';
if (liveDoc && !liveDoc.endsWith('\n\n')) liveDoc += '\n';
liveBlocks.push({ text: '', html: '' });
const div = document.createElement('div');
div.className = 'block';
div.dataset.i = liveBlocks.length - 1;
liveDocEl.insertBefore(div, liveDocEl.lastElementChild);
activateBlock(div);
});
}
liveDocEl.addEventListener('click', async (e) => {
const a = e.target.closest('a');
if (a) {
const href = a.getAttribute('href') || '';
if (href.startsWith('hashtag:')) {
e.preventDefault();
runSearch(decodeURIComponent(href.slice(8)), 'tag');
return;
}
if (href.startsWith('wikilink:')) {
e.preventDefault();
if (!currentFolder) return;
const target = decodeURIComponent(href.slice('wikilink:'.length)).split('#')[0];
try {
await commitActiveBlock(false);
const resolved = await invoke('resolve_wikilink', { root: currentFolder, target, from: currentFile || '' });
openFile(resolved);
} catch (err) { showError(String(err)); }
return;
}
return; }
if (e.target.matches('input[type="checkbox"]')) {
e.preventDefault();
const boxes = [...liveDocEl.querySelectorAll('input[type="checkbox"]')];
const index = boxes.indexOf(e.target);
const flipped = flipTaskInString(liveDoc, index);
if (flipped !== null) {
liveDoc = flipped;
scheduleLiveSave();
renderLiveDoc();
}
return;
}
const block = e.target.closest('.block');
if (block && !activeBlockEl?.contains(e.target)) activateBlock(block);
});
bindHoverPreview(liveDocEl);
function flipTaskInString(str, index) {
const lines = str.split('\n');
let seen = 0;
for (let i = 0; i < lines.length; i++) {
const t = lines[i].trimStart();
if (/^[-*+] \[.\]/.test(t)) {
if (seen === index) {
const pos = lines[i].length - t.length + 3;
const state = lines[i][pos] === ' ' ? 'x' : ' ';
lines[i] = lines[i].slice(0, pos) + state + lines[i].slice(pos + 1);
return lines.join('\n');
}
seen++;
}
}
return null;
}
async function setViewMode(mode) {
if (mode === viewMode) return;
acHide();
if (viewMode === 'split') { if (dirty) await saveNow(); editMode = false; editorWrap.classList.remove('show'); }
if (viewMode === 'live') { await commitActiveBlock(false); await saveLive(); liveWrap.classList.remove('show'); }
document.getElementById('scroll').style.display = 'none';
viewMode = mode;
const btn = document.getElementById('edit-toggle');
if (mode === 'read') {
document.getElementById('scroll').style.display = '';
btn.textContent = 'Edit';
window._noAutoLive = Date.now();
if (currentFile) openFile(currentFile, false, true);
} else if (mode === 'live') {
if (!currentFile || !/\.(md|markdown)$/.test(currentFile)) {
document.getElementById('scroll').style.display = '';
viewMode = 'read';
showError('Live editing works on Markdown files.');
return;
}
liveDoc = await invoke('read_source', { path: currentFile });
liveDirty = false;
liveWrap.classList.add('show');
btn.textContent = 'Reading';
renderLiveDoc(false);
} else if (mode === 'split') {
editMode = true;
editor.value = await invoke('read_source', { path: currentFile });
editorWrap.classList.add('show');
btn.textContent = 'Live';
refreshEditPreview();
refreshHighlight();
editor.focus();
}
}
async function currentDocForExport() {
if (viewMode === 'live') { await commitActiveBlock(false); return liveDoc; }
return currentDocText();
}
async function exportAs(kind) {
const md = await currentDocForExport();
if (!md) { showError('Open a document first.'); return; }
const title = (currentFile || 'document').split('/').pop();
try {
const saved = await invoke(kind === 'docx' ? 'export_docx' : 'export_rtf', { title, md });
if (saved) toast('Exported ' + saved.split('/').pop(), 'success');
} catch (e) { toast('Export failed: ' + friendlyError(e), 'error'); }
}
const MENU_ACTIONS = {
'open-folder': () => document.getElementById('open-folder').click(),
'open-file': () => document.getElementById('open-file').click(),
'new-note': () => document.getElementById('new-note').click(),
'daily-note': () => openDailyNote(),
'export-html': () => document.getElementById('export-html').click(),
'export-docx': () => exportAs('docx'),
'export-rtf': () => exportAs('rtf'),
'print': () => window.print(),
'mode-read': () => setViewMode('read'),
'mode-live': () => setViewMode('live'),
'mode-split': () => setViewMode('split'),
'zen': () => toggleZen(),
'theme-system': () => applyTheme('system'),
'theme-light': () => applyTheme('light'),
'theme-dark': () => applyTheme('dark'),
'theme-sepia': () => applyTheme('sepia'),
};
listen('menu-action', (e) => { MENU_ACTIONS[e.payload]?.(); });
async function handleOsOpen(paths) {
const p = (paths || [])[0];
if (!p) return;
try {
await invoke('list_tree', { root: p });
openFolder(p);
} catch {
openFile(p);
}
}
listen('os-open-file', (e) => handleOsOpen(e.payload));
invoke('take_pending_opens').then(handleOsOpen).catch(() => {});
const importBar = document.getElementById('import-bar');
document.getElementById('import-btn').onclick = () => {
importBar.style.display = importBar.style.display === 'none' ? '' : 'none';
if (importBar.style.display !== 'none') document.getElementById('import-url').focus();
};
async function finishImport(converted) {
const pick = document.getElementById('import-pick').checked || !currentFolder;
const saved = await invoke('save_import', {
root: currentFolder,
suggestedName: converted.suggested_name,
markdown: converted.markdown,
pick,
});
if (!saved) { toast('Import cancelled'); return; }
toast('Imported ' + saved.split('/').pop(), 'success');
if (currentFolder && saved.startsWith(currentFolder)) {
await refreshTree();
openInNewTab(saved);
} else {
openFile(saved);
}
}
async function convertFile(path, btn) {
await withBusy(btn, async () => {
toast('Converting ' + path.split('/').pop() + '…');
try {
const converted = await invoke('convert_file_to_markdown', { path });
await finishImport(converted);
} catch (e) { toast('Conversion failed: ' + friendlyError(e), 'error', 6000); }
});
}
document.getElementById('import-file').onclick = async (e) => {
const f = await invoke('pick_file');
if (f) convertFile(f, e.target);
};
document.getElementById('import-url').addEventListener('keydown', async (e) => {
if (e.key !== 'Enter') return;
const url = e.target.value.trim();
if (!url) return;
await withBusy(document.getElementById('import-file'), async () => {
toast('Fetching ' + url + '…');
try {
const converted = await invoke('convert_url_to_markdown', { url });
e.target.value = '';
await finishImport(converted);
} catch (err) { toast('Conversion failed: ' + friendlyError(err), 'error', 6000); }
});
});
async function appendToNote(text) {
const src = await invoke('read_source', { path: currentFile });
const nl = src.endsWith('\n') ? '' : '\n';
await invoke('save_file', { path: currentFile, content: src + nl + text + '\n', vaultRoot: currentFolder });
toast('Added at end of note', 'success');
}
async function insertIntoNote(text) {
if (!currentFile) return;
if (viewMode === 'split') {
const p = editor.selectionStart;
editor.setRangeText(text, p, p, 'end');
editor.dispatchEvent(new Event('input'));
editor.focus();
} else if (viewMode === 'live') {
const ta = document.querySelector('#live-doc textarea.block-edit');
if (ta) {
const p = ta.selectionStart;
ta.setRangeText(text, p, p, 'end');
ta.dispatchEvent(new Event('input'));
ta.focus();
} else {
await appendToNote(text);
}
} else { await appendToNote(text);
}
}
let dropDialogBusy = false;
const dropOverlay = document.getElementById('drop-overlay');
const dropDialogEl = document.getElementById('drop-dialog');
const DROP_DIALOG_TEMPLATE = `<h3 id="drop-title"></h3>
<button data-act="copy">Copy into vault & link</button>
<button data-act="linkorig">Link original location</button>
<button data-act="convnote">Convert to new linked note</button>
<button data-act="convinline">Convert inline</button>
<button data-act="cancel">Cancel (Esc)</button>`;
function restoreDropDialog() { dropDialogEl.innerHTML = DROP_DIALOG_TEMPLATE; }
let localizeDialogClose = null;
function resolveDropDialog(act) {
dropOverlay.classList.remove('show'); releaseFocus(dropOverlay);
dropDialogBusy = false;
const resolve = pendingDropResolve;
pendingDropResolve = null;
if (resolve) resolve(act);
}
function askDropAction(title, convertible) {
dropDialogBusy = true;
return new Promise((resolve) => {
pendingDropResolve = resolve;
document.getElementById('drop-title').textContent = title;
dropOverlay.querySelectorAll('button').forEach(b => {
b.disabled = !convertible && (b.dataset.act === 'convnote' || b.dataset.act === 'convinline');
b.title = b.disabled ? 'This file type cannot be converted' : '';
b.onclick = () => resolveDropDialog(b.dataset.act);
});
dropOverlay.classList.add('show'); trapFocus(dropOverlay);
});
}
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && dropOverlay.classList.contains('show')) {
e.stopPropagation();
if (localizeDialogClose) localizeDialogClose();
else resolveDropDialog('cancel');
}
}, true);
const confirmInsertOverlay = document.getElementById('confirm-insert-overlay');
let pendingConfirmResolve = null;
function resolveConfirmInsert(ok) {
confirmInsertOverlay.classList.remove('show'); releaseFocus(confirmInsertOverlay);
dropDialogBusy = false;
const resolve = pendingConfirmResolve;
pendingConfirmResolve = null;
if (resolve) resolve(ok);
}
function askConfirmInsert(len) {
dropDialogBusy = true;
return new Promise((resolve) => {
pendingConfirmResolve = resolve;
document.getElementById('confirm-insert-title').textContent =
`Converted text is ${(len / 1024).toFixed(0)} KB — insert anyway?`;
confirmInsertOverlay.querySelectorAll('button').forEach(b => {
b.onclick = () => resolveConfirmInsert(b.dataset.act === 'insert');
});
confirmInsertOverlay.classList.add('show'); trapFocus(confirmInsertOverlay);
});
}
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && confirmInsertOverlay.classList.contains('show')) { e.stopPropagation(); resolveConfirmInsert(false); }
}, true);
const IMG_RE = /\.(png|jpe?g|gif|webp|svg|bmp)$/i;
async function applyDropAction(act, p) {
const name = p.split('/').pop();
if (act === 'copy') {
const a = await invoke('store_attachment', { root: currentFolder, srcPath: p });
await insertIntoNote(a.embed);
} else if (act === 'linkorig') {
await insertIntoNote(`[${name}](file://${encodeURI(p)})`);
} else if (act === 'convnote') {
const conv = await invoke('convert_file_to_markdown', { path: p });
const saved = await invoke('save_import', { root: currentFolder, suggestedName: conv.suggested_name, markdown: conv.markdown, pick: false });
if (saved) await insertIntoNote(`[[${saved.split('/').pop().replace(/\.md$/, '')}]]`);
} else if (act === 'convinline') {
const conv = await invoke('convert_file_to_markdown', { path: p });
if (conv.markdown.length > 200 * 1024 && !(await askConfirmInsert(conv.markdown.length))) return;
await insertIntoNote(conv.markdown);
}
}
async function handleNoteDrop(paths) {
if (dropDialogBusy) { toast('Finish the current drop dialog first', 'info'); return; }
const imgs = paths.filter(p => IMG_RE.test(p));
const others = paths.filter(p => !IMG_RE.test(p));
for (const p of imgs) {
try { const a = await invoke('store_attachment', { root: currentFolder, srcPath: p }); await insertIntoNote(a.embed); }
catch (e) { toast(friendlyError(e), 'error'); }
}
if (!others.length) return;
const convertible = await invoke('is_convertible', { path: others[0] }).catch(() => false);
const title = others.length === 1 ? others[0].split('/').pop() : `${others.length} files`;
const act = await askDropAction(title, convertible);
if (act === 'cancel') return;
for (const p of others) {
try { await applyDropAction(act, p); } catch (e) { toast(friendlyError(e), 'error'); }
}
}
function dropPosToClient(pos) {
const dpr = window.devicePixelRatio || 1;
return { x: pos.x / dpr, y: pos.y / dpr };
}
function pointInRect(pt, rect) {
return pt.x >= rect.left && pt.x <= rect.right && pt.y >= rect.top && pt.y <= rect.bottom;
}
function unwrapImageSearchUrl(uri) {
try {
const u = new URL(uri);
for (const param of ['imgurl', 'mediaurl', 'iu', 'u']) {
const v = u.searchParams.get(param);
if (v && /^https?:\/\//.test(v)) { logDropDiag('APP', 'unwrap', `${param} -> ${v.slice(0, 80)}`); return v; }
}
} catch {}
return uri;
}
async function handleUrlDrop(uri) {
uri = unwrapImageSearchUrl(uri);
logDropDiag('APP', 'url-drop', uri.slice(0, 100));
if (IMG_RE.test(uri.split('?')[0].split('#')[0])) {
const act = await askImageAction(uri, true);
if (act === 'cancel') return;
if (act === 'link') { await insertIntoNote(``); return; }
try { const a = await invoke('store_url_attachment', { root: currentFolder, url: uri }); await insertIntoNote(a.embed); }
catch { await insertIntoNote(``); toast('Could not download — inserted a link instead', 'info'); }
} else {
const act = await askDropAction(uri, true);
if (act === 'cancel') return;
if (act === 'copy') { const a = await invoke('store_url_attachment', { root: currentFolder, url: uri }); await insertIntoNote(a.embed); }
else if (act === 'linkorig') await insertIntoNote(`<${uri}>`);
else {
const conv = await invoke('convert_url_to_markdown', { url: uri });
if (act === 'convnote') {
const saved = await invoke('save_import', { root: currentFolder, suggestedName: conv.suggested_name, markdown: conv.markdown, pick: false });
if (saved) await insertIntoNote(`[[${saved.split('/').pop().replace(/\.md$/, '')}]]`);
} else if (conv.markdown.length <= 200 * 1024 || (await askConfirmInsert(conv.markdown.length))) {
await insertIntoNote(conv.markdown);
}
}
}
}
listen('tauri://drag-drop', async (event) => {
const paths = event.payload.paths || [];
if (dropDialogBusy) { toast('Finish the current drop dialog first', 'info'); return; }
if (!paths.length) {
if (!currentFolder || !currentFile) return;
try {
const pb = await invoke('read_drag_pasteboard');
logDropDiag('APP', 'pasteboard', `types=[${(pb.types || []).join(',')}] url=${pb.url ? pb.url.slice(0, 60) : '-'} img=${pb.image_ext || '-'}`);
const uri = pb.url ? unwrapImageSearchUrl(pb.url) : null;
const uriIsImage = uri && IMG_RE.test(uri.split('?')[0].split('#')[0]);
if (uriIsImage) {
await handleUrlDrop(uri);
} else if (pb.image_base64) {
const act = await askImageAction(uri || 'Dropped image', !!uri);
if (act === 'cancel') return;
if (act === 'link') { await insertIntoNote(``); return; }
const embed = await invoke('paste_image', { root: currentFolder, base64Data: pb.image_base64, extension: pb.image_ext || 'png' });
await insertIntoNote(embed);
} else if (uri) {
await handleUrlDrop(uri);
} else {
toast('Could not read the dragged content — try copying the image and pasting it (Cmd+V)', 'info', 5000);
}
} catch (e) { toast(friendlyError(e), 'error'); }
return;
}
const p = paths[0];
let isDir = false;
try { await invoke('list_tree', { root: p }); isDir = true; } catch {}
if (!isDir && currentFolder && currentFile && event.payload.position) {
const pt = dropPosToClient(event.payload.position);
const noteEl = viewMode === 'read' ? document.getElementById('scroll')
: viewMode === 'live' ? document.getElementById('live-wrap')
: document.getElementById('editor-wrap');
if (noteEl && noteEl.offsetParent !== null && pointInRect(pt, noteEl.getBoundingClientRect())) {
handleNoteDrop(paths);
return;
}
}
if (isDir) { openFolder(p); return; }
if (!/\.(md|markdown|canvas|txt)$/i.test(p) && await invoke('is_convertible', { path: p }).catch(() => false)) {
convertFile(p, document.getElementById('import-btn'));
} else {
openFile(p);
}
});
const dropDiag = [];
let dropDiagToastShown = {};
function logDropDiag(src, kind, detail) {
const line = `${new Date().toISOString().slice(11, 23)} [${src}] ${kind} ${detail}`;
dropDiag.push(line);
if (dropDiag.length > 200) dropDiag.shift();
console.log('drop-diag:', line);
invoke('debug_log', { line: `drop-diag [${src}] ${kind} ${detail}` }).catch(() => {});
const key = src + kind;
if (!dropDiagToastShown[key]) { dropDiagToastShown[key] = 1; toast(`drop-diag: ${src} ${kind}`, 'info', 2500); }
}
function dtSummary(dt) {
if (!dt) return 'no dataTransfer';
const types = [...(dt.types || [])].join(',');
const items = [...(dt.items || [])].map(i => `${i.kind}:${i.type || '?'}`).join(' ');
let uri = '';
try { uri = dt.getData('text/uri-list').split('\n')[0] || ''; } catch {}
return `types=[${types}] items=[${items}]${uri ? ' uri=' + uri.slice(0, 80) : ''}`;
}
for (const ev of ['dragenter', 'dragover', 'drop', 'dragleave']) {
window.addEventListener(ev, (e) => {
if (ev === 'dragover' && dropDiag.length && dropDiag[dropDiag.length - 1].includes('DOM] dragover')) return; logDropDiag('DOM', ev, `target=${e.target?.id || e.target?.tagName || '?'} ${ev === 'dragover' ? '' : dtSummary(e.dataTransfer)}`);
}, true);
}
for (const tev of ['tauri://drag-enter', 'tauri://drag-over', 'tauri://drag-drop', 'tauri://drag-leave']) {
listen(tev, (event) => {
if (tev === 'tauri://drag-over') return; const p = event.payload || {};
logDropDiag('TAURI', tev.replace('tauri://', ''), `paths=${JSON.stringify(p.paths || [])} pos=${p.position ? p.position.x + ',' + p.position.y : '-'}`);
}).catch(() => {});
}
async function showDropDiag() {
const logPath = await invoke('debug_log_path').catch(() => '(unavailable)');
const md = '# Drop diagnostics\n\nDrag something onto the note area, then reopen this view.\n\n```\n'
+ (dropDiag.length ? dropDiag.join('\n') : '(no drag/drop events captured yet)') + '\n```\n\n'
+ `Environment: devicePixelRatio=${window.devicePixelRatio}, platform=${navigator.platform}, viewMode=${viewMode}, currentFile=${currentFile ? 'open' : 'none'}\n\n`
+ `Persistent log (attach this file to bug reports): \`${logPath}\`\n`;
await showResult('Drop Diagnostics', md);
}
async function revealDebugLog() {
try {
const p = await invoke('debug_log_path');
await invoke('debug_log', { line: '--- log revealed by user ---' }); await invoke('reveal_in_finder', { path: p });
} catch (e) { toast(friendlyError(e), 'error'); }
}
const imageDropOverlay = document.getElementById('image-drop-overlay');
let pendingImageResolve = null;
function resolveImageDrop(act) {
imageDropOverlay.classList.remove('show'); releaseFocus(imageDropOverlay);
dropDialogBusy = false;
const resolve = pendingImageResolve;
pendingImageResolve = null;
if (resolve) resolve(act);
}
function askImageAction(label, linkAvailable) {
dropDialogBusy = true;
return new Promise((resolve) => {
pendingImageResolve = resolve;
document.getElementById('image-drop-title').textContent = label;
imageDropOverlay.querySelectorAll('button').forEach(b => {
b.disabled = b.dataset.act === 'link' && !linkAvailable;
b.title = b.disabled ? 'No URL available for this image — it can only be saved' : '';
b.onclick = () => resolveImageDrop(b.dataset.act);
});
imageDropOverlay.classList.add('show'); trapFocus(imageDropOverlay);
});
}
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && imageDropOverlay.classList.contains('show')) { e.stopPropagation(); resolveImageDrop('cancel'); }
}, true);
for (const el of [document.getElementById('scroll'), document.getElementById('editor-wrap'), document.getElementById('live-wrap')]) {
el.addEventListener('dragover', (e) => { e.preventDefault(); });
el.addEventListener('drop', async (e) => {
if (!currentFile) return;
if (dropDialogBusy) { e.preventDefault(); toast('Finish the current drop dialog first', 'info'); return; }
e.preventDefault();
const uri = e.dataTransfer.getData('text/uri-list').split('\n').find(l => l && !l.startsWith('#'));
const item = [...(e.dataTransfer.items || [])].find(i => i.kind === 'file' && i.type.startsWith('image/'));
try {
if (item) { const file = item.getAsFile();
if (!file) { toast('Could not read the dropped image', 'error'); return; }
const httpUri = uri && /^https?:\/\//.test(uri) ? uri : null;
const act = await askImageAction(file.name || httpUri || 'Dropped image', !!httpUri);
if (act === 'cancel') return;
if (act === 'link') {
await insertIntoNote(``);
} else {
const buf = new Uint8Array(await file.arrayBuffer());
let bin = ''; buf.forEach(b => bin += String.fromCharCode(b));
const ext = (item.type.split('/')[1] || 'png').replace('jpeg', 'jpg').replace('svg+xml', 'svg');
const embed = await invoke('paste_image', { root: currentFolder, base64Data: btoa(bin), extension: ext });
await insertIntoNote(embed);
}
} else if (uri && /^https?:\/\//.test(uri)) {
await handleUrlDrop(uri);
}
} catch (err) { toast(friendlyError(err), 'error'); }
});
}
['prompt-overlay', 'switcher-overlay', 'result-overlay', 'settings-overlay', 'graph-overlay']
.forEach(id => watchOverlayFocus(document.getElementById(id)));
const FOCUS_ROWS = '.node, .result, .tag-item, .task-item, .bl, .pin-row, .tab, #toc li, #recents li, #stats';
new MutationObserver(() => {
document.querySelectorAll(FOCUS_ROWS).forEach(el => {
if (!el.hasAttribute('tabindex')) el.tabIndex = 0;
});
}).observe(document.body, { childList: true, subtree: true });
document.addEventListener('keydown', (e) => {
if (e.key !== 'Enter' && e.key !== ' ') return;
const t = e.target;
if (t instanceof Element && t.matches(FOCUS_ROWS)) { e.preventDefault(); t.click(); }
});
renderRecents();
</script>
<script src="js/tab-manager.js"></script>
<script src="image-zoom-ui.js"></script>
</body>
</html>