feat(browse): SPA overhaul — context menu, YAML editor, icons, hovercard, deep links, autofilter
Major upgrade to the browse tool's UX, plus a few shared modules other tools can adopt. User-facing: - Right-click context menu on tree rows AND empty pane space. Traditional file-manager grouping (Open / Download / New / Rename-Delete / Copy / Tree ops / View). Items stay visible but disabled when not applicable so muscle memory carries. Generic shared/context-menu.js framework supports normal items, toggles, submenus, separators, danger styling. - YAML editor for .yaml / .yml / .zddc files (CodeMirror 5 vendored at shared/vendor/codemirror-yaml.min.*). js-yaml lint on every change for parse errors. For .zddc cascade files, an additional schema-aware lint pass flags unknown keys, bad enum values, and wrong types. - Per-row drag-drop upload using webkitGetAsEntry (folder uploads work recursively). Per-row drop indicator; doc-level overlay still fires for blank-space drops at drop_target scopes. - New folder / New markdown file context-menu items (server mode). Rename + Delete with native confirm() dialog. File-API helpers removeNode / renameNode use the existing PUT/POST/DELETE endpoints. - Hover info card with the row's full metadata (ZDDC fields + filesystem info + path/URL). Interactive — mouse into it, drag-select text, Ctrl/Cmd-C or right-click → Copy. 200ms grace before dismiss. - Autofilter input at the top of the tree pane. Same grammar as archive's column filters (zddc.filter.parse / matches). Filters files; folders without matches collapse out. Non-matching folders force-open visually when descendants match, without mutating the user's actual expand state. - Two-line ZDDC label: title-first, tracking/rev/status as monospace meta below. Icon column anchors to the title line. Chevron is a Lucide outline `chevron-right` SVG, rotated 90° on `.expanded`. - File-type Lucide icon sprite (shared/icons.js — 16 outline glyphs, ~5 KB). PDF / Word / Spreadsheet / Slides / Image / Video / Audio / CAD / Web / Config / Code / Archive get distinct icons; folders tinted with --primary. - Header wraps gracefully at narrow viewports (shared/base.css flex-wrap + title min-width:0 ellipsis). Body becomes flex column in browse so a wrapping header doesn't break #appMain height. - Markdown editor opens in WYSIWYG mode by default. YAML front-matter + TOC sidebar reworked: flexbox layout (single visible resizer between FM and TOC), both bodies overflow:auto for X+Y scrollbars. - `?file=<path>` deep links open browse pre-positioned at a specific file. Multi-segment paths walk into subdirectories on the way. Auto-flips Show hidden when a segment is dot/underscore-prefixed. - Refresh + show-hidden toggle preserve expansion / selection / preview pinning. Path-keyed snapshot survives a re-fetched listing. - "Add Local Directory" → "Use Local Directory" across the four tools that have it (browse, archive, classifier, +transmittal comment). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e5ba2b6168
commit
94b2e29448
20 changed files with 3301 additions and 270 deletions
|
|
@ -27,8 +27,12 @@ concat_files \
|
||||||
"../shared/nav.css" \
|
"../shared/nav.css" \
|
||||||
"../shared/logo.css" \
|
"../shared/logo.css" \
|
||||||
"../shared/vendor/toastui-editor.min.css" \
|
"../shared/vendor/toastui-editor.min.css" \
|
||||||
|
"../shared/vendor/codemirror-yaml.min.css" \
|
||||||
|
"../shared/context-menu.css" \
|
||||||
|
"../shared/elevation.css" \
|
||||||
"css/base.css" \
|
"css/base.css" \
|
||||||
"css/tree.css" \
|
"css/tree.css" \
|
||||||
|
"css/preview-yaml.css" \
|
||||||
> "$css_temp"
|
> "$css_temp"
|
||||||
|
|
||||||
# JS files: shared canonical helpers, then browse modules.
|
# JS files: shared canonical helpers, then browse modules.
|
||||||
|
|
@ -39,6 +43,8 @@ concat_files \
|
||||||
concat_files \
|
concat_files \
|
||||||
"../shared/vendor/jszip.min.js" \
|
"../shared/vendor/jszip.min.js" \
|
||||||
"../shared/vendor/utif.min.js" \
|
"../shared/vendor/utif.min.js" \
|
||||||
|
"../shared/vendor/js-yaml.min.js" \
|
||||||
|
"../shared/vendor/codemirror-yaml.min.js" \
|
||||||
"../shared/vendor/toastui-editor-all.min.js" \
|
"../shared/vendor/toastui-editor-all.min.js" \
|
||||||
"../shared/zddc.js" \
|
"../shared/zddc.js" \
|
||||||
"../shared/zddc-filter.js" \
|
"../shared/zddc-filter.js" \
|
||||||
|
|
@ -49,12 +55,17 @@ concat_files \
|
||||||
"../shared/logo.js" \
|
"../shared/logo.js" \
|
||||||
"../shared/help.js" \
|
"../shared/help.js" \
|
||||||
"../shared/preview-lib.js" \
|
"../shared/preview-lib.js" \
|
||||||
|
"../shared/context-menu.js" \
|
||||||
|
"../shared/elevation.js" \
|
||||||
|
"../shared/icons.js" \
|
||||||
"../shared/zddc-source.js" \
|
"../shared/zddc-source.js" \
|
||||||
"js/init.js" \
|
"js/init.js" \
|
||||||
"js/loader.js" \
|
"js/loader.js" \
|
||||||
"js/tree.js" \
|
"js/tree.js" \
|
||||||
"js/preview.js" \
|
"js/preview.js" \
|
||||||
"js/preview-markdown.js" \
|
"js/preview-markdown.js" \
|
||||||
|
"js/preview-yaml.js" \
|
||||||
|
"js/hovercard.js" \
|
||||||
"js/grid.js" \
|
"js/grid.js" \
|
||||||
"js/upload.js" \
|
"js/upload.js" \
|
||||||
"js/download.js" \
|
"js/download.js" \
|
||||||
|
|
|
||||||
110
browse/css/preview-yaml.css
Normal file
110
browse/css/preview-yaml.css
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
/* preview-yaml.css — YAML editor pane styling. Mirrors the
|
||||||
|
.md-shell info-header geometry; everything below is a CodeMirror 5
|
||||||
|
host with dark-mode overrides so the editor blends into the theme
|
||||||
|
instead of fighting it. */
|
||||||
|
|
||||||
|
.yaml-shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto 1fr;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.yaml-shell__editor {
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Schema-label badge — extends .md-shell__source so it sits next to
|
||||||
|
"local"/"server"/"read-only (zip)" with the same chip styling. The
|
||||||
|
primary-colored variant distinguishes ".zddc schema" from the
|
||||||
|
plain "YAML" label. */
|
||||||
|
.yaml-shell__schema {
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
.yaml-shell__schema:not(:empty) {
|
||||||
|
border-color: var(--primary);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CodeMirror has to fill the grid cell. The vendored CSS sets
|
||||||
|
`height: 300px` by default — we override to 100% so it grows with
|
||||||
|
the preview pane. */
|
||||||
|
.yaml-shell__editor .CodeMirror {
|
||||||
|
height: 100%;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.yaml-shell__editor .CodeMirror-gutters {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.yaml-shell__editor .CodeMirror-linenumber {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.yaml-shell__editor .CodeMirror-cursor {
|
||||||
|
border-left-color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.yaml-shell__editor .CodeMirror-selected {
|
||||||
|
background: var(--bg-selected);
|
||||||
|
}
|
||||||
|
|
||||||
|
.yaml-shell__editor .CodeMirror-focused .CodeMirror-selected {
|
||||||
|
background: var(--primary-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* YAML token tints. CM5 emits semantic class names from the yaml
|
||||||
|
mode; map them onto our palette so themes flip with the OS / data
|
||||||
|
attribute. */
|
||||||
|
.yaml-shell__editor .cm-keyword,
|
||||||
|
.yaml-shell__editor .cm-atom { color: var(--primary); font-weight: 600; }
|
||||||
|
.yaml-shell__editor .cm-string { color: #2e8b57; }
|
||||||
|
.yaml-shell__editor .cm-comment { color: var(--text-muted); font-style: italic; }
|
||||||
|
.yaml-shell__editor .cm-number { color: #b06000; }
|
||||||
|
.yaml-shell__editor .cm-meta { color: #6f42c1; }
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
html:not([data-theme="light"]) .yaml-shell__editor .cm-string { color: #98c379; }
|
||||||
|
html:not([data-theme="light"]) .yaml-shell__editor .cm-number { color: #e5c07b; }
|
||||||
|
html:not([data-theme="light"]) .yaml-shell__editor .cm-meta { color: #c678dd; }
|
||||||
|
}
|
||||||
|
[data-theme="dark"] .yaml-shell__editor .cm-string { color: #98c379; }
|
||||||
|
[data-theme="dark"] .yaml-shell__editor .cm-number { color: #e5c07b; }
|
||||||
|
[data-theme="dark"] .yaml-shell__editor .cm-meta { color: #c678dd; }
|
||||||
|
|
||||||
|
/* Lint markers: keep CM's defaults for the gutter dots but make the
|
||||||
|
inline underline play nicely with our background. Errors stay red,
|
||||||
|
warnings amber. */
|
||||||
|
.yaml-shell__editor .CodeMirror-lint-mark-error {
|
||||||
|
background-image: none;
|
||||||
|
border-bottom: 2px wavy var(--danger);
|
||||||
|
}
|
||||||
|
.yaml-shell__editor .CodeMirror-lint-mark-warning {
|
||||||
|
background-image: none;
|
||||||
|
border-bottom: 2px wavy var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tooltip popping out of a lint marker — uses the shared menu shadow
|
||||||
|
so it doesn't look like a separate component. */
|
||||||
|
.CodeMirror-lint-tooltip {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18),
|
||||||
|
0 2px 6px rgba(0, 0, 0, 0.10);
|
||||||
|
font-family: var(--font);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
padding: 0.3rem 0.55rem;
|
||||||
|
max-width: 32rem;
|
||||||
|
}
|
||||||
|
|
@ -4,15 +4,33 @@ html, body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
|
||||||
font-family: var(--font);
|
font-family: var(--font);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
background-color: var(--bg);
|
background-color: var(--bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Body is a flex column so the header (which may wrap to a second
|
||||||
|
row at narrow viewports), #appMain, and the status bar each get
|
||||||
|
their natural height — no more fixed-pixel calc() that breaks
|
||||||
|
when the header reflows. Horizontal overflow scrolls on the body
|
||||||
|
as a final fallback when content can't shrink any further. */
|
||||||
|
body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
/* Hard floor for the body. Below this, the html-level scrollbar
|
||||||
|
picks up and the user can pan horizontally rather than seeing
|
||||||
|
the right edge clipped. */
|
||||||
|
min-width: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
#appMain {
|
#appMain {
|
||||||
position: relative;
|
position: relative;
|
||||||
height: calc(100vh - 2.65rem); /* clear .app-header */
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
height: auto; /* override the old calc(100vh - 2.65rem) */
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
@ -109,12 +127,6 @@ html, body {
|
||||||
vertical-align: -0.15em;
|
vertical-align: -0.15em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar__count {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Two-pane browse view ────────────────────────────────────────────────── */
|
/* ── Two-pane browse view ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.browse-view {
|
.browse-view {
|
||||||
|
|
@ -139,6 +151,42 @@ html, body {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tree-pane__toolbar {
|
||||||
|
padding: 0.4rem 0.5rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Single-input autofilter — same grammar as the archive app's column
|
||||||
|
filters (terms, quotes, !negation, multi-word AND). type=search so
|
||||||
|
the browser ships the native clear-X for free; the .filter-active
|
||||||
|
class amber-highlights the input while a query is set, matching
|
||||||
|
the archive `.column-filter.filter-active` cue. */
|
||||||
|
.tree-filter {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0.3rem 0.5rem;
|
||||||
|
font-family: var(--font);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.12s, background 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-filter:focus {
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 2px var(--primary-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-filter.filter-active {
|
||||||
|
background: rgba(234, 179, 8, 0.18);
|
||||||
|
border-color: rgba(234, 179, 8, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
.tree-pane__body {
|
.tree-pane__body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
|
@ -250,9 +298,12 @@ html, body {
|
||||||
|
|
||||||
.tree-row {
|
.tree-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
/* Top-aligned so the chevron + icon anchor to the title line on
|
||||||
|
two-line ZDDC rows. Single-line rows are unaffected because the
|
||||||
|
icon, chevron, and label all share a top edge. */
|
||||||
|
align-items: flex-start;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
padding: 0.15rem 0.5rem;
|
padding: 0.2rem 0.5rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
|
|
@ -268,37 +319,76 @@ html, body {
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Per-row drop target highlight: applied while a file/folder drag is
|
||||||
|
hovering this row. The dashed outline reads as "drop here" without
|
||||||
|
shifting layout. */
|
||||||
|
.tree-row.is-droptarget {
|
||||||
|
background: var(--primary-light);
|
||||||
|
outline: 2px dashed var(--primary);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
.tree-row.is-selected .tree-name__label {
|
.tree-row.is-selected .tree-name__label {
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-name__chevron {
|
.tree-name__chevron {
|
||||||
display: inline-block;
|
/* Fixed-width slot so leaf rows (empty chevron) still align with
|
||||||
|
expandable rows. The SVG inside is sized via the rule below.
|
||||||
|
Top-anchored to the title-line baseline by the row's flex-start
|
||||||
|
alignment + this small top offset. */
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
width: 1rem;
|
width: 1rem;
|
||||||
text-align: center;
|
height: 1.2em;
|
||||||
color: var(--text-muted);
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
font-family: monospace;
|
color: var(--text-muted);
|
||||||
font-size: 0.65rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-row[data-isdir="true"] .tree-name__chevron::before,
|
.tree-name__chevron svg {
|
||||||
.tree-row[data-iszip="true"] .tree-name__chevron::before {
|
width: 0.85em;
|
||||||
content: "▸";
|
height: 0.85em;
|
||||||
|
transition: transform 0.12s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-row[data-isdir="true"].expanded .tree-name__chevron::before,
|
/* Expanded state — rotate the same chevron 90° rather than swapping
|
||||||
.tree-row[data-iszip="true"].expanded .tree-name__chevron::before {
|
to a second glyph. Smooth, single-sprite, and consistent with the
|
||||||
content: "▾";
|
way most modern file trees indicate expand state. */
|
||||||
}
|
.tree-row.expanded .tree-name__chevron svg {
|
||||||
|
transform: rotate(90deg);
|
||||||
.tree-name__chevron--leaf::before {
|
|
||||||
content: "";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-name__icon {
|
.tree-name__icon {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
font-size: 0.95rem;
|
/* Fixed-width column keeps label alignment consistent regardless
|
||||||
|
of which symbol the row picks. Height matches one line of label
|
||||||
|
text so the icon anchors to the title row on two-line layouts. */
|
||||||
|
width: 1.2em;
|
||||||
|
height: 1.2em;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-name__icon svg {
|
||||||
|
width: 1em;
|
||||||
|
height: 1em;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Folder rows get the primary accent so directories stand out from
|
||||||
|
files at a glance — same convention as macOS Finder / GNOME Files. */
|
||||||
|
.tree-row[data-isdir="true"] .tree-name__icon,
|
||||||
|
.tree-row[data-iszip="true"] .tree-name__icon {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Selected rows tint icon to match the label color (the bg-selected
|
||||||
|
token already differentiates the row background). */
|
||||||
|
.tree-row.is-selected .tree-name__icon {
|
||||||
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-name__label {
|
.tree-name__label {
|
||||||
|
|
@ -306,6 +396,48 @@ html, body {
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Two-line ZDDC variant. Top line is monospace + small + muted so the
|
||||||
|
trackingNumber / revision / status fields line up vertically across
|
||||||
|
adjacent rows (every field has a fixed width by convention). Bottom
|
||||||
|
line is the human-readable title at normal weight. */
|
||||||
|
.tree-name__label--zddc {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
line-height: 1.15;
|
||||||
|
/* Tight gap between meta and title; tweak by 1-2 px if the rows
|
||||||
|
feel crowded on dense lists. */
|
||||||
|
gap: 0.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-name__meta {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
/* Explicit weight: the folder-row rule below bolds .tree-name__label,
|
||||||
|
which would otherwise inherit through to the meta span. We want
|
||||||
|
the meta to stay light + muted on every row. */
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--text-muted);
|
||||||
|
/* Belt-and-braces: monospace already gives column-alignment, but
|
||||||
|
tabular-nums hardens it on the rare proportional fallback. */
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-name__title {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-row.is-selected .tree-name__title {
|
||||||
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-row[data-isdir="true"] .tree-name__label,
|
.tree-row[data-isdir="true"] .tree-name__label,
|
||||||
|
|
@ -427,12 +559,15 @@ html, body {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Sidebar (col 1): two stacked sections — Front matter (top, fixed
|
/* Sidebar (col 1): three stacked items — Front matter (fixed height,
|
||||||
default 180 px, drag-resizable) and TOC (bottom, takes the rest). */
|
drag-resizable), the horizontal resizer (between FM and TOC), then
|
||||||
|
the TOC section taking the remaining height. Flexbox keeps the
|
||||||
|
resizer position unambiguous; the previous grid-overlay approach
|
||||||
|
was hard to read and prone to misplacement. */
|
||||||
.md-shell__sidebar {
|
.md-shell__sidebar {
|
||||||
grid-area: sidebar;
|
grid-area: sidebar;
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-rows: 180px 1fr; /* JS overrides on resize */
|
flex-direction: column;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-right: 1px solid var(--border);
|
border-right: 1px solid var(--border);
|
||||||
|
|
@ -460,20 +595,17 @@ html, body {
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Horizontal resizer between front-matter and TOC inside the sidebar.
|
/* Horizontal resizer — a real flex item between FM and TOC. Drag
|
||||||
Spans both rows by placement, then absolutely positioned to overlay
|
it up/down to change the front-matter pane's height; the JS
|
||||||
the grid-row boundary. */
|
handler updates fmSection.style.height directly. */
|
||||||
.md-shell__fmresizer {
|
.md-shell__fmresizer {
|
||||||
grid-column: 1;
|
flex: 0 0 6px;
|
||||||
grid-row: 1;
|
|
||||||
align-self: end;
|
|
||||||
justify-self: stretch;
|
|
||||||
height: 6px;
|
height: 6px;
|
||||||
margin-bottom: -3px;
|
|
||||||
cursor: row-resize;
|
cursor: row-resize;
|
||||||
background: transparent;
|
background: var(--border);
|
||||||
z-index: 2;
|
|
||||||
transition: background 0.12s;
|
transition: background 0.12s;
|
||||||
|
/* Subtle "grab" affordance — a slightly darker bar appears on
|
||||||
|
hover so users see this is the drag handle. */
|
||||||
}
|
}
|
||||||
.md-shell__fmresizer:hover,
|
.md-shell__fmresizer:hover,
|
||||||
.md-shell__fmresizer.is-dragging,
|
.md-shell__fmresizer.is-dragging,
|
||||||
|
|
@ -558,15 +690,30 @@ html, body {
|
||||||
}
|
}
|
||||||
|
|
||||||
.md-side {
|
.md-side {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-rows: auto 1fr;
|
flex-direction: column;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.md-side--toc {
|
|
||||||
border-top: 1px solid var(--border);
|
/* Front-matter section: fixed (resizable) height, set inline by the
|
||||||
|
markdown plugin's mount + drag-handler. flex:0 0 auto so the
|
||||||
|
explicit height wins over the parent flex layout. */
|
||||||
|
.md-side--fm {
|
||||||
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* TOC section: takes everything that's left. min-height:0 so the
|
||||||
|
inner body's overflow:auto kicks in instead of pushing the
|
||||||
|
resizer off-screen. */
|
||||||
|
.md-side--toc {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.md-side__header {
|
.md-side__header {
|
||||||
|
/* Header is its own flex item so the body can stretch to fill. */
|
||||||
|
flex: 0 0 auto;
|
||||||
padding: 0.35rem 0.75rem;
|
padding: 0.35rem 0.75rem;
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
|
|
@ -576,8 +723,13 @@ html, body {
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.md-side__body {
|
.md-side__body {
|
||||||
overflow-y: auto;
|
/* Both axes — the textarea uses white-space:pre so long YAML
|
||||||
|
lines need horizontal scroll, and the TOC entries below now
|
||||||
|
extend their full width so deep headings need it too. */
|
||||||
|
flex: 1 1 auto;
|
||||||
|
overflow: auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
padding: 0.3rem 0;
|
padding: 0.3rem 0;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
|
|
@ -604,10 +756,11 @@ html, body {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-left: 2px solid transparent;
|
border-left: 2px solid transparent;
|
||||||
transition: background 0.1s, border-color 0.1s, color 0.1s;
|
transition: background 0.1s, border-color 0.1s, color 0.1s;
|
||||||
/* Truncate long headings rather than wrap; the title attribute
|
/* Single-line items but no ellipsis — long headings extend the
|
||||||
carries the full text. */
|
item's intrinsic width, and the parent .md-side__body has
|
||||||
overflow: hidden;
|
overflow:auto, so they create a horizontal scrollbar instead
|
||||||
text-overflow: ellipsis;
|
of getting clipped. The title attribute still carries the
|
||||||
|
full text for SR users. */
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.md-toc__item:hover {
|
.md-toc__item:hover {
|
||||||
|
|
@ -670,44 +823,105 @@ html, body {
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Sort control ────────────────────────────────────────────────────────── */
|
|
||||||
.sort-control {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.35rem;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sort-control__label {
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sort-control__select {
|
|
||||||
font-family: var(--font);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
padding: 0.2rem 0.4rem;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--text);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sort-control__select:focus {
|
|
||||||
outline: 2px solid var(--primary);
|
|
||||||
outline-offset: -1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sort-control__checkbox {
|
|
||||||
/* Pair with the "Show hidden" label as a unified control. The
|
|
||||||
parent .sort-control already does horizontal flex + gap, so the
|
|
||||||
checkbox just needs sensible vertical alignment + a clickable
|
|
||||||
hit target. */
|
|
||||||
margin: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Older .md-fm-section / .fm-list / .md-toc-resizer rules were replaced
|
/* Older .md-fm-section / .fm-list / .md-toc-resizer rules were replaced
|
||||||
by the .md-shell BEM block above. */
|
by the .md-shell BEM block above. */
|
||||||
|
|
||||||
|
/* ── Hover info card ────────────────────────────────────────────────────── */
|
||||||
|
/* Singleton element appended to <body> by browse/js/hovercard.js.
|
||||||
|
Replaces the native title="…" tooltip on tree rows with a rich
|
||||||
|
metadata view (ZDDC parse fields + filesystem info). */
|
||||||
|
.tree-hovercard {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 9000;
|
||||||
|
max-width: 28rem;
|
||||||
|
min-width: 17rem;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18),
|
||||||
|
0 2px 6px rgba(0, 0, 0, 0.10);
|
||||||
|
padding: 0.5rem 0.7rem 0.45rem;
|
||||||
|
font-family: var(--font);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
/* pointer-events:auto so the user can mouse into the card to
|
||||||
|
select text. The hide is delayed (HIDE_DELAY_MS in hovercard.js)
|
||||||
|
so the cursor has time to traverse the gap between row and card
|
||||||
|
before the card dismisses. */
|
||||||
|
pointer-events: auto;
|
||||||
|
/* The tree rows set user-select:none — explicitly allow it here
|
||||||
|
so dragging across the card builds a real selection that can be
|
||||||
|
Ctrl/Cmd-C'd or right-click-Copied via the browser's native menu. */
|
||||||
|
user-select: text;
|
||||||
|
cursor: default;
|
||||||
|
transition: opacity 0.1s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-hovercard.is-visible {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Highlight selected text inside the card with the primary accent so
|
||||||
|
it reads as "yes, you can copy this" rather than the default browser
|
||||||
|
selection color. */
|
||||||
|
.tree-hovercard ::selection {
|
||||||
|
background: var(--primary-light);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-hovercard__header {
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-hovercard__title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: var(--text);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-hovercard__sub {
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-hovercard__list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: max-content 1fr;
|
||||||
|
gap: 0.12rem 0.7rem;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-hovercard__key {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.74rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-hovercard__val {
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-hovercard__val--mono {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Separator stretches across both grid columns. Bleed into the
|
||||||
|
card's padding so it visually reads as a divider, not a hairline. */
|
||||||
|
.tree-hovercard__sep {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
margin: 0.25rem -0.7rem;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,9 +19,76 @@
|
||||||
// Expose for events.js's client-side rescope on dblclick.
|
// Expose for events.js's client-side rescope on dblclick.
|
||||||
window.app.modules.augmentRoot = passThroughEntries;
|
window.app.modules.augmentRoot = passThroughEntries;
|
||||||
|
|
||||||
|
// Walk a `?file=` path segment-by-segment from the current root.
|
||||||
|
// Each non-leaf segment is matched against the parent's children
|
||||||
|
// by name; if found and it's a folder, expand+load it (so its
|
||||||
|
// children populate state.nodes) and recurse into them. The leaf
|
||||||
|
// segment becomes the selected/previewed entry. Silently no-ops
|
||||||
|
// when any segment doesn't resolve — deep links aren't a hard
|
||||||
|
// contract, just an affordance.
|
||||||
|
async function openDeepLink(path) {
|
||||||
|
var segs = path.split('/').filter(Boolean);
|
||||||
|
if (segs.length === 0) return;
|
||||||
|
var tree = window.app.modules.tree;
|
||||||
|
var prev = window.app.modules.preview;
|
||||||
|
|
||||||
|
// Lookup helper: find a node by name within a given parent's
|
||||||
|
// immediate children. Top-level walk uses state.rootIds.
|
||||||
|
function findChild(parentIds, name) {
|
||||||
|
for (var i = 0; i < parentIds.length; i++) {
|
||||||
|
var n = window.app.state.nodes.get(parentIds[i]);
|
||||||
|
if (n && n.name === name) return n;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var ids = window.app.state.rootIds;
|
||||||
|
for (var i = 0; i < segs.length; i++) {
|
||||||
|
var node = findChild(ids, segs[i]);
|
||||||
|
if (!node) return; // segment not present in this listing
|
||||||
|
if (i === segs.length - 1) {
|
||||||
|
// Leaf — select + preview.
|
||||||
|
window.app.state.selectedId = node.id;
|
||||||
|
window.app.state.lastPreviewedNodeId = node.id;
|
||||||
|
tree.render();
|
||||||
|
if (prev && !node.isDir) prev.showFilePreview(node);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Intermediate — must be a folder we can expand into.
|
||||||
|
if (!(node.isDir || node.isZip)) return;
|
||||||
|
if (!node.loaded) {
|
||||||
|
await tree.toggleFolder(node.id); // loads + sets expanded
|
||||||
|
} else if (!node.expanded) {
|
||||||
|
node.expanded = true;
|
||||||
|
}
|
||||||
|
ids = node.childIds;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
events.init();
|
events.init();
|
||||||
|
|
||||||
|
// Honor ?file=<path> deep links: external clients (the profile
|
||||||
|
// page's "edit your .zddc files" list, future bookmarks, etc.)
|
||||||
|
// can link directly to "open browse at <dir>, with this entry
|
||||||
|
// selected and previewed". Single-segment names (?file=foo.md)
|
||||||
|
// match in the current directory; multi-segment paths
|
||||||
|
// (?file=a/b/foo.md) walk into a/ then b/ then open foo.md,
|
||||||
|
// loading intermediate directories on the way.
|
||||||
|
//
|
||||||
|
// When the LEAF (or any intermediate segment) is hidden
|
||||||
|
// (.zddc, .form.yaml, …), flip showHidden ON BEFORE the
|
||||||
|
// initial listing fetch so dotfiles appear in the tree.
|
||||||
|
var qs = new URLSearchParams(location.search);
|
||||||
|
var deepFile = qs.get('file');
|
||||||
|
if (deepFile) {
|
||||||
|
var segs = deepFile.split('/').filter(Boolean);
|
||||||
|
for (var si = 0; si < segs.length; si++) {
|
||||||
|
var c = segs[si].charAt(0);
|
||||||
|
if (c === '.' || c === '_') { state.showHidden = true; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Try server auto-detect. If this page is served by zddc-server
|
// Try server auto-detect. If this page is served by zddc-server
|
||||||
// (or any server with a Caddy-shaped JSON listing), load the
|
// (or any server with a Caddy-shaped JSON listing), load the
|
||||||
// current directory automatically. Otherwise show the empty
|
// current directory automatically. Otherwise show the empty
|
||||||
|
|
@ -40,6 +107,14 @@
|
||||||
// response, re-resolve so an /incoming URL auto-activates
|
// response, re-resolve so an /incoming URL auto-activates
|
||||||
// grid mode.
|
// grid mode.
|
||||||
if (events.applyResolvedViewMode) events.applyResolvedViewMode();
|
if (events.applyResolvedViewMode) events.applyResolvedViewMode();
|
||||||
|
|
||||||
|
// Final step of the deep link: walk the path segment by
|
||||||
|
// segment, expanding + loading intermediate directories
|
||||||
|
// before opening the leaf. Single-segment names use the
|
||||||
|
// same code path with one iteration.
|
||||||
|
if (deepFile) {
|
||||||
|
await openDeepLink(deepFile);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Else: empty state stays visible; user can click Select Directory.
|
// Else: empty state stays visible; user can click Select Directory.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,16 @@
|
||||||
// download.js — "Download (zip)" for the currently-viewed directory.
|
// download.js — per-node downloads, surfaced through the tree's
|
||||||
|
// right-click menu (downloadFile / downloadFolder).
|
||||||
//
|
//
|
||||||
// Server mode: just point an <a download> at "<currentPath>?zip=1" —
|
// downloadFile: a single file. Server mode lets the browser pull
|
||||||
// zddc-server streams an ACL-filtered .zip of the subtree, so nothing
|
// node.url (zddc-server emits Content-Disposition); FS-API mode
|
||||||
// is held in the browser.
|
// reads bytes through the file handle and blob-downloads.
|
||||||
//
|
//
|
||||||
// FS-API (offline) mode: there's no server, so we walk the picked
|
// downloadFolder: an arbitrary directory node as a .zip. Server
|
||||||
// folder ourselves, bundle every file with JSZip, and download the
|
// mode points an <a download> at "<node-path>/?zip=1" so zddc-server
|
||||||
// blob. A two-pass walk (metadata first, then bytes) lets us warn
|
// streams an ACL-filtered archive without buffering on the client.
|
||||||
// before loading a very large tree into memory.
|
// FS-API mode walks the picked handle in two passes — metadata
|
||||||
|
// first, then bytes — so we can warn before loading a very large
|
||||||
|
// tree into memory.
|
||||||
(function () {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
|
@ -103,39 +106,75 @@
|
||||||
ev.statusInfo('Downloaded ' + rootHandle.name + '.zip (' + files.length + ' files)');
|
ev.statusInfo('Downloaded ' + rootHandle.name + '.zip (' + files.length + ' files)');
|
||||||
}
|
}
|
||||||
|
|
||||||
function downloadServerSubtree() {
|
|
||||||
var dir = (state.currentPath || '/').replace(/\/$/, '');
|
|
||||||
var name = (dir.split('/').filter(Boolean).pop()) || 'download';
|
|
||||||
events().statusInfo('Preparing ' + name + '.zip…');
|
|
||||||
downloadUrl(name + '.zip', dir + '/?zip=1');
|
|
||||||
// The browser owns the download from here; clear the hint shortly.
|
|
||||||
setTimeout(function () { events().statusClear(); }, 2500);
|
|
||||||
}
|
|
||||||
|
|
||||||
var busy = false;
|
var busy = false;
|
||||||
|
|
||||||
async function downloadCurrentSubtree() {
|
// Download a single file node. Server mode: rely on the node's
|
||||||
|
// own URL (the server emits Content-Disposition). FS mode: read
|
||||||
|
// bytes through the handle and trigger a blob download. Works
|
||||||
|
// for ordinary files, for .zip members (the loader sets node.url
|
||||||
|
// for zip members in server mode and a ZipFileHandle offline),
|
||||||
|
// and for the .zip file itself.
|
||||||
|
async function downloadFile(node) {
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
var btn = document.getElementById('downloadZipBtn');
|
if (!node || node.isDir) {
|
||||||
|
events().statusError('Not a file: ' + (node && node.name));
|
||||||
|
return;
|
||||||
|
}
|
||||||
busy = true;
|
busy = true;
|
||||||
if (btn) btn.disabled = true;
|
|
||||||
try {
|
try {
|
||||||
if (state.source === 'server') {
|
if (node.url) {
|
||||||
downloadServerSubtree();
|
events().statusInfo('Downloading ' + node.name + '…');
|
||||||
} else if (state.source === 'fs' && state.rootHandle) {
|
downloadUrl(node.name, node.url);
|
||||||
await downloadFsSubtree(state.rootHandle);
|
setTimeout(function () { events().statusClear(); }, 2500);
|
||||||
|
} else if (node.handle && typeof node.handle.getFile === 'function') {
|
||||||
|
events().statusInfo('Preparing ' + node.name + '…');
|
||||||
|
var f = await node.handle.getFile();
|
||||||
|
var blob = new Blob([await f.arrayBuffer()]);
|
||||||
|
downloadBlob(node.name, blob);
|
||||||
|
events().statusInfo('Downloaded ' + node.name);
|
||||||
} else {
|
} else {
|
||||||
events().statusError('Nothing to download — open a directory first.');
|
events().statusError('No download path for ' + node.name);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
events().statusError('Download failed: ' + (e && e.message ? e.message : e));
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download an arbitrary folder node as a .zip — same dispatch as
|
||||||
|
// downloadCurrentSubtree but scoped to the picked node instead of
|
||||||
|
// state.currentPath / state.rootHandle. Server mode hits
|
||||||
|
// "<node-path>/?zip=1"; FS mode walks the directory handle.
|
||||||
|
async function downloadFolder(node) {
|
||||||
|
if (busy) return;
|
||||||
|
if (!node || !node.isDir) {
|
||||||
|
events().statusError('Not a folder: ' + (node && node.name));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
if (state.source === 'server') {
|
||||||
|
var tree = window.app.modules.tree;
|
||||||
|
var dir = tree.pathFor(node).replace(/\/$/, '');
|
||||||
|
events().statusInfo('Preparing ' + node.name + '.zip…');
|
||||||
|
downloadUrl(node.name + '.zip', dir + '/?zip=1');
|
||||||
|
setTimeout(function () { events().statusClear(); }, 2500);
|
||||||
|
} else if (state.source === 'fs' && node.handle
|
||||||
|
&& node.handle.kind === 'directory') {
|
||||||
|
await downloadFsSubtree(node.handle);
|
||||||
|
} else {
|
||||||
|
events().statusError('Cannot download ' + node.name);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
events().statusError('Download failed: ' + (e && e.message ? e.message : e));
|
events().statusError('Download failed: ' + (e && e.message ? e.message : e));
|
||||||
} finally {
|
} finally {
|
||||||
busy = false;
|
busy = false;
|
||||||
if (btn) btn.disabled = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.app.modules.download = {
|
window.app.modules.download = {
|
||||||
downloadCurrentSubtree: downloadCurrentSubtree
|
downloadFile: downloadFile,
|
||||||
|
downloadFolder: downloadFolder
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,6 @@
|
||||||
function applySourceUI() {
|
function applySourceUI() {
|
||||||
var add = document.getElementById('addDirectoryBtn');
|
var add = document.getElementById('addDirectoryBtn');
|
||||||
var refresh = document.getElementById('refreshHeaderBtn');
|
var refresh = document.getElementById('refreshHeaderBtn');
|
||||||
var dlZip = document.getElementById('downloadZipBtn');
|
|
||||||
if (add) {
|
if (add) {
|
||||||
if (state.source === 'server') {
|
if (state.source === 'server') {
|
||||||
add.classList.remove('btn-primary');
|
add.classList.remove('btn-primary');
|
||||||
|
|
@ -86,18 +85,16 @@
|
||||||
refresh.classList.add('hidden');
|
refresh.classList.add('hidden');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// "Download (zip)" is meaningful once a directory is loaded
|
|
||||||
// (server or local); it zips the directory currently in view.
|
|
||||||
if (dlZip) {
|
|
||||||
if (state.source) {
|
|
||||||
dlZip.classList.remove('hidden');
|
|
||||||
} else {
|
|
||||||
dlZip.classList.add('hidden');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshListing() {
|
async function refreshListing() {
|
||||||
|
// Snapshot expanded paths + selection BEFORE setRoot clears the
|
||||||
|
// tree, then re-apply after the new root is in place. Keeps
|
||||||
|
// the user's layout (which folders were open, which row was
|
||||||
|
// highlighted, what the preview was pinned to) stable across
|
||||||
|
// a refresh — including the auto-refresh triggered by the
|
||||||
|
// "Show hidden files" toggle.
|
||||||
|
var snap = tree.snapshotState();
|
||||||
if (state.source === 'server') {
|
if (state.source === 'server') {
|
||||||
var raw;
|
var raw;
|
||||||
try {
|
try {
|
||||||
|
|
@ -107,6 +104,7 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
tree.setRoot(raw);
|
tree.setRoot(raw);
|
||||||
|
await tree.restoreState(snap);
|
||||||
tree.render();
|
tree.render();
|
||||||
statusInfo('Refreshed (' + raw.length + ' item'
|
statusInfo('Refreshed (' + raw.length + ' item'
|
||||||
+ (raw.length === 1 ? '' : 's') + ')');
|
+ (raw.length === 1 ? '' : 's') + ')');
|
||||||
|
|
@ -119,6 +117,7 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
tree.setRoot(raw2);
|
tree.setRoot(raw2);
|
||||||
|
await tree.restoreState(snap);
|
||||||
tree.render();
|
tree.render();
|
||||||
statusInfo('Refreshed');
|
statusInfo('Refreshed');
|
||||||
}
|
}
|
||||||
|
|
@ -132,38 +131,31 @@
|
||||||
var refresh = document.getElementById('refreshHeaderBtn');
|
var refresh = document.getElementById('refreshHeaderBtn');
|
||||||
if (refresh) refresh.addEventListener('click', refreshListing);
|
if (refresh) refresh.addEventListener('click', refreshListing);
|
||||||
|
|
||||||
var dlZip = document.getElementById('downloadZipBtn');
|
// Tree autofilter — parses input through zddc.filter.parse so
|
||||||
if (dlZip) dlZip.addEventListener('click', function () {
|
// the same query grammar that the archive app uses (terms,
|
||||||
var d = window.app.modules.download;
|
// quotes, !negation, multi-word AND) works here. The AST is
|
||||||
if (d) d.downloadCurrentSubtree();
|
// cached on state.filterAST; tree.render reads it and skips
|
||||||
});
|
// non-matching rows. Escape clears.
|
||||||
|
var filterInput = document.getElementById('treeFilter');
|
||||||
// Sort dropdown — change → tree re-renders with the new sort.
|
if (filterInput) {
|
||||||
// Format of option value: "<key>:<asc|desc>". Defaults match
|
var filterDebounce = null;
|
||||||
// state.sort initial values (name:asc).
|
var applyFilter = function () {
|
||||||
var sortSel = document.getElementById('sortBy');
|
var raw = filterInput.value || '';
|
||||||
if (sortSel) {
|
state.filterText = raw;
|
||||||
sortSel.value = state.sort.key + ':' + (state.sort.dir > 0 ? 'asc' : 'desc');
|
state.filterAST = raw ? window.zddc.filter.parse(raw) : null;
|
||||||
sortSel.addEventListener('change', function () {
|
filterInput.classList.toggle('filter-active', !!raw);
|
||||||
var parts = sortSel.value.split(':');
|
tree.render();
|
||||||
var key = parts[0];
|
};
|
||||||
var dir = parts[1] === 'desc' ? -1 : 1;
|
filterInput.addEventListener('input', function () {
|
||||||
tree.setSortExplicit(key, dir);
|
if (filterDebounce) clearTimeout(filterDebounce);
|
||||||
|
filterDebounce = setTimeout(applyFilter, 80);
|
||||||
});
|
});
|
||||||
}
|
filterInput.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Escape' && filterInput.value) {
|
||||||
// "Show hidden" checkbox — toggles state.showHidden, which the
|
e.preventDefault();
|
||||||
// loader reads to append ?hidden=1 to listing requests. Re-uses
|
filterInput.value = '';
|
||||||
// the existing refreshListing flow so the tree pulls a fresh
|
applyFilter();
|
||||||
// listing. ACL is still server-side; this just relaxes the
|
}
|
||||||
// client-visible filter for entries the user is already
|
|
||||||
// allowed to read.
|
|
||||||
var hiddenCb = document.getElementById('showHidden');
|
|
||||||
if (hiddenCb) {
|
|
||||||
hiddenCb.checked = !!state.showHidden;
|
|
||||||
hiddenCb.addEventListener('change', function () {
|
|
||||||
state.showHidden = hiddenCb.checked;
|
|
||||||
refreshListing();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -314,9 +306,520 @@
|
||||||
}
|
}
|
||||||
navigateIntoFolder(node);
|
navigateIntoFolder(node);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Right-click → context menu. Two surfaces:
|
||||||
|
// - on a tree row: per-row menu (Open, Rename, Delete, …)
|
||||||
|
// - on empty space in the pane: directory-scope menu
|
||||||
|
// (New folder, Refresh, Sort by, …)
|
||||||
|
treeBody.addEventListener('contextmenu', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var row = e.target.closest('.tree-row');
|
||||||
|
if (row) {
|
||||||
|
var id = parseInt(row.dataset.id, 10);
|
||||||
|
var node = state.nodes.get(id);
|
||||||
|
if (!node) return;
|
||||||
|
state.selectedId = id;
|
||||||
|
tree.render();
|
||||||
|
window.zddc.menu.open({
|
||||||
|
x: e.clientX,
|
||||||
|
y: e.clientY,
|
||||||
|
context: { node: node, row: row },
|
||||||
|
items: buildTreeRowMenu
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
window.zddc.menu.open({
|
||||||
|
x: e.clientX,
|
||||||
|
y: e.clientY,
|
||||||
|
context: { dir: state.currentPath || '/' },
|
||||||
|
items: buildPaneMenu
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Per-row drag-drop. Any row is a drop target — folders
|
||||||
|
// upload into themselves; files upload into their parent
|
||||||
|
// folder. Highlighting is purely visual; server-side ACL
|
||||||
|
// is the source of truth (a 403 surfaces as an error toast).
|
||||||
|
wirePerRowDrop(treeBody);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Per-row drag/drop targets ─────────────────────────────────────────
|
||||||
|
|
||||||
|
// Translate a node into the directory that should receive uploads
|
||||||
|
// dropped onto its row. Folders → themselves; files → their parent.
|
||||||
|
// Returns a server path with a trailing slash, or null when there's
|
||||||
|
// no usable destination (offline mode, virtual node, etc.).
|
||||||
|
function targetDirForNode(node) {
|
||||||
|
if (!node || node.virtual) return null;
|
||||||
|
if (state.source !== 'server') return null;
|
||||||
|
if (node.isZip) return null; // can't upload INTO a zip via PUT
|
||||||
|
var dirNode = node;
|
||||||
|
if (!node.isDir) {
|
||||||
|
if (node.parentId == null) {
|
||||||
|
// Top-level file → upload to current scope.
|
||||||
|
return state.currentPath || '/';
|
||||||
|
}
|
||||||
|
dirNode = state.nodes.get(node.parentId);
|
||||||
|
if (!dirNode) return null;
|
||||||
|
}
|
||||||
|
var p = tree.pathFor(dirNode);
|
||||||
|
if (!p.endsWith('/')) p += '/';
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dragHasFiles(e) {
|
||||||
|
if (!e.dataTransfer || !e.dataTransfer.types) return false;
|
||||||
|
var types = e.dataTransfer.types;
|
||||||
|
for (var i = 0; i < types.length; i++) {
|
||||||
|
if (types[i] === 'Files') return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wirePerRowDrop(treeBody) {
|
||||||
|
var lastOver = null;
|
||||||
|
function clearHighlight() {
|
||||||
|
if (lastOver) {
|
||||||
|
lastOver.classList.remove('is-droptarget');
|
||||||
|
lastOver = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
treeBody.addEventListener('dragover', function (e) {
|
||||||
|
if (!dragHasFiles(e)) return;
|
||||||
|
var row = e.target.closest('.tree-row');
|
||||||
|
if (!row) { clearHighlight(); return; }
|
||||||
|
var id = parseInt(row.dataset.id, 10);
|
||||||
|
var node = state.nodes.get(id);
|
||||||
|
if (!node) return;
|
||||||
|
var dest = targetDirForNode(node);
|
||||||
|
if (!dest) {
|
||||||
|
if (e.dataTransfer) e.dataTransfer.dropEffect = 'none';
|
||||||
|
clearHighlight();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
e.preventDefault(); // signals "this is a drop target"
|
||||||
|
e.stopPropagation(); // suppress doc-level overlay
|
||||||
|
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
|
||||||
|
if (lastOver !== row) {
|
||||||
|
clearHighlight();
|
||||||
|
row.classList.add('is-droptarget');
|
||||||
|
lastOver = row;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
treeBody.addEventListener('dragleave', function (e) {
|
||||||
|
// dragleave fires on row crossings too — only clear when the
|
||||||
|
// pointer actually leaves the tree body.
|
||||||
|
if (!e.relatedTarget || !treeBody.contains(e.relatedTarget)) {
|
||||||
|
clearHighlight();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
treeBody.addEventListener('drop', async function (e) {
|
||||||
|
if (!dragHasFiles(e)) return;
|
||||||
|
var row = e.target.closest('.tree-row');
|
||||||
|
clearHighlight();
|
||||||
|
if (!row) return;
|
||||||
|
var id = parseInt(row.dataset.id, 10);
|
||||||
|
var node = state.nodes.get(id);
|
||||||
|
if (!node) return;
|
||||||
|
var dest = targetDirForNode(node);
|
||||||
|
if (!dest) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation(); // pre-empt doc-level handler
|
||||||
|
var up = window.app.modules.upload;
|
||||||
|
if (!up) return;
|
||||||
|
try {
|
||||||
|
await up.uploadToDir(dest, e.dataTransfer);
|
||||||
|
} catch (err) {
|
||||||
|
statusError('Upload failed: ' + (err.message || err));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Create new folder / file (server mode) ────────────────────────────
|
||||||
|
|
||||||
|
// Reject names with path separators, leading dots, or empty input —
|
||||||
|
// mirrors the server-side hidden-segment / no-traversal guards so
|
||||||
|
// the user sees the rejection without a round-trip.
|
||||||
|
function validateName(name) {
|
||||||
|
name = (name || '').trim();
|
||||||
|
if (!name) return { ok: false, msg: 'Name required.' };
|
||||||
|
if (name.indexOf('/') !== -1) return { ok: false, msg: 'No slashes allowed.' };
|
||||||
|
if (name === '.' || name === '..') return { ok: false, msg: 'Invalid name.' };
|
||||||
|
if (name.charAt(0) === '.' || name.charAt(0) === '_') {
|
||||||
|
return { ok: false, msg: 'Names beginning with "." or "_" are reserved.' };
|
||||||
|
}
|
||||||
|
return { ok: true, name: name };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve "the directory new items go into" for a given row.
|
||||||
|
// Folders/zips: create inside them. Files: create alongside (in
|
||||||
|
// their parent). Used by the row-context New menu items.
|
||||||
|
function parentDirFor(node) {
|
||||||
|
var parentDir;
|
||||||
|
if (!node) {
|
||||||
|
parentDir = state.currentPath || '/';
|
||||||
|
} else if (node.isDir || node.isZip) {
|
||||||
|
parentDir = tree.pathFor(node);
|
||||||
|
} else if (node.parentId != null) {
|
||||||
|
var parent = state.nodes.get(node.parentId);
|
||||||
|
parentDir = parent ? tree.pathFor(parent) : (state.currentPath || '/');
|
||||||
|
} else {
|
||||||
|
parentDir = state.currentPath || '/';
|
||||||
|
}
|
||||||
|
if (!parentDir.endsWith('/')) parentDir += '/';
|
||||||
|
return parentDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createInDir(parentDir, kind) {
|
||||||
|
var up = window.app.modules.upload;
|
||||||
|
if (!up) return;
|
||||||
|
var promptMsg = kind === 'folder'
|
||||||
|
? 'New folder name (under ' + parentDir + '):'
|
||||||
|
: 'New markdown filename (under ' + parentDir + '):';
|
||||||
|
var defaultName = kind === 'folder' ? 'new-folder' : 'new.md';
|
||||||
|
var raw = window.prompt(promptMsg, defaultName);
|
||||||
|
if (raw == null) return;
|
||||||
|
var v = validateName(raw);
|
||||||
|
if (!v.ok) {
|
||||||
|
statusError(v.msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (kind === 'folder') {
|
||||||
|
await up.makeDir(parentDir, v.name);
|
||||||
|
statusInfo('Created folder ' + v.name);
|
||||||
|
} else {
|
||||||
|
var name = /\.(md|markdown)$/i.test(v.name) ? v.name : v.name + '.md';
|
||||||
|
var template = '# ' + name.replace(/\.(md|markdown)$/i, '') + '\n\n';
|
||||||
|
await up.makeFile(parentDir, name, template, 'text/markdown; charset=utf-8');
|
||||||
|
statusInfo('Created ' + name);
|
||||||
|
}
|
||||||
|
await reloadDir(parentDir);
|
||||||
|
} catch (e) {
|
||||||
|
statusError('Create failed: ' + (e.message || e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createInside(node, kind) { return createInDir(parentDirFor(node), kind); }
|
||||||
|
|
||||||
|
// Reload a directory's children in the tree so a create/delete/
|
||||||
|
// rename is reflected. Works for both the current scope (root)
|
||||||
|
// and any expanded subdirectory.
|
||||||
|
async function reloadDir(dirPath) {
|
||||||
|
var loader = window.app.modules.loader;
|
||||||
|
if (!loader) return;
|
||||||
|
if (!dirPath.endsWith('/')) dirPath += '/';
|
||||||
|
// Root-scope reload — refresh the visible top-level listing.
|
||||||
|
if (dirPath === state.currentPath) {
|
||||||
|
try {
|
||||||
|
var es = state.source === 'server'
|
||||||
|
? await loader.fetchServerChildren(dirPath)
|
||||||
|
: (state.rootHandle ? await loader.fetchFsChildren(state.rootHandle) : []);
|
||||||
|
tree.setRoot(es);
|
||||||
|
} catch (_e) { /* swallow */ }
|
||||||
|
tree.render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Otherwise find the node whose path matches and reload it.
|
||||||
|
var noSlash = dirPath.replace(/\/$/, '');
|
||||||
|
var hit = null;
|
||||||
|
state.nodes.forEach(function (n) {
|
||||||
|
if (hit || !n.isDir) return;
|
||||||
|
if (tree.pathFor(n).replace(/\/$/, '') === noSlash) hit = n;
|
||||||
|
});
|
||||||
|
if (hit) {
|
||||||
|
try {
|
||||||
|
var raw = state.source === 'server'
|
||||||
|
? await loader.fetchServerChildren(dirPath)
|
||||||
|
: (hit.handle ? await loader.fetchFsChildren(hit.handle) : []);
|
||||||
|
tree.setChildren(hit.id, raw);
|
||||||
|
hit.expanded = true;
|
||||||
|
} catch (_e) { /* swallow */ }
|
||||||
|
tree.render();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rename / Delete ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function renameNode(node) {
|
||||||
|
var up = window.app.modules.upload;
|
||||||
|
if (!up || !up.canMutate(node)) return;
|
||||||
|
var raw = window.prompt('Rename "' + node.name + '" to:', node.name);
|
||||||
|
if (raw == null) return;
|
||||||
|
var v = validateName(raw);
|
||||||
|
if (!v.ok) { statusError(v.msg); return; }
|
||||||
|
if (v.name === node.name) return;
|
||||||
|
try {
|
||||||
|
await up.renameNode(node, v.name);
|
||||||
|
statusInfo('Renamed to ' + v.name);
|
||||||
|
var parentPath = node.parentId != null
|
||||||
|
? tree.pathFor(state.nodes.get(node.parentId))
|
||||||
|
: (state.currentPath || '/');
|
||||||
|
await reloadDir(parentPath);
|
||||||
|
} catch (e) {
|
||||||
|
statusError('Rename failed: ' + (e.message || e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteNode(node) {
|
||||||
|
var up = window.app.modules.upload;
|
||||||
|
if (!up || !up.canMutate(node)) return;
|
||||||
|
var what = node.isDir ? 'folder' : 'file';
|
||||||
|
// Native confirm() is intentional — destructive actions
|
||||||
|
// benefit from the browser's blocking, OS-styled dialog
|
||||||
|
// (signals "this is serious"). A custom modal would look
|
||||||
|
// friendlier; we want it to NOT look friendly.
|
||||||
|
var msg = 'Permanently delete this ' + what + '?\n\n' + node.name;
|
||||||
|
if (node.isDir) {
|
||||||
|
msg += '\n\nThis will remove every file inside it.';
|
||||||
|
}
|
||||||
|
if (!window.confirm(msg)) return;
|
||||||
|
try {
|
||||||
|
await up.removeNode(node);
|
||||||
|
statusInfo('Deleted ' + node.name);
|
||||||
|
// Clear selection / preview when they pointed at the
|
||||||
|
// now-gone node, so the right pane doesn't keep a ghost.
|
||||||
|
if (state.selectedId === node.id) state.selectedId = null;
|
||||||
|
if (state.lastPreviewedNodeId === node.id) {
|
||||||
|
state.lastPreviewedNodeId = null;
|
||||||
|
var pb = document.getElementById('previewBody');
|
||||||
|
if (pb) pb.innerHTML =
|
||||||
|
'<div class="preview-empty">Click a file in the tree to preview it.</div>';
|
||||||
|
var pt = document.getElementById('previewTitle');
|
||||||
|
if (pt) pt.textContent = 'No file selected';
|
||||||
|
var pm = document.getElementById('previewMeta');
|
||||||
|
if (pm) pm.textContent = '';
|
||||||
|
}
|
||||||
|
var parentPath = node.parentId != null
|
||||||
|
? tree.pathFor(state.nodes.get(node.parentId))
|
||||||
|
: (state.currentPath || '/');
|
||||||
|
await reloadDir(parentPath);
|
||||||
|
} catch (e) {
|
||||||
|
statusError('Delete failed: ' + (e.message || e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared submenu (used by both the row menu and the pane menu).
|
||||||
|
// Toggle items so the active sort is checked in both surfaces.
|
||||||
|
var SORT_BY_ITEMS = [
|
||||||
|
{ label: 'Name',
|
||||||
|
checked: function () { return state.sort.key === 'name'; },
|
||||||
|
action: function () { tree.setSortExplicit('name', 1); } },
|
||||||
|
{ label: 'Modified',
|
||||||
|
checked: function () { return state.sort.key === 'date'; },
|
||||||
|
action: function () { tree.setSortExplicit('date', -1); } },
|
||||||
|
{ label: 'Size',
|
||||||
|
checked: function () { return state.sort.key === 'size'; },
|
||||||
|
action: function () { tree.setSortExplicit('size', -1); } },
|
||||||
|
{ label: 'Type',
|
||||||
|
checked: function () { return state.sort.key === 'ext'; },
|
||||||
|
action: function () { tree.setSortExplicit('ext', 1); } }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Row context menu — traditional file-manager layout:
|
||||||
|
// Open / Open in new tab / Pop out preview
|
||||||
|
// ─
|
||||||
|
// Download (label flips on type)
|
||||||
|
// ─
|
||||||
|
// New folder / New markdown file
|
||||||
|
// ─
|
||||||
|
// Rename / Delete (permission-gated, disabled
|
||||||
|
// when the row can't be mutated)
|
||||||
|
// ─
|
||||||
|
// Copy path / Copy name
|
||||||
|
// ─
|
||||||
|
// Expand / Collapse / Navigate into
|
||||||
|
// ─
|
||||||
|
// Sort by … / Show hidden files
|
||||||
|
//
|
||||||
|
// Items are kept VISIBLE but DISABLED when they don't apply, so
|
||||||
|
// every menu has the same shape regardless of what the user
|
||||||
|
// right-clicked. Predictable position = muscle memory.
|
||||||
|
function buildTreeRowMenu(ctx) {
|
||||||
|
var serverMode = state.source === 'server';
|
||||||
|
var canMutate = function (c) {
|
||||||
|
var up = window.app.modules.upload;
|
||||||
|
return !!(up && up.canMutate(c.node));
|
||||||
|
};
|
||||||
|
return [
|
||||||
|
// ── Open / preview cluster ──
|
||||||
|
{
|
||||||
|
label: function (c) {
|
||||||
|
if (c.node.isDir) return 'Open';
|
||||||
|
if (c.node.isZip) return 'Open archive';
|
||||||
|
return 'Preview';
|
||||||
|
},
|
||||||
|
disabled: function (c) { return !!c.node.virtual; },
|
||||||
|
action: function (c) {
|
||||||
|
if (c.node.isDir || c.node.isZip) {
|
||||||
|
tree.toggleFolder(c.node.id);
|
||||||
|
} else {
|
||||||
|
var p = previewMod();
|
||||||
|
if (p) p.showFilePreview(c.node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Open in new tab',
|
||||||
|
accel: 'Ctrl+Click',
|
||||||
|
disabled: function (c) { return !c.node.url; },
|
||||||
|
action: function (c) {
|
||||||
|
if (c.node.url) window.open(c.node.url, '_blank', 'noopener');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Pop out preview',
|
||||||
|
disabled: function (c) { return c.node.isDir || c.node.isZip; },
|
||||||
|
action: function (c) {
|
||||||
|
var p = previewMod();
|
||||||
|
if (p) p.showFilePreview(c.node, { popup: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ separator: true },
|
||||||
|
|
||||||
|
// ── Download (single item; label flips on type) ──
|
||||||
|
{
|
||||||
|
label: function (c) { return c.node.isDir ? 'Download ZIP' : 'Download'; },
|
||||||
|
icon: '⤓',
|
||||||
|
disabled: function (c) { return !!c.node.virtual; },
|
||||||
|
action: function (c) {
|
||||||
|
var d = window.app.modules.download;
|
||||||
|
if (!d) return;
|
||||||
|
if (c.node.isDir) d.downloadFolder(c.node);
|
||||||
|
else d.downloadFile(c.node);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ separator: true },
|
||||||
|
|
||||||
|
// ── Create new (in the row's parent folder) ──
|
||||||
|
{
|
||||||
|
label: 'New folder',
|
||||||
|
disabled: !serverMode,
|
||||||
|
action: function (c) { createInside(c.node, 'folder'); }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'New markdown file',
|
||||||
|
disabled: !serverMode,
|
||||||
|
action: function (c) { createInside(c.node, 'markdown'); }
|
||||||
|
},
|
||||||
|
{ separator: true },
|
||||||
|
|
||||||
|
// ── Rename + Delete (the permission-gated pair) ──
|
||||||
|
{
|
||||||
|
label: 'Rename…',
|
||||||
|
disabled: function (c) { return !canMutate(c); },
|
||||||
|
action: function (c) { renameNode(c.node); }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Delete…',
|
||||||
|
icon: '🗑',
|
||||||
|
danger: true,
|
||||||
|
disabled: function (c) { return !canMutate(c); },
|
||||||
|
action: function (c) { deleteNode(c.node); }
|
||||||
|
},
|
||||||
|
{ separator: true },
|
||||||
|
|
||||||
|
// ── Clipboard / identifiers ──
|
||||||
|
{
|
||||||
|
label: 'Copy path',
|
||||||
|
action: function (c) {
|
||||||
|
var path = tree.pathFor(c.node);
|
||||||
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
|
navigator.clipboard.writeText(path).then(
|
||||||
|
function () { statusInfo('Copied: ' + path); },
|
||||||
|
function () { statusError('Clipboard copy denied'); }
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
statusInfo(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Copy name',
|
||||||
|
action: function (c) {
|
||||||
|
// Always include the file extension. node.name
|
||||||
|
// already does for normal listings, but re-joining
|
||||||
|
// via zddc.joinExtension is defensive against any
|
||||||
|
// upstream that ever returns the basename split.
|
||||||
|
var n = c.node.name;
|
||||||
|
var ext = c.node.ext;
|
||||||
|
if (!c.node.isDir && ext
|
||||||
|
&& !n.toLowerCase().endsWith('.' + ext.toLowerCase())) {
|
||||||
|
n = window.zddc.joinExtension(n, ext);
|
||||||
|
}
|
||||||
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
|
navigator.clipboard.writeText(n);
|
||||||
|
}
|
||||||
|
statusInfo('Copied: ' + n);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ separator: true },
|
||||||
|
|
||||||
|
// ── Tree-view ops (folder/zip rows only) ──
|
||||||
|
{
|
||||||
|
label: 'Expand subtree',
|
||||||
|
accel: 'Shift+Click',
|
||||||
|
disabled: function (c) { return !(c.node.isDir || c.node.isZip); },
|
||||||
|
action: function (c) { tree.expandSubtree(c.node.id); }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Collapse subtree',
|
||||||
|
disabled: function (c) { return !(c.node.isDir || c.node.isZip); },
|
||||||
|
action: function (c) { tree.collapseSubtree(c.node.id); }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Navigate into',
|
||||||
|
accel: 'Dbl-click',
|
||||||
|
disabled: function (c) { return !c.node.isDir; },
|
||||||
|
action: function (c) { navigateIntoFolder(c.node); }
|
||||||
|
},
|
||||||
|
{ separator: true },
|
||||||
|
|
||||||
|
// ── View ──
|
||||||
|
{ label: 'Sort by', items: SORT_BY_ITEMS },
|
||||||
|
{ label: 'Show hidden files',
|
||||||
|
checked: function () { return !!state.showHidden; },
|
||||||
|
action: function () {
|
||||||
|
state.showHidden = !state.showHidden;
|
||||||
|
refreshListing();
|
||||||
|
} }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Right-click on empty space in the tree pane → directory-scope
|
||||||
|
// menu. Operations apply to the current scope (state.currentPath),
|
||||||
|
// not any specific row.
|
||||||
|
function buildPaneMenu() {
|
||||||
|
var serverMode = state.source === 'server';
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: 'New folder',
|
||||||
|
disabled: !serverMode,
|
||||||
|
action: function () { createInDir(state.currentPath || '/', 'folder'); }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'New markdown file',
|
||||||
|
disabled: !serverMode,
|
||||||
|
action: function () { createInDir(state.currentPath || '/', 'markdown'); }
|
||||||
|
},
|
||||||
|
{ separator: true },
|
||||||
|
{
|
||||||
|
label: 'Refresh',
|
||||||
|
accel: 'F5',
|
||||||
|
action: function () { refreshListing(); }
|
||||||
|
},
|
||||||
|
{ separator: true },
|
||||||
|
{ label: 'Sort by', items: SORT_BY_ITEMS },
|
||||||
|
{ label: 'Show hidden files',
|
||||||
|
checked: function () { return !!state.showHidden; },
|
||||||
|
action: function () {
|
||||||
|
state.showHidden = !state.showHidden;
|
||||||
|
refreshListing();
|
||||||
|
} }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
// View mode is URL-driven, not UI-driven.
|
// View mode is URL-driven, not UI-driven.
|
||||||
//
|
//
|
||||||
// ?view=grid → grid mode (only honored where classifier is
|
// ?view=grid → grid mode (only honored where classifier is
|
||||||
|
|
|
||||||
258
browse/js/hovercard.js
Normal file
258
browse/js/hovercard.js
Normal file
|
|
@ -0,0 +1,258 @@
|
||||||
|
// hovercard.js — rich-metadata tooltip for tree rows.
|
||||||
|
//
|
||||||
|
// Replaces the native title="…" attribute with a custom card that
|
||||||
|
// surfaces every field we know about for a row: parsed ZDDC fields
|
||||||
|
// (trackingNumber / revision / status / title / date), type, size,
|
||||||
|
// modTime, on-server path, and URL. A delayed reveal (~350 ms) keeps
|
||||||
|
// the card out of the way during fast traversal; it dismisses on
|
||||||
|
// any click, right-click, scroll, or row change.
|
||||||
|
//
|
||||||
|
// Singleton DOM element appended to <body>; positioned fixed.
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
if (!window.app || !window.app.modules) return;
|
||||||
|
|
||||||
|
var SHOW_DELAY_MS = 350;
|
||||||
|
// Grace period after the cursor leaves the row before the card
|
||||||
|
// hides. Lets the user move INTO the card to select / copy text;
|
||||||
|
// the card cancels this timer on mouseenter.
|
||||||
|
var HIDE_DELAY_MS = 200;
|
||||||
|
|
||||||
|
var state = window.app.state;
|
||||||
|
var card = null;
|
||||||
|
var showTimer = null;
|
||||||
|
var hideTimer = null;
|
||||||
|
var currentRow = null;
|
||||||
|
|
||||||
|
function ensureCard() {
|
||||||
|
if (card) return card;
|
||||||
|
card = document.createElement('div');
|
||||||
|
card.className = 'tree-hovercard';
|
||||||
|
card.setAttribute('aria-hidden', 'true');
|
||||||
|
// Mouse interaction inside the card: cancel any pending hide
|
||||||
|
// so the user can stay in it as long as they want, then re-
|
||||||
|
// schedule on leave. Pointer-events:auto in the CSS lets the
|
||||||
|
// mouse enter; user-select:text (default) lets them drag a
|
||||||
|
// selection; right-click inside fires the browser's native
|
||||||
|
// Copy menu since we never call preventDefault for it here.
|
||||||
|
card.addEventListener('mouseenter', cancelHide);
|
||||||
|
card.addEventListener('mouseleave', scheduleHide);
|
||||||
|
document.body.appendChild(card);
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelHide() {
|
||||||
|
if (hideTimer) { clearTimeout(hideTimer); hideTimer = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleHide() {
|
||||||
|
cancelHide();
|
||||||
|
hideTimer = setTimeout(hide, HIDE_DELAY_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hide() {
|
||||||
|
if (showTimer) { clearTimeout(showTimer); showTimer = null; }
|
||||||
|
cancelHide();
|
||||||
|
if (card) card.classList.remove('is-visible');
|
||||||
|
currentRow = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Formatting (kept local so this module is self-contained) ──
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s).replace(/&/g, '&').replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtSize(bytes) {
|
||||||
|
if (bytes == null) return '';
|
||||||
|
if (bytes < 1024) return bytes + ' B';
|
||||||
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||||
|
if (bytes < 1024 * 1024 * 1024) {
|
||||||
|
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||||
|
}
|
||||||
|
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(d) {
|
||||||
|
if (!d) return '';
|
||||||
|
var pad = function (n) { return n < 10 ? '0' + n : '' + n; };
|
||||||
|
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate())
|
||||||
|
+ ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes());
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeLabelFor(node) {
|
||||||
|
if (node.isDir) return 'Folder';
|
||||||
|
if (node.isZip) return 'Zip archive';
|
||||||
|
if (node.ext) return node.ext.toUpperCase() + ' file';
|
||||||
|
return 'File';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRowsHtml(node) {
|
||||||
|
var tree = window.app.modules.tree;
|
||||||
|
var z = window.zddc;
|
||||||
|
var parsed = null;
|
||||||
|
if (z) {
|
||||||
|
parsed = node.isDir
|
||||||
|
? z.parseFolder(node.name)
|
||||||
|
: z.parseFilename(node.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
var html = '';
|
||||||
|
|
||||||
|
// ZDDC fields first when the basename parses.
|
||||||
|
if (parsed && parsed.valid) {
|
||||||
|
if (parsed.date) html += kv('Date', parsed.date, true);
|
||||||
|
if (parsed.trackingNumber) html += kv('Tracking number', parsed.trackingNumber, true);
|
||||||
|
if (parsed.revision) html += kv('Revision', parsed.revision, true);
|
||||||
|
if (parsed.status) html += kv('Status', parsed.status, true);
|
||||||
|
if (parsed.title) html += kv('Title', parsed.title);
|
||||||
|
html += '<div class="tree-hovercard__sep"></div>';
|
||||||
|
} else if (node.displayName) {
|
||||||
|
// Operator-supplied display name — only useful as info if
|
||||||
|
// it differs from the on-disk name.
|
||||||
|
html += kv('Display name', node.displayName);
|
||||||
|
}
|
||||||
|
|
||||||
|
html += kv('Type', typeLabelFor(node));
|
||||||
|
if (!node.isDir) html += kv('Filename', node.name, true);
|
||||||
|
if (!node.isDir && node.size != null) html += kv('Size', fmtSize(node.size));
|
||||||
|
if (node.modTime) html += kv('Modified', fmtDate(node.modTime));
|
||||||
|
if (node.virtual) html += kv('Virtual', 'Not yet created on disk');
|
||||||
|
|
||||||
|
// Path comes last (longest, most likely to wrap).
|
||||||
|
var path = tree ? tree.pathFor(node) : '';
|
||||||
|
if (path) html += kv('Path', path, true);
|
||||||
|
if (node.url && node.url !== path) html += kv('URL', node.url, true);
|
||||||
|
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function kv(key, val, mono) {
|
||||||
|
return '<span class="tree-hovercard__key">' + escapeHtml(key) + '</span>'
|
||||||
|
+ '<span class="tree-hovercard__val'
|
||||||
|
+ (mono ? ' tree-hovercard__val--mono' : '')
|
||||||
|
+ '">' + escapeHtml(val) + '</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(node) {
|
||||||
|
var z = window.zddc;
|
||||||
|
var parsed = z
|
||||||
|
? (node.isDir ? z.parseFolder(node.name) : z.parseFilename(node.name))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
var primary, secondary = '';
|
||||||
|
if (parsed && parsed.valid) {
|
||||||
|
primary = parsed.title;
|
||||||
|
var parts = node.isDir
|
||||||
|
? [parsed.date, parsed.trackingNumber, parsed.status]
|
||||||
|
: [parsed.trackingNumber, parsed.revision, parsed.status];
|
||||||
|
secondary = parts.filter(Boolean).join(' · ');
|
||||||
|
} else if (node.displayName) {
|
||||||
|
primary = node.displayName;
|
||||||
|
} else {
|
||||||
|
primary = node.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
card.innerHTML = ''
|
||||||
|
+ '<div class="tree-hovercard__header">'
|
||||||
|
+ '<div class="tree-hovercard__title">' + escapeHtml(primary) + '</div>'
|
||||||
|
+ (secondary
|
||||||
|
? '<div class="tree-hovercard__sub">' + escapeHtml(secondary) + '</div>'
|
||||||
|
: '')
|
||||||
|
+ '</div>'
|
||||||
|
+ '<div class="tree-hovercard__list">' + buildRowsHtml(node) + '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function position(row) {
|
||||||
|
// Two-pass measure: temporarily make visible-but-invisible so
|
||||||
|
// we can read offsetWidth / offsetHeight, compute placement,
|
||||||
|
// then reveal at the final coordinates.
|
||||||
|
card.style.left = '0px';
|
||||||
|
card.style.top = '0px';
|
||||||
|
card.style.visibility = 'hidden';
|
||||||
|
card.classList.add('is-visible');
|
||||||
|
var cw = card.offsetWidth;
|
||||||
|
var ch = card.offsetHeight;
|
||||||
|
var rect = row.getBoundingClientRect();
|
||||||
|
var GAP = 8;
|
||||||
|
var x = rect.right + GAP;
|
||||||
|
if (x + cw > window.innerWidth - GAP) {
|
||||||
|
x = rect.left - cw - GAP;
|
||||||
|
}
|
||||||
|
if (x < GAP) {
|
||||||
|
// Fallback: anchor under the row (last resort when the
|
||||||
|
// pane is wide enough that neither side fits).
|
||||||
|
x = Math.max(GAP, Math.min(rect.left, window.innerWidth - cw - GAP));
|
||||||
|
}
|
||||||
|
var y = rect.top;
|
||||||
|
if (y + ch > window.innerHeight - GAP) {
|
||||||
|
y = Math.max(GAP, window.innerHeight - ch - GAP);
|
||||||
|
}
|
||||||
|
if (y < GAP) y = GAP;
|
||||||
|
card.style.left = x + 'px';
|
||||||
|
card.style.top = y + 'px';
|
||||||
|
card.style.visibility = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showFor(row, node) {
|
||||||
|
ensureCard();
|
||||||
|
render(node);
|
||||||
|
position(row);
|
||||||
|
card.classList.add('is-visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
var treeBody = document.getElementById('treeBody');
|
||||||
|
if (!treeBody) return;
|
||||||
|
|
||||||
|
treeBody.addEventListener('mouseover', function (e) {
|
||||||
|
// Returning to the tree from the card cancels any pending
|
||||||
|
// hide; the show logic below handles row changes.
|
||||||
|
cancelHide();
|
||||||
|
var row = e.target.closest('.tree-row');
|
||||||
|
if (row === currentRow) return;
|
||||||
|
// Row → row or row → empty space — reset.
|
||||||
|
if (showTimer) { clearTimeout(showTimer); showTimer = null; }
|
||||||
|
if (card) card.classList.remove('is-visible');
|
||||||
|
currentRow = row || null;
|
||||||
|
if (!row) return;
|
||||||
|
showTimer = setTimeout(function () {
|
||||||
|
if (currentRow !== row) return;
|
||||||
|
var id = parseInt(row.dataset.id, 10);
|
||||||
|
var node = state.nodes.get(id);
|
||||||
|
if (node) showFor(row, node);
|
||||||
|
}, SHOW_DELAY_MS);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Leaving the tree schedules a hide rather than hiding
|
||||||
|
// immediately, so the cursor has time to traverse the gap to
|
||||||
|
// the card. The card's own mouseenter cancels the hide.
|
||||||
|
treeBody.addEventListener('mouseleave', scheduleHide);
|
||||||
|
treeBody.addEventListener('contextmenu', hide);
|
||||||
|
window.addEventListener('scroll', hide, true);
|
||||||
|
window.addEventListener('resize', hide);
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Escape') hide();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click anywhere outside the card dismisses it. Clicks INSIDE
|
||||||
|
// the card are allowed through so the user can drag-select
|
||||||
|
// text, right-click for the browser's native Copy menu, or
|
||||||
|
// hit Ctrl/Cmd-C.
|
||||||
|
document.addEventListener('mousedown', function (e) {
|
||||||
|
if (!card || !card.classList.contains('is-visible')) return;
|
||||||
|
if (card.contains(e.target)) return;
|
||||||
|
hide();
|
||||||
|
}, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
} else {
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.app.modules.hovercard = { hide: hide };
|
||||||
|
})();
|
||||||
|
|
@ -8,6 +8,15 @@
|
||||||
window.app = { modules: {}, state: {} };
|
window.app = { modules: {}, state: {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mount the shared Lucide outline-icon sprite into <body> before
|
||||||
|
// the tree first renders. The sprite is hidden (display:none on
|
||||||
|
// the outer <svg>) — it only exists so per-row <use href="#…"/>
|
||||||
|
// refs resolve. Falls back to deferring until DOMContentLoaded
|
||||||
|
// when <body> isn't ready yet.
|
||||||
|
if (window.zddc && window.zddc.icons) {
|
||||||
|
window.zddc.icons.inject();
|
||||||
|
}
|
||||||
|
|
||||||
window.app.state = {
|
window.app.state = {
|
||||||
// Source: 'server' | 'fs' | null. Determines how the loader
|
// Source: 'server' | 'fs' | null. Determines how the loader
|
||||||
// resolves entries.
|
// resolves entries.
|
||||||
|
|
@ -61,6 +70,13 @@
|
||||||
// scopeDefaultTool: cascade's default_tool at currentPath
|
// scopeDefaultTool: cascade's default_tool at currentPath
|
||||||
// (empty when no default declared)
|
// (empty when no default declared)
|
||||||
scopeDropTarget: false,
|
scopeDropTarget: false,
|
||||||
scopeDefaultTool: ''
|
scopeDefaultTool: '',
|
||||||
|
|
||||||
|
// Autofilter — when non-empty, the tree hides files that
|
||||||
|
// don't match and folders whose subtree has no matches.
|
||||||
|
// Parsed once on input change so visibleIds() / rowHtml()
|
||||||
|
// can run filter.matches(text, ast) cheaply per node.
|
||||||
|
filterText: '',
|
||||||
|
filterAST: null
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -346,13 +346,18 @@
|
||||||
container.appendChild(shell);
|
container.appendChild(shell);
|
||||||
|
|
||||||
// ── Sidebar (col 1): front matter (top) + TOC (bottom) ──────────────
|
// ── Sidebar (col 1): front matter (top) + TOC (bottom) ──────────────
|
||||||
|
// Sidebar is a flex column: FM section (fixed height, set
|
||||||
|
// inline below) + horizontal resizer + TOC section (1fr).
|
||||||
var sidebar = document.createElement('div');
|
var sidebar = document.createElement('div');
|
||||||
sidebar.className = 'md-shell__sidebar';
|
sidebar.className = 'md-shell__sidebar';
|
||||||
sidebar.style.gridTemplateRows = lastFmHeight + 'px 1fr';
|
|
||||||
shell.appendChild(sidebar);
|
shell.appendChild(sidebar);
|
||||||
|
|
||||||
var fmSection = document.createElement('section');
|
var fmSection = document.createElement('section');
|
||||||
fmSection.className = 'md-side md-side--fm';
|
fmSection.className = 'md-side md-side--fm';
|
||||||
|
// Front-matter height is driven inline (persisted across
|
||||||
|
// remounts via lastFmHeight) so the resizer's drag-handler
|
||||||
|
// mutates a single source of truth.
|
||||||
|
fmSection.style.height = lastFmHeight + 'px';
|
||||||
var fmHeader = document.createElement('div');
|
var fmHeader = document.createElement('div');
|
||||||
fmHeader.className = 'md-side__header';
|
fmHeader.className = 'md-side__header';
|
||||||
fmHeader.textContent = 'YAML front matter';
|
fmHeader.textContent = 'YAML front matter';
|
||||||
|
|
@ -502,7 +507,10 @@
|
||||||
var editor = new window.toastui.Editor({
|
var editor = new window.toastui.Editor({
|
||||||
el: editorHost,
|
el: editorHost,
|
||||||
height: '100%',
|
height: '100%',
|
||||||
initialEditType: 'markdown',
|
// WYSIWYG by default — most users want the rendered view
|
||||||
|
// out of the gate; the markdown/WYSIWYG toggle in the
|
||||||
|
// Toast UI toolbar still flips to source mode in one click.
|
||||||
|
initialEditType: 'wysiwyg',
|
||||||
previewStyle: 'vertical',
|
previewStyle: 'vertical',
|
||||||
initialValue: bodyText,
|
initialValue: bodyText,
|
||||||
usageStatistics: false,
|
usageStatistics: false,
|
||||||
|
|
@ -592,7 +600,7 @@
|
||||||
var dy = e.clientY - startY;
|
var dy = e.clientY - startY;
|
||||||
var h = Math.max(FM_MIN, Math.min(maxFmHeight(), startH + dy));
|
var h = Math.max(FM_MIN, Math.min(maxFmHeight(), startH + dy));
|
||||||
lastFmHeight = h;
|
lastFmHeight = h;
|
||||||
sidebar.style.gridTemplateRows = h + 'px 1fr';
|
fmSection.style.height = h + 'px';
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
function onUp() {
|
function onUp() {
|
||||||
|
|
@ -616,7 +624,7 @@
|
||||||
var step = e.key === 'ArrowUp' ? -24 : 24;
|
var step = e.key === 'ArrowUp' ? -24 : 24;
|
||||||
var h = Math.max(FM_MIN, Math.min(maxFmHeight(), lastFmHeight + step));
|
var h = Math.max(FM_MIN, Math.min(maxFmHeight(), lastFmHeight + step));
|
||||||
lastFmHeight = h;
|
lastFmHeight = h;
|
||||||
sidebar.style.gridTemplateRows = h + 'px 1fr';
|
fmSection.style.height = h + 'px';
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|
|
||||||
533
browse/js/preview-yaml.js
Normal file
533
browse/js/preview-yaml.js
Normal file
|
|
@ -0,0 +1,533 @@
|
||||||
|
// preview-yaml.js — YAML editor plugin for the browse preview pane.
|
||||||
|
//
|
||||||
|
// Routes any .yaml / .yml file, plus the .zddc cascade files
|
||||||
|
// (`.zddc` and `*.zddc.yaml`), through a CodeMirror 5 editor with
|
||||||
|
// syntax highlighting and live linting. js-yaml.loadAll feeds parse
|
||||||
|
// errors into CM's lint gutter; for .zddc files an additional
|
||||||
|
// schema-aware pass flags unknown keys, bad enum values, and wrong
|
||||||
|
// types.
|
||||||
|
//
|
||||||
|
// Layout (single column):
|
||||||
|
// ┌─────────────────────────────────────────────────────────────┐
|
||||||
|
// │ name | dirty | status | source | [Save] │
|
||||||
|
// ├─────────────────────────────────────────────────────────────┤
|
||||||
|
// │ CodeMirror editor (line numbers + lint gutter) │
|
||||||
|
// └─────────────────────────────────────────────────────────────┘
|
||||||
|
//
|
||||||
|
// Save (Ctrl+S) writes back via PUT (server mode) or
|
||||||
|
// FileSystemWritableFileStream (FS-API). Zip members and
|
||||||
|
// virtual nodes are read-only — Save stays disabled.
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
if (!window.app || !window.app.modules) return;
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s).replace(/&/g, '&').replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Filename routing ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// True for .zddc cascade files — `.zddc` (literal name, no ext)
|
||||||
|
// and `<anything>.zddc.yaml` (e.g. `defaults.zddc.yaml`). These
|
||||||
|
// get the schema-aware lint layer.
|
||||||
|
function isZddcFile(name) {
|
||||||
|
if (!name) return false;
|
||||||
|
if (name === '.zddc') return true;
|
||||||
|
return /\.zddc\.ya?ml$/i.test(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isYamlFile(node) {
|
||||||
|
if (!node || !node.name) return false;
|
||||||
|
if (isZddcFile(node.name)) return true;
|
||||||
|
var ext = (node.ext || '').toLowerCase();
|
||||||
|
return ext === 'yaml' || ext === 'yml';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Save (mirrors preview-markdown.js) ─────────────────────────────────
|
||||||
|
|
||||||
|
async function saveContent(node, content) {
|
||||||
|
if (node.handle && typeof node.handle.createWritable === 'function') {
|
||||||
|
var writable = await node.handle.createWritable();
|
||||||
|
await writable.write(content);
|
||||||
|
await writable.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (node.url && window.app.state.source === 'server') {
|
||||||
|
var resp = await fetch(node.url, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/x-yaml; charset=utf-8' },
|
||||||
|
body: content,
|
||||||
|
credentials: 'same-origin'
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error('No write target for this file (read-only source).');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isZipMemberNode(node) {
|
||||||
|
if (node.handle && node.handle.isZipEntry) return true;
|
||||||
|
if (node.url && window.app.state.source === 'server'
|
||||||
|
&& /\.zip\//i.test(node.url)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canSave(node) {
|
||||||
|
if (isZipMemberNode(node)) return false;
|
||||||
|
if (node.virtual) return false;
|
||||||
|
if (node.handle && typeof node.handle.createWritable === 'function') return true;
|
||||||
|
if (node.url && window.app.state.source === 'server') return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hashContent(text) {
|
||||||
|
if (!window.crypto || !window.crypto.subtle) return null;
|
||||||
|
var enc = new TextEncoder().encode(text);
|
||||||
|
var buf = await window.crypto.subtle.digest('SHA-256', enc);
|
||||||
|
var bytes = new Uint8Array(buf);
|
||||||
|
var hex = '';
|
||||||
|
for (var i = 0; i < bytes.length; i++) {
|
||||||
|
hex += bytes[i].toString(16).padStart(2, '0');
|
||||||
|
}
|
||||||
|
return hex;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── .zddc schema ────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Mirrors the Go-side decoder in zddc/internal/zddc/*. Allowed
|
||||||
|
// tool names are the embedded set (always available) plus the
|
||||||
|
// composable ones served when declared in apps:. Unknown keys at
|
||||||
|
// any level surface as warnings — typos like `defaul_tool` are
|
||||||
|
// common and the cascade silently ignores them.
|
||||||
|
|
||||||
|
var ALLOWED_TOOLS = {
|
||||||
|
archive: 1, browse: 1, landing: 1, transmittal: 1, classifier: 1,
|
||||||
|
tables: 1, form: 1
|
||||||
|
};
|
||||||
|
|
||||||
|
var TOP_KEYS = {
|
||||||
|
title: 'string',
|
||||||
|
acl: 'acl',
|
||||||
|
admins: 'string[]',
|
||||||
|
roles: 'rolemap',
|
||||||
|
available_tools: 'tools[]',
|
||||||
|
default_tool: 'tool',
|
||||||
|
dir_tool: 'tool',
|
||||||
|
auto_own: 'bool',
|
||||||
|
auto_own_fenced: 'bool',
|
||||||
|
virtual: 'bool',
|
||||||
|
drop_target: 'bool',
|
||||||
|
worm: 'string[]',
|
||||||
|
paths: 'pathmap',
|
||||||
|
display: 'stringmap',
|
||||||
|
apps: 'appsmap',
|
||||||
|
apps_pubkey: 'string',
|
||||||
|
tables: 'stringmap',
|
||||||
|
convert: 'convert',
|
||||||
|
created_by: 'string',
|
||||||
|
inherit: 'bool'
|
||||||
|
};
|
||||||
|
|
||||||
|
var ACL_KEYS = { inherit: 'bool', permissions: 'stringmap',
|
||||||
|
allow: 'string[]', deny: 'string[]' };
|
||||||
|
var ROLE_KEYS = { members: 'string[]', reset: 'bool' };
|
||||||
|
var CONVERT_KEYS = { client: 'string', project: 'string',
|
||||||
|
contractor: 'string', project_number: 'string' };
|
||||||
|
|
||||||
|
function typeOf(v) {
|
||||||
|
if (v === null || v === undefined) return 'null';
|
||||||
|
if (Array.isArray(v)) return 'array';
|
||||||
|
return typeof v; // 'string' | 'number' | 'boolean' | 'object'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect schema issues for a parsed .zddc document. Each issue is
|
||||||
|
// { keyPath: string[], message: string, severity: 'error' | 'warning' }.
|
||||||
|
// keyPath is used by findLine() to locate the offending source line.
|
||||||
|
function validateZddc(doc) {
|
||||||
|
var issues = [];
|
||||||
|
if (typeOf(doc) === 'null') return issues;
|
||||||
|
if (typeOf(doc) !== 'object') {
|
||||||
|
issues.push({ keyPath: [], severity: 'error',
|
||||||
|
message: 'Root must be a map (got ' + typeOf(doc) + ').' });
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
walkObject(doc, TOP_KEYS, [], issues);
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
function walkObject(obj, schema, path, issues) {
|
||||||
|
for (var key in obj) {
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(obj, key)) continue;
|
||||||
|
var here = path.concat([key]);
|
||||||
|
var kind = schema[key];
|
||||||
|
if (!kind) {
|
||||||
|
issues.push({ keyPath: here, severity: 'warning',
|
||||||
|
message: 'Unknown key "' + key + '" — typo? It will be silently ignored.' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
checkValue(obj[key], kind, here, issues);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkValue(val, kind, path, issues) {
|
||||||
|
var t = typeOf(val);
|
||||||
|
switch (kind) {
|
||||||
|
case 'string':
|
||||||
|
if (t !== 'string' && t !== 'null') addTypeErr(path, kind, t, issues);
|
||||||
|
return;
|
||||||
|
case 'bool':
|
||||||
|
if (t !== 'boolean' && t !== 'null') addTypeErr(path, kind, t, issues);
|
||||||
|
return;
|
||||||
|
case 'string[]':
|
||||||
|
if (t !== 'array' && t !== 'null') addTypeErr(path, kind, t, issues);
|
||||||
|
return;
|
||||||
|
case 'tools[]':
|
||||||
|
if (t !== 'array' && t !== 'null') {
|
||||||
|
addTypeErr(path, kind, t, issues); return;
|
||||||
|
}
|
||||||
|
if (t === 'array') {
|
||||||
|
for (var i = 0; i < val.length; i++) {
|
||||||
|
if (typeOf(val[i]) !== 'string') {
|
||||||
|
issues.push({ keyPath: path, severity: 'error',
|
||||||
|
message: 'available_tools[' + i + '] must be a string.' });
|
||||||
|
} else if (!ALLOWED_TOOLS[val[i]]) {
|
||||||
|
issues.push({ keyPath: path, severity: 'warning',
|
||||||
|
message: 'Unknown tool "' + val[i]
|
||||||
|
+ '". Known: ' + Object.keys(ALLOWED_TOOLS).join(', ') + '.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
case 'tool':
|
||||||
|
if (t === 'null') return;
|
||||||
|
if (t !== 'string') { addTypeErr(path, kind, t, issues); return; }
|
||||||
|
if (!ALLOWED_TOOLS[val]) {
|
||||||
|
issues.push({ keyPath: path, severity: 'warning',
|
||||||
|
message: 'Unknown tool "' + val + '". Known: '
|
||||||
|
+ Object.keys(ALLOWED_TOOLS).join(', ') + '.' });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
case 'stringmap':
|
||||||
|
if (t === 'null') return;
|
||||||
|
if (t !== 'object') { addTypeErr(path, kind, t, issues); return; }
|
||||||
|
for (var k in val) {
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(val, k)) continue;
|
||||||
|
if (typeOf(val[k]) !== 'string') {
|
||||||
|
issues.push({ keyPath: path.concat([k]), severity: 'error',
|
||||||
|
message: 'Value must be a string (got '
|
||||||
|
+ typeOf(val[k]) + ').' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
case 'pathmap':
|
||||||
|
if (t === 'null') return;
|
||||||
|
if (t !== 'object') { addTypeErr(path, kind, t, issues); return; }
|
||||||
|
for (var seg in val) {
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(val, seg)) continue;
|
||||||
|
if (seg.indexOf('/') !== -1) {
|
||||||
|
issues.push({ keyPath: path.concat([seg]), severity: 'error',
|
||||||
|
message: 'Path keys must be a single segment — '
|
||||||
|
+ 'nest blocks instead of using "' + seg + '".' });
|
||||||
|
}
|
||||||
|
var v = val[seg];
|
||||||
|
if (typeOf(v) === 'null') continue;
|
||||||
|
if (typeOf(v) !== 'object') {
|
||||||
|
issues.push({ keyPath: path.concat([seg]), severity: 'error',
|
||||||
|
message: 'paths.' + seg + ' must be a map of cascade rules.' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
walkObject(v, TOP_KEYS, path.concat([seg]), issues);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
case 'appsmap':
|
||||||
|
if (t === 'null') return;
|
||||||
|
if (t !== 'object') { addTypeErr(path, kind, t, issues); return; }
|
||||||
|
for (var app in val) {
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(val, app)) continue;
|
||||||
|
if (!ALLOWED_TOOLS[app]) {
|
||||||
|
issues.push({ keyPath: path.concat([app]), severity: 'warning',
|
||||||
|
message: 'Unknown tool "' + app + '" in apps:.' });
|
||||||
|
}
|
||||||
|
if (typeOf(val[app]) !== 'string') {
|
||||||
|
issues.push({ keyPath: path.concat([app]), severity: 'error',
|
||||||
|
message: 'apps.' + app + ' must be a spec string '
|
||||||
|
+ '(channel | v<semver> | URL | path).' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
case 'rolemap':
|
||||||
|
if (t === 'null') return;
|
||||||
|
if (t !== 'object') { addTypeErr(path, kind, t, issues); return; }
|
||||||
|
for (var rn in val) {
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(val, rn)) continue;
|
||||||
|
var rv = val[rn];
|
||||||
|
if (typeOf(rv) === 'null') continue;
|
||||||
|
if (typeOf(rv) !== 'object') {
|
||||||
|
issues.push({ keyPath: path.concat([rn]), severity: 'error',
|
||||||
|
message: 'roles.' + rn + ' must be a map ({members, reset}).' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
walkObject(rv, ROLE_KEYS, path.concat([rn]), issues);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
case 'acl':
|
||||||
|
if (t === 'null') return;
|
||||||
|
if (t !== 'object') { addTypeErr(path, kind, t, issues); return; }
|
||||||
|
walkObject(val, ACL_KEYS, path, issues);
|
||||||
|
return;
|
||||||
|
case 'convert':
|
||||||
|
if (t === 'null') return;
|
||||||
|
if (t !== 'object') { addTypeErr(path, kind, t, issues); return; }
|
||||||
|
walkObject(val, CONVERT_KEYS, path, issues);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addTypeErr(path, expected, got, issues) {
|
||||||
|
issues.push({ keyPath: path, severity: 'error',
|
||||||
|
message: 'Expected ' + expected + ', got ' + got + '.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Locate the source line for a key path. .zddc files are
|
||||||
|
// block-style YAML in practice (no flow style, no anchors), so a
|
||||||
|
// simple indent-aware scan works: for each segment, find a line
|
||||||
|
// matching "<indent><key>:" whose indent is deeper than the
|
||||||
|
// previously-matched line. Falls back to line 0 if no match.
|
||||||
|
function findLine(source, keyPath) {
|
||||||
|
if (!keyPath || keyPath.length === 0) return 0;
|
||||||
|
var lines = source.split('\n');
|
||||||
|
var prevIndent = -1;
|
||||||
|
var prevLine = 0;
|
||||||
|
for (var i = 0; i < keyPath.length; i++) {
|
||||||
|
var key = keyPath[i];
|
||||||
|
var found = -1;
|
||||||
|
// Escape regex metachars in the key.
|
||||||
|
var keyRe = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
var re = new RegExp('^(\\s*)"?' + keyRe + '"?\\s*:');
|
||||||
|
for (var j = prevLine; j < lines.length; j++) {
|
||||||
|
var m = lines[j].match(re);
|
||||||
|
if (m && m[1].length > prevIndent) {
|
||||||
|
found = j;
|
||||||
|
prevIndent = m[1].length;
|
||||||
|
prevLine = j + 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (found === -1) return prevLine > 0 ? prevLine - 1 : 0;
|
||||||
|
}
|
||||||
|
return prevLine > 0 ? prevLine - 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CodeMirror lint helper ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
function registerLinter(CM) {
|
||||||
|
// The lint helper signature: function(text, options, editor) → annotations[]
|
||||||
|
// Each annotation: { from, to, message, severity }.
|
||||||
|
CM.registerHelper('lint', 'yaml', function (text, _opts, editor) {
|
||||||
|
var out = [];
|
||||||
|
if (!window.jsyaml) return out;
|
||||||
|
var parsed;
|
||||||
|
try {
|
||||||
|
// loadAll handles multi-doc YAML; we only validate the
|
||||||
|
// first doc against the schema (the .zddc cascade reads
|
||||||
|
// only the first document).
|
||||||
|
var docs = [];
|
||||||
|
window.jsyaml.loadAll(text, function (d) { docs.push(d); });
|
||||||
|
parsed = docs[0];
|
||||||
|
} catch (e) {
|
||||||
|
var mark = e.mark;
|
||||||
|
var pos = mark ? CM.Pos(mark.line, mark.column) : CM.Pos(0, 0);
|
||||||
|
out.push({ from: pos, to: pos, severity: 'error',
|
||||||
|
message: e.message || String(e) });
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
// Schema layer — only for .zddc cascade files.
|
||||||
|
var node = editor._zddcNode;
|
||||||
|
if (node && isZddcFile(node.name)) {
|
||||||
|
var issues = validateZddc(parsed);
|
||||||
|
for (var i = 0; i < issues.length; i++) {
|
||||||
|
var ln = findLine(text, issues[i].keyPath);
|
||||||
|
out.push({
|
||||||
|
from: CM.Pos(ln, 0),
|
||||||
|
to: CM.Pos(ln, (editor.getLine(ln) || '').length),
|
||||||
|
severity: issues[i].severity,
|
||||||
|
message: issues[i].message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mount ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
var currentEditor = null;
|
||||||
|
|
||||||
|
function dispose() {
|
||||||
|
// CM doesn't have an explicit destroy(); GC handles it once
|
||||||
|
// the host element is removed. Clear our reference so a stale
|
||||||
|
// editor doesn't keep handlers alive.
|
||||||
|
currentEditor = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function render(node, container, ctx) {
|
||||||
|
if (typeof window.CodeMirror === 'undefined') {
|
||||||
|
container.innerHTML =
|
||||||
|
'<div class="preview-empty" style="color:var(--danger)">'
|
||||||
|
+ 'CodeMirror isn\'t bundled in this build.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dispose();
|
||||||
|
|
||||||
|
var text;
|
||||||
|
try {
|
||||||
|
var buf = await ctx.getArrayBuffer(node);
|
||||||
|
text = new TextDecoder('utf-8', { fatal: false }).decode(buf);
|
||||||
|
} catch (e) {
|
||||||
|
container.innerHTML =
|
||||||
|
'<div class="preview-empty" style="color:var(--danger)">'
|
||||||
|
+ 'Could not read ' + escapeHtml(node.name) + ': '
|
||||||
|
+ escapeHtml(e.message || String(e)) + '</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = '';
|
||||||
|
var shell = document.createElement('div');
|
||||||
|
shell.className = 'yaml-shell';
|
||||||
|
container.appendChild(shell);
|
||||||
|
|
||||||
|
// Info header — same look as the markdown plugin's info-header
|
||||||
|
// so the two editors feel like one family.
|
||||||
|
var infohdr = document.createElement('div');
|
||||||
|
infohdr.className = 'md-shell__infohdr yaml-shell__infohdr';
|
||||||
|
|
||||||
|
var titleEl = document.createElement('span');
|
||||||
|
titleEl.className = 'md-shell__title';
|
||||||
|
titleEl.textContent = node.name;
|
||||||
|
titleEl.title = node.name;
|
||||||
|
|
||||||
|
var schemaTag = document.createElement('span');
|
||||||
|
schemaTag.className = 'md-shell__source yaml-shell__schema';
|
||||||
|
if (isZddcFile(node.name)) {
|
||||||
|
schemaTag.textContent = '.zddc schema';
|
||||||
|
schemaTag.title = 'Linted against the .zddc cascade schema '
|
||||||
|
+ '(unknown keys, bad enums, and wrong types are flagged).';
|
||||||
|
} else {
|
||||||
|
schemaTag.textContent = 'YAML';
|
||||||
|
}
|
||||||
|
|
||||||
|
var dirtyEl = document.createElement('span');
|
||||||
|
dirtyEl.className = 'md-shell__dirty';
|
||||||
|
|
||||||
|
var statusEl = document.createElement('span');
|
||||||
|
statusEl.className = 'md-shell__status';
|
||||||
|
|
||||||
|
var sourceEl = document.createElement('span');
|
||||||
|
sourceEl.className = 'md-shell__source';
|
||||||
|
if (isZipMemberNode(node)) sourceEl.textContent = 'read-only (zip)';
|
||||||
|
else if (node.handle) sourceEl.textContent = 'local';
|
||||||
|
else if (node.url) sourceEl.textContent = 'server';
|
||||||
|
|
||||||
|
var saveBtn = document.createElement('button');
|
||||||
|
saveBtn.className = 'btn btn-sm btn-primary md-shell__save';
|
||||||
|
saveBtn.type = 'button';
|
||||||
|
saveBtn.textContent = 'Save';
|
||||||
|
saveBtn.disabled = true;
|
||||||
|
|
||||||
|
infohdr.appendChild(titleEl);
|
||||||
|
infohdr.appendChild(schemaTag);
|
||||||
|
infohdr.appendChild(dirtyEl);
|
||||||
|
infohdr.appendChild(statusEl);
|
||||||
|
infohdr.appendChild(sourceEl);
|
||||||
|
infohdr.appendChild(saveBtn);
|
||||||
|
shell.appendChild(infohdr);
|
||||||
|
|
||||||
|
var editorHost = document.createElement('div');
|
||||||
|
editorHost.className = 'yaml-shell__editor';
|
||||||
|
shell.appendChild(editorHost);
|
||||||
|
|
||||||
|
// Register the lint helper once per page lifetime.
|
||||||
|
if (!window.CodeMirror.__zddcYamlLinterReady) {
|
||||||
|
registerLinter(window.CodeMirror);
|
||||||
|
window.CodeMirror.__zddcYamlLinterReady = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var editor = window.CodeMirror(editorHost, {
|
||||||
|
value: text,
|
||||||
|
mode: 'yaml',
|
||||||
|
lineNumbers: true,
|
||||||
|
tabSize: 2,
|
||||||
|
indentUnit: 2,
|
||||||
|
indentWithTabs: false,
|
||||||
|
lineWrapping: false,
|
||||||
|
gutters: ['CodeMirror-lint-markers', 'CodeMirror-linenumbers'],
|
||||||
|
lint: { hasGutters: true }
|
||||||
|
});
|
||||||
|
// Stash the node on the editor so the lint helper can decide
|
||||||
|
// whether to apply the .zddc schema layer.
|
||||||
|
editor._zddcNode = node;
|
||||||
|
// Force an initial lint pass now that _zddcNode is set.
|
||||||
|
editor.performLint();
|
||||||
|
currentEditor = editor;
|
||||||
|
|
||||||
|
var writable = canSave(node);
|
||||||
|
if (!writable) {
|
||||||
|
saveBtn.disabled = true;
|
||||||
|
saveBtn.title = 'Save not available — read-only source.';
|
||||||
|
editor.setOption('readOnly', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
var initialHash = await hashContent(text);
|
||||||
|
|
||||||
|
function markDirty(isDirty) {
|
||||||
|
saveBtn.disabled = !isDirty || !writable;
|
||||||
|
dirtyEl.textContent = isDirty ? '● modified' : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
editor.on('change', async function () {
|
||||||
|
var h = await hashContent(editor.getValue());
|
||||||
|
markDirty(h !== initialHash);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (saveBtn.disabled) return;
|
||||||
|
var content = editor.getValue();
|
||||||
|
try {
|
||||||
|
statusEl.textContent = 'Saving…';
|
||||||
|
await saveContent(node, content);
|
||||||
|
initialHash = await hashContent(content);
|
||||||
|
markDirty(false);
|
||||||
|
statusEl.textContent = 'Saved ' + new Date().toLocaleTimeString();
|
||||||
|
if (window.zddc && window.zddc.toast) {
|
||||||
|
window.zddc.toast('Saved ' + node.name, 'success');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
statusEl.textContent = 'Save failed: ' + (e.message || e);
|
||||||
|
if (window.zddc && window.zddc.toast) {
|
||||||
|
window.zddc.toast('Save failed: ' + (e.message || e), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
saveBtn.addEventListener('click', save);
|
||||||
|
editor.setOption('extraKeys', {
|
||||||
|
'Ctrl-S': save,
|
||||||
|
'Cmd-S': save
|
||||||
|
});
|
||||||
|
|
||||||
|
// CM defers layout until its host has a size — refresh after
|
||||||
|
// mount so the gutters and viewport sync to the grid cell.
|
||||||
|
setTimeout(function () { try { editor.refresh(); } catch (_e) {} }, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handles(node) {
|
||||||
|
if (!node || node.isDir || node.isZip) return false;
|
||||||
|
return isYamlFile(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.app.modules.yamledit = {
|
||||||
|
handles: handles,
|
||||||
|
render: render
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
@ -117,6 +117,19 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// YAML plugin: .yaml / .yml / .zddc / *.zddc.yaml route to a
|
||||||
|
// CodeMirror 5 editor with js-yaml linting; .zddc files also
|
||||||
|
// get a schema-aware lint pass.
|
||||||
|
var yamlMod = window.app.modules.yamledit;
|
||||||
|
if (yamlMod && yamlMod.handles(node)) {
|
||||||
|
try {
|
||||||
|
await yamlMod.render(node, container, { getArrayBuffer: getArrayBuffer });
|
||||||
|
} catch (e) {
|
||||||
|
renderError(container, 'YAML render failed: ' + (e.message || e));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// PDF / HTML → iframe.
|
// PDF / HTML → iframe.
|
||||||
if (ext === 'pdf' || ext === 'html' || ext === 'htm') {
|
if (ext === 'pdf' || ext === 'html' || ext === 'htm') {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -111,15 +111,24 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
// Walk nodes in render order. Skips the children of a collapsed
|
// Walk nodes in render order. Skips the children of a collapsed
|
||||||
// expandable.
|
// expandable. When state.filterAST is set, also skips nodes that
|
||||||
|
// don't match (files) or whose subtree has no matches (folders),
|
||||||
|
// and force-walks into folders that have matching descendants so
|
||||||
|
// those matches are visible even when the user hadn't expanded
|
||||||
|
// the folder. The user's actual node.expanded flag stays untouched
|
||||||
|
// so clearing the filter restores their original layout.
|
||||||
function visibleIds() {
|
function visibleIds() {
|
||||||
var out = [];
|
var out = [];
|
||||||
function walk(ids) {
|
function walk(ids) {
|
||||||
for (var i = 0; i < ids.length; i++) {
|
for (var i = 0; i < ids.length; i++) {
|
||||||
var n = state.nodes.get(ids[i]);
|
var n = state.nodes.get(ids[i]);
|
||||||
if (!n) continue;
|
if (!n) continue;
|
||||||
|
if (state.filterAST && !passesFilter(n)) continue;
|
||||||
out.push(ids[i]);
|
out.push(ids[i]);
|
||||||
if ((n.isDir || n.isZip) && n.expanded) walk(n.childIds);
|
if (n.isDir || n.isZip) {
|
||||||
|
var forceWalk = !!state.filterAST;
|
||||||
|
if (forceWalk || n.expanded) walk(n.childIds);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Re-sort everything at all levels so a sort change reorders
|
// Re-sort everything at all levels so a sort change reorders
|
||||||
|
|
@ -132,6 +141,59 @@
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Filter ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Build the haystack string we run the filter AST against. We
|
||||||
|
// concatenate every searchable field — name, displayName, plus any
|
||||||
|
// ZDDC parts the basename parses to — so users can type a tracking
|
||||||
|
// number, a status code, a date, or a piece of the title.
|
||||||
|
function filterHaystack(node) {
|
||||||
|
var parts = [node.name];
|
||||||
|
if (node.displayName) parts.push(node.displayName);
|
||||||
|
var z = window.zddc;
|
||||||
|
if (z) {
|
||||||
|
var parsed = node.isDir ? z.parseFolder(node.name)
|
||||||
|
: z.parseFilename(node.name);
|
||||||
|
if (parsed && parsed.valid) {
|
||||||
|
if (parsed.trackingNumber) parts.push(parsed.trackingNumber);
|
||||||
|
if (parsed.title) parts.push(parsed.title);
|
||||||
|
if (parsed.status) parts.push(parsed.status);
|
||||||
|
if (parsed.revision) parts.push(parsed.revision);
|
||||||
|
if (parsed.date) parts.push(parsed.date);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodeMatchesFilter(node) {
|
||||||
|
if (!state.filterAST) return true;
|
||||||
|
return window.zddc.filter.matches(filterHaystack(node), state.filterAST);
|
||||||
|
}
|
||||||
|
|
||||||
|
// True when this node should appear in the filtered view: either
|
||||||
|
// the node itself matches, or it's an expandable with at least
|
||||||
|
// one matching descendant (so we keep the path to a match visible).
|
||||||
|
function passesFilter(node) {
|
||||||
|
if (!state.filterAST) return true;
|
||||||
|
if (nodeMatchesFilter(node)) return true;
|
||||||
|
if (!(node.isDir || node.isZip)) return false;
|
||||||
|
if (!node.loaded) return false; // unloaded subtrees aren't searched
|
||||||
|
for (var i = 0; i < node.childIds.length; i++) {
|
||||||
|
var child = state.nodes.get(node.childIds[i]);
|
||||||
|
if (child && passesFilter(child)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Is this folder being "forced open" by an active filter because
|
||||||
|
// a descendant matches? Used by rowHtml to render the chevron as
|
||||||
|
// expanded without mutating node.expanded.
|
||||||
|
function filterForcesOpen(node) {
|
||||||
|
if (!state.filterAST) return false;
|
||||||
|
if (!(node.isDir || node.isZip)) return false;
|
||||||
|
return passesFilter(node) && !nodeMatchesFilter(node);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Rendering ────────────────────────────────────────────────────────
|
// ── Rendering ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function fmtSize(bytes) {
|
function fmtSize(bytes) {
|
||||||
|
|
@ -154,6 +216,127 @@
|
||||||
.replace(/>/g, '>').replace(/"/g, '"');
|
.replace(/>/g, '>').replace(/"/g, '"');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-extension icon map → Lucide outline-icon sprite ids. The
|
||||||
|
// actual SVG markup is produced by window.zddc.icons.html(id),
|
||||||
|
// which inlines `<svg><use href="#id"/></svg>` so the page CSS
|
||||||
|
// can size and tint via currentColor.
|
||||||
|
//
|
||||||
|
// book-marked PDF file-pen markdown
|
||||||
|
// file-text word / txt file-spreadsheet spreadsheet
|
||||||
|
// presentation slides file-image image
|
||||||
|
// file-video video file-audio audio
|
||||||
|
// ruler CAD / drawing globe web
|
||||||
|
// file-cog config / .zddc file-code source code
|
||||||
|
// file-archive non-nav archive folder-archive .zip (navigable)
|
||||||
|
// file generic folder directory
|
||||||
|
var ICON_BY_EXT = {
|
||||||
|
pdf: 'icon-book-marked',
|
||||||
|
md: 'icon-file-pen', markdown: 'icon-file-pen',
|
||||||
|
doc: 'icon-file-text', docx: 'icon-file-text', rtf: 'icon-file-text', odt: 'icon-file-text',
|
||||||
|
xls: 'icon-file-spreadsheet', xlsx: 'icon-file-spreadsheet',
|
||||||
|
csv: 'icon-file-spreadsheet', ods: 'icon-file-spreadsheet', tsv: 'icon-file-spreadsheet',
|
||||||
|
ppt: 'icon-presentation', pptx: 'icon-presentation', odp: 'icon-presentation',
|
||||||
|
txt: 'icon-file-text', log: 'icon-file-text',
|
||||||
|
jpg: 'icon-file-image', jpeg: 'icon-file-image', png: 'icon-file-image',
|
||||||
|
gif: 'icon-file-image', webp: 'icon-file-image', svg: 'icon-file-image',
|
||||||
|
bmp: 'icon-file-image', tif: 'icon-file-image', tiff: 'icon-file-image',
|
||||||
|
ico: 'icon-file-image', heic: 'icon-file-image',
|
||||||
|
mp4: 'icon-file-video', mov: 'icon-file-video', avi: 'icon-file-video',
|
||||||
|
mkv: 'icon-file-video', webm: 'icon-file-video', m4v: 'icon-file-video',
|
||||||
|
mp3: 'icon-file-audio', wav: 'icon-file-audio', flac: 'icon-file-audio',
|
||||||
|
ogg: 'icon-file-audio', m4a: 'icon-file-audio', aac: 'icon-file-audio',
|
||||||
|
dwg: 'icon-ruler', dxf: 'icon-ruler', step: 'icon-ruler',
|
||||||
|
stp: 'icon-ruler', iges: 'icon-ruler', igs: 'icon-ruler',
|
||||||
|
html: 'icon-globe', htm: 'icon-globe',
|
||||||
|
yaml: 'icon-file-cog', yml: 'icon-file-cog', json: 'icon-file-cog',
|
||||||
|
toml: 'icon-file-cog', ini: 'icon-file-cog', xml: 'icon-file-cog',
|
||||||
|
conf: 'icon-file-cog', cfg: 'icon-file-cog',
|
||||||
|
'7z': 'icon-file-archive', rar: 'icon-file-archive', tar: 'icon-file-archive',
|
||||||
|
gz: 'icon-file-archive', tgz: 'icon-file-archive',
|
||||||
|
bz2: 'icon-file-archive', xz: 'icon-file-archive',
|
||||||
|
// Code — share one glyph across languages so users build the
|
||||||
|
// "this is source" pattern. Distinguishing per language would
|
||||||
|
// be visual noise without much added signal.
|
||||||
|
js: 'icon-file-code', mjs: 'icon-file-code', cjs: 'icon-file-code',
|
||||||
|
ts: 'icon-file-code', tsx: 'icon-file-code', jsx: 'icon-file-code',
|
||||||
|
py: 'icon-file-code', go: 'icon-file-code', rs: 'icon-file-code',
|
||||||
|
c: 'icon-file-code', cc: 'icon-file-code', cpp: 'icon-file-code',
|
||||||
|
h: 'icon-file-code', hpp: 'icon-file-code', java: 'icon-file-code',
|
||||||
|
rb: 'icon-file-code', php: 'icon-file-code', sh: 'icon-file-code',
|
||||||
|
bash: 'icon-file-code', zsh: 'icon-file-code', lua: 'icon-file-code',
|
||||||
|
swift: 'icon-file-code', kt: 'icon-file-code', kts: 'icon-file-code',
|
||||||
|
css: 'icon-file-code', scss: 'icon-file-code', less: 'icon-file-code'
|
||||||
|
};
|
||||||
|
|
||||||
|
function symbolForNode(node) {
|
||||||
|
if (node.isDir) return 'icon-folder';
|
||||||
|
if (node.isZip) return 'icon-folder-archive';
|
||||||
|
// `.zddc` (no extension) is the cascade config — same family
|
||||||
|
// as yaml. Match the literal basename before falling through
|
||||||
|
// to the extension table.
|
||||||
|
if (node.name === '.zddc') return 'icon-file-cog';
|
||||||
|
var ext = (node.ext || '').toLowerCase();
|
||||||
|
return ICON_BY_EXT[ext] || 'icon-file';
|
||||||
|
}
|
||||||
|
|
||||||
|
function iconForNode(node) {
|
||||||
|
return window.zddc.icons.html(symbolForNode(node));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render the label cell for a row. When the basename parses as a
|
||||||
|
// ZDDC-conformant filename (files) or transmittal folder name
|
||||||
|
// (directories), split into a two-line layout:
|
||||||
|
// top — trackingNumber · [revision · ]status (small, muted)
|
||||||
|
// bot — title (normal weight)
|
||||||
|
// Otherwise fall back to a single line.
|
||||||
|
//
|
||||||
|
// .zddc `display:` overrides always render as a single line — the
|
||||||
|
// operator chose that string for a reason; we don't try to second-
|
||||||
|
// guess it by parsing for ZDDC structure.
|
||||||
|
function labelHtml(node) {
|
||||||
|
// No native title="…" — the rich hovercard (browse/js/hovercard.js)
|
||||||
|
// replaces the browser tooltip with a metadata view that's
|
||||||
|
// both more informative and styled to match the rest of the UI.
|
||||||
|
if (node.displayName) {
|
||||||
|
return '<span class="tree-name__label">'
|
||||||
|
+ escapeHtml(node.displayName)
|
||||||
|
+ '</span>';
|
||||||
|
}
|
||||||
|
var z = window.zddc;
|
||||||
|
var parsed = null;
|
||||||
|
if (z) {
|
||||||
|
parsed = node.isDir
|
||||||
|
? z.parseFolder(node.name)
|
||||||
|
: z.parseFilename(node.name);
|
||||||
|
}
|
||||||
|
if (parsed && parsed.valid) {
|
||||||
|
// Folders carry a date (no revision); files carry a
|
||||||
|
// revision (no date). Status is present on both.
|
||||||
|
var parts;
|
||||||
|
if (node.isDir) {
|
||||||
|
parts = [parsed.date, parsed.trackingNumber, parsed.status];
|
||||||
|
} else {
|
||||||
|
parts = [parsed.trackingNumber, parsed.revision, parsed.status];
|
||||||
|
}
|
||||||
|
var metaText = parts.filter(Boolean).join(' · ');
|
||||||
|
// Title-first: primary content on the top line so the row
|
||||||
|
// reads like a normal file manager / mail list. Meta sits
|
||||||
|
// below as the supporting "subtitle" — same hierarchy
|
||||||
|
// pattern as Gmail, Linear, Notion file rows.
|
||||||
|
return '<span class="tree-name__label tree-name__label--zddc">'
|
||||||
|
+ '<span class="tree-name__title">'
|
||||||
|
+ escapeHtml(parsed.title)
|
||||||
|
+ '</span>'
|
||||||
|
+ '<span class="tree-name__meta">'
|
||||||
|
+ escapeHtml(metaText)
|
||||||
|
+ '</span>'
|
||||||
|
+ '</span>';
|
||||||
|
}
|
||||||
|
return '<span class="tree-name__label">'
|
||||||
|
+ escapeHtml(node.name)
|
||||||
|
+ '</span>';
|
||||||
|
}
|
||||||
|
|
||||||
// Render a single tree row as a flat <div>. Indentation via
|
// Render a single tree row as a flat <div>. Indentation via
|
||||||
// padding-left so the row's hover background spans the full
|
// padding-left so the row's hover background spans the full
|
||||||
// pane width. Files are rendered as plain rows (no anchor) —
|
// pane width. Files are rendered as plain rows (no anchor) —
|
||||||
|
|
@ -163,26 +346,39 @@
|
||||||
function rowHtml(node) {
|
function rowHtml(node) {
|
||||||
var indent = 0.4 + node.depth * 1.0;
|
var indent = 0.4 + node.depth * 1.0;
|
||||||
var expandable = node.isDir || node.isZip;
|
var expandable = node.isDir || node.isZip;
|
||||||
var iconChar = node.isDir ? '📁' : (node.isZip ? '🗜️' : '📄');
|
var iconChar = iconForNode(node);
|
||||||
var chevronClass = 'tree-name__chevron'
|
var chevronClass = 'tree-name__chevron'
|
||||||
+ (expandable ? '' : ' tree-name__chevron--leaf');
|
+ (expandable ? '' : ' tree-name__chevron--leaf');
|
||||||
|
// Outline Lucide chevron — single sprite glyph, rotated 90°
|
||||||
|
// via CSS for the expanded state. Leaf rows ship an empty
|
||||||
|
// chevron span so the icon column stays aligned.
|
||||||
|
var chevronGlyph = expandable
|
||||||
|
? window.zddc.icons.html('icon-chevron-right')
|
||||||
|
: '';
|
||||||
|
// While a filter is active, folders that contain a matching
|
||||||
|
// descendant are rendered as visually expanded so the user
|
||||||
|
// can see the match — even if node.expanded is still false.
|
||||||
|
// The actual flag stays untouched so clearing the filter
|
||||||
|
// restores the user's original tree shape.
|
||||||
|
var visuallyExpanded = node.expanded || filterForcesOpen(node);
|
||||||
var selected = state.selectedId === node.id ? ' is-selected' : '';
|
var selected = state.selectedId === node.id ? ' is-selected' : '';
|
||||||
var virtualCls = node.virtual ? ' tree-row--virtual' : '';
|
var virtualCls = node.virtual ? ' tree-row--virtual' : '';
|
||||||
|
// No native title — the hovercard surfaces a dedicated
|
||||||
|
// "Virtual: Not yet created on disk" row for these nodes.
|
||||||
var virtualHint = node.virtual
|
var virtualHint = node.virtual
|
||||||
? '<span class="tree-name__hint" title="Folder not yet created on disk — opens an empty workspace">(empty)</span>'
|
? '<span class="tree-name__hint">(empty)</span>'
|
||||||
: '';
|
: '';
|
||||||
return ''
|
return ''
|
||||||
+ '<div class="tree-row ' + (node.expanded ? 'expanded' : '') + selected + virtualCls
|
+ '<div class="tree-row ' + (visuallyExpanded ? 'expanded' : '') + selected + virtualCls
|
||||||
+ '" data-id="' + node.id
|
+ '" data-id="' + node.id
|
||||||
+ '" data-isdir="' + node.isDir
|
+ '" data-isdir="' + node.isDir
|
||||||
+ '" data-iszip="' + node.isZip + '"'
|
+ '" data-iszip="' + node.isZip + '"'
|
||||||
+ (node.virtual ? ' data-virtual="true"' : '')
|
+ (node.virtual ? ' data-virtual="true"' : '')
|
||||||
+ ' style="padding-left:' + indent + 'rem"'
|
+ ' style="padding-left:' + indent + 'rem"'
|
||||||
+ ' role="treeitem" tabindex="-1">'
|
+ ' role="treeitem" tabindex="-1">'
|
||||||
+ '<span class="' + chevronClass + '"></span>'
|
+ '<span class="' + chevronClass + '">' + chevronGlyph + '</span>'
|
||||||
+ '<span class="tree-name__icon">' + iconChar + '</span>'
|
+ '<span class="tree-name__icon">' + iconChar + '</span>'
|
||||||
+ '<span class="tree-name__label" title="' + escapeHtml(node.name) + '">'
|
+ labelHtml(node)
|
||||||
+ escapeHtml(node.displayName || node.name) + '</span>'
|
|
||||||
+ virtualHint
|
+ virtualHint
|
||||||
+ '</div>';
|
+ '</div>';
|
||||||
}
|
}
|
||||||
|
|
@ -196,33 +392,9 @@
|
||||||
html += rowHtml(state.nodes.get(ids[i]));
|
html += rowHtml(state.nodes.get(ids[i]));
|
||||||
}
|
}
|
||||||
body.innerHTML = html;
|
body.innerHTML = html;
|
||||||
updateCount();
|
|
||||||
renderBreadcrumbs();
|
renderBreadcrumbs();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count nodes that render at the root + every expanded subtree.
|
|
||||||
function expandedSetSize() {
|
|
||||||
var n = 0;
|
|
||||||
function walk(ids) {
|
|
||||||
for (var i = 0; i < ids.length; i++) {
|
|
||||||
n++;
|
|
||||||
var node = state.nodes.get(ids[i]);
|
|
||||||
if (node && (node.isDir || node.isZip) && node.expanded) {
|
|
||||||
walk(node.childIds);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
walk(state.rootIds);
|
|
||||||
return n;
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateCount() {
|
|
||||||
var el = document.getElementById('entryCount');
|
|
||||||
if (!el) return;
|
|
||||||
var total = expandedSetSize();
|
|
||||||
el.textContent = total + ' item' + (total === 1 ? '' : 's');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Breadcrumbs ──────────────────────────────────────────────────────
|
// ── Breadcrumbs ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
// Inline outline home icon. Stroke-based so it tints with the
|
// Inline outline home icon. Stroke-based so it tints with the
|
||||||
|
|
@ -431,6 +603,61 @@
|
||||||
return parts.join('/');
|
return parts.join('/');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── State snapshot / restore ───────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Used by refresh + show-hidden so the user doesn't lose their
|
||||||
|
// tree layout when the listing reloads. The key is the absolute
|
||||||
|
// path of each node, computed by pathFor; on restore we walk the
|
||||||
|
// new tree and re-apply expansion + selection to nodes whose
|
||||||
|
// paths match.
|
||||||
|
|
||||||
|
function snapshotState() {
|
||||||
|
var expanded = {};
|
||||||
|
var selectedPath = null;
|
||||||
|
var previewPath = null;
|
||||||
|
state.nodes.forEach(function (n) {
|
||||||
|
if ((n.isDir || n.isZip) && n.expanded) {
|
||||||
|
expanded[pathFor(n)] = true;
|
||||||
|
}
|
||||||
|
if (n.id === state.selectedId) selectedPath = pathFor(n);
|
||||||
|
if (n.id === state.lastPreviewedNodeId) previewPath = pathFor(n);
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
expanded: expanded,
|
||||||
|
selectedPath: selectedPath,
|
||||||
|
previewPath: previewPath
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk the current tree (already populated by setRoot) and re-
|
||||||
|
// load + expand every folder whose path appears in snapshot.expanded.
|
||||||
|
// Sets selectedId and lastPreviewedNodeId by matching the snapshot
|
||||||
|
// paths to the freshly-issued node IDs.
|
||||||
|
async function restoreState(snap) {
|
||||||
|
if (!snap) return;
|
||||||
|
async function walk(ids) {
|
||||||
|
for (var i = 0; i < ids.length; i++) {
|
||||||
|
var n = state.nodes.get(ids[i]);
|
||||||
|
if (!n) continue;
|
||||||
|
var p = pathFor(n);
|
||||||
|
if (snap.selectedPath && p === snap.selectedPath) {
|
||||||
|
state.selectedId = n.id;
|
||||||
|
}
|
||||||
|
if (snap.previewPath && p === snap.previewPath) {
|
||||||
|
state.lastPreviewedNodeId = n.id;
|
||||||
|
}
|
||||||
|
if ((n.isDir || n.isZip) && snap.expanded[p]) {
|
||||||
|
await loadChildren(n);
|
||||||
|
if (n.loaded) {
|
||||||
|
n.expanded = true;
|
||||||
|
await walk(n.childIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await walk(state.rootIds);
|
||||||
|
}
|
||||||
|
|
||||||
// Public API
|
// Public API
|
||||||
window.app.modules.tree = {
|
window.app.modules.tree = {
|
||||||
setRoot: setRoot,
|
setRoot: setRoot,
|
||||||
|
|
@ -439,6 +666,9 @@
|
||||||
toggleFolder: toggleFolder,
|
toggleFolder: toggleFolder,
|
||||||
expandSubtree: expandSubtree,
|
expandSubtree: expandSubtree,
|
||||||
collapseSubtree: collapseSubtree,
|
collapseSubtree: collapseSubtree,
|
||||||
|
loadChildren: loadChildren,
|
||||||
|
snapshotState: snapshotState,
|
||||||
|
restoreState: restoreState,
|
||||||
setSort: function (key) {
|
setSort: function (key) {
|
||||||
if (state.sort.key === key) {
|
if (state.sort.key === key) {
|
||||||
state.sort.dir = -state.sort.dir;
|
state.sort.dir = -state.sort.dir;
|
||||||
|
|
|
||||||
|
|
@ -85,13 +85,17 @@
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function uploadUrl(filename) {
|
// Join a directory path and a relative path safely. dir is expected
|
||||||
var base = state.currentPath || '/';
|
// to be /-prefixed and may or may not have a trailing /; rel is a
|
||||||
|
// forward-slash relative path (no leading /). Each segment is
|
||||||
|
// URI-encoded so spaces and friends survive the round trip.
|
||||||
|
function joinUrl(dir, rel) {
|
||||||
|
var base = dir || '/';
|
||||||
if (!base.endsWith('/')) base += '/';
|
if (!base.endsWith('/')) base += '/';
|
||||||
return base + encodeURIComponent(filename);
|
return base + rel.split('/').map(encodeURIComponent).join('/');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function uploadOne(file) {
|
async function uploadOne(file, destDir, relPath) {
|
||||||
if (file.size > UPLOAD_MAX_BYTES) {
|
if (file.size > UPLOAD_MAX_BYTES) {
|
||||||
return {
|
return {
|
||||||
file: file,
|
file: file,
|
||||||
|
|
@ -101,7 +105,7 @@
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
var resp = await fetch(uploadUrl(file.name), {
|
var resp = await fetch(joinUrl(destDir, relPath), {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: file,
|
body: file,
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
|
|
@ -125,6 +129,286 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Folder-upload helpers (webkitGetAsEntry recursion) ─────────────────
|
||||||
|
// Browsers expose dropped folders only through the entries API.
|
||||||
|
// walkEntry flattens a tree into [{ relPath, file }] so uploadOne
|
||||||
|
// can PUT each file individually. The server's PUT auto-creates
|
||||||
|
// intermediate directories, so no explicit mkdir is needed.
|
||||||
|
|
||||||
|
function readAllEntries(reader) {
|
||||||
|
return new Promise(function (resolve, reject) {
|
||||||
|
var collected = [];
|
||||||
|
function loop() {
|
||||||
|
reader.readEntries(function (batch) {
|
||||||
|
if (batch.length === 0) return resolve(collected);
|
||||||
|
collected = collected.concat(batch);
|
||||||
|
loop();
|
||||||
|
}, reject);
|
||||||
|
}
|
||||||
|
loop();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function entryToFile(entry) {
|
||||||
|
return new Promise(function (resolve, reject) {
|
||||||
|
entry.file(resolve, reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function walkEntry(entry, prefix, out) {
|
||||||
|
if (entry.isFile) {
|
||||||
|
try {
|
||||||
|
var f = await entryToFile(entry);
|
||||||
|
out.push({ relPath: prefix + entry.name, file: f });
|
||||||
|
} catch (_e) { /* skip unreadable file */ }
|
||||||
|
} else if (entry.isDirectory) {
|
||||||
|
var reader = entry.createReader();
|
||||||
|
var kids = await readAllEntries(reader);
|
||||||
|
for (var i = 0; i < kids.length; i++) {
|
||||||
|
await walkEntry(kids[i], prefix + entry.name + '/', out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract { relPath, file } pairs from a DataTransfer. Uses
|
||||||
|
// webkitGetAsEntry when available (so folder uploads work);
|
||||||
|
// falls back to dataTransfer.files for cases where entries
|
||||||
|
// aren't exposed (some browsers / cross-origin).
|
||||||
|
async function collectUploads(dt) {
|
||||||
|
var out = [];
|
||||||
|
if (dt.items && dt.items.length) {
|
||||||
|
var entries = [];
|
||||||
|
for (var i = 0; i < dt.items.length; i++) {
|
||||||
|
var item = dt.items[i];
|
||||||
|
if (item.kind !== 'file') continue;
|
||||||
|
var entry = typeof item.webkitGetAsEntry === 'function'
|
||||||
|
? item.webkitGetAsEntry()
|
||||||
|
: null;
|
||||||
|
if (entry) {
|
||||||
|
entries.push(entry);
|
||||||
|
} else {
|
||||||
|
var f = item.getAsFile();
|
||||||
|
if (f) out.push({ relPath: f.name, file: f });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (var j = 0; j < entries.length; j++) {
|
||||||
|
await walkEntry(entries[j], '', out);
|
||||||
|
}
|
||||||
|
if (out.length) return out;
|
||||||
|
}
|
||||||
|
if (dt.files) {
|
||||||
|
for (var k = 0; k < dt.files.length; k++) {
|
||||||
|
out.push({ relPath: dt.files[k].name, file: dt.files[k] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run a batch of uploads against an arbitrary destination directory.
|
||||||
|
// Surfaces per-file errors as toasts; refreshes the tree afterward
|
||||||
|
// so newly-uploaded entries appear. Returns { ok, fail } counts.
|
||||||
|
async function uploadBatch(uploads, destDir) {
|
||||||
|
var note = window.zddc && window.zddc.toast;
|
||||||
|
if (note) {
|
||||||
|
note('Uploading ' + uploads.length + ' item'
|
||||||
|
+ (uploads.length === 1 ? '' : 's') + '…', 'info');
|
||||||
|
}
|
||||||
|
var ok = 0, fail = 0;
|
||||||
|
for (var i = 0; i < uploads.length; i++) {
|
||||||
|
var u = uploads[i];
|
||||||
|
var res = await uploadOne(u.file, destDir, u.relPath);
|
||||||
|
if (res.ok) ok++;
|
||||||
|
else {
|
||||||
|
fail++;
|
||||||
|
if (note) {
|
||||||
|
note('Upload failed: ' + u.relPath + ' — ' + res.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (note) {
|
||||||
|
if (fail === 0) {
|
||||||
|
note('Uploaded ' + ok + ' file' + (ok === 1 ? '' : 's')
|
||||||
|
+ ' → ' + destDir, 'success');
|
||||||
|
} else if (ok === 0) {
|
||||||
|
note('All ' + fail + ' upload' + (fail === 1 ? '' : 's') + ' failed', 'error');
|
||||||
|
} else {
|
||||||
|
note(ok + ' uploaded, ' + fail + ' failed', 'warning');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ok: ok, fail: fail };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Create-new helpers ────────────────────────────────────────────────
|
||||||
|
// Both go through the same server endpoints used by upload: PUT
|
||||||
|
// for files (with an empty/template body) and POST + X-ZDDC-Op:
|
||||||
|
// mkdir for directories. Client-side enforcement is best-effort;
|
||||||
|
// the server's ACL is the source of truth.
|
||||||
|
|
||||||
|
async function makeDir(parentDir, name) {
|
||||||
|
var url = joinUrl(parentDir, name);
|
||||||
|
if (!url.endsWith('/')) url += '/';
|
||||||
|
var resp = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { 'X-ZDDC-Op': 'mkdir' }
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function makeFile(parentDir, name, body, contentType) {
|
||||||
|
var resp = await fetch(joinUrl(parentDir, name), {
|
||||||
|
method: 'PUT',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { 'Content-Type': contentType || 'application/octet-stream' },
|
||||||
|
body: body == null ? '' : body
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Delete + rename ─────────────────────────────────────────────────────
|
||||||
|
// Both run through the same FS Access API + file-API endpoints used
|
||||||
|
// by the create helpers above:
|
||||||
|
// - Server mode: DELETE / POST X-ZDDC-Op: move. ACL is enforced
|
||||||
|
// server-side; a 403/405 surfaces as an error toast.
|
||||||
|
// - FS-API mode: FileSystemHandle.remove({recursive:true}) and
|
||||||
|
// .move(newName) — both are Chromium-110+ features. We feature-
|
||||||
|
// detect at the handle level; callers see a clear "not supported"
|
||||||
|
// error message if the browser is too old.
|
||||||
|
|
||||||
|
function pathForNode(node) {
|
||||||
|
var tree = window.app.modules.tree;
|
||||||
|
return tree ? tree.pathFor(node) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isZipMember(node) {
|
||||||
|
if (node.handle && node.handle.isZipEntry) return true;
|
||||||
|
if (node.url && state.source === 'server' && /\.zip\//i.test(node.url)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// True when this node's write API is reachable. The server can
|
||||||
|
// still refuse the action on ACL grounds; this only gates the
|
||||||
|
// menu's disabled-state for the cases where there's clearly no
|
||||||
|
// write target at all.
|
||||||
|
function canMutate(node) {
|
||||||
|
if (!node || node.virtual) return false;
|
||||||
|
if (isZipMember(node)) return false;
|
||||||
|
if (state.source === 'server') return true;
|
||||||
|
if (node.handle && typeof node.handle.remove === 'function') return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeNode(node) {
|
||||||
|
if (!node) throw new Error('no node');
|
||||||
|
if (isZipMember(node)) {
|
||||||
|
throw new Error('Cannot delete a file inside a zip archive.');
|
||||||
|
}
|
||||||
|
if (node.virtual) {
|
||||||
|
throw new Error('Virtual folder — nothing on disk to delete.');
|
||||||
|
}
|
||||||
|
if (state.source === 'server') {
|
||||||
|
var url = pathForNode(node);
|
||||||
|
if (node.isDir && !url.endsWith('/')) url += '/';
|
||||||
|
var resp = await fetch(url, {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'same-origin'
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
if (resp.status === 403) throw new Error('Permission denied (403).');
|
||||||
|
if (resp.status === 405) throw new Error('Delete not allowed for this entry.');
|
||||||
|
throw new Error('HTTP ' + resp.status);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// FS-API path. FileSystemHandle.remove() is Chromium 110+
|
||||||
|
// (browsers that didn't ship it expose no equivalent — the
|
||||||
|
// legacy removeEntry() lives on the PARENT directory handle
|
||||||
|
// and we don't retain ancestor handles).
|
||||||
|
if (node.handle && typeof node.handle.remove === 'function') {
|
||||||
|
await node.handle.remove({ recursive: !!node.isDir });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error('Delete not supported by this browser in offline mode.');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renameNode(node, newName) {
|
||||||
|
if (!node) throw new Error('no node');
|
||||||
|
if (!newName) throw new Error('Name required.');
|
||||||
|
if (newName === node.name) return;
|
||||||
|
if (isZipMember(node)) {
|
||||||
|
throw new Error('Cannot rename a file inside a zip archive.');
|
||||||
|
}
|
||||||
|
if (node.virtual) {
|
||||||
|
throw new Error('Virtual folder — nothing on disk to rename.');
|
||||||
|
}
|
||||||
|
if (state.source === 'server') {
|
||||||
|
var src = pathForNode(node);
|
||||||
|
if (node.isDir && !src.endsWith('/')) src += '/';
|
||||||
|
// Destination = same parent, new basename.
|
||||||
|
var lastSlash = src.replace(/\/$/, '').lastIndexOf('/');
|
||||||
|
var parent = lastSlash >= 0 ? src.substring(0, lastSlash + 1) : '/';
|
||||||
|
var dst = parent + encodeURIComponent(newName) + (node.isDir ? '/' : '');
|
||||||
|
var resp = await fetch(src, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: {
|
||||||
|
'X-ZDDC-Op': 'move',
|
||||||
|
'X-ZDDC-Destination': dst
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
if (resp.status === 403) throw new Error('Permission denied (403).');
|
||||||
|
if (resp.status === 409) throw new Error('A file with that name already exists.');
|
||||||
|
throw new Error('HTTP ' + resp.status);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// FS-API: handle.move(newName) is Chromium 110+.
|
||||||
|
if (node.handle && typeof node.handle.move === 'function') {
|
||||||
|
await node.handle.move(newName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error('Rename not supported by this browser in offline mode.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh either the root listing (when the upload targeted the
|
||||||
|
// current scope) or just one folder node's children (when the
|
||||||
|
// upload targeted a subfolder via a per-row drop).
|
||||||
|
async function refreshAfterUpload(targetDir) {
|
||||||
|
var loader = window.app.modules.loader;
|
||||||
|
var tree = window.app.modules.tree;
|
||||||
|
if (!loader || !tree) return;
|
||||||
|
if (state.currentPath && targetDir === state.currentPath) {
|
||||||
|
try {
|
||||||
|
var es = await loader.fetchServerChildren(state.currentPath);
|
||||||
|
tree.setRoot(es);
|
||||||
|
tree.render();
|
||||||
|
} catch (_e) { /* swallow */ }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Find any tree node whose path matches targetDir and reload
|
||||||
|
// its children. Walks state.nodes flat — n is small enough for
|
||||||
|
// a linear scan.
|
||||||
|
var dirNoSlash = (targetDir || '').replace(/\/$/, '');
|
||||||
|
var hit = null;
|
||||||
|
state.nodes.forEach(function (n) {
|
||||||
|
if (hit || !n.isDir) return;
|
||||||
|
if (tree.pathFor(n).replace(/\/$/, '') === dirNoSlash) hit = n;
|
||||||
|
});
|
||||||
|
if (hit && hit.expanded) {
|
||||||
|
try {
|
||||||
|
var raw = await loader.fetchServerChildren(targetDir);
|
||||||
|
tree.setChildren(hit.id, raw);
|
||||||
|
tree.render();
|
||||||
|
} catch (_e) { /* swallow */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Document-level drop: targets the currently-viewed scope. The
|
||||||
|
// per-row drop (events.js) calls uploadToDir directly with a
|
||||||
|
// different destination.
|
||||||
async function handleDrop(e) {
|
async function handleDrop(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
@ -133,46 +417,21 @@
|
||||||
|
|
||||||
if (!currentScopeAllows()) return;
|
if (!currentScopeAllows()) return;
|
||||||
var dt = e.dataTransfer;
|
var dt = e.dataTransfer;
|
||||||
if (!dt || !dt.files || dt.files.length === 0) return;
|
if (!dt) return;
|
||||||
|
var uploads = await collectUploads(dt);
|
||||||
|
if (!uploads.length) return;
|
||||||
|
await uploadBatch(uploads, state.currentPath);
|
||||||
|
await refreshAfterUpload(state.currentPath);
|
||||||
|
}
|
||||||
|
|
||||||
var files = Array.from(dt.files);
|
// Public entry for per-row drops or programmatic uploads. destDir
|
||||||
var note = window.zddc && window.zddc.toast;
|
// must be a server path (/-prefixed, slash-terminated optional).
|
||||||
if (note) note('Uploading ' + files.length + ' file' + (files.length === 1 ? '' : 's') + '…', 'info');
|
async function uploadToDir(destDir, dataTransfer) {
|
||||||
|
var uploads = await collectUploads(dataTransfer);
|
||||||
// Sequential — predictable progress + ordering. Can parallelise
|
if (!uploads.length) return { ok: 0, fail: 0 };
|
||||||
// later if it matters.
|
var res = await uploadBatch(uploads, destDir);
|
||||||
var ok = 0, fail = 0;
|
await refreshAfterUpload(destDir);
|
||||||
for (var i = 0; i < files.length; i++) {
|
return res;
|
||||||
var res = await uploadOne(files[i]);
|
|
||||||
if (res.ok) {
|
|
||||||
ok++;
|
|
||||||
} else {
|
|
||||||
fail++;
|
|
||||||
if (note) {
|
|
||||||
note('Upload failed: ' + res.file.name + ' — ' + res.message, 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (note) {
|
|
||||||
if (fail === 0) {
|
|
||||||
note('Uploaded ' + ok + ' file' + (ok === 1 ? '' : 's'), 'success');
|
|
||||||
} else if (ok === 0) {
|
|
||||||
note('All ' + fail + ' upload' + (fail === 1 ? '' : 's') + ' failed', 'error');
|
|
||||||
} else {
|
|
||||||
note(ok + ' uploaded, ' + fail + ' failed', 'warning');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refresh the listing so newly-uploaded files appear.
|
|
||||||
var loader = window.app.modules.loader;
|
|
||||||
var tree = window.app.modules.tree;
|
|
||||||
if (loader && tree && state.currentPath) {
|
|
||||||
try {
|
|
||||||
var es = await loader.fetchServerChildren(state.currentPath);
|
|
||||||
tree.setRoot(es);
|
|
||||||
tree.render();
|
|
||||||
} catch (_e) { /* swallow; user can hard-reload */ }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onEnter(e) {
|
function onEnter(e) {
|
||||||
|
|
@ -215,6 +474,12 @@
|
||||||
|
|
||||||
window.app.modules.upload = {
|
window.app.modules.upload = {
|
||||||
currentScopeAllows: currentScopeAllows,
|
currentScopeAllows: currentScopeAllows,
|
||||||
|
uploadToDir: uploadToDir,
|
||||||
|
makeDir: makeDir,
|
||||||
|
makeFile: makeFile,
|
||||||
|
removeNode: removeNode,
|
||||||
|
renameNode: renameNode,
|
||||||
|
canMutate: canMutate,
|
||||||
UPLOAD_MAX_BYTES: UPLOAD_MAX_BYTES
|
UPLOAD_MAX_BYTES: UPLOAD_MAX_BYTES
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -24,10 +24,16 @@
|
||||||
<span class="app-header__title">ZDDC Browse</span>
|
<span class="app-header__title">ZDDC Browse</span>
|
||||||
<span class="build-timestamp">{{BUILD_LABEL}}</span>
|
<span class="build-timestamp">{{BUILD_LABEL}}</span>
|
||||||
</div>
|
</div>
|
||||||
<button id="addDirectoryBtn" class="btn btn-primary">Add Local Directory</button>
|
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
|
||||||
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh listing" aria-label="Refresh listing">⟳</button>
|
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh listing" aria-label="Refresh listing">⟳</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
|
<!-- Elevation toggle slot. shared/elevation.js fills it
|
||||||
|
when /.profile/access reports the user has admin
|
||||||
|
authority; stays empty + hidden for non-admins so
|
||||||
|
the chrome is quiet for the common case. -->
|
||||||
|
<span id="elevation-toggle" class="elevation-toggle hidden"
|
||||||
|
title="Opt into your admin powers for the next 30 minutes (sudo-style)."></span>
|
||||||
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)">◐</button>
|
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)">◐</button>
|
||||||
<button id="help-btn" class="btn btn-secondary" title="Help" aria-label="Help">?</button>
|
<button id="help-btn" class="btn btn-secondary" title="Help" aria-label="Help">?</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -41,7 +47,7 @@
|
||||||
<ul class="welcome-list">
|
<ul class="welcome-list">
|
||||||
<li><b>Online</b> — when this page is served by zddc-server, the
|
<li><b>Online</b> — when this page is served by zddc-server, the
|
||||||
listing for the current directory loads automatically.</li>
|
listing for the current directory loads automatically.</li>
|
||||||
<li><b>Local</b> — click <i>Add Local Directory</i> to pick any folder
|
<li><b>Local</b> — click <i>Use Local Directory</i> to pick any folder
|
||||||
on your computer (Chromium-based browsers).</li>
|
on your computer (Chromium-based browsers).</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p>Once loaded: click folders to expand, click files to preview them in
|
<p>Once loaded: click folders to expand, click files to preview them in
|
||||||
|
|
@ -54,33 +60,20 @@
|
||||||
<div id="browseRoot" class="browse-root hidden">
|
<div id="browseRoot" class="browse-root hidden">
|
||||||
<div class="browse-toolbar">
|
<div class="browse-toolbar">
|
||||||
<nav class="breadcrumbs" id="breadcrumbs" aria-label="Path"></nav>
|
<nav class="breadcrumbs" id="breadcrumbs" aria-label="Path"></nav>
|
||||||
<span class="toolbar__count" id="entryCount"></span>
|
|
||||||
<button id="downloadZipBtn" class="btn btn-sm btn-secondary hidden"
|
|
||||||
title="Download this folder (and everything under it you can access) as a .zip"
|
|
||||||
aria-label="Download this folder as a zip">⤓ Download (zip)</button>
|
|
||||||
<label class="sort-control" for="sortBy" title="Sort tree entries">
|
|
||||||
<span class="sort-control__label">Sort:</span>
|
|
||||||
<select id="sortBy" class="sort-control__select" aria-label="Sort tree entries">
|
|
||||||
<option value="name:asc">Name (A→Z)</option>
|
|
||||||
<option value="name:desc">Name (Z→A)</option>
|
|
||||||
<option value="date:desc">Modified (new→old)</option>
|
|
||||||
<option value="date:asc">Modified (old→new)</option>
|
|
||||||
<option value="size:desc">Size (large→small)</option>
|
|
||||||
<option value="size:asc">Size (small→large)</option>
|
|
||||||
<option value="ext:asc">Type (A→Z)</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label class="sort-control" for="showHidden"
|
|
||||||
title="Surface .-prefixed and _-prefixed entries (.zddc, .converted/, _app/, …). ACL still applies — you only see what you'd already be allowed to read.">
|
|
||||||
<input type="checkbox" id="showHidden" class="sort-control__checkbox"
|
|
||||||
aria-label="Show hidden files">
|
|
||||||
<span class="sort-control__label">Show hidden</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Browse mode (default): two-pane tree + preview -->
|
<!-- Browse mode (default): two-pane tree + preview -->
|
||||||
<div id="browseView" class="browse-view">
|
<div id="browseView" class="browse-view">
|
||||||
<div class="pane tree-pane" id="treePane">
|
<div class="pane tree-pane" id="treePane">
|
||||||
|
<div class="tree-pane__toolbar">
|
||||||
|
<input type="search"
|
||||||
|
id="treeFilter"
|
||||||
|
class="tree-filter"
|
||||||
|
placeholder="Filter files…"
|
||||||
|
aria-label="Filter the tree by name, tracking number, status, revision, or title"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false">
|
||||||
|
</div>
|
||||||
<div class="tree-pane__body" id="treeBody" role="tree" aria-label="Files"></div>
|
<div class="tree-pane__body" id="treeBody" role="tree" aria-label="Files"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="pane-resizer" data-resizer-for="tree-pane" aria-hidden="true"></div>
|
<div class="pane-resizer" data-resizer-for="tree-pane" aria-hidden="true"></div>
|
||||||
|
|
@ -135,18 +128,22 @@
|
||||||
<dd>Recursive expand or collapse — the whole subtree.</dd>
|
<dd>Recursive expand or collapse — the whole subtree.</dd>
|
||||||
<dt>Click a file</dt>
|
<dt>Click a file</dt>
|
||||||
<dd>Preview it in the right pane.</dd>
|
<dd>Preview it in the right pane.</dd>
|
||||||
|
<dt>Right-click any row</dt>
|
||||||
|
<dd>Opens a context menu with Open, Download, Copy path, Sort, and
|
||||||
|
folder-specific actions. Toggle items show a ✓ when active; submenus
|
||||||
|
open on hover.</dd>
|
||||||
<dt>⤴ Pop out</dt>
|
<dt>⤴ Pop out</dt>
|
||||||
<dd>Open the current preview in a separate window — useful for a second
|
<dd>Open the current preview in a separate window — useful for a second
|
||||||
monitor.</dd>
|
monitor.</dd>
|
||||||
<dt>ZIP files</dt>
|
<dt>ZIP files</dt>
|
||||||
<dd>Behave as folders — click to inspect contents inline. JSZip is
|
<dd>Behave as folders — click to inspect contents inline. JSZip is
|
||||||
bundled, so this works offline.</dd>
|
bundled, so this works offline.</dd>
|
||||||
<dt>⤓ Download (zip)</dt>
|
<dt>Download / Download ZIP</dt>
|
||||||
<dd>Downloads the directory you're currently viewing — and everything
|
<dd>Right-click a file for <b>Download</b>, or a folder for
|
||||||
under it that you're allowed to see — as a single <code>.zip</code>.
|
<b>Download ZIP</b> (everything under it that you're allowed to see,
|
||||||
Navigate into a subfolder first to download just that subtree. Online,
|
bundled into one archive). Online, the server streams it; locally,
|
||||||
the server streams it; locally, the browser bundles the picked folder
|
the browser bundles the picked folder (a confirmation appears if it's
|
||||||
(a confirmation appears if it's very large).</dd>
|
very large).</dd>
|
||||||
<dt>Refresh</dt>
|
<dt>Refresh</dt>
|
||||||
<dd>Re-fetches the current directory listing — works for both
|
<dd>Re-fetches the current directory listing — works for both
|
||||||
local (re-enumerates the FS handle) and online (re-fetches the JSON).</dd>
|
local (re-enumerates the FS handle) and online (re-fetches the JSON).</dd>
|
||||||
|
|
@ -154,7 +151,7 @@
|
||||||
|
|
||||||
<h3>Header buttons</h3>
|
<h3>Header buttons</h3>
|
||||||
<dl>
|
<dl>
|
||||||
<dt>Add Local Directory</dt>
|
<dt>Use Local Directory</dt>
|
||||||
<dd>Pick a folder from your computer. Works in both modes; in online
|
<dd>Pick a folder from your computer. Works in both modes; in online
|
||||||
mode it's de-emphasized but still available.</dd>
|
mode it's de-emphasized but still available.</dd>
|
||||||
<dt>⟳ Refresh</dt>
|
<dt>⟳ Refresh</dt>
|
||||||
|
|
|
||||||
|
|
@ -230,7 +230,7 @@ a:hover {
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Subdued / de-emphasized variant.
|
/* Subdued / de-emphasized variant.
|
||||||
Used on the "Add Local Directory" button when a tool is operating
|
Used on the "Use Local Directory" button when a tool is operating
|
||||||
in server (online) mode — the local-dir affordance is still
|
in server (online) mode — the local-dir affordance is still
|
||||||
available but visually quieter, since the typical user already
|
available but visually quieter, since the typical user already
|
||||||
has the directory loaded from the server. */
|
has the directory loaded from the server. */
|
||||||
|
|
@ -292,6 +292,11 @@ a:hover {
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
/* Let the left / right groups wrap to a second row at narrow
|
||||||
|
viewports rather than overflowing the viewport edge. row-gap
|
||||||
|
gives a small breathing strip when wrapped. */
|
||||||
|
flex-wrap: wrap;
|
||||||
|
row-gap: 0.3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Left and right groups inside .app-header. Both flex-row so their
|
/* Left and right groups inside .app-header. Both flex-row so their
|
||||||
|
|
@ -303,16 +308,35 @@ a:hover {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
|
/* Allow the title to shrink (and ellipsize) before the action
|
||||||
|
buttons get pushed off-screen at narrow viewports. */
|
||||||
|
min-width: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
row-gap: 0.3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-right {
|
.header-right {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Title group (title + build label). Made shrinkable so narrow
|
||||||
|
viewports don't push the action buttons out of view; the title
|
||||||
|
itself ellipsizes via the rule below. */
|
||||||
|
.header-title-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-width: 0;
|
||||||
|
flex-shrink: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tool name inside the header. Renders in the display serif so the
|
/* Tool name inside the header. Renders in the display serif so the
|
||||||
tool's identity reads as a document title, not a UI label. */
|
tool's identity reads as a document title, not a UI label.
|
||||||
|
overflow + ellipsis on min-width:0 lets the title compress
|
||||||
|
gracefully when there's no room. */
|
||||||
.app-header__title {
|
.app-header__title {
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
|
|
@ -320,6 +344,9 @@ a:hover {
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Brand logo — sits left of the title in every tool's app-header.
|
/* Brand logo — sits left of the title in every tool's app-header.
|
||||||
|
|
|
||||||
109
shared/context-menu.css
Normal file
109
shared/context-menu.css
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
/* shared/context-menu.css — generic styles for window.zddc.menu.
|
||||||
|
Mirrors the look-and-feel of native context menus: tight rows,
|
||||||
|
five-column grid (check | icon | label | accel | arrow), subtle
|
||||||
|
border + shadow, hover background from the shared --bg-hover token,
|
||||||
|
danger items tinted with --danger. */
|
||||||
|
|
||||||
|
.zddc-menu {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 10000;
|
||||||
|
min-width: 12rem;
|
||||||
|
max-width: 22rem;
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18),
|
||||||
|
0 2px 6px rgba(0, 0, 0, 0.10);
|
||||||
|
font-family: var(--font);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
user-select: none;
|
||||||
|
/* Allow focus styles inside without leaking to the menu itself. */
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zddc-menu__sep {
|
||||||
|
height: 1px;
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.zddc-menu__item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.1rem 1.25rem 1fr auto 0.9rem;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.3rem 0.7rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text);
|
||||||
|
/* Suppress the focus ring on the row itself — hover/focus
|
||||||
|
background handles the cue. */
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zddc-menu__item:hover,
|
||||||
|
.zddc-menu__item:focus,
|
||||||
|
.zddc-menu__item:focus-visible {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.zddc-menu__item.is-disabled {
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zddc-menu__item.is-disabled:hover,
|
||||||
|
.zddc-menu__item.is-disabled:focus {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zddc-menu__item--danger {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.zddc-menu__item--danger:hover,
|
||||||
|
.zddc-menu__item--danger:focus {
|
||||||
|
background: var(--danger);
|
||||||
|
color: var(--text-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.zddc-menu__check {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.zddc-menu__icon {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zddc-menu__label {
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zddc-menu__accel {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
padding-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zddc-menu__item--danger .zddc-menu__accel {
|
||||||
|
color: inherit;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zddc-menu__arrow {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zddc-menu__item--has-sub .zddc-menu__arrow {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
381
shared/context-menu.js
Normal file
381
shared/context-menu.js
Normal file
|
|
@ -0,0 +1,381 @@
|
||||||
|
// shared/context-menu.js — generic context-menu framework exposed on
|
||||||
|
// window.zddc.menu. Built so every ZDDC tool can drop a right-click
|
||||||
|
// menu (or any programmatically-opened menu) onto its UI without
|
||||||
|
// shipping its own implementation.
|
||||||
|
//
|
||||||
|
// API:
|
||||||
|
// window.zddc.menu.open({ x, y, items, context })
|
||||||
|
// window.zddc.menu.close()
|
||||||
|
//
|
||||||
|
// `items` is an array (or a function returning an array, evaluated
|
||||||
|
// against `context` at open-time). Each entry is one of:
|
||||||
|
// { label, action, icon?, accel?, disabled?, visible?, danger? }
|
||||||
|
// — a normal menu item; `action(ctx)` fires on click/Enter.
|
||||||
|
// { label, checked, action, ... }
|
||||||
|
// — toggle item; `checked` may be a bool or a fn(ctx). Renders
|
||||||
|
// a ✓ in the gutter when truthy.
|
||||||
|
// { label, items, ... }
|
||||||
|
// — submenu; `items` may itself be an array or fn(ctx).
|
||||||
|
// { separator: true }
|
||||||
|
// — horizontal divider. Leading/trailing/duplicate separators
|
||||||
|
// are collapsed automatically so callers can build items
|
||||||
|
// conditionally without managing dividers.
|
||||||
|
//
|
||||||
|
// Any of `label`, `checked`, `visible`, `disabled`, and `items` may
|
||||||
|
// be a function — each is invoked with the context object so callers
|
||||||
|
// can render fully context-aware menus from a single declarative
|
||||||
|
// config.
|
||||||
|
//
|
||||||
|
// Keyboard: ArrowUp/Down move within a menu, ArrowRight opens a
|
||||||
|
// submenu, ArrowLeft / Escape backs up one level (or closes if
|
||||||
|
// already at the root), Enter / Space activates. Click-outside,
|
||||||
|
// window blur, scroll, and resize all dismiss.
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
if (!window.zddc) window.zddc = {};
|
||||||
|
if (window.zddc.menu) return;
|
||||||
|
|
||||||
|
var SUBMENU_HOVER_MS = 180;
|
||||||
|
|
||||||
|
// Open menu stack — index 0 is the root, deeper entries are
|
||||||
|
// nested submenus. Each frame: { el, depth, parentRow? }.
|
||||||
|
var stack = [];
|
||||||
|
var rootContext = null;
|
||||||
|
var submenuTimer = null;
|
||||||
|
|
||||||
|
function resolve(val, ctx) {
|
||||||
|
return typeof val === 'function' ? val(ctx) : val;
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
if (submenuTimer) { clearTimeout(submenuTimer); submenuTimer = null; }
|
||||||
|
for (var i = 0; i < stack.length; i++) {
|
||||||
|
var fr = stack[i];
|
||||||
|
if (fr.el && fr.el.parentNode) fr.el.parentNode.removeChild(fr.el);
|
||||||
|
}
|
||||||
|
stack = [];
|
||||||
|
rootContext = null;
|
||||||
|
document.removeEventListener('mousedown', onDocMouseDown, true);
|
||||||
|
document.removeEventListener('keydown', onDocKeyDown, true);
|
||||||
|
// blur is bound WITHOUT capture so we only react to the window
|
||||||
|
// itself losing focus — capturing would also fire when any
|
||||||
|
// inner element blurs (which happens every time the user moves
|
||||||
|
// the mouse between menu rows, since hover focuses the row).
|
||||||
|
window.removeEventListener('blur', close);
|
||||||
|
window.removeEventListener('resize', close, true);
|
||||||
|
window.removeEventListener('scroll', onDocScroll, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function open(opts) {
|
||||||
|
opts = opts || {};
|
||||||
|
close();
|
||||||
|
rootContext = opts.context || {};
|
||||||
|
var items = resolve(opts.items, rootContext) || [];
|
||||||
|
var el = buildMenu(items, rootContext, 0);
|
||||||
|
document.body.appendChild(el);
|
||||||
|
position(el, opts.x || 0, opts.y || 0, null);
|
||||||
|
stack.push({ el: el, depth: 0 });
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', onDocMouseDown, true);
|
||||||
|
document.addEventListener('keydown', onDocKeyDown, true);
|
||||||
|
window.addEventListener('blur', close);
|
||||||
|
window.addEventListener('resize', close, true);
|
||||||
|
window.addEventListener('scroll', onDocScroll, true);
|
||||||
|
|
||||||
|
focusFirst(el);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Building ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function collapseSeparators(items) {
|
||||||
|
var out = [];
|
||||||
|
for (var i = 0; i < items.length; i++) {
|
||||||
|
var it = items[i];
|
||||||
|
if (it && it.separator) {
|
||||||
|
if (out.length === 0) continue;
|
||||||
|
if (out[out.length - 1].separator) continue;
|
||||||
|
out.push(it);
|
||||||
|
} else if (it) {
|
||||||
|
out.push(it);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while (out.length && out[out.length - 1].separator) out.pop();
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMenu(items, ctx, depth) {
|
||||||
|
var menu = document.createElement('div');
|
||||||
|
menu.className = 'zddc-menu';
|
||||||
|
menu.setAttribute('role', 'menu');
|
||||||
|
menu.dataset.depth = String(depth);
|
||||||
|
// Suppress the native context menu over our own menu.
|
||||||
|
menu.addEventListener('contextmenu', function (e) { e.preventDefault(); });
|
||||||
|
|
||||||
|
var filtered = items.filter(function (it) {
|
||||||
|
if (!it) return false;
|
||||||
|
if (it.separator) return true;
|
||||||
|
if ('visible' in it && !resolve(it.visible, ctx)) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
var pruned = collapseSeparators(filtered);
|
||||||
|
|
||||||
|
for (var i = 0; i < pruned.length; i++) {
|
||||||
|
menu.appendChild(buildRow(pruned[i], ctx, depth));
|
||||||
|
}
|
||||||
|
return menu;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRow(item, ctx, depth) {
|
||||||
|
if (item.separator) {
|
||||||
|
var sep = document.createElement('div');
|
||||||
|
sep.className = 'zddc-menu__sep';
|
||||||
|
sep.setAttribute('role', 'separator');
|
||||||
|
return sep;
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasSub = !!item.items;
|
||||||
|
var isToggle = ('checked' in item);
|
||||||
|
var disabled = 'disabled' in item ? !!resolve(item.disabled, ctx) : false;
|
||||||
|
|
||||||
|
var row = document.createElement('div');
|
||||||
|
row.className = 'zddc-menu__item';
|
||||||
|
if (item.danger) row.classList.add('zddc-menu__item--danger');
|
||||||
|
if (hasSub) row.classList.add('zddc-menu__item--has-sub');
|
||||||
|
if (disabled) {
|
||||||
|
row.classList.add('is-disabled');
|
||||||
|
row.setAttribute('aria-disabled', 'true');
|
||||||
|
}
|
||||||
|
row.setAttribute('role',
|
||||||
|
hasSub ? 'menuitem'
|
||||||
|
: (isToggle ? 'menuitemcheckbox' : 'menuitem'));
|
||||||
|
row.tabIndex = -1;
|
||||||
|
|
||||||
|
// Check gutter — present on every row so columns align.
|
||||||
|
var check = document.createElement('span');
|
||||||
|
check.className = 'zddc-menu__check';
|
||||||
|
if (isToggle) {
|
||||||
|
var on = !!resolve(item.checked, ctx);
|
||||||
|
if (on) {
|
||||||
|
check.textContent = '✓';
|
||||||
|
row.classList.add('is-checked');
|
||||||
|
row.setAttribute('aria-checked', 'true');
|
||||||
|
} else {
|
||||||
|
row.setAttribute('aria-checked', 'false');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
row.appendChild(check);
|
||||||
|
|
||||||
|
// Icon column.
|
||||||
|
var icon = document.createElement('span');
|
||||||
|
icon.className = 'zddc-menu__icon';
|
||||||
|
if (item.icon) icon.textContent = item.icon;
|
||||||
|
row.appendChild(icon);
|
||||||
|
|
||||||
|
// Label.
|
||||||
|
var label = document.createElement('span');
|
||||||
|
label.className = 'zddc-menu__label';
|
||||||
|
label.textContent = String(resolve(item.label, ctx) || '');
|
||||||
|
row.appendChild(label);
|
||||||
|
|
||||||
|
// Accelerator hint (visual only; no binding).
|
||||||
|
var accel = document.createElement('span');
|
||||||
|
accel.className = 'zddc-menu__accel';
|
||||||
|
if (item.accel) accel.textContent = item.accel;
|
||||||
|
row.appendChild(accel);
|
||||||
|
|
||||||
|
// Submenu arrow.
|
||||||
|
var arrow = document.createElement('span');
|
||||||
|
arrow.className = 'zddc-menu__arrow';
|
||||||
|
if (hasSub) arrow.textContent = '▸';
|
||||||
|
row.appendChild(arrow);
|
||||||
|
|
||||||
|
if (!disabled) {
|
||||||
|
row.addEventListener('mouseenter', function () {
|
||||||
|
// Hovering any row in a menu collapses deeper menus
|
||||||
|
// (so traversing siblings closes a previously-opened
|
||||||
|
// submenu) and re-focuses this row for keyboard nav.
|
||||||
|
closeBelow(depth);
|
||||||
|
if (submenuTimer) { clearTimeout(submenuTimer); submenuTimer = null; }
|
||||||
|
if (hasSub) {
|
||||||
|
submenuTimer = setTimeout(function () {
|
||||||
|
openSubmenu(row, item, ctx, depth + 1, false);
|
||||||
|
}, SUBMENU_HOVER_MS);
|
||||||
|
}
|
||||||
|
try { row.focus({ preventScroll: true }); } catch (_e) { row.focus(); }
|
||||||
|
});
|
||||||
|
row.addEventListener('click', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (submenuTimer) { clearTimeout(submenuTimer); submenuTimer = null; }
|
||||||
|
if (hasSub) {
|
||||||
|
openSubmenu(row, item, ctx, depth + 1, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
activate(item, ctx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function activate(item, ctx) {
|
||||||
|
try {
|
||||||
|
if (typeof item.action === 'function') item.action(ctx);
|
||||||
|
} finally {
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSubmenu(parentRow, parentItem, ctx, depth, takeFocus) {
|
||||||
|
closeBelow(depth - 1);
|
||||||
|
var items = resolve(parentItem.items, ctx) || [];
|
||||||
|
var el = buildMenu(items, ctx, depth);
|
||||||
|
document.body.appendChild(el);
|
||||||
|
var rect = parentRow.getBoundingClientRect();
|
||||||
|
// Slight overlap so pointer-cross feels continuous.
|
||||||
|
position(el, rect.right - 2, rect.top - 4, parentRow);
|
||||||
|
stack.push({ el: el, depth: depth, parentRow: parentRow });
|
||||||
|
if (takeFocus) focusFirst(el);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeBelow(depth) {
|
||||||
|
while (stack.length && stack[stack.length - 1].depth > depth) {
|
||||||
|
var fr = stack.pop();
|
||||||
|
if (fr.el && fr.el.parentNode) fr.el.parentNode.removeChild(fr.el);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Positioning ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function position(el, x, y, parentRow) {
|
||||||
|
// Fixed so we ignore document scroll; measure after layout.
|
||||||
|
el.style.position = 'fixed';
|
||||||
|
el.style.left = '0px';
|
||||||
|
el.style.top = '0px';
|
||||||
|
el.style.visibility = 'hidden';
|
||||||
|
var rect = el.getBoundingClientRect();
|
||||||
|
var w = rect.width;
|
||||||
|
var h = rect.height;
|
||||||
|
var vw = window.innerWidth;
|
||||||
|
var vh = window.innerHeight;
|
||||||
|
|
||||||
|
var leftX = x;
|
||||||
|
if (leftX + w > vw - 4) {
|
||||||
|
if (parentRow) {
|
||||||
|
var pr = parentRow.getBoundingClientRect();
|
||||||
|
leftX = pr.left - w + 2; // flip submenu to the left
|
||||||
|
} else {
|
||||||
|
leftX = Math.max(4, x - w); // flip root menu left of cursor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (leftX < 4) leftX = 4;
|
||||||
|
|
||||||
|
var topY = y;
|
||||||
|
if (topY + h > vh - 4) topY = Math.max(4, vh - h - 4);
|
||||||
|
if (topY < 4) topY = 4;
|
||||||
|
|
||||||
|
el.style.left = leftX + 'px';
|
||||||
|
el.style.top = topY + 'px';
|
||||||
|
el.style.visibility = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Focus + keyboard ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function focusable(menuEl) {
|
||||||
|
return Array.prototype.slice.call(
|
||||||
|
menuEl.querySelectorAll('.zddc-menu__item:not(.is-disabled)'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusFirst(menuEl) {
|
||||||
|
var items = focusable(menuEl);
|
||||||
|
if (items.length) {
|
||||||
|
try { items[0].focus({ preventScroll: true }); }
|
||||||
|
catch (_e) { items[0].focus(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDocMouseDown(e) {
|
||||||
|
for (var i = 0; i < stack.length; i++) {
|
||||||
|
if (stack[i].el.contains(e.target)) return;
|
||||||
|
}
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scroll listener uses capture so scrolls inside any element (the
|
||||||
|
// tree pane, the document, etc.) dismiss the menu — its position
|
||||||
|
// is fixed and would otherwise hang over stale content. Scrolls
|
||||||
|
// that originate inside the menu itself (a future tall submenu)
|
||||||
|
// are ignored.
|
||||||
|
function onDocScroll(e) {
|
||||||
|
var t = e.target;
|
||||||
|
for (var i = 0; i < stack.length; i++) {
|
||||||
|
if (stack[i].el === t || (t && t.nodeType === 1 && stack[i].el.contains(t))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDocKeyDown(e) {
|
||||||
|
if (!stack.length) return;
|
||||||
|
var top = stack[stack.length - 1];
|
||||||
|
var items = focusable(top.el);
|
||||||
|
var active = document.activeElement;
|
||||||
|
var idx = items.indexOf(active);
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case 'Escape':
|
||||||
|
e.preventDefault();
|
||||||
|
if (stack.length > 1) {
|
||||||
|
var fr = stack.pop();
|
||||||
|
if (fr.el.parentNode) fr.el.parentNode.removeChild(fr.el);
|
||||||
|
if (fr.parentRow) fr.parentRow.focus();
|
||||||
|
} else {
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
case 'ArrowDown':
|
||||||
|
e.preventDefault();
|
||||||
|
if (!items.length) return;
|
||||||
|
items[idx < 0 ? 0 : (idx + 1) % items.length].focus();
|
||||||
|
return;
|
||||||
|
case 'ArrowUp':
|
||||||
|
e.preventDefault();
|
||||||
|
if (!items.length) return;
|
||||||
|
items[idx < 0 ? items.length - 1
|
||||||
|
: (idx - 1 + items.length) % items.length].focus();
|
||||||
|
return;
|
||||||
|
case 'Home':
|
||||||
|
e.preventDefault();
|
||||||
|
if (items.length) items[0].focus();
|
||||||
|
return;
|
||||||
|
case 'End':
|
||||||
|
e.preventDefault();
|
||||||
|
if (items.length) items[items.length - 1].focus();
|
||||||
|
return;
|
||||||
|
case 'ArrowRight':
|
||||||
|
if (active && active.classList.contains('zddc-menu__item--has-sub')) {
|
||||||
|
e.preventDefault();
|
||||||
|
active.click();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
case 'ArrowLeft':
|
||||||
|
if (stack.length > 1) {
|
||||||
|
e.preventDefault();
|
||||||
|
var fr2 = stack.pop();
|
||||||
|
if (fr2.el.parentNode) fr2.el.parentNode.removeChild(fr2.el);
|
||||||
|
if (fr2.parentRow) fr2.parentRow.focus();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
case 'Enter':
|
||||||
|
case ' ':
|
||||||
|
if (active) {
|
||||||
|
e.preventDefault();
|
||||||
|
active.click();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.zddc.menu = { open: open, close: close };
|
||||||
|
})();
|
||||||
162
shared/icons.js
Normal file
162
shared/icons.js
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
// shared/icons.js — minimal outline SVG sprite for ZDDC tools.
|
||||||
|
//
|
||||||
|
// Vendored from Lucide (https://lucide.dev, ISC). Only the 16
|
||||||
|
// file-type glyphs the browse tree maps to are bundled; total weight
|
||||||
|
// is ~4.5 KB of SVG path data. Each symbol viewBox is 0 0 24 24 with
|
||||||
|
// no stroke/fill attributes — those are applied at the call site via
|
||||||
|
// CSS so the icons inherit `currentColor` and tint with the theme.
|
||||||
|
//
|
||||||
|
// API:
|
||||||
|
// window.zddc.icons.inject() // mount sprite into <body> once
|
||||||
|
// window.zddc.icons.html('icon-foo') // → '<svg viewBox="0 0 24 24"><use href="#icon-foo"/></svg>'
|
||||||
|
// window.zddc.icons.ID // string set of valid symbol ids
|
||||||
|
//
|
||||||
|
// Callers concat html() output into innerHTML the same way they
|
||||||
|
// previously concat'd emoji glyphs. The injected sprite is hidden
|
||||||
|
// (`display:none` on the outer <svg>) so it costs zero layout.
|
||||||
|
//
|
||||||
|
// Why a sprite (rather than per-row inline paths): a hundred tree
|
||||||
|
// rows × 300 bytes of duplicated path data is 30 KB of churn on
|
||||||
|
// every re-render. With <use>, each row carries only a ~60-byte
|
||||||
|
// reference. The sprite is parsed once.
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
if (!window.zddc) window.zddc = {};
|
||||||
|
if (window.zddc.icons) return;
|
||||||
|
|
||||||
|
// ── Sprite (Lucide outline glyphs, viewBox 24×24) ──────────────────────
|
||||||
|
// Concatenated from upstream lucide-static@1.16.0 SVGs; class/style
|
||||||
|
// attributes stripped. Order matches the icons-mapped block below
|
||||||
|
// so a diff against Lucide's source stays readable.
|
||||||
|
var SYMBOLS = ''
|
||||||
|
+ '<symbol id="icon-folder" viewBox="0 0 24 24">'
|
||||||
|
+ '<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/>'
|
||||||
|
+ '</symbol>'
|
||||||
|
+ '<symbol id="icon-folder-archive" viewBox="0 0 24 24">'
|
||||||
|
+ '<circle cx="15" cy="19" r="2"/>'
|
||||||
|
+ '<path d="M20.9 19.8A2 2 0 0 0 22 18V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2h5.1"/>'
|
||||||
|
+ '<path d="M15 11v-1"/>'
|
||||||
|
+ '<path d="M15 17v-2"/>'
|
||||||
|
+ '</symbol>'
|
||||||
|
+ '<symbol id="icon-file" viewBox="0 0 24 24">'
|
||||||
|
+ '<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/>'
|
||||||
|
+ '<path d="M14 2v5a1 1 0 0 0 1 1h5"/>'
|
||||||
|
+ '</symbol>'
|
||||||
|
+ '<symbol id="icon-file-text" viewBox="0 0 24 24">'
|
||||||
|
+ '<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/>'
|
||||||
|
+ '<path d="M14 2v5a1 1 0 0 0 1 1h5"/>'
|
||||||
|
+ '<path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/>'
|
||||||
|
+ '</symbol>'
|
||||||
|
+ '<symbol id="icon-file-image" viewBox="0 0 24 24">'
|
||||||
|
+ '<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/>'
|
||||||
|
+ '<path d="M14 2v5a1 1 0 0 0 1 1h5"/>'
|
||||||
|
+ '<circle cx="10" cy="12" r="2"/>'
|
||||||
|
+ '<path d="m20 17-1.296-1.296a2.41 2.41 0 0 0-3.408 0L9 22"/>'
|
||||||
|
+ '</symbol>'
|
||||||
|
+ '<symbol id="icon-file-video" viewBox="0 0 24 24">'
|
||||||
|
+ '<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/>'
|
||||||
|
+ '<path d="M14 2v5a1 1 0 0 0 1 1h5"/>'
|
||||||
|
+ '<path d="M15.033 13.44a.647.647 0 0 1 0 1.12l-4.065 2.352a.645.645 0 0 1-.968-.56v-4.704a.645.645 0 0 1 .967-.56z"/>'
|
||||||
|
+ '</symbol>'
|
||||||
|
+ '<symbol id="icon-file-audio" viewBox="0 0 24 24">'
|
||||||
|
+ '<path d="M4 6.835V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.706.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2h-.343"/>'
|
||||||
|
+ '<path d="M14 2v5a1 1 0 0 0 1 1h5"/>'
|
||||||
|
+ '<path d="M2 19a2 2 0 0 1 4 0v1a2 2 0 0 1-4 0v-4a6 6 0 0 1 12 0v4a2 2 0 0 1-4 0v-1a2 2 0 0 1 4 0"/>'
|
||||||
|
+ '</symbol>'
|
||||||
|
+ '<symbol id="icon-file-archive" viewBox="0 0 24 24">'
|
||||||
|
+ '<path d="M13.659 22H18a2 2 0 0 0 2-2V8a2.4 2.4 0 0 0-.706-1.706l-3.588-3.588A2.4 2.4 0 0 0 14 2H6a2 2 0 0 0-2 2v11.5"/>'
|
||||||
|
+ '<path d="M14 2v5a1 1 0 0 0 1 1h5"/>'
|
||||||
|
+ '<path d="M8 12v-1"/><path d="M8 18v-2"/><path d="M8 7V6"/>'
|
||||||
|
+ '<circle cx="8" cy="20" r="2"/>'
|
||||||
|
+ '</symbol>'
|
||||||
|
+ '<symbol id="icon-file-spreadsheet" viewBox="0 0 24 24">'
|
||||||
|
+ '<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/>'
|
||||||
|
+ '<path d="M14 2v5a1 1 0 0 0 1 1h5"/>'
|
||||||
|
+ '<path d="M8 13h2"/><path d="M14 13h2"/><path d="M8 17h2"/><path d="M14 17h2"/>'
|
||||||
|
+ '</symbol>'
|
||||||
|
+ '<symbol id="icon-file-code" viewBox="0 0 24 24">'
|
||||||
|
+ '<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/>'
|
||||||
|
+ '<path d="M14 2v5a1 1 0 0 0 1 1h5"/>'
|
||||||
|
+ '<path d="M10 12.5 8 15l2 2.5"/>'
|
||||||
|
+ '<path d="m14 12.5 2 2.5-2 2.5"/>'
|
||||||
|
+ '</symbol>'
|
||||||
|
+ '<symbol id="icon-file-cog" viewBox="0 0 24 24">'
|
||||||
|
+ '<path d="M15 8a1 1 0 0 1-1-1V2a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8z"/>'
|
||||||
|
+ '<path d="M20 8v12a2 2 0 0 1-2 2h-4.182"/>'
|
||||||
|
+ '<path d="m3.305 19.53.923-.382"/>'
|
||||||
|
+ '<path d="M4 10.592V4a2 2 0 0 1 2-2h8"/>'
|
||||||
|
+ '<path d="m4.228 16.852-.924-.383"/>'
|
||||||
|
+ '<path d="m5.852 15.228-.383-.923"/>'
|
||||||
|
+ '<path d="m5.852 20.772-.383.924"/>'
|
||||||
|
+ '<path d="m8.148 15.228.383-.923"/>'
|
||||||
|
+ '<path d="m8.53 21.696-.382-.924"/>'
|
||||||
|
+ '<path d="m9.773 16.852.922-.383"/>'
|
||||||
|
+ '<path d="m9.773 19.148.922.383"/>'
|
||||||
|
+ '<circle cx="7" cy="18" r="3"/>'
|
||||||
|
+ '</symbol>'
|
||||||
|
+ '<symbol id="icon-file-pen" viewBox="0 0 24 24">'
|
||||||
|
+ '<path d="M12.659 22H18a2 2 0 0 0 2-2V8a2.4 2.4 0 0 0-.706-1.706l-3.588-3.588A2.4 2.4 0 0 0 14 2H6a2 2 0 0 0-2 2v9.34"/>'
|
||||||
|
+ '<path d="M14 2v5a1 1 0 0 0 1 1h5"/>'
|
||||||
|
+ '<path d="M10.378 12.622a1 1 0 0 1 3 3.003L8.36 20.637a2 2 0 0 1-.854.506l-2.867.837a.5.5 0 0 1-.62-.62l.836-2.869a2 2 0 0 1 .506-.853z"/>'
|
||||||
|
+ '</symbol>'
|
||||||
|
+ '<symbol id="icon-book-marked" viewBox="0 0 24 24">'
|
||||||
|
+ '<path d="M10 2v8l3-3 3 3V2"/>'
|
||||||
|
+ '<path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H19a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H6.5a1 1 0 0 1 0-5H20"/>'
|
||||||
|
+ '</symbol>'
|
||||||
|
+ '<symbol id="icon-presentation" viewBox="0 0 24 24">'
|
||||||
|
+ '<path d="M2 3h20"/>'
|
||||||
|
+ '<path d="M21 3v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V3"/>'
|
||||||
|
+ '<path d="m7 21 5-5 5 5"/>'
|
||||||
|
+ '</symbol>'
|
||||||
|
+ '<symbol id="icon-ruler" viewBox="0 0 24 24">'
|
||||||
|
+ '<path d="M21.3 15.3a2.4 2.4 0 0 1 0 3.4l-2.6 2.6a2.4 2.4 0 0 1-3.4 0L2.7 8.7a2.41 2.41 0 0 1 0-3.4l2.6-2.6a2.41 2.41 0 0 1 3.4 0Z"/>'
|
||||||
|
+ '<path d="m14.5 12.5 2-2"/>'
|
||||||
|
+ '<path d="m11.5 9.5 2-2"/>'
|
||||||
|
+ '<path d="m8.5 6.5 2-2"/>'
|
||||||
|
+ '<path d="m17.5 15.5 2-2"/>'
|
||||||
|
+ '</symbol>'
|
||||||
|
+ '<symbol id="icon-globe" viewBox="0 0 24 24">'
|
||||||
|
+ '<circle cx="12" cy="12" r="10"/>'
|
||||||
|
+ '<path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/>'
|
||||||
|
+ '<path d="M2 12h20"/>'
|
||||||
|
+ '</symbol>'
|
||||||
|
// Lightweight outline chevron — used by the tree as the
|
||||||
|
// expand/collapse affordance. The single glyph rotates 90°
|
||||||
|
// via CSS to indicate the expanded state, so we only ship
|
||||||
|
// one path instead of two.
|
||||||
|
+ '<symbol id="icon-chevron-right" viewBox="0 0 24 24">'
|
||||||
|
+ '<path d="m9 18 6-6-6-6"/>'
|
||||||
|
+ '</symbol>';
|
||||||
|
|
||||||
|
var injected = false;
|
||||||
|
|
||||||
|
function inject() {
|
||||||
|
if (injected) return;
|
||||||
|
// insertAdjacentHTML on body parses the SVG namespace correctly
|
||||||
|
// across all modern browsers (innerHTML on a <div> wrapper has
|
||||||
|
// historically tripped over <symbol> in some engines).
|
||||||
|
var sprite = '<svg xmlns="http://www.w3.org/2000/svg" '
|
||||||
|
+ 'aria-hidden="true" style="position:absolute;width:0;height:0;'
|
||||||
|
+ 'overflow:hidden" focusable="false">'
|
||||||
|
+ SYMBOLS
|
||||||
|
+ '</svg>';
|
||||||
|
if (document.body) {
|
||||||
|
document.body.insertAdjacentHTML('afterbegin', sprite);
|
||||||
|
injected = true;
|
||||||
|
} else {
|
||||||
|
document.addEventListener('DOMContentLoaded', inject, { once: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Produces the per-row markup callers concat into innerHTML.
|
||||||
|
// Bundles the size + stroke defaults inline so the SVG renders
|
||||||
|
// correctly even before the page CSS runs (e.g. mid-paint).
|
||||||
|
function html(symbolId) {
|
||||||
|
return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" '
|
||||||
|
+ 'stroke-width="2" stroke-linecap="round" stroke-linejoin="round" '
|
||||||
|
+ 'aria-hidden="true"><use href="#' + symbolId + '"/></svg>';
|
||||||
|
}
|
||||||
|
|
||||||
|
window.zddc.icons = { inject: inject, html: html };
|
||||||
|
})();
|
||||||
79
shared/vendor/codemirror-yaml.min.css
vendored
Normal file
79
shared/vendor/codemirror-yaml.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
shared/vendor/codemirror-yaml.min.js
vendored
Normal file
1
shared/vendor/codemirror-yaml.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue