diff --git a/browse/build.sh b/browse/build.sh index 72838b6..cd71584 100755 --- a/browse/build.sh +++ b/browse/build.sh @@ -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" \ diff --git a/browse/css/preview-yaml.css b/browse/css/preview-yaml.css new file mode 100644 index 0000000..082edb1 --- /dev/null +++ b/browse/css/preview-yaml.css @@ -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; +} diff --git a/browse/css/tree.css b/browse/css/tree.css index 56ee0f5..9c6d912 100644 --- a/browse/css/tree.css +++ b/browse/css/tree.css @@ -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
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; +} diff --git a/browse/js/app.js b/browse/js/app.js index 0ceb90c..014bff3 100644 --- a/browse/js/app.js +++ b/browse/js/app.js @@ -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=