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/logo.css" \
|
||||
"../shared/vendor/toastui-editor.min.css" \
|
||||
"../shared/vendor/codemirror-yaml.min.css" \
|
||||
"../shared/context-menu.css" \
|
||||
"../shared/elevation.css" \
|
||||
"css/base.css" \
|
||||
"css/tree.css" \
|
||||
"css/preview-yaml.css" \
|
||||
> "$css_temp"
|
||||
|
||||
# JS files: shared canonical helpers, then browse modules.
|
||||
|
|
@ -39,6 +43,8 @@ concat_files \
|
|||
concat_files \
|
||||
"../shared/vendor/jszip.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/zddc.js" \
|
||||
"../shared/zddc-filter.js" \
|
||||
|
|
@ -49,12 +55,17 @@ concat_files \
|
|||
"../shared/logo.js" \
|
||||
"../shared/help.js" \
|
||||
"../shared/preview-lib.js" \
|
||||
"../shared/context-menu.js" \
|
||||
"../shared/elevation.js" \
|
||||
"../shared/icons.js" \
|
||||
"../shared/zddc-source.js" \
|
||||
"js/init.js" \
|
||||
"js/loader.js" \
|
||||
"js/tree.js" \
|
||||
"js/preview.js" \
|
||||
"js/preview-markdown.js" \
|
||||
"js/preview-yaml.js" \
|
||||
"js/hovercard.js" \
|
||||
"js/grid.js" \
|
||||
"js/upload.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;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
font-family: var(--font);
|
||||
color: var(--text);
|
||||
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 {
|
||||
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;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
|
@ -109,12 +127,6 @@ html, body {
|
|||
vertical-align: -0.15em;
|
||||
}
|
||||
|
||||
.toolbar__count {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Two-pane browse view ────────────────────────────────────────────────── */
|
||||
|
||||
.browse-view {
|
||||
|
|
@ -139,6 +151,42 @@ html, body {
|
|||
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 {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
|
|
@ -250,9 +298,12 @@ html, body {
|
|||
|
||||
.tree-row {
|
||||
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;
|
||||
padding: 0.15rem 0.5rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
border-radius: 0;
|
||||
|
|
@ -268,37 +319,76 @@ html, body {
|
|||
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 {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.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;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
height: 1.2em;
|
||||
flex-shrink: 0;
|
||||
font-family: monospace;
|
||||
font-size: 0.65rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.tree-row[data-isdir="true"] .tree-name__chevron::before,
|
||||
.tree-row[data-iszip="true"] .tree-name__chevron::before {
|
||||
content: "▸";
|
||||
.tree-name__chevron svg {
|
||||
width: 0.85em;
|
||||
height: 0.85em;
|
||||
transition: transform 0.12s ease;
|
||||
}
|
||||
|
||||
.tree-row[data-isdir="true"].expanded .tree-name__chevron::before,
|
||||
.tree-row[data-iszip="true"].expanded .tree-name__chevron::before {
|
||||
content: "▾";
|
||||
}
|
||||
|
||||
.tree-name__chevron--leaf::before {
|
||||
content: "";
|
||||
/* Expanded state — rotate the same chevron 90° rather than swapping
|
||||
to a second glyph. Smooth, single-sprite, and consistent with the
|
||||
way most modern file trees indicate expand state. */
|
||||
.tree-row.expanded .tree-name__chevron svg {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.tree-name__icon {
|
||||
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 {
|
||||
|
|
@ -306,6 +396,48 @@ html, body {
|
|||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
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,
|
||||
|
|
@ -427,12 +559,15 @@ html, body {
|
|||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Sidebar (col 1): two stacked sections — Front matter (top, fixed
|
||||
default 180 px, drag-resizable) and TOC (bottom, takes the rest). */
|
||||
/* Sidebar (col 1): three stacked items — Front matter (fixed height,
|
||||
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 {
|
||||
grid-area: sidebar;
|
||||
display: grid;
|
||||
grid-template-rows: 180px 1fr; /* JS overrides on resize */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border-right: 1px solid var(--border);
|
||||
|
|
@ -460,20 +595,17 @@ html, body {
|
|||
outline: none;
|
||||
}
|
||||
|
||||
/* Horizontal resizer between front-matter and TOC inside the sidebar.
|
||||
Spans both rows by placement, then absolutely positioned to overlay
|
||||
the grid-row boundary. */
|
||||
/* Horizontal resizer — a real flex item between FM and TOC. Drag
|
||||
it up/down to change the front-matter pane's height; the JS
|
||||
handler updates fmSection.style.height directly. */
|
||||
.md-shell__fmresizer {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
align-self: end;
|
||||
justify-self: stretch;
|
||||
flex: 0 0 6px;
|
||||
height: 6px;
|
||||
margin-bottom: -3px;
|
||||
cursor: row-resize;
|
||||
background: transparent;
|
||||
z-index: 2;
|
||||
background: var(--border);
|
||||
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.is-dragging,
|
||||
|
|
@ -558,15 +690,30 @@ html, body {
|
|||
}
|
||||
|
||||
.md-side {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
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 {
|
||||
/* Header is its own flex item so the body can stretch to fill. */
|
||||
flex: 0 0 auto;
|
||||
padding: 0.35rem 0.75rem;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
|
|
@ -576,8 +723,13 @@ html, body {
|
|||
letter-spacing: 0.06em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.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;
|
||||
padding: 0.3rem 0;
|
||||
font-size: 0.85rem;
|
||||
|
|
@ -604,10 +756,11 @@ html, body {
|
|||
cursor: pointer;
|
||||
border-left: 2px solid transparent;
|
||||
transition: background 0.1s, border-color 0.1s, color 0.1s;
|
||||
/* Truncate long headings rather than wrap; the title attribute
|
||||
carries the full text. */
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
/* Single-line items but no ellipsis — long headings extend the
|
||||
item's intrinsic width, and the parent .md-side__body has
|
||||
overflow:auto, so they create a horizontal scrollbar instead
|
||||
of getting clipped. The title attribute still carries the
|
||||
full text for SR users. */
|
||||
white-space: nowrap;
|
||||
}
|
||||
.md-toc__item:hover {
|
||||
|
|
@ -670,44 +823,105 @@ html, body {
|
|||
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
|
||||
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.
|
||||
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() {
|
||||
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
|
||||
// (or any server with a Caddy-shaped JSON listing), load the
|
||||
// current directory automatically. Otherwise show the empty
|
||||
|
|
@ -40,6 +107,14 @@
|
|||
// response, re-resolve so an /incoming URL auto-activates
|
||||
// grid mode.
|
||||
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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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" —
|
||||
// zddc-server streams an ACL-filtered .zip of the subtree, so nothing
|
||||
// is held in the browser.
|
||||
// downloadFile: a single file. Server mode lets the browser pull
|
||||
// node.url (zddc-server emits Content-Disposition); FS-API mode
|
||||
// reads bytes through the file handle and blob-downloads.
|
||||
//
|
||||
// FS-API (offline) mode: there's no server, so we walk the picked
|
||||
// folder ourselves, bundle every file with JSZip, and download the
|
||||
// blob. A two-pass walk (metadata first, then bytes) lets us warn
|
||||
// before loading a very large tree into memory.
|
||||
// downloadFolder: an arbitrary directory node as a .zip. Server
|
||||
// mode points an <a download> at "<node-path>/?zip=1" so zddc-server
|
||||
// streams an ACL-filtered archive without buffering on the client.
|
||||
// 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 () {
|
||||
'use strict';
|
||||
|
||||
|
|
@ -103,39 +106,75 @@
|
|||
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;
|
||||
|
||||
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;
|
||||
var btn = document.getElementById('downloadZipBtn');
|
||||
if (!node || node.isDir) {
|
||||
events().statusError('Not a file: ' + (node && node.name));
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
if (btn) btn.disabled = true;
|
||||
try {
|
||||
if (state.source === 'server') {
|
||||
downloadServerSubtree();
|
||||
} else if (state.source === 'fs' && state.rootHandle) {
|
||||
await downloadFsSubtree(state.rootHandle);
|
||||
if (node.url) {
|
||||
events().statusInfo('Downloading ' + node.name + '…');
|
||||
downloadUrl(node.name, node.url);
|
||||
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 {
|
||||
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) {
|
||||
events().statusError('Download failed: ' + (e && e.message ? e.message : e));
|
||||
} finally {
|
||||
busy = false;
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
window.app.modules.download = {
|
||||
downloadCurrentSubtree: downloadCurrentSubtree
|
||||
downloadFile: downloadFile,
|
||||
downloadFolder: downloadFolder
|
||||
};
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -69,7 +69,6 @@
|
|||
function applySourceUI() {
|
||||
var add = document.getElementById('addDirectoryBtn');
|
||||
var refresh = document.getElementById('refreshHeaderBtn');
|
||||
var dlZip = document.getElementById('downloadZipBtn');
|
||||
if (add) {
|
||||
if (state.source === 'server') {
|
||||
add.classList.remove('btn-primary');
|
||||
|
|
@ -86,18 +85,16 @@
|
|||
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() {
|
||||
// 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') {
|
||||
var raw;
|
||||
try {
|
||||
|
|
@ -107,6 +104,7 @@
|
|||
return;
|
||||
}
|
||||
tree.setRoot(raw);
|
||||
await tree.restoreState(snap);
|
||||
tree.render();
|
||||
statusInfo('Refreshed (' + raw.length + ' item'
|
||||
+ (raw.length === 1 ? '' : 's') + ')');
|
||||
|
|
@ -119,6 +117,7 @@
|
|||
return;
|
||||
}
|
||||
tree.setRoot(raw2);
|
||||
await tree.restoreState(snap);
|
||||
tree.render();
|
||||
statusInfo('Refreshed');
|
||||
}
|
||||
|
|
@ -132,38 +131,31 @@
|
|||
var refresh = document.getElementById('refreshHeaderBtn');
|
||||
if (refresh) refresh.addEventListener('click', refreshListing);
|
||||
|
||||
var dlZip = document.getElementById('downloadZipBtn');
|
||||
if (dlZip) dlZip.addEventListener('click', function () {
|
||||
var d = window.app.modules.download;
|
||||
if (d) d.downloadCurrentSubtree();
|
||||
});
|
||||
|
||||
// Sort dropdown — change → tree re-renders with the new sort.
|
||||
// Format of option value: "<key>:<asc|desc>". Defaults match
|
||||
// state.sort initial values (name:asc).
|
||||
var sortSel = document.getElementById('sortBy');
|
||||
if (sortSel) {
|
||||
sortSel.value = state.sort.key + ':' + (state.sort.dir > 0 ? 'asc' : 'desc');
|
||||
sortSel.addEventListener('change', function () {
|
||||
var parts = sortSel.value.split(':');
|
||||
var key = parts[0];
|
||||
var dir = parts[1] === 'desc' ? -1 : 1;
|
||||
tree.setSortExplicit(key, dir);
|
||||
// Tree autofilter — parses input through zddc.filter.parse so
|
||||
// the same query grammar that the archive app uses (terms,
|
||||
// quotes, !negation, multi-word AND) works here. The AST is
|
||||
// cached on state.filterAST; tree.render reads it and skips
|
||||
// non-matching rows. Escape clears.
|
||||
var filterInput = document.getElementById('treeFilter');
|
||||
if (filterInput) {
|
||||
var filterDebounce = null;
|
||||
var applyFilter = function () {
|
||||
var raw = filterInput.value || '';
|
||||
state.filterText = raw;
|
||||
state.filterAST = raw ? window.zddc.filter.parse(raw) : null;
|
||||
filterInput.classList.toggle('filter-active', !!raw);
|
||||
tree.render();
|
||||
};
|
||||
filterInput.addEventListener('input', function () {
|
||||
if (filterDebounce) clearTimeout(filterDebounce);
|
||||
filterDebounce = setTimeout(applyFilter, 80);
|
||||
});
|
||||
filterInput.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape' && filterInput.value) {
|
||||
e.preventDefault();
|
||||
filterInput.value = '';
|
||||
applyFilter();
|
||||
}
|
||||
|
||||
// "Show hidden" checkbox — toggles state.showHidden, which the
|
||||
// loader reads to append ?hidden=1 to listing requests. Re-uses
|
||||
// the existing refreshListing flow so the tree pulls a fresh
|
||||
// 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,7 +306,518 @@
|
|||
}
|
||||
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.
|
||||
|
|
|
|||
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: {} };
|
||||
}
|
||||
|
||||
// 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 = {
|
||||
// Source: 'server' | 'fs' | null. Determines how the loader
|
||||
// resolves entries.
|
||||
|
|
@ -61,6 +70,13 @@
|
|||
// scopeDefaultTool: cascade's default_tool at currentPath
|
||||
// (empty when no default declared)
|
||||
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);
|
||||
|
||||
// ── 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');
|
||||
sidebar.className = 'md-shell__sidebar';
|
||||
sidebar.style.gridTemplateRows = lastFmHeight + 'px 1fr';
|
||||
shell.appendChild(sidebar);
|
||||
|
||||
var fmSection = document.createElement('section');
|
||||
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');
|
||||
fmHeader.className = 'md-side__header';
|
||||
fmHeader.textContent = 'YAML front matter';
|
||||
|
|
@ -502,7 +507,10 @@
|
|||
var editor = new window.toastui.Editor({
|
||||
el: editorHost,
|
||||
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',
|
||||
initialValue: bodyText,
|
||||
usageStatistics: false,
|
||||
|
|
@ -592,7 +600,7 @@
|
|||
var dy = e.clientY - startY;
|
||||
var h = Math.max(FM_MIN, Math.min(maxFmHeight(), startH + dy));
|
||||
lastFmHeight = h;
|
||||
sidebar.style.gridTemplateRows = h + 'px 1fr';
|
||||
fmSection.style.height = h + 'px';
|
||||
e.preventDefault();
|
||||
}
|
||||
function onUp() {
|
||||
|
|
@ -616,7 +624,7 @@
|
|||
var step = e.key === 'ArrowUp' ? -24 : 24;
|
||||
var h = Math.max(FM_MIN, Math.min(maxFmHeight(), lastFmHeight + step));
|
||||
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;
|
||||
}
|
||||
|
||||
// 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.
|
||||
if (ext === 'pdf' || ext === 'html' || ext === 'htm') {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -111,15 +111,24 @@
|
|||
}
|
||||
|
||||
// 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() {
|
||||
var out = [];
|
||||
function walk(ids) {
|
||||
for (var i = 0; i < ids.length; i++) {
|
||||
var n = state.nodes.get(ids[i]);
|
||||
if (!n) continue;
|
||||
if (state.filterAST && !passesFilter(n)) continue;
|
||||
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
|
||||
|
|
@ -132,6 +141,59 @@
|
|||
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 ────────────────────────────────────────────────────────
|
||||
|
||||
function fmtSize(bytes) {
|
||||
|
|
@ -154,6 +216,127 @@
|
|||
.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
|
||||
// padding-left so the row's hover background spans the full
|
||||
// pane width. Files are rendered as plain rows (no anchor) —
|
||||
|
|
@ -163,26 +346,39 @@
|
|||
function rowHtml(node) {
|
||||
var indent = 0.4 + node.depth * 1.0;
|
||||
var expandable = node.isDir || node.isZip;
|
||||
var iconChar = node.isDir ? '📁' : (node.isZip ? '🗜️' : '📄');
|
||||
var iconChar = iconForNode(node);
|
||||
var chevronClass = 'tree-name__chevron'
|
||||
+ (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 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
|
||||
? '<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 ''
|
||||
+ '<div class="tree-row ' + (node.expanded ? 'expanded' : '') + selected + virtualCls
|
||||
+ '<div class="tree-row ' + (visuallyExpanded ? 'expanded' : '') + selected + virtualCls
|
||||
+ '" data-id="' + node.id
|
||||
+ '" data-isdir="' + node.isDir
|
||||
+ '" data-iszip="' + node.isZip + '"'
|
||||
+ (node.virtual ? ' data-virtual="true"' : '')
|
||||
+ ' style="padding-left:' + indent + 'rem"'
|
||||
+ ' role="treeitem" tabindex="-1">'
|
||||
+ '<span class="' + chevronClass + '"></span>'
|
||||
+ '<span class="' + chevronClass + '">' + chevronGlyph + '</span>'
|
||||
+ '<span class="tree-name__icon">' + iconChar + '</span>'
|
||||
+ '<span class="tree-name__label" title="' + escapeHtml(node.name) + '">'
|
||||
+ escapeHtml(node.displayName || node.name) + '</span>'
|
||||
+ labelHtml(node)
|
||||
+ virtualHint
|
||||
+ '</div>';
|
||||
}
|
||||
|
|
@ -196,33 +392,9 @@
|
|||
html += rowHtml(state.nodes.get(ids[i]));
|
||||
}
|
||||
body.innerHTML = html;
|
||||
updateCount();
|
||||
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 ──────────────────────────────────────────────────────
|
||||
|
||||
// Inline outline home icon. Stroke-based so it tints with the
|
||||
|
|
@ -431,6 +603,61 @@
|
|||
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
|
||||
window.app.modules.tree = {
|
||||
setRoot: setRoot,
|
||||
|
|
@ -439,6 +666,9 @@
|
|||
toggleFolder: toggleFolder,
|
||||
expandSubtree: expandSubtree,
|
||||
collapseSubtree: collapseSubtree,
|
||||
loadChildren: loadChildren,
|
||||
snapshotState: snapshotState,
|
||||
restoreState: restoreState,
|
||||
setSort: function (key) {
|
||||
if (state.sort.key === key) {
|
||||
state.sort.dir = -state.sort.dir;
|
||||
|
|
|
|||
|
|
@ -85,13 +85,17 @@
|
|||
return false;
|
||||
}
|
||||
|
||||
function uploadUrl(filename) {
|
||||
var base = state.currentPath || '/';
|
||||
// Join a directory path and a relative path safely. dir is expected
|
||||
// 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 += '/';
|
||||
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) {
|
||||
return {
|
||||
file: file,
|
||||
|
|
@ -101,7 +105,7 @@
|
|||
};
|
||||
}
|
||||
try {
|
||||
var resp = await fetch(uploadUrl(file.name), {
|
||||
var resp = await fetch(joinUrl(destDir, relPath), {
|
||||
method: 'PUT',
|
||||
body: file,
|
||||
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) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
|
@ -133,46 +417,21 @@
|
|||
|
||||
if (!currentScopeAllows()) return;
|
||||
var dt = e.dataTransfer;
|
||||
if (!dt || !dt.files || dt.files.length === 0) return;
|
||||
|
||||
var files = Array.from(dt.files);
|
||||
var note = window.zddc && window.zddc.toast;
|
||||
if (note) note('Uploading ' + files.length + ' file' + (files.length === 1 ? '' : 's') + '…', 'info');
|
||||
|
||||
// Sequential — predictable progress + ordering. Can parallelise
|
||||
// later if it matters.
|
||||
var ok = 0, fail = 0;
|
||||
for (var i = 0; i < files.length; i++) {
|
||||
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');
|
||||
}
|
||||
if (!dt) return;
|
||||
var uploads = await collectUploads(dt);
|
||||
if (!uploads.length) return;
|
||||
await uploadBatch(uploads, state.currentPath);
|
||||
await refreshAfterUpload(state.currentPath);
|
||||
}
|
||||
|
||||
// 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 */ }
|
||||
}
|
||||
// Public entry for per-row drops or programmatic uploads. destDir
|
||||
// must be a server path (/-prefixed, slash-terminated optional).
|
||||
async function uploadToDir(destDir, dataTransfer) {
|
||||
var uploads = await collectUploads(dataTransfer);
|
||||
if (!uploads.length) return { ok: 0, fail: 0 };
|
||||
var res = await uploadBatch(uploads, destDir);
|
||||
await refreshAfterUpload(destDir);
|
||||
return res;
|
||||
}
|
||||
|
||||
function onEnter(e) {
|
||||
|
|
@ -215,6 +474,12 @@
|
|||
|
||||
window.app.modules.upload = {
|
||||
currentScopeAllows: currentScopeAllows,
|
||||
uploadToDir: uploadToDir,
|
||||
makeDir: makeDir,
|
||||
makeFile: makeFile,
|
||||
removeNode: removeNode,
|
||||
renameNode: renameNode,
|
||||
canMutate: canMutate,
|
||||
UPLOAD_MAX_BYTES: UPLOAD_MAX_BYTES
|
||||
};
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -24,10 +24,16 @@
|
|||
<span class="app-header__title">ZDDC Browse</span>
|
||||
<span class="build-timestamp">{{BUILD_LABEL}}</span>
|
||||
</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>
|
||||
</div>
|
||||
<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="help-btn" class="btn btn-secondary" title="Help" aria-label="Help">?</button>
|
||||
</div>
|
||||
|
|
@ -41,7 +47,7 @@
|
|||
<ul class="welcome-list">
|
||||
<li><b>Online</b> — when this page is served by zddc-server, the
|
||||
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>
|
||||
</ul>
|
||||
<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 class="browse-toolbar">
|
||||
<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>
|
||||
|
||||
<!-- Browse mode (default): two-pane tree + preview -->
|
||||
<div id="browseView" class="browse-view">
|
||||
<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>
|
||||
<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>
|
||||
<dt>Click a file</dt>
|
||||
<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>
|
||||
<dd>Open the current preview in a separate window — useful for a second
|
||||
monitor.</dd>
|
||||
<dt>ZIP files</dt>
|
||||
<dd>Behave as folders — click to inspect contents inline. JSZip is
|
||||
bundled, so this works offline.</dd>
|
||||
<dt>⤓ Download (zip)</dt>
|
||||
<dd>Downloads the directory you're currently viewing — and everything
|
||||
under it that you're allowed to see — as a single <code>.zip</code>.
|
||||
Navigate into a subfolder first to download just that subtree. Online,
|
||||
the server streams it; locally, the browser bundles the picked folder
|
||||
(a confirmation appears if it's very large).</dd>
|
||||
<dt>Download / Download ZIP</dt>
|
||||
<dd>Right-click a file for <b>Download</b>, or a folder for
|
||||
<b>Download ZIP</b> (everything under it that you're allowed to see,
|
||||
bundled into one archive). Online, the server streams it; locally,
|
||||
the browser bundles the picked folder (a confirmation appears if it's
|
||||
very large).</dd>
|
||||
<dt>Refresh</dt>
|
||||
<dd>Re-fetches the current directory listing — works for both
|
||||
local (re-enumerates the FS handle) and online (re-fetches the JSON).</dd>
|
||||
|
|
@ -154,7 +151,7 @@
|
|||
|
||||
<h3>Header buttons</h3>
|
||||
<dl>
|
||||
<dt>Add Local Directory</dt>
|
||||
<dt>Use Local Directory</dt>
|
||||
<dd>Pick a folder from your computer. Works in both modes; in online
|
||||
mode it's de-emphasized but still available.</dd>
|
||||
<dt>⟳ Refresh</dt>
|
||||
|
|
|
|||
|
|
@ -230,7 +230,7 @@ a:hover {
|
|||
}
|
||||
|
||||
/* 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
|
||||
available but visually quieter, since the typical user already
|
||||
has the directory loaded from the server. */
|
||||
|
|
@ -292,6 +292,11 @@ a:hover {
|
|||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
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
|
||||
|
|
@ -303,16 +308,35 @@ a:hover {
|
|||
display: flex;
|
||||
align-items: center;
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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'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 {
|
||||
font-family: var(--font-display);
|
||||
font-size: 18px;
|
||||
|
|
@ -320,6 +344,9 @@ a:hover {
|
|||
color: var(--text);
|
||||
letter-spacing: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 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