diff --git a/AGENTS.md b/AGENTS.md index 85a4d06..ffb6b3b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,7 +27,7 @@ ./deploy --releases # only dist/release-output/ → /srv/zddc/releases/ # Single-tool dev build for testing (does NOT touch dist/release-output/): -sh tool/build.sh # archive|transmittal|classifier|mdedit|landing|form|tables|browse +sh tool/build.sh # archive|transmittal|classifier|landing|form|tables|browse # Single-tool release (rare; prefer ./build alpha|beta|release so versions # don't drift between tools). Same flag form as before. @@ -38,7 +38,7 @@ sh tool/build.sh --release [|alpha|beta] npm test # Test single tool -npx playwright test tool # archive | transmittal | classifier | mdedit | form-safety | tables +npx playwright test tool # archive | transmittal | classifier | browse | form-safety | tables # Dev server (cache-busting HTTP, on port 8000) ./dev-server start @@ -60,7 +60,7 @@ because the bundle is complete, dangling-link errors mean a real bug. ## Architecture -Eight independent single-file HTML tools (`archive`, `transmittal`, `classifier`, `mdedit`, `landing`, `form`, `tables`, `browse`). Each compiles to one self-contained `.html` in `dist/` with all CSS and JS inlined — most name their output `dist/tool.html`; `landing` writes `dist/index.html` (served at `/` by `zddc-server`). Tools share a small set of canonical helpers in `shared/` (filename parsing, ZDDC filter UI, theme, help) — see "Shared modules" below. `form` is the schema-driven renderer used by zddc-server's form-data system; `tables` is its read/aggregate counterpart, rendering a directory of YAML files as a sortable table whose rows click through to the form editor — discovered presence-based via `.table.yaml` next to a sibling `/` rows-dir (see "Form-data system" and "Tables system" below). +Seven independent single-file HTML tools (`archive`, `transmittal`, `classifier`, `landing`, `form`, `tables`, `browse`). Each compiles to one self-contained `.html` in `dist/` with all CSS and JS inlined — most name their output `dist/tool.html`; `landing` writes `dist/index.html` (served at `/` by `zddc-server`). Tools share a small set of canonical helpers in `shared/` (filename parsing, ZDDC filter UI, theme, help) — see "Shared modules" below. `form` is the schema-driven renderer used by zddc-server's form-data system; `tables` is its read/aggregate counterpart, rendering a directory of YAML files as a sortable table whose rows click through to the form editor — discovered presence-based via `.table.yaml` next to a sibling `/` rows-dir (see "Form-data system" and "Tables system" below). `browse` is the file-tree navigator and also hosts the in-place markdown editor (`browse/js/preview-markdown.js`); the dedicated `mdedit/` tool has been retired. ``` tool/ @@ -202,7 +202,7 @@ Format: `trackingNumber_revision (status) - title.extension` - Feature-branch workflow; squash-merge feature branches to `main` - Conventional commits: `feat(archive): ...`, `fix(transmittal): ...` -- Release tags: `-v` per tool, all nine sharing the same X.Y.Z on a coordinated cut (e.g. `archive-v0.0.8`, `transmittal-v0.0.8`, `classifier-v0.0.8`, `mdedit-v0.0.8`, `landing-v0.0.8`, `form-v0.0.8`, `tables-v0.0.8`, `browse-v0.0.8`, `zddc-server-v0.0.8`) +- Release tags: `-v` per tool, all eight sharing the same X.Y.Z on a coordinated cut (e.g. `archive-v0.0.8`, `transmittal-v0.0.8`, `classifier-v0.0.8`, `landing-v0.0.8`, `form-v0.0.8`, `tables-v0.0.8`, `browse-v0.0.8`, `zddc-server-v0.0.8`) - `dist/` is gitignored. Build artifacts (per-tool `dist/.html` and `dist/release-output/`) are NOT committed to this repo. Reproduce them from a tag with `./build release X.Y.Z` - Hand-edited website content lives in a separate Codeberg repo (`codeberg.org/VARASYS/ZDDC-website`, cloned at `~/src/zddc-website/`). Source-code commits go to `main` here; content commits go to that repo - Release artifacts live on the deploy host (`/srv/zddc/`), not in any git history. Use `./deploy` to publish @@ -215,7 +215,7 @@ Format: `trackingNumber_revision (status) - title.extension` | Artifact | Type | Layout | |---|---|---| -| `_v.html` | real, immutable | per-version HTML for each of archive, transmittal, classifier, mdedit, landing, form, tables, browse | +| `_v.html` | real, immutable | per-version HTML for each of archive, transmittal, classifier, landing, form, tables, browse | | `_v.html`, `_v.html` | symlinks | partial-version pins | | `_.html` | symlink (or real bytes during active channel dev) | mutable channel mirror per tool, channel ∈ {stable, beta, alpha} | | `zddc-server_v_` | real binary | per-version cross-compiled binary, platform ∈ {linux-amd64, darwin-amd64, darwin-arm64, windows-amd64.exe} | @@ -284,7 +284,7 @@ The build pipeline used is the one **at the tag**, not on `main`. That is intent No install script. Two paths: - **Local** — download a tool `.html` from `https://zddc.varasys.io/releases/` and open it. Done. -- **Server** (`zddc-server`) — every tool is `//go:embed`'d into the binary at compile time (the current-stable build). Which tool a directory URL serves is driven by the `.zddc` cascade, not hardcoded: the baked-in baseline (`zddc/internal/zddc/defaults.zddc.yaml`, dumpable via `zddc-server show-defaults`) declares, via a recursive `paths:` tree, a `default_tool` (the no-slash form: `archive` everywhere, `transmittal` under `staging/`, `mdedit` under `working/`, `classifier` under `incoming/`, `tables` at `archive//mdl`, `landing` at the deployment root) and `available_tools` (which tools may be auto-served / offered) per folder. The trailing-slash form serves `dir_tool` (defaults to `browse`). See `internal/apps/availability.go` (`DefaultAppAt`, `AppAvailableAt`) and `internal/zddc/lookups.go` (`DefaultToolAt`, `DirToolAt`, `AvailableToolsAt`); the dispatcher chokepoint is `serveSpecializedNoSlash` in `cmd/zddc-server/main.go`. Where the cascade declares no tool, requesting `.html` returns 404 like any other missing file. **The full canonical-folder convention (auto-own, WORM, virtual folders, standard roles) is documented in ARCHITECTURE.md § "Canonical folders, URL routing & the `.zddc` cascade".** +- **Server** (`zddc-server`) — every tool is `//go:embed`'d into the binary at compile time (the current-stable build). Which tool a directory URL serves is driven by the `.zddc` cascade, not hardcoded: the baked-in baseline (`zddc/internal/zddc/defaults.zddc.yaml`, dumpable via `zddc-server show-defaults`) declares, via a recursive `paths:` tree, a `default_tool` (the no-slash form: `archive` everywhere, `transmittal` under `staging/`, `browse` under `working/`+`reviewing/` (hosts the markdown editor), `classifier` under `incoming/`, `tables` at `archive//mdl`, `landing` at the deployment root) and `available_tools` (which tools may be auto-served / offered) per folder. The trailing-slash form serves `dir_tool` (defaults to `browse`). See `internal/apps/availability.go` (`DefaultAppAt`, `AppAvailableAt`) and `internal/zddc/lookups.go` (`DefaultToolAt`, `DirToolAt`, `AvailableToolsAt`); the dispatcher chokepoint is `serveSpecializedNoSlash` in `cmd/zddc-server/main.go`. Where the cascade declares no tool, requesting `.html` returns 404 like any other missing file. **The full canonical-folder convention (auto-own, WORM, virtual folders, standard roles) is documented in ARCHITECTURE.md § "Canonical folders, URL routing & the `.zddc` cascade".** To override at any level, either: 1. Drop a real `.html` file at the path → static handler serves it (highest priority). @@ -314,11 +314,27 @@ Use `git worktree` to run multiple agents on separate branches simultaneously wi - No runtime CDN loads. Every vendor library (jszip, docx-preview, xlsx, UTIF, Toast UI) is bundled at build time via `concat_files`. The dist HTML is fully self-contained — "ship the record player with the record." - Published payload stored in `` (not `<\/script>`) to close the ` sequence). We close with - # the real because only that exact string terminates a script - # block per the HTML5 spec. - print "" - next - } - { print } -' "$src_html" > "$output_html" - -echo "Wrote $output_html ($(wc -c < "$output_html") bytes)" - -if [ "$is_release" = "1" ]; then - promote_release "mdedit" -fi diff --git a/mdedit/css/base.css b/mdedit/css/base.css deleted file mode 100644 index 1fb1b75..0000000 --- a/mdedit/css/base.css +++ /dev/null @@ -1,405 +0,0 @@ -/* mdedit component styles — reset and tokens from shared/base.css */ - -/* Pane resizer */ -.pane-resizer:hover { - background-color: var(--primary) !important; -} - -/* File tree */ -.file-tree { - font-size: 0.9rem; -} - -.directory-item, -.file-item { - transition: background-color 0.15s ease; -} - -.dir-icon { - display: inline-flex; - align-items: center; - justify-content: center; - transform: rotate(90deg); - transition: transform 0.2s ease; - vertical-align: middle; -} - -.dir-icon svg { - width: 12px; - height: 12px; - stroke: currentColor; - stroke-width: 2; -} - -.directory-item.collapsed .dir-icon { - transform: rotate(0deg); -} - -/* Two-line filename styles */ -.filename-main { - font-size: 0.9rem; - font-weight: 500; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.filename-secondary { - font-size: 0.75rem; - color: var(--text-muted); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -/* Active file highlighting */ -.active-file { - background-color: var(--primary) !important; - color: var(--text-light) !important; - font-weight: 500; -} - -.active-file * { - color: var(--text-light) !important; -} - -/* ── File Tree Action Buttons ──────────────────────────────────────────────── */ -.tree-actions { - display: flex; - gap: 0.25rem; - align-items: center; - margin-left: auto; - opacity: 0; - transition: opacity 0.2s ease; -} - -.directory-item:hover .tree-actions, -.file-item:hover .tree-actions, -.active-file .tree-actions { - opacity: 1; -} - -/* Always-visible action buttons (e.g. scratchpad download) */ -.tree-actions--always { opacity: 1; } - -.tree-btn:disabled, -.tree-btn.is-disabled { - opacity: 0.35; - cursor: not-allowed; -} - -.tree-btn { - display: inline-flex; - align-items: center; - justify-content: center; - width: 1.25rem; - height: 1.25rem; - padding: 0; - border: none; - background-color: transparent; - color: var(--text-muted); - cursor: pointer; - border-radius: 0.25rem; - transition: background-color 0.15s ease, color 0.15s ease; -} - -.tree-btn:hover { - background-color: var(--bg-secondary); - color: var(--text); -} - -.tree-btn--danger:hover { - background-color: #fee2e2; - color: #dc2626; -} - -[data-theme="dark"] .tree-btn--danger:hover { - background-color: rgba(127, 29, 29, 0.5); - color: #fca5a5; -} - -@media (prefers-color-scheme: dark) { - :root:not([data-theme="light"]) .tree-btn--danger:hover { - background-color: rgba(127, 29, 29, 0.5); - color: #fca5a5; - } -} - -.tree-btn svg { - width: 1rem; - height: 1rem; - stroke: currentColor; - stroke-width: 2; - stroke-linecap: round; - stroke-linejoin: round; -} - -/* Directory toggle indicator */ -.directory-item { - position: relative; -} - -.directory-item.collapsed .directory-contents { - display: none; -} - -/* File view container */ -.file-view-container { - display: flex; - flex-direction: column; - height: 100%; - width: 100%; -} - -/* File header */ -.file-header { - display: flex; - justify-content: space-between; - align-items: center; - padding: 0.5rem 1rem; - background-color: var(--bg-secondary); - border-bottom: 1px solid var(--border); -} - -.file-title { - font-size: 1.125rem; - font-weight: 500; - color: var(--text); - margin: 0; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -/* File content area */ -.file-content-area { - display: flex; - flex: 1; - overflow: hidden; -} - -/* Content container */ -#content-container { - flex-direction: column; - height: 100%; -} - -/* Image preview */ -.image-preview-container { - display: flex; - align-items: center; - justify-content: center; - background-color: var(--bg-secondary); -} - -.image-preview { - max-width: 100%; - max-height: 100%; - object-fit: contain; -} - -/* HTML preview iframe */ -.html-preview-container { - width: 100%; - height: 100%; -} - -.html-preview-iframe { - width: 100%; - height: 100%; - border: 0; -} - -/* Dirty indicator */ -.dirty-indicator { - margin-left: 0.25rem; - color: var(--warning); - font-weight: bold; -} - -.is-dirty { - font-style: italic; -} - -/* ── Tailwind class overrides: use CSS tokens instead of hardcoded colours ── */ -/* bg-white / bg-gray-100 are used on the pane backgrounds in template.html. */ -/* Override them here so they follow the design-token system (light + dark). */ -.bg-white { background-color: var(--bg) !important; } -.bg-gray-100 { background-color: var(--bg-secondary) !important; } - -/* ── Section headers (YAML front matter, TOC, etc.) ───────────────────────── */ -/* Shared style for all collapsible/section headers inside the side pane — - keeps font, padding, weight identical to the file-tree pane header. */ -.pane-section-header { - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.5rem 1rem; - background-color: var(--bg-secondary); - color: var(--text); - border-bottom: 1px solid var(--border); - font-size: 0.9rem; - font-weight: 500; - user-select: none; -} - -.pane-section-header .toggle-icon { - font-size: 0.75rem; - color: var(--text-muted); - width: 0.75rem; - text-align: center; -} - -/* ── Front matter section ──────────────────────────────────────────────────── */ -.front-matter-nav { - display: flex; - flex-direction: column; - overflow: hidden; - flex-shrink: 0; - background-color: var(--bg); -} - -.front-matter-header:hover { - background-color: var(--bg-hover); -} - -.front-matter-content { - flex: 1; - overflow: auto; - min-height: 0; -} - -/* When collapsed, hide content; height shrinks to header */ -.front-matter-nav.collapsed { - height: auto !important; - flex-shrink: 0; -} - -.front-matter-nav.collapsed .front-matter-content { - display: none; -} - -/* Front matter textarea fills the content area */ -.front-matter-textarea { - color: var(--text); - background-color: var(--bg); - border: none; - resize: none; - font-family: var(--font-mono); - font-size: 0.8rem; - white-space: pre; - overflow: auto; - width: 100%; - height: 100%; - padding: 0.5rem 1rem; - box-sizing: border-box; - display: block; -} - -.front-matter-textarea:focus { - outline: none; -} - -/* ── Horizontal pane resizer (height split) ─────────────────────────────── */ -.pane-resizer.horizontal { - height: 4px; - width: 100%; - cursor: row-resize; - background-color: var(--border); - flex-shrink: 0; - transition: background-color 0.15s ease; -} - -.pane-resizer.horizontal:hover, -.pane-resizer.horizontal.active { - background-color: var(--primary); -} - -/* ── Hidden utility (for disabled buttons) ─────────────────────────────────── */ -.hide { display: none; } - -/* ── File tree row layout ───────────────────────────────────────────────────── */ -.tree-row { - display: flex; - align-items: center; - min-width: 0; -} - -.tree-row__label { - flex: 1; - min-width: 0; - overflow: hidden; - display: flex; - align-items: center; - gap: 0.25rem; -} - -/* The text wrapper inside a tree-row label. For ZDDC-conforming files and - folders, this wraps two stacked
s (filename-main + filename-secondary) - so the row reads top-to-bottom as title + metadata — same shape the archive - tool uses for its transmittal-folder list. For non-ZDDC entries it just - contains a single line. flex column makes the two-line case work; min-width:0 - lets each line truncate independently. */ -.tree-row__name { - display: flex; - flex-direction: column; - min-width: 0; - flex: 1; - line-height: 1.25; -} - -/* ── New-file modal ─────────────────────────────────────────────────────────── */ -.modal-overlay { - position: fixed; - inset: 0; - background: rgba(0,0,0,0.4); - display: flex; - align-items: center; - justify-content: center; - z-index: 1000; -} -.modal-overlay.hidden { display: none; } -.modal-box { - background: var(--bg); - border: 1px solid var(--border); - border-radius: 0.5rem; - padding: 1.5rem; - min-width: 20rem; - display: flex; - flex-direction: column; - gap: 1rem; - box-shadow: 0 4px 24px rgba(0,0,0,0.18); -} -.modal-title { - font-size: 1rem; - font-weight: 600; - color: var(--text); - margin: 0; -} -.modal-input { - width: 100%; - padding: 0.4rem 0.6rem; - border: 1px solid var(--border); - border-radius: 0.25rem; - font-size: 0.9rem; - color: var(--text); - background: var(--bg); - box-sizing: border-box; -} -.modal-input:focus { - outline: 2px solid var(--primary); - outline-offset: 1px; -} -.modal-actions { - display: flex; - justify-content: flex-end; - gap: 0.5rem; -} - -/* File-nav pane: initial width + minimum size. Runtime resizer (resizer.js) - overrides via inline style.width when the user drags; the min-width here - is a defensive backstop. */ -#file-nav { - width: 450px; - min-width: 200px; -} diff --git a/mdedit/css/editor.css b/mdedit/css/editor.css deleted file mode 100644 index d303a95..0000000 --- a/mdedit/css/editor.css +++ /dev/null @@ -1,119 +0,0 @@ -/* Toast UI Editor styles */ -#markdown-editor { - display: block !important; - height: 100% !important; - min-height: 500px !important; - width: 100% !important; - position: relative !important; - z-index: 10; -} - -.editor-instance { - height: 100% !important; - min-height: 500px !important; -} - -.toastui-editor-defaultUI { - height: 100% !important; -} - -.toastui-editor-defaultUI-toolbar, -.toastui-editor-main, -.toastui-editor-main .ProseMirror, -.toastui-editor-main .toastui-editor-md-preview { - height: 100% !important; -} - -/* ── Toast UI Editor — dark-theme overrides ─────────────────────────────── - Toast UI ships with light-mode chrome and edit surfaces by default. In - mdedit's dark mode the editor's text (#222) falls onto the transparent - md-container, which inherits var(--bg) dark = #1e1e1e → effectively - black-on-black. Override the load-bearing surfaces with mdedit's tokens - so the editor harmonises with the rest of the chrome. - The selectors target both manual override (data-theme="dark") and the - OS-pref auto fallback (prefers-color-scheme + no data-theme="light"). */ - -/* Manual dark override */ -[data-theme="dark"] .toastui-editor-defaultUI, -[data-theme="dark"] .toastui-editor-md-container, -[data-theme="dark"] .toastui-editor-md-preview, -[data-theme="dark"] .toastui-editor-ww-container, -[data-theme="dark"] .toastui-editor-mode-switch, -[data-theme="dark"] .toastui-editor-main, -[data-theme="dark"] .ProseMirror { - background-color: var(--bg); - color: var(--text); -} -[data-theme="dark"] .toastui-editor-defaultUI-toolbar { - background-color: var(--bg-secondary); - border-bottom-color: var(--border); -} -[data-theme="dark"] .toastui-editor-md-splitter { - background-color: var(--border); -} -[data-theme="dark"] .toastui-editor-toolbar-icons { - /* Toast UI's icons are sprite-baked dark; invert flips them to light. */ - filter: invert(0.85) hue-rotate(180deg); -} -[data-theme="dark"] .toastui-editor-toolbar-divider { - background-color: var(--border); -} -[data-theme="dark"] .toastui-editor-mode-switch { - border-top-color: var(--border); -} -[data-theme="dark"] .toastui-editor-mode-switch .tab-item { - color: var(--text-muted); -} -[data-theme="dark"] .toastui-editor-mode-switch .tab-item.active { - color: var(--text); - background-color: var(--bg); -} -[data-theme="dark"] .toastui-editor-popup, -[data-theme="dark"] .toastui-editor-context-menu { - background-color: var(--bg-secondary); - color: var(--text); - border-color: var(--border); -} - -/* OS-pref auto fallback (matches every selector above) */ -@media (prefers-color-scheme: dark) { - :root:not([data-theme="light"]) .toastui-editor-defaultUI, - :root:not([data-theme="light"]) .toastui-editor-md-container, - :root:not([data-theme="light"]) .toastui-editor-md-preview, - :root:not([data-theme="light"]) .toastui-editor-ww-container, - :root:not([data-theme="light"]) .toastui-editor-mode-switch, - :root:not([data-theme="light"]) .toastui-editor-main, - :root:not([data-theme="light"]) .ProseMirror { - background-color: var(--bg); - color: var(--text); - } - :root:not([data-theme="light"]) .toastui-editor-defaultUI-toolbar { - background-color: var(--bg-secondary); - border-bottom-color: var(--border); - } - :root:not([data-theme="light"]) .toastui-editor-md-splitter { - background-color: var(--border); - } - :root:not([data-theme="light"]) .toastui-editor-toolbar-icons { - filter: invert(0.85) hue-rotate(180deg); - } - :root:not([data-theme="light"]) .toastui-editor-toolbar-divider { - background-color: var(--border); - } - :root:not([data-theme="light"]) .toastui-editor-mode-switch { - border-top-color: var(--border); - } - :root:not([data-theme="light"]) .toastui-editor-mode-switch .tab-item { - color: var(--text-muted); - } - :root:not([data-theme="light"]) .toastui-editor-mode-switch .tab-item.active { - color: var(--text); - background-color: var(--bg); - } - :root:not([data-theme="light"]) .toastui-editor-popup, - :root:not([data-theme="light"]) .toastui-editor-context-menu { - background-color: var(--bg-secondary); - color: var(--text); - border-color: var(--border); - } -} diff --git a/mdedit/css/markdown.css b/mdedit/css/markdown.css deleted file mode 100644 index a3a9c00..0000000 --- a/mdedit/css/markdown.css +++ /dev/null @@ -1,223 +0,0 @@ -/* Markdown content rendering styles */ -.markdown-content { - line-height: 1.6; -} - -.markdown-content h1, -.toastui-editor-contents h1 { - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; - font-size: 2em; - font-weight: 600; - line-height: 1.25; - margin-top: 24px; - margin-bottom: 16px; - padding-bottom: 0.3em; - border-bottom: 1px solid var(--border); - color: var(--text); - word-wrap: break-word; -} - -.markdown-content h2, -.toastui-editor-contents h2 { - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; - font-size: 1.5em; - font-weight: 600; - line-height: 1.25; - margin-top: 24px; - margin-bottom: 16px; - padding-bottom: 0.3em; - border-bottom: 1px solid var(--border); - color: var(--text); - word-wrap: break-word; -} - -.markdown-content h3, -.toastui-editor-contents h3 { - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; - font-size: 1.25em; - font-weight: 600; - line-height: 1.25; - margin-top: 16px; - margin-bottom: 16px; - color: var(--text); - word-wrap: break-word; -} - -.markdown-content h4, -.toastui-editor-contents h4 { - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; - font-size: 1em; - font-weight: 600; - line-height: 1.25; - margin-top: 16px; - margin-bottom: 16px; - color: var(--text); - word-wrap: break-word; -} - -.markdown-content h5, -.toastui-editor-contents h5 { - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; - font-size: 0.875em; - font-weight: 600; - line-height: 1.25; - margin-top: 16px; - margin-bottom: 16px; - color: var(--text); - word-wrap: break-word; -} - -.markdown-content h6, -.toastui-editor-contents h6 { - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; - font-size: 0.85em; - font-weight: 600; - line-height: 1.25; - margin-top: 16px; - margin-bottom: 16px; - color: var(--text-muted); - word-wrap: break-word; -} - -/* Reset margin-top for first-child headings */ -.markdown-content h1:first-child, -.markdown-content h2:first-child, -.markdown-content h3:first-child, -.markdown-content h4:first-child, -.markdown-content h5:first-child, -.markdown-content h6:first-child, -.toastui-editor-contents h1:first-child, -.toastui-editor-contents h2:first-child, -.toastui-editor-contents h3:first-child, -.toastui-editor-contents h4:first-child, -.toastui-editor-contents h5:first-child, -.toastui-editor-contents h6:first-child { - margin-top: 0; -} - -/* Reduce spacing between consecutive headings */ -.markdown-content h1 + h2, -.toastui-editor-contents h1 + h2 { - margin-top: 1rem; -} - -.markdown-content h2 + h3, -.toastui-editor-contents h2 + h3 { - margin-top: 1.5rem; -} - -.markdown-content h3 + h4, -.toastui-editor-contents h3 + h4 { - margin-top: 1.25rem; -} - -.markdown-content h4 + h5, -.toastui-editor-contents h4 + h5 { - margin-top: 1rem; -} - -.markdown-content h5 + h6, -.toastui-editor-contents h5 + h6 { - margin-top: 0.75rem; -} - -.markdown-content p, -.toastui-editor-contents p { - margin-bottom: 1rem; -} - -.markdown-content ul, -.markdown-content ol, -.toastui-editor-contents ul, -.toastui-editor-contents ol { - margin-bottom: 1rem; - padding-left: 2rem; -} - -.markdown-content ul, -.toastui-editor-contents ul { - list-style-type: disc; -} - -.markdown-content ol, -.toastui-editor-contents ol { - list-style-type: decimal; -} - -.markdown-content li, -.toastui-editor-contents li { - margin-bottom: 0.25rem; -} - -.markdown-content code, -.toastui-editor-contents code { - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; - font-size: 0.9em; - padding: 0.2em 0.4em; - background-color: var(--bg-secondary); - border-radius: 0.25rem; -} - -.markdown-content pre, -.toastui-editor-contents pre { - margin-bottom: 1rem; - padding: 1rem; - background-color: var(--bg-secondary); - border-radius: 0.375rem; - overflow-x: auto; -} - -.markdown-content pre code, -.toastui-editor-contents pre code { - background-color: transparent; - padding: 0; - border-radius: 0; -} - -.markdown-content blockquote, -.toastui-editor-contents blockquote { - border-left: 4px solid var(--border); - padding-left: 1rem; - margin-left: 0; - margin-right: 0; - margin-bottom: 1rem; - color: var(--text-muted); -} - -.markdown-content a, -.toastui-editor-contents a { - color: var(--primary); - text-decoration: none; -} - -.markdown-content a:hover, -.toastui-editor-contents a:hover { - text-decoration: underline; -} - -.markdown-content table, -.toastui-editor-contents table { - border-collapse: collapse; - width: 100%; - margin-bottom: 1rem; -} - -.markdown-content th, -.markdown-content td, -.toastui-editor-contents th, -.toastui-editor-contents td { - border: 1px solid var(--border); - padding: 0.5rem; - text-align: left; -} - -.markdown-content th, -.toastui-editor-contents th { - background-color: var(--bg-secondary); - font-weight: 600; -} - -.markdown-content tr:nth-child(even), -.toastui-editor-contents tr:nth-child(even) { - background-color: var(--bg-hover); -} diff --git a/mdedit/css/tailwind-utils.css b/mdedit/css/tailwind-utils.css deleted file mode 100644 index 7ff54ab..0000000 --- a/mdedit/css/tailwind-utils.css +++ /dev/null @@ -1,184 +0,0 @@ -/* - * Tailwind utility subset for mdedit - * - * This file replaces the Tailwind Play CDN. It contains only the utility - * classes actually used in template.html, hand-written to match Tailwind v3 - * output exactly. If new Tailwind classes are needed in template.html, add - * them here and remove the class from this comment. - * - * Generated from: grep -o 'class="[^"]*"' template.html | tr ' ' '\n' | sort -u - * Tailwind version parity: v3.x (default spacing scale, gray palette, etc.) - */ - -/* ── Reset ── */ -*, ::before, ::after { box-sizing: border-box; } - -/* ── Display ── */ -.flex { display: flex; } -.inline-flex { display: inline-flex; } -/* .hidden lives in shared/base.css (uses !important) */ - -/* ── Flex direction ── */ -.flex-col { flex-direction: column; } -.flex-row { flex-direction: row; } - -/* ── Flex grow ── */ -.flex-1 { flex: 1 1 0%; } - -/* ── Alignment ── */ -.items-center { align-items: center; } -.justify-between { justify-content: space-between; } -.justify-center { justify-content: center; } - -/* ── Gap ── */ -.gap-1 { gap: 0.25rem; } -.gap-2 { gap: 0.5rem; } -.gap-4 { gap: 1rem; } -.gap-6 { gap: 1.5rem; } - -/* ── Overflow ── */ -.overflow-hidden { overflow: hidden; } -.overflow-auto { overflow: auto; } - -/* ── Sizing ── */ -.h-screen { height: 100vh; } -.h-full { height: 100%; } -.h-12 { height: 3rem; } -.h-6 { height: 1.5rem; } -.h-3\.5 { height: 0.875rem; } -.h-24 { height: 6rem; } - -/* ── Resize ── */ -.resize-none { resize: none; } - -/* ── Border ── */ -.border-0 { border-width: 0; } - -/* ── Outline ── */ -.focus\:outline-none:focus { outline: none; } -.w-full { width: 100%; } -.w-1 { width: 0.25rem; } -.w-3\.5 { width: 0.875rem; } - -/* ── Positioning ── */ -.relative { position: relative; } -.z-10 { z-index: 10; } - -/* ── Spacing ── */ -.p-4 { padding: 1rem; } -.p-6 { padding: 1.5rem; } -.px-2 { padding-left: 0.5rem; padding-right: 0.5rem; } -.pl-2 { padding-left: 0.5rem; } -.px-3 { padding-left: 0.75rem; padding-right: 0.75rem; } -.px-4 { padding-left: 1rem; padding-right: 1rem; } -.py-1 { padding-top: 0.25rem; padding-bottom: 0.25rem; } -.py-2 { padding-top: 0.5rem; padding-bottom: 0.5rem; } -.mt-2 { margin-top: 0.5rem; } -.mb-2 { margin-bottom: 0.5rem; } -.mb-4 { margin-bottom: 1rem; } - -/* ── Typography ── */ -.text-xl { font-size: 1.25rem; line-height: 1.75rem; } -.text-sm { font-size: 0.875rem; line-height: 1.25rem; } -.text-xs { font-size: 0.75rem; line-height: 1rem; } -.font-semibold { font-weight: 600; } -.font-medium { font-weight: 500; } -.text-center { text-align: center; } -.leading-none { line-height: 1; } -.select-none { user-select: none; } - -/* ── Colors — text ── */ -.text-white { color: #ffffff; } -.text-gray-800 { color: #1f2937; } -.text-gray-700 { color: #374151; } -.text-gray-500 { color: #6b7280; } -.text-amber-600 { color: #d97706; } - -/* ── Colors — background ── */ -.bg-white { background-color: #ffffff; } -.bg-gray-100 { background-color: #f3f4f6; } -.bg-gray-200 { background-color: #e5e7eb; } -.bg-transparent { background-color: transparent; } -.bg-blue-500 { background-color: #3b82f6; } - -/* ── Borders ── */ -.border { border-width: 1px; border-style: solid; } -.border-b { border-bottom-width: 1px; border-bottom-style: solid; } -.border-t { border-top-width: 1px; border-top-style: solid; } -.border-gray-200 { border-color: #e5e7eb; } -.border-gray-300 { border-color: #d1d5db; } -.rounded { border-radius: 0.25rem; } - -/* ── Opacity ── */ -.opacity-70 { opacity: 0.7; } -.opacity-80 { opacity: 0.8; } - -/* ── SVG ── */ -.fill-current { fill: currentColor; } - -/* ── Cursor ── */ -.cursor-pointer { cursor: pointer; } -.cursor-col-resize { cursor: col-resize; } - -/* ── Transitions ── */ -.transition-all { transition-property: all; transition-timing-function: cubic-bezier(0.4,0,0.2,1); transition-duration: 150ms; } -.transition-colors { transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke; transition-timing-function: cubic-bezier(0.4,0,0.2,1); transition-duration: 150ms; } -.transition-opacity { transition-property: opacity; transition-timing-function: cubic-bezier(0.4,0,0.2,1); transition-duration: 150ms; } - -/* ── Pseudo-class: hover ── */ -.hover\:bg-blue-500:hover { background-color: #3b82f6; } -.hover\:bg-blue-600:hover { background-color: #2563eb; } -.hover\:bg-gray-200:hover { background-color: #e5e7eb; } -.hover\:opacity-80:hover { opacity: 0.8; } - -/* ── Pseudo-class: disabled ── */ -.disabled\:bg-gray-400:disabled { background-color: #9ca3af; } -.disabled\:cursor-not-allowed:disabled { cursor: not-allowed; } - -/* ── Dark mode (prefers-color-scheme or manual [data-theme="dark"]) ── */ -@media (prefers-color-scheme: dark) { - :root:not([data-theme="light"]) .dark\:bg-gray-700 { background-color: #374151; } - :root:not([data-theme="light"]) .dark\:bg-gray-800 { background-color: #1f2937; } - :root:not([data-theme="light"]) .dark\:bg-gray-900 { background-color: #111827; } - :root:not([data-theme="light"]) .dark\:border-gray-600 { border-color: #4b5563; } - :root:not([data-theme="light"]) .dark\:border-gray-700 { border-color: #374151; } - :root:not([data-theme="light"]) .dark\:text-gray-200 { color: #e5e7eb; } - :root:not([data-theme="light"]) .dark\:text-gray-400 { color: #9ca3af; } - :root:not([data-theme="light"]) .dark\:hover\:bg-gray-700:hover { background-color: #374151; } - :root:not([data-theme="light"]) .dark\:hover\:bg-gray-800:hover { background-color: #1f2937; } -} - -/* Manual dark override */ -[data-theme="dark"] .dark\:bg-gray-700 { background-color: #374151; } -[data-theme="dark"] .dark\:bg-gray-800 { background-color: #1f2937; } -[data-theme="dark"] .dark\:bg-gray-900 { background-color: #111827; } -[data-theme="dark"] .dark\:border-gray-600 { border-color: #4b5563; } -[data-theme="dark"] .dark\:border-gray-700 { border-color: #374151; } -[data-theme="dark"] .dark\:text-gray-200 { color: #e5e7eb; } -[data-theme="dark"] .dark\:text-gray-400 { color: #9ca3af; } -[data-theme="dark"] .dark\:hover\:bg-gray-700:hover { background-color: #374151; } -[data-theme="dark"] .dark\:hover\:bg-gray-800:hover { background-color: #1f2937; } - -/* Manual light override — ensure bg-white/bg-gray-100 are NOT overridden by above */ -[data-theme="light"] .dark\:bg-gray-700, -[data-theme="light"] .dark\:bg-gray-800, -[data-theme="light"] .dark\:bg-gray-900 { background-color: revert; } - -/* ── Directional spacing (used in JS-generated elements) ── */ -.ml-1 { margin-left: 0.25rem; } -.ml-4 { margin-left: 1rem; } -.mr-1 { margin-right: 0.25rem; } -.pl-0 { padding-left: 0; } -.pl-4 { padding-left: 1rem; } - -/* ── Additional missing utilities ── */ -.whitespace-nowrap { white-space: nowrap; } -.text-ellipsis { text-overflow: ellipsis; } -.font-bold { font-weight: 700; } -.border-r { border-right-width: 1px; border-right-style: solid; } -.mt-1 { margin-top: 0.25rem; } -.text-amber-500 { color: #f59e0b; } -.text-blue-600 { color: #2563eb; } -.hover\:bg-gray-100:hover { background-color: #f3f4f6; } -.hover\:text-blue-800:hover { color: #1e40af; } -.hover\:underline:hover { text-decoration: underline; } diff --git a/mdedit/css/toc.css b/mdedit/css/toc.css deleted file mode 100644 index 21f21fd..0000000 --- a/mdedit/css/toc.css +++ /dev/null @@ -1,280 +0,0 @@ -/* Table of Contents styles */ -.toc-pane { - height: 100%; - display: flex; - flex-direction: column; - overflow: hidden; -} - -.toc-section { - flex: 1; - display: flex; - flex-direction: column; - min-height: 0; -} - -.toc-container, -.toc-content { - flex: 1; - overflow-y: auto; - padding: 1rem; -} - -/* Header layout — font/padding/weight come from .pane-section-header. */ -.toc-header { - justify-content: space-between; -} - -.toc-depth-selector { - font-size: 0.85rem; - padding: 2px 6px; - border-radius: 3px; - border: 1px solid var(--border); - background: var(--bg); - color: var(--text); -} - -.toc-list { - list-style: none; - padding-left: 0; - margin: 0; - font-size: 0.8rem; -} - -.toc-item { - padding: 0; - margin: 0; - line-height: 1.2; -} - -/* TOC heading level styles */ -.toc-level-1 > a { - font-size: 0.9rem; - font-weight: 600; - color: var(--text); -} - -.toc-level-2 > a { - font-size: 0.85rem; - font-weight: 600; - color: var(--text); -} - -.toc-level-3 > a { - font-size: 0.8rem; - font-weight: 600; - color: var(--text-muted); -} - -.toc-level-4 > a { - font-size: 0.75rem; - font-weight: 600; - color: var(--text-muted); -} - -.toc-level-5 > a, -.toc-level-6 > a { - font-size: 0.7rem; - font-weight: 600; - color: var(--text-muted); -} - -/* Nested list spacing */ -.toc-list ul { - list-style: none; - padding-left: 6px; - margin: 0; -} - -.toc-list li { - margin-bottom: 1px; - line-height: 1.2; -} - -.toc-list li a { - display: block; - padding: 2px 6px; - color: var(--text-muted); - text-decoration: none; - border-radius: 3px; - transition: background-color 0.15s ease; -} - -.toc-list li a:hover { - background-color: var(--bg-hover); - color: var(--text); -} - -/* Active TOC item highlighting */ -.toc-list li.toc-active { - background-color: var(--primary); - border-radius: 3px; -} - -/* Use high-specificity selectors to override per-level color rules */ -.toc-list li.toc-active > a, -.toc-list li.toc-active > a:hover, -.toc-list li.toc-level-1.toc-active > a, -.toc-list li.toc-level-2.toc-active > a, -.toc-list li.toc-level-3.toc-active > a, -.toc-list li.toc-level-4.toc-active > a, -.toc-list li.toc-level-5.toc-active > a, -.toc-list li.toc-level-6.toc-active > a { - color: var(--text-light); - border-bottom-color: transparent; - background-color: transparent; -} - -.toc-list li.toc-level-1 { - font-weight: 700; - font-size: 1rem; - padding-left: 0px; -} - -.toc-list li.toc-level-1 a { - color: var(--text); - border-bottom: 1px solid var(--primary); - padding-bottom: 2px; -} - -/* Tree-style connecting lines for TOC hierarchy */ -.toc-list li.toc-level-2 { - font-weight: 650; - font-size: 0.9rem; - padding-left: 16px; - position: relative; -} - -.toc-list li.toc-level-2::before { - content: ''; - position: absolute; - left: 6px; - top: 0; - bottom: 50%; - border-left: 1px solid var(--border); - border-bottom: 1px solid var(--border); - width: 8px; -} - -.toc-list li.toc-level-2 a { - color: var(--text); -} - -.toc-list li.toc-level-3 { - font-weight: 600; - font-size: 0.8rem; - padding-left: 32px; - position: relative; -} - -.toc-list li.toc-level-3::before { - content: ''; - position: absolute; - left: 22px; - top: 0; - bottom: 50%; - border-left: 1px solid var(--border); - border-bottom: 1px solid var(--border); - width: 8px; -} - -.toc-list li.toc-level-3 a { - color: var(--text-muted); -} - -.toc-list li.toc-level-4 { - font-weight: 600; - font-size: 0.75rem; - padding-left: 48px; - position: relative; -} - -.toc-list li.toc-level-4::before { - content: ''; - position: absolute; - left: 38px; - top: 0; - bottom: 50%; - border-left: 1px solid var(--border); - border-bottom: 1px solid var(--border); - width: 8px; -} - -.toc-list li.toc-level-4 a { - color: var(--text-muted); -} - -.toc-list li.toc-level-5 { - font-weight: 600; - font-size: 0.7rem; - padding-left: 64px; - font-style: italic; - position: relative; -} - -.toc-list li.toc-level-5::before { - content: ''; - position: absolute; - left: 54px; - top: 0; - bottom: 50%; - border-left: 1px solid var(--border); - border-bottom: 1px solid var(--border); - width: 8px; -} - -.toc-list li.toc-level-5 a { - color: var(--text-muted); -} - -.toc-list li.toc-level-6 { - font-weight: 600; - font-size: 0.65rem; - padding-left: 80px; - font-style: italic; - text-transform: uppercase; - letter-spacing: 0.05em; - position: relative; -} - -.toc-list li.toc-level-6::before { - content: ''; - position: absolute; - left: 70px; - top: 0; - bottom: 50%; - border-left: 1px solid var(--border); - border-bottom: 1px solid var(--border); - width: 8px; -} - -.toc-list li.toc-level-6 a { - color: var(--text-muted); -} - -/* Vertical connecting lines */ -.toc-list li:not(.toc-level-1)::after { - content: ''; - position: absolute; - left: 6px; - top: 50%; - bottom: -2px; - border-left: 1px solid var(--border); - z-index: -1; -} - -.toc-list li.toc-level-3::after { - left: 22px; -} - -.toc-list li.toc-level-4::after { - left: 38px; -} - -.toc-list li.toc-level-5::after { - left: 54px; -} - -.toc-list li.toc-level-6::after { - left: 70px; -} diff --git a/mdedit/js/app.js b/mdedit/js/app.js deleted file mode 100644 index 61ba7c3..0000000 --- a/mdedit/js/app.js +++ /dev/null @@ -1,43 +0,0 @@ -/** - * Global application state and constants - */ - -// Set to true to enable verbose console logging for development. -const DEBUG = false; - -// Check if File System Access API is available -const hasFileSystemAccess = 'showDirectoryPicker' in window; - -// Directory and file handles -let directoryHandle = null; -let fileTree = {}; -let currentFileHandle = null; - -// True when the page is served over HTTP(S) and the file tree is sourced -// from the server's JSON directory listing instead of the local FS API. -let serverSourceMode = false; - -// Map to store editor instances for each file -// Key: file path, Value: { editor, container, tocContainer, etc. } -const editorInstances = new Map(); - -// Current TOC max depth (1-6) -let tocMaxDepth = 3; - -// Scratchpad ID constant -const SCRATCHPAD_ID = '__scratchpad__'; - -// Default scratchpad markdown — shown the first time mdedit loads. -// Acts as both a welcome message and a starter pad for quick notes. -const SCRATCHPAD_WELCOME = [ - '# Welcome to ZDDC Markdown', - '', - 'All editing happens locally on your computer — nothing is uploaded.', - '', - 'Use this **Scratchpad** for quick notes. Download it any time with the ⬇', - 'button on the Scratchpad row in the file list.', - '', - 'Click **Add Local Directory** above to open a folder of Markdown files,', - 'or just start typing here.', - '', -].join('\n'); diff --git a/mdedit/js/editor.js b/mdedit/js/editor.js deleted file mode 100644 index a030ec6..0000000 --- a/mdedit/js/editor.js +++ /dev/null @@ -1,419 +0,0 @@ -/** - * Toast UI Editor initialization and management - */ - -/** - * Initialize or update the Toast UI Editor for a file - * @param {string} content - Content to display - * @param {boolean} isMarkdown - Whether content is markdown - * @param {string} filePath - Path of the file - * @param {string} fileName - Name of the file - * @param {FileSystemFileHandle} fileHandle - File handle for saving - * @param {number} lastModified - Timestamp of last modification - */ -function initializeEditor(content, isMarkdown = true, filePath = '', fileName = '', fileHandle = null, lastModified = null) { - // Parse front matter - let frontMatterData = {}; - let markdownBody = content; - - if (isMarkdown && content) { - try { - const parsed = parseFrontMatter(content); - frontMatterData = parsed.data; - markdownBody = parsed.content; - } catch (error) { - console.error('Failed to parse front matter:', error); - } - } - - const contentContainer = document.getElementById('content-container'); - if (!contentContainer) { - alert('Error: content-container element not found!'); - return; - } - - // Hide all file view containers - document.querySelectorAll('.file-view-container').forEach(container => { - container.style.display = 'none'; - }); - - // Check if file already has an instance - if (editorInstances.has(filePath)) { - const existingInstance = editorInstances.get(filePath); - if (existingInstance.fileViewContainer) { - existingInstance.fileViewContainer.style.display = 'flex'; - } - return existingInstance.editor; - } - - // Create file view container - const fileViewContainer = document.createElement('div'); - fileViewContainer.className = 'file-view-container flex flex-col h-full'; - - // Create file header - const fileHeader = document.createElement('div'); - fileHeader.className = 'file-header flex justify-between items-center px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 font-medium border-b border-gray-200 dark:border-gray-700'; - - const fileTitle = document.createElement('span'); - fileTitle.textContent = fileName || 'No file selected'; - fileHeader.appendChild(fileTitle); - - // Button container for alignment - const buttonContainer = document.createElement('div'); - buttonContainer.className = 'flex gap-2'; - - // Determine if this is a scratchpad (no file handle) - const isScratchpad = !fileHandle; - const isReadOnlyHandle = !!(fileHandle && fileHandle._readOnly); - - // Save button (or Save As for scratchpads / read-only server files) - const saveButton = document.createElement('button'); - saveButton.className = 'btn btn-primary btn-sm'; - saveButton.textContent = (isScratchpad || isReadOnlyHandle) ? 'Save As...' : 'Save File'; - saveButton.disabled = !isScratchpad; // Scratchpads can always save; read-only enables on edit - buttonContainer.appendChild(saveButton); - - // Reload button (only for files, not scratchpads) — icon to match file-tree refresh - let reloadButton = null; - if (!isScratchpad) { - reloadButton = document.createElement('button'); - reloadButton.className = 'btn btn-secondary btn-sm'; - reloadButton.textContent = '↻'; - reloadButton.title = 'Reload from disk (discards unsaved changes)'; - reloadButton.setAttribute('aria-label', 'Reload from disk'); - buttonContainer.appendChild(reloadButton); - } - - fileHeader.appendChild(buttonContainer); - - fileViewContainer.appendChild(fileHeader); - - // Content area - const contentArea = document.createElement('div'); - contentArea.className = 'flex flex-col flex-1 overflow-hidden'; - - // Editor area with TOC - const editorArea = document.createElement('div'); - editorArea.className = 'flex flex-row flex-1 overflow-hidden'; - - // TOC pane (markdown only) - let tocContainer = null; - let frontMatterTextarea = null; - if (isMarkdown) { - const tocPane = document.createElement('div'); - tocPane.className = 'toc-pane bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-700'; - tocPane.style.width = '325px'; - tocPane.style.minWidth = '150px'; - - // Front matter section (collapsible, height-resizable) - const frontMatterNav = document.createElement('div'); - frontMatterNav.className = 'front-matter-nav'; - frontMatterNav.style.height = '180px'; - - const frontMatterHeader = document.createElement('div'); - frontMatterHeader.className = 'front-matter-header pane-section-header cursor-pointer'; - - const toggleIcon = document.createElement('span'); - toggleIcon.textContent = '▼'; - toggleIcon.className = 'toggle-icon'; - frontMatterHeader.appendChild(toggleIcon); - - const headerText = document.createElement('span'); - headerText.textContent = 'YAML Front Matter'; - frontMatterHeader.appendChild(headerText); - - frontMatterNav.appendChild(frontMatterHeader); - - const frontMatterContent = document.createElement('div'); - frontMatterContent.className = 'front-matter-content'; - - frontMatterTextarea = document.createElement('textarea'); - frontMatterTextarea.className = 'front-matter-textarea'; - frontMatterTextarea.placeholder = 'title: Document Title\ndate: 2024-01-01\ntags: [example]'; - - if (frontMatterData && Object.keys(frontMatterData).length > 0) { - try { - let yamlText = ''; - for (const [key, value] of Object.entries(frontMatterData)) { - if (Array.isArray(value)) { - yamlText += `${key}: [${value.map(v => `"${v}"`).join(', ')}]\n`; - } else { - yamlText += `${key}: ${value}\n`; - } - } - frontMatterTextarea.value = yamlText.trim(); - } catch (error) { - console.warn('Failed to stringify front matter:', error); - frontMatterTextarea.value = ''; - } - } - - frontMatterContent.appendChild(frontMatterTextarea); - frontMatterNav.appendChild(frontMatterContent); - tocPane.appendChild(frontMatterNav); - - // Horizontal resizer between front-matter and TOC - const fmTocResizer = document.createElement('div'); - fmTocResizer.className = 'pane-resizer horizontal'; - tocPane.appendChild(fmTocResizer); - - // TOC section - const tocSection = document.createElement('div'); - tocSection.className = 'toc-section'; - - const tocHeader = document.createElement('div'); - tocHeader.className = 'toc-header pane-section-header'; - - const tocTitle = document.createElement('span'); - tocTitle.textContent = 'Table of Contents'; - tocHeader.appendChild(tocTitle); - - const tocDepthSelector = document.createElement('select'); - tocDepthSelector.className = 'toc-depth-selector'; - tocDepthSelector.innerHTML = ` - - - - - - - `; - tocHeader.appendChild(tocDepthSelector); - - tocSection.appendChild(tocHeader); - - tocContainer = document.createElement('div'); - tocContainer.className = 'toc-container toc-content'; - tocSection.appendChild(tocContainer); - - tocPane.appendChild(tocSection); - - // Toggle: collapsed only shows the header. Hide content + horizontal resizer. - let fmIsCollapsed = false; - frontMatterHeader.addEventListener('click', () => { - fmIsCollapsed = !fmIsCollapsed; - frontMatterNav.classList.toggle('collapsed', fmIsCollapsed); - toggleIcon.textContent = fmIsCollapsed ? '▶' : '▼'; - fmTocResizer.style.display = fmIsCollapsed ? 'none' : ''; - if (fmIsCollapsed) { - frontMatterNav.style.height = ''; - } else { - frontMatterNav.style.height = '180px'; - } - }); - - editorArea.appendChild(tocPane); - - // Vertical resizer between toc-pane and editor (placed inside editorArea) - const tocResizer = document.createElement('div'); - tocResizer.className = 'pane-resizer bg-gray-200 dark:bg-gray-700 transition-colors relative z-10 w-1 cursor-col-resize hover:bg-blue-500'; - tocResizer.setAttribute('data-resizer-for', 'toc-pane'); - editorArea.appendChild(tocResizer); - - makeResizable(tocResizer, tocPane); - - // Make the front-matter / TOC split height-adjustable - makeHeightResizable(fmTocResizer, frontMatterNav, tocPane); - - tocDepthSelector.addEventListener('change', function () { - const depth = parseInt(this.value); - if (editorInstance) { - const currentContent = editorInstance.getMarkdown(); - updateToc(currentContent, tocContainer, editorInstance, depth); - } - }); - } - - // Editor container - const editorContainer = document.createElement('div'); - editorContainer.className = 'editor-instance flex-1 overflow-hidden'; - editorArea.appendChild(editorContainer); - - contentArea.appendChild(editorArea); - fileViewContainer.appendChild(contentArea); - contentContainer.appendChild(fileViewContainer); - - // Check Toast UI availability - if (typeof toastui === 'undefined') { - alert('Error: Toast UI library not loaded!'); - editorContainer.innerHTML = '
Error: Toast UI library not loaded!
'; - return; - } - - let editorInstance; - - try { - // Initialize Toast UI Editor - const editor = new toastui.Editor({ - el: editorContainer, - height: '100%', - initialEditType: 'markdown', - previewStyle: 'vertical', - initialValue: markdownBody, - toolbarItems: [ - ['heading', 'bold', 'italic', 'strike'], - ['hr', 'quote'], - ['ul', 'ol', 'task', 'indent', 'outdent'], - ['table', 'image', 'link'], - ['code', 'codeblock'] - ] - }); - - editorInstance = editor; - - if (!isMarkdown) { - editorInstance.changeMode('wysiwyg'); - } - - // Generate initial TOC - if (isMarkdown && tocContainer) { - try { - updateToc(markdownBody, tocContainer, editorInstance, tocMaxDepth); - } catch (error) { - console.error('Error generating TOC:', error); - } - - const debouncedUpdateToc = debounce(() => { - const currentContent = editorInstance.getMarkdown(); - updateToc(currentContent, tocContainer, editorInstance, tocMaxDepth); - }, 300); - - editorInstance.on('change', () => { - debouncedUpdateToc(); - - const instanceData = editorInstances.get(filePath); - if (instanceData && !instanceData.isDirty) { - instanceData.isDirty = true; - updateFileDirtyStatus(filePath, true); - updateUnsavedCount(); - } - saveButton.disabled = false; - - if (filePath === SCRATCHPAD_ID) updateScratchpadDownloadState(); - }); - - // Scroll listener for TOC highlighting - const mdPreview = editorInstance.getEditorElements().mdPreview; - if (mdPreview) { - let activeTimeout = null; - let lastHeader = null; - - const updateActiveHeader = () => { - // Re-query live headings (TOC may have been regenerated) - const liveHeaders = mdPreview.querySelectorAll('h1, h2, h3, h4, h5, h6'); - const previewRect = mdPreview.getBoundingClientRect(); - // Use a threshold slightly below the top so a header touching - // the top edge counts as "active" - const threshold = previewRect.top + 4; - let activeHeader = null; - for (const header of liveHeaders) { - if (header.getBoundingClientRect().top <= threshold) { - activeHeader = header.textContent.trim(); - } else { - break; - } - } - if (activeHeader !== lastHeader) { - lastHeader = activeHeader; - setActiveTocItem(tocContainer, activeHeader); - } - }; - - const onScroll = () => { - cancelAnimationFrame(activeTimeout); - activeTimeout = requestAnimationFrame(updateActiveHeader); - }; - - mdPreview.addEventListener('scroll', onScroll); - } - } else { - editorInstance.on('change', () => { - const instanceData = editorInstances.get(filePath); - if (instanceData && !instanceData.isDirty) { - instanceData.isDirty = true; - updateFileDirtyStatus(filePath, true); - updateUnsavedCount(); - } - saveButton.disabled = false; - }); - } - - // Front matter change listener - if (frontMatterTextarea) { - frontMatterTextarea.addEventListener('input', () => { - const instanceData = editorInstances.get(filePath); - if (instanceData && !instanceData.isDirty) { - instanceData.isDirty = true; - updateFileDirtyStatus(filePath, true); - updateUnsavedCount(); - } - saveButton.disabled = false; - }); - } - - // Button event listeners - saveButton.addEventListener('click', async () => { - if (isScratchpad) { - // For scratchpads, use Save As - const content = editorInstance.getMarkdown(); - const savedHandle = await saveFileAs(content, 'untitled.md'); - if (savedHandle && hasFileSystemAccess) { - // Check if saved to current directory - add to file tree - if (directoryHandle) { - try { - // Try to get the file from the directory to verify it's there - const checkHandle = await directoryHandle.getFileHandle(savedHandle.name); - // File is in current directory, add to tree - fileTree.entries[savedHandle.name] = { - name: savedHandle.name, - type: 'file', - handle: checkHandle - }; - renderFileTree(); - - } catch (e) { - // File not in current directory, that's fine - } - } - // Clear scratchpad content after successful save - editorInstance.setMarkdown(''); - saveButton.disabled = true; - const instanceData = editorInstances.get(filePath); - if (instanceData) { - instanceData.isDirty = false; - } - } - } else { - saveFile(filePath); - } - }); - - if (reloadButton) { - reloadButton.addEventListener('click', async () => { - await reloadFileFromDisk(filePath); - }); - } - - // Store instance data - const instanceData = { - editor: editor, - fileViewContainer: fileViewContainer, - tocContainer: tocContainer, - saveButton: saveButton, - reloadButton: reloadButton, - frontMatterTextarea: frontMatterTextarea, - frontMatterData: frontMatterData, - fileHandle: fileHandle, - lastModified: lastModified, - isDirty: false - }; - - editorInstances.set(filePath, instanceData); - - return editorInstance; - } catch (error) { - console.error('Error initializing editor:', error); - alert(`Error initializing Toast UI Editor: ${error}`); - return null; - } -} diff --git a/mdedit/js/events.js b/mdedit/js/events.js deleted file mode 100644 index 92264dd..0000000 --- a/mdedit/js/events.js +++ /dev/null @@ -1,81 +0,0 @@ -/** - * Event listeners setup - */ - -/** - * Set up all event listeners for the application - */ -function setupEventListeners() { - // Add Local Directory button (was id="select-directory" / "refresh-directory") - const selectDirectoryBtn = document.getElementById('addDirectoryBtn'); - if (selectDirectoryBtn) { - selectDirectoryBtn.addEventListener('click', openDirectory); - } - - // Refresh button (now in header, was in file-nav pane) - const refreshDirectoryBtn = document.getElementById('refreshHeaderBtn'); - if (refreshDirectoryBtn) { - refreshDirectoryBtn.addEventListener('click', refreshDirectory); - } - - // New file (root) button - const newFileRootBtn = document.getElementById('new-file-root'); - if (newFileRootBtn) { - newFileRootBtn.addEventListener('click', () => { - if (directoryHandle) { - createNewFile(''); - } - }); - } - - // Save All button - const saveAllBtn = document.getElementById('save-all'); - if (saveAllBtn) { - saveAllBtn.addEventListener('click', saveAllFiles); - } - - // Warn when leaving with unsaved changes - window.addEventListener('beforeunload', function (e) { - let hasUnsavedChanges = false; - - editorInstances.forEach((instanceData) => { - if (instanceData.isDirty) { - hasUnsavedChanges = true; - } - }); - - if (hasUnsavedChanges) { - e.preventDefault(); - return 'You have unsaved changes. If you leave now, your changes will be lost.'; - } - }); -} - -/** - * Set up TOC depth selector - */ -function setupTocDepthSelector() { - const depthSelector = document.getElementById('toc-depth-selector'); - if (!depthSelector) return; - - depthSelector.value = tocMaxDepth.toString(); - - depthSelector.addEventListener('change', function () { - tocMaxDepth = parseInt(this.value, 10); - - if (currentFileHandle && currentFileHandle.name.match(/\.(md|markdown)$/i)) { - const filePath = currentFileHandle.name; - const instance = editorInstances.get(filePath); - - if (instance && instance.editor && instance.tocContainer) { - const content = instance.editor.getMarkdown(); - - try { - updateToc(content, instance.tocContainer, instance.editor, tocMaxDepth); - } catch (error) { - console.error('Error updating TOC depth:', error); - } - } - } - }); -} diff --git a/mdedit/js/file-ops.js b/mdedit/js/file-ops.js deleted file mode 100644 index 5c2c267..0000000 --- a/mdedit/js/file-ops.js +++ /dev/null @@ -1,400 +0,0 @@ -/** - * File management operations (create, rename, delete) - * Plain functions, no module wrapper - */ - -/** - * Resolve a node in fileTree by filePath - * @param {string} filePath - Path like 'subdir/file.md' or '' - * @returns {Object|null} The node object or null if not found - */ -function resolveNode(filePath) { - if (!filePath) return fileTree; - const parts = filePath.split('/'); - let node = fileTree; - for (const part of parts) { - if (!node.entries || !node.entries[part]) return null; - node = node.entries[part]; - } - return node; -} - -/** - * Resolve the parent directory handle for a given file path - * @param {string} filePath - Full path like 'subdir/file.md' - * @returns {FileSystemDirectoryHandle|null} Parent directory handle or null - */ -function resolveParentDirHandle(filePath) { - const parts = filePath.split('/'); - if (parts.length === 1) return directoryHandle; - let node = fileTree; - for (let i = 0; i < parts.length - 1; i++) { - node = node.entries[parts[i]]; - if (!node) return null; - } - return node.handle; -} - -/** - * Create a new file - * @param {string} parentDirPath - '' for root, or 'subdir', 'a/b/c' - */ -async function createNewFile(parentDirPath) { - // Resolve parent directory handle first (no user activation needed for reads) - let parentHandle; - if (parentDirPath === '') { - parentHandle = directoryHandle; - } else { - const node = resolveNode(parentDirPath); - if (!node || !node.handle) { - alert('Could not locate parent directory.'); - return; - } - parentHandle = node.handle; - } - - // Show in-page modal and wait for user to confirm or cancel. - // Returns the filename string, or null if cancelled. - const name = await new Promise((resolve) => { - const modal = document.getElementById('new-file-modal'); - const input = document.getElementById('new-file-input'); - const confirmBtn = document.getElementById('new-file-confirm'); - const cancelBtn = document.getElementById('new-file-cancel'); - - input.value = 'untitled.md'; - modal.classList.remove('hidden'); - input.focus(); - input.select(); - - function cleanup() { - modal.classList.add('hidden'); - confirmBtn.removeEventListener('click', onConfirm); - cancelBtn.removeEventListener('click', onCancel); - input.removeEventListener('keydown', onKey); - } - - function onConfirm() { - const val = input.value.trim(); - cleanup(); - resolve(val || null); - } - - function onCancel() { - cleanup(); - resolve(null); - } - - function onKey(e) { - if (e.key === 'Enter') onConfirm(); - if (e.key === 'Escape') onCancel(); - } - - confirmBtn.addEventListener('click', onConfirm); - cancelBtn.addEventListener('click', onCancel); - input.addEventListener('keydown', onKey); - }); - - if (!name) { - if (DEBUG) console.log('New file creation cancelled'); - return; - } - - // Validate name - if (name.includes('/') || name.includes('\\')) { - alert('Invalid filename: cannot contain / or \\.'); - return; - } - - // Check if file already exists - try { - await parentHandle.getFileHandle(name); - const overwrite = window.confirm('A file named "' + name + '" already exists. Overwrite it?'); - if (!overwrite) return; - } catch (e) { - if (e.name !== 'NotFoundError') throw e; - } - - // Create the file — this must happen after the modal's button click - // which is the user activation token. - try { - const newHandle = await parentHandle.getFileHandle(name, { create: true }); - - const writable = await newHandle.createWritable(); - await writable.write(''); - await writable.close(); - - if (DEBUG) console.log(`Created new file: ${parentDirPath ? parentDirPath + '/' : ''}${name}`); - - await refreshDirectory(); - - const newFilePath = parentDirPath ? parentDirPath + '/' + name : name; - const element = document.querySelector('.file-item[data-path="' + CSS.escape(newFilePath) + '"]'); - if (element) { - handleFileClick(newHandle, newFilePath, element); - } - } catch (error) { - console.error('Error creating new file:', error); - alert('Error creating file: ' + error.message); - } -} - -/** - * Rename a file or directory - * @param {string} filePath - Full path like 'subdir/file.md' - * @param {boolean} isDirectory - true if renaming a directory (not supported on Chrome) - */ -async function renameEntry(filePath, isDirectory) { - const currentName = filePath.split('/').pop(); - const newName = window.prompt('Rename to:', currentName); - - if (newName === null || newName === currentName) { - if (DEBUG) console.log('Rename cancelled or unchanged'); - return; - } - - // Validate name - if (newName.includes('/') || newName.includes('\\') || newName.trim() === '') { - alert('Invalid filename: cannot contain / or \\ and must not be empty.'); - return; - } - - // Resolve parent directory handle - const parentHandle = resolveParentDirHandle(filePath); - if (!parentHandle) { - alert('Could not locate parent directory.'); - return; - } - - // For files: rename via File System Access API - if (!isDirectory) { - try { - // Check if new name already exists (file or directory) - try { - const existing = await parentHandle.getFileHandle(newName); - // A file with that name exists - const overwrite = window.confirm('A file named "' + newName + '" already exists. Overwrite?'); - if (!overwrite) return; - } catch (fileErr) { - if (fileErr.name === 'TypeMismatchError') { - // A directory with that name exists - window.alert('A folder named "' + newName + '" already exists. Choose a different name.'); - return; - } - if (fileErr.name !== 'NotFoundError') throw fileErr; - // NotFoundError = safe to create - } - - const oldHandle = resolveNode(filePath); - if (!oldHandle || !oldHandle.handle) { - alert('Could not find file to rename.'); - return; - } - - const file = await oldHandle.handle.getFile(); - const content = await file.text(); - - const newHandle = await parentHandle.getFileHandle(newName, { create: true }); - const writable = await newHandle.createWritable(); - await writable.write(content); - await writable.close(); - const newFile = await newHandle.getFile(); - - await parentHandle.removeEntry(currentName); - - // Update editor instances - if (editorInstances.has(filePath)) { - const instance = editorInstances.get(filePath); - const newFilePath = filePath.substring(0, filePath.length - currentName.length) + newName; - - // Remove old instance - const data = editorInstances.get(filePath); - if (data.fileViewContainer) { - data.fileViewContainer.classList.add('hidden'); - } - editorInstances.delete(filePath); - - // Re-add with new path - editorInstances.set(newFilePath, { ...data, fileHandle: newHandle, lastModified: newFile.lastModified }); - - // Update active state - if (instance.fileViewContainer) { - instance.fileViewContainer.classList.remove('hidden'); - instance.fileViewContainer.dataset.path = newFilePath; - } - - // Update fileTree entries - const parts = filePath.split('/'); - const fileName = parts.pop(); - const dirPath = parts.join('/'); - let targetEntries = fileTree.entries; - if (dirPath) { - const dirParts = dirPath.split('/'); - let current = fileTree; - for (const part of dirParts) { - current = current.entries[part]; - } - targetEntries = current.entries; - } - if (targetEntries && targetEntries[currentName]) { - delete targetEntries[currentName]; - targetEntries[newName] = { - name: newName, - type: 'file', - handle: newHandle - }; - } - - renderFileTree(); - restoreActiveFile(newFilePath); - } else { - renderFileTree(); - } - - } catch (error) { - console.error('Error renaming file:', error); - alert('Error renaming file: ' + error.message); - } - } else { - // For directories: not supported by browser API - alert('Directory rename is not supported by the browser File System API. Please rename the folder in your OS file manager and refresh.'); - } -} - -/** - * Delete a file or directory - * @param {string} filePath - Full path like 'subdir/file.md' or 'subdir' - * @param {boolean} isDirectory - true if deleting a directory - */ -async function deleteEntry(filePath, isDirectory) { - const name = filePath.split('/').pop(); - - const message = isDirectory - ? 'Delete folder "' + name + '" and all its contents?' - : 'Delete "' + name + '"?'; - - const ok = window.confirm(message); - if (!ok) { - if (DEBUG) console.log('Delete cancelled by user'); - return; - } - - // Resolve parent directory handle - const parentHandle = resolveParentDirHandle(filePath); - if (!parentHandle) { - alert('Could not locate parent directory.'); - return; - } - - let deleted = false; - try { - await parentHandle.removeEntry(name, { recursive: isDirectory }); - deleted = true; - } catch (error) { - if (error.name === 'NotFoundError') { - // Already gone — treat as success for cleanup purposes - deleted = true; - } else { - console.error('Error deleting entry:', error); - alert('Error deleting entry: ' + error.message); - } - } - - if (deleted) { - // Close editor if open - if (!isDirectory && editorInstances.has(filePath)) { - closeEditorInstance(filePath); - } else if (isDirectory) { - // Close any editors under this directory - const dirsToClose = []; - editorInstances.forEach(function(instance, key) { - if (key === filePath || key.startsWith(filePath + '/')) { - dirsToClose.push(key); - } - }); - dirsToClose.forEach(function(key) { - closeEditorInstance(key); - }); - } - - // Remove from fileTree entries - const parts = filePath.split('/'); - const entryName = parts.pop(); - const dirPath = parts.join('/'); - let targetEntries = fileTree.entries; - if (dirPath) { - const dirParts = dirPath.split('/'); - let current = fileTree; - for (const part of dirParts) { - current = current.entries[part]; - } - targetEntries = current.entries; - } - if (targetEntries && targetEntries[entryName]) { - delete targetEntries[entryName]; - } - - renderFileTree(); - updateStatusCountsFromTree(); - } -} - -/** - * Close an editor instance and show welcome screen if no files open - * @param {string} filePath - Path of file to close - */ -function closeEditorInstance(filePath) { - const instance = editorInstances.get(filePath); - if (!instance) return; - - if (instance.fileViewContainer) { - instance.fileViewContainer.classList.add('hidden'); - } - editorInstances.delete(filePath); - - // Check if any visible file-view-container children remain - const contentContainer = document.getElementById('content-container'); - if (contentContainer) { - const visibleChildren = Array.from(contentContainer.querySelectorAll('.file-view-container')) - .filter(function(el) { return !el.classList.contains('hidden'); }); - if (visibleChildren.length === 0) { - document.getElementById('welcome-screen').classList.remove('hidden'); - contentContainer.classList.add('hidden'); - } - } -} - -/** - * Restore active file state after rename - * @param {string} newFilePath - New path of the file - */ -function restoreActiveFile(newFilePath) { - const element = document.querySelector('.file-item[data-path="' + CSS.escape(newFilePath) + '"]'); - if (element) { - element.classList.add('active-file'); - element.style.backgroundColor = ''; - element.style.color = ''; - } -} - -/** - * Update status counts from fileTree - */ -function updateStatusCountsFromTree() { - let folderCount = 0; - let fileCount = 0; - - function countEntries(entries) { - if (!entries) return; - for (const [name, item] of Object.entries(entries)) { - if (item.type === 'directory') { - folderCount++; - countEntries(item.entries); - } else if (item.type === 'file') { - fileCount++; - } - } - } - - countEntries(fileTree.entries); - updateStatusCounts(folderCount, fileCount); -} diff --git a/mdedit/js/file-system.js b/mdedit/js/file-system.js deleted file mode 100644 index f805e0f..0000000 --- a/mdedit/js/file-system.js +++ /dev/null @@ -1,808 +0,0 @@ -/** - * File system operations using File System Access API - */ - -/** - * Open the scratchpad editor - */ -function openScratchpad() { - // Check if scratchpad already exists - if (editorInstances.has(SCRATCHPAD_ID)) { - // Just show it - const instance = editorInstances.get(SCRATCHPAD_ID); - document.getElementById('welcome-screen').classList.add('hidden'); - document.getElementById('content-container').classList.remove('hidden'); - - // Hide all other editors, show scratchpad - editorInstances.forEach((data, path) => { - if (data.fileViewContainer) { - data.fileViewContainer.style.display = path === SCRATCHPAD_ID ? 'flex' : 'none'; - } - }); - return; - } - - // Hide welcome screen, show content container - document.getElementById('welcome-screen').classList.add('hidden'); - document.getElementById('content-container').classList.remove('hidden'); - - // Initialize editor with the welcome text seeded as the starting content. - initializeEditor(SCRATCHPAD_WELCOME, true, SCRATCHPAD_ID, 'Scratchpad', null, null); - - // Mark as scratchpad - const instance = editorInstances.get(SCRATCHPAD_ID); - if (instance) { - instance.isScratchpad = true; - } - - // Reflect non-empty starting content on the scratchpad row's download button. - updateScratchpadDownloadState(); - - if (DEBUG) console.log('Opened scratchpad'); -} - -/** - * Enable/disable the scratchpad-row download button based on whether the - * scratchpad currently holds any content. Idempotent — safe to call from - * editor change listeners. - */ -function updateScratchpadDownloadState() { - const btn = document.getElementById('scratchpad-download-btn'); - if (!btn) return; - const instance = editorInstances.get(SCRATCHPAD_ID); - let hasContent = false; - if (instance && instance.editor) { - try { - hasContent = (instance.editor.getMarkdown() || '').trim().length > 0; - } catch (_) { /* editor may not be ready yet */ } - } - btn.disabled = !hasContent; - btn.classList.toggle('is-disabled', !hasContent); -} - -/** - * Trigger a browser download of the current scratchpad markdown. - * No-op if the scratchpad has no content. - */ -function downloadScratchpad() { - const instance = editorInstances.get(SCRATCHPAD_ID); - if (!instance || !instance.editor) return; - let content = ''; - try { content = instance.editor.getMarkdown() || ''; } catch (_) { return; } - - // Pull front matter from the textarea if any - if (instance.frontMatterTextarea) { - const fmText = instance.frontMatterTextarea.value.trim(); - if (fmText) content = `---\n${fmText}\n---\n${content}`; - } - - if (!content.trim()) return; - - // Suggest a filename derived from the first H1 if present - let suggested = 'scratchpad.md'; - const h1 = content.match(/^#\s+(.+)$/m); - if (h1) { - const slug = h1[1].trim().toLowerCase() - .replace(/[^a-z0-9\s-]/g, '') - .replace(/\s+/g, '-') - .substring(0, 60); - if (slug) suggested = `${slug}.md`; - } - - saveFileAs(content, suggested); -} - -/** - * Save file using Save As dialog (for scratchpads or new saves) - * @param {string} content - Content to save - * @param {string} suggestedName - Suggested filename - * @returns {Promise} File handle if saved, null otherwise - */ -async function saveFileAs(content, suggestedName = 'untitled.md') { - if (hasFileSystemAccess) { - try { - const fileHandle = await window.showSaveFilePicker({ - suggestedName: suggestedName, - types: [{ - description: 'Markdown files', - accept: { 'text/markdown': ['.md', '.markdown'] } - }] - }); - - const writable = await fileHandle.createWritable(); - await writable.write(content); - await writable.close(); - - if (DEBUG) console.log(`File saved as: ${fileHandle.name}`); - return fileHandle; - } catch (error) { - if (error.name === 'AbortError') { - if (DEBUG) console.log('Save cancelled by user'); - return null; - } - throw error; - } - } else { - // Fallback: download as blob - const blob = new Blob([content], { type: 'text/markdown' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = suggestedName; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - if (DEBUG) console.log(`File downloaded as: ${suggestedName}`); - return null; - } -} - -/** - * Open directory picker and handle selection - */ -async function openDirectory() { - try { - if (!('showDirectoryPicker' in window)) { - throw new Error('The File System API is not supported in this browser.'); - } - - directoryHandle = await window.showDirectoryPicker(); - if (DEBUG) console.log('Directory selected:', directoryHandle.name); - - // Local picker wins over any active server-source mode. - serverSourceMode = false; - - updateDirectoryStatus(directoryHandle.name); - await readDirectory(directoryHandle); - - } catch (error) { - if (error.name === 'AbortError') { - if (DEBUG) console.log('User cancelled the directory selection'); - } else { - console.error('Error selecting directory:', error); - alert(`Error: ${error.message}`); - } - } -} - -/** - * Update UI to show selected directory - * @param {string} directoryName - Name of the selected directory - */ -function updateDirectoryStatus(directoryName) { - // Standardized header pattern (across all ZDDC tools): the button - // keeps the label "Add Local Directory"; de-emphasize it once a - // directory is loaded (the user can still click to pick another) - // by applying the shared btn--subtle variant. The directory name - // is shown in the file-nav pane, not on the button. - const selectDirectoryBtn = document.getElementById('addDirectoryBtn'); - if (selectDirectoryBtn) { - selectDirectoryBtn.classList.remove('btn-primary'); - selectDirectoryBtn.classList.add('btn--subtle'); - selectDirectoryBtn.title = `Loaded: ${directoryName} — click to switch`; - } - - const refreshBtn = document.getElementById('refreshHeaderBtn'); - if (refreshBtn) { - refreshBtn.classList.remove('hidden'); - } - - // Show new file button when directory is selected - const newFileRootBtn = document.getElementById('new-file-root'); - if (newFileRootBtn) { - newFileRootBtn.classList.remove('hidden'); - } -} - -/** - * Read directory contents and build tree structure - * @param {FileSystemDirectoryHandle} dirHandle - Directory handle - * @param {Object} parentNode - Parent node in tree (for recursion) - * @returns {Object} Statistics about the directory - */ -async function readDirectory(dirHandle, parentNode = null) { - if (parentNode === null) { - fileTree = { - name: dirHandle.name, - type: 'directory', - handle: dirHandle, - entries: {} - }; - - const fileTreeElement = document.getElementById('file-tree'); - if (fileTreeElement) { - fileTreeElement.innerHTML = ''; - } - - parentNode = fileTree; - } - - try { - let stats = { folderCount: 0, fileCount: 0 }; - - for await (const entry of dirHandle.values()) { - if (entry.kind === 'file' && !entry.name.startsWith('_')) { - parentNode.entries[entry.name] = { - name: entry.name, - type: 'file', - handle: entry - }; - stats.fileCount++; - } else if (entry.kind === 'directory' && !entry.name.startsWith('_')) { - const dirNode = { - name: entry.name, - type: 'directory', - handle: entry, - entries: {} - }; - - parentNode.entries[entry.name] = dirNode; - - const subStats = await readDirectory(entry, dirNode); - stats.folderCount += subStats.folderCount + 1; - stats.fileCount += subStats.fileCount; - } - } - - if (parentNode === fileTree) { - renderFileTree(); - updateStatusCounts(stats.folderCount, stats.fileCount); - } - - return stats; - } catch (error) { - console.error('Error reading directory:', error); - return { folderCount: 0, fileCount: 0 }; - } -} - -/** - * Save a file by its path - * @param {string} filePath - Path of the file to save - * @returns {Promise} Whether save was successful - */ -async function saveFile(filePath) { - if (!filePath && currentFileHandle) { - filePath = currentFileHandle.name; - } else if (!filePath) { - alert('No file is currently open'); - return false; - } - - try { - const editorInstance = editorInstances.get(filePath); - if (!editorInstance) { - throw new Error('No editor instance found for this file'); - } - - if (!editorInstance.isDirty) { - if (DEBUG) console.log(`File ${filePath} is not dirty, skipping save`); - return true; - } - - const fileHandle = editorInstance.fileHandle; - if (!fileHandle) { - throw new Error('No file handle available for this file'); - } - - // Check for external modifications - const file = await fileHandle.getFile(); - const currentLastModified = file.lastModified; - const storedLastModified = editorInstance.lastModified; - - if (storedLastModified && currentLastModified !== storedLastModified) { - const confirmSave = confirm( - 'Warning: This file has been modified outside of the application since you opened it. ' + - 'Saving will overwrite those changes. Do you want to continue?' - ); - - if (!confirmSave) { - if (DEBUG) console.log('Save aborted by user due to external file modifications'); - return false; - } - } - - // Get markdown content from editor - const markdownContent = editorInstance.editor.getMarkdown(); - - // Get front matter from textarea - let frontMatterData = {}; - if (editorInstance.frontMatterTextarea) { - const frontMatterText = editorInstance.frontMatterTextarea.value.trim(); - if (frontMatterText) { - try { - const yamlContent = `---\n${frontMatterText}\n---\n`; - const parsed = parseFrontMatter(yamlContent); - frontMatterData = parsed.data; - } catch (error) { - console.error('Error parsing front matter:', error); - throw new Error(`Invalid YAML front matter: ${error.message}`); - } - } - } - - // Apply before save hooks - frontMatterData = await applyBeforeSaveHooks(frontMatterData, markdownContent, fileHandle); - - // Combine front matter with markdown - const finalContent = frontMatterData && Object.keys(frontMatterData).length > 0 - ? stringifyFrontMatter(markdownContent, frontMatterData) - : markdownContent; - - // Server-mode files are read-only: fall back to a Save-As download. - if (typeof fileHandle.createWritable !== 'function') { - const downloadName = (fileHandle.name || filePath.split('/').pop() || 'untitled.md'); - await saveFileAs(finalContent, downloadName); - editorInstance.isDirty = false; - updateFileDirtyStatus(filePath, false); - updateUnsavedCount(); - if (editorInstance.saveButton) editorInstance.saveButton.disabled = true; - return true; - } - - // Write to file - const writable = await fileHandle.createWritable(); - await writable.write(finalContent); - await writable.close(); - - // Update state - const updatedFile = await fileHandle.getFile(); - editorInstance.lastModified = updatedFile.lastModified; - editorInstance.isDirty = false; - updateFileDirtyStatus(filePath, false); - updateUnsavedCount(); - - if (editorInstance.saveButton) { - editorInstance.saveButton.disabled = true; - } - - if (DEBUG) console.log(`File ${filePath} saved successfully!`); - - await applyAfterSaveHooks(frontMatterData, markdownContent, fileHandle); - - return true; - } catch (error) { - console.error(`Error saving file ${filePath}:`, error); - alert(`Error saving file: ${error.message}`); - return false; - } -} - -/** - * Save all files with unsaved changes - * @returns {Promise<{saved: number, failed: number}>} - */ -async function saveAllFiles() { - let saved = 0; - let failed = 0; - - const dirtyFiles = []; - editorInstances.forEach((instance, filePath) => { - if (instance.isDirty) { - dirtyFiles.push(filePath); - } - }); - - if (dirtyFiles.length === 0) { - if (DEBUG) console.log('No files with unsaved changes'); - return { saved, failed }; - } - - for (const filePath of dirtyFiles) { - try { - const success = await saveFile(filePath); - if (success) { - saved++; - } else { - failed++; - } - } catch (error) { - console.error(`Error saving file ${filePath}:`, error); - failed++; - } - } - - if (failed === 0) { - if (DEBUG) console.log(`All ${saved} files saved successfully`); - } else { - if (DEBUG) console.log(`Saved ${saved} files, ${failed} files failed to save`); - } - - return { saved, failed }; -} - -/** - * Reload file from disk - * @param {string} filePath - Path of file to reload - * @returns {Promise} Whether reload was successful - */ -async function reloadFileFromDisk(filePath) { - try { - const editorInstance = editorInstances.get(filePath); - if (!editorInstance) { - throw new Error('No editor instance found for this file'); - } - - if (editorInstance.isDirty) { - const confirmReload = confirm( - 'This file has unsaved changes. Reloading will discard all changes. ' + - 'Do you want to continue?' - ); - - if (!confirmReload) { - if (DEBUG) console.log('Reload cancelled by user'); - return false; - } - } - - const fileHandle = editorInstance.fileHandle; - if (!fileHandle) { - throw new Error('No file handle available for this file'); - } - - const file = await fileHandle.getFile(); - const fileContent = await file.text(); - - editorInstance.lastModified = file.lastModified; - - if (filePath.endsWith('.md') || filePath.endsWith('.markdown')) { - const parsed = parseFrontMatter(fileContent); - - if (editorInstance.frontMatterTextarea) { - const frontMatterYaml = stringifyFrontMatterToTextarea(parsed.data); - editorInstance.frontMatterTextarea.value = frontMatterYaml; - } - - let currentScrollTop = 0; - try { - currentScrollTop = editorInstance.editor.getScrollTop(); - } catch (error) { - if (DEBUG) console.debug('Could not get scroll position:', error); - } - - editorInstance.editor.setMarkdown(parsed.content); - - setTimeout(() => { - try { - editorInstance.editor.setScrollTop(currentScrollTop); - } catch (error) { - if (DEBUG) console.debug('Could not restore scroll position:', error); - } - }, 100); - - if (editorInstance.tocContainer) { - try { - updateToc(parsed.content, editorInstance.tocContainer, editorInstance.editor, tocMaxDepth); - } catch (error) { - console.error('Error updating TOC during reload:', error); - } - } - } else { - editorInstance.editor.setMarkdown(fileContent); - } - - editorInstance.isDirty = false; - updateFileDirtyStatus(filePath, false); - updateUnsavedCount(); - - if (editorInstance.saveButton) { - editorInstance.saveButton.disabled = true; - } - - if (DEBUG) console.log(`File ${filePath} reloaded successfully from disk!`); - return true; - } catch (error) { - console.error(`Error reloading file ${filePath}:`, error); - alert(`Error reloading file: ${error.message}`); - return false; - } -} - -/** - * Before save hook - apply modifications before saving - */ -async function applyBeforeSaveHooks(frontMatter, markdownContent, fileHandle) { - frontMatter.lastModified = new Date().toISOString(); - - if (!frontMatter.title) { - const firstHeading = markdownContent.match(/^#\s+(.+)$/m); - if (firstHeading) { - frontMatter.title = firstHeading[1]; - } - } - - const customTags = (markdownContent.match(/<(deliverable|meeting|report|trkno)>/g) || []).length; - if (customTags > 0) { - frontMatter.customTagCount = customTags; - } - - return frontMatter; -} - -/** - * After save hook - perform actions after saving - */ -async function applyAfterSaveHooks(frontMatter, markdownContent, fileHandle) { - const tags = ['deliverable', 'meeting', 'report', 'trkno']; - const preservedTags = tags.filter(tag => markdownContent.includes(`<${tag}>`)); - if (preservedTags.length > 0) { - if (DEBUG) console.log(`Preserved custom tags: ${preservedTags.join(', ')}`); - } -} - -/** - * Refresh directory from disk without losing unsaved work - */ -async function refreshDirectory() { - if (serverSourceMode) { - await loadServerDirectory(); - return; - } - if (!directoryHandle) { - if (DEBUG) console.log('No directory selected, cannot refresh'); - return; - } - - // Get active file path from DOM before refresh - const activeFileEl = document.querySelector('.file-item.active-file'); - const activeFilePath = activeFileEl ? activeFileEl.dataset.path : null; - - // Get dirty files from editorInstances - const dirtyFiles = new Set(); - editorInstances.forEach((instance, filePath) => { - if (instance.isDirty) { - dirtyFiles.add(filePath); - } - }); - - // Re-read directory (calls renderFileTree at the end) - await readDirectory(directoryHandle); - - // Restore active file state - if (activeFilePath) { - const activeElement = document.querySelector(`.file-item[data-path="${activeFilePath}"]`); - if (activeElement) { - activeElement.classList.add('active-file'); - } - } - - // Restore dirty indicators - dirtyFiles.forEach(filePath => { - updateFileDirtyStatus(filePath, true); - }); -} - -/** - * Surface a clear "no permission to list this directory" message in - * the file tree pane when the server returns 403 on the initial - * listing. Distinct from "host doesn't serve JSON" so the user - * understands why the tree is empty. - */ -function showServerForbiddenMessage() { - const treeEl = document.getElementById('file-tree'); - if (!treeEl) return; - treeEl.innerHTML = - '
' + - 'No permission to list this directory.' + - '

Your account does not have read access here. ' + - 'Contact the document controller if you believe this is wrong.

' + - '
'; -} - -/** - * Build a CRUD-capable file handle backed by a URL — uses the shared - * HTTP polyfill from window.zddc.source. The polyfill's getFile() does - * a GET, and createWritable() PUTs bytes back (file API on zddc-server). - * - * Adds `_serverUrl` for legacy code paths that still expect that field. - * Marks `_readOnly: false` so editor.js leaves save buttons enabled. - */ -function createServerFileHandle(name, url) { - const handle = new window.zddc.source.HttpFileHandle(url, name); - handle._serverUrl = url; - handle._readOnly = false; - return handle; -} - -/** - * Build a CRUD-capable directory handle backed by a server URL — uses - * the shared HTTP polyfill. Supports values()/entries(), getFileHandle, - * getDirectoryHandle({create}), and removeEntry() against the server - * file API. _serverUrl/_readOnly are kept for legacy probes. - */ -function createServerDirectoryHandle(name, url) { - const handle = new window.zddc.source.HttpDirectoryHandle(url, name); - handle._serverUrl = url; - handle._readOnly = false; - return handle; -} - -/** - * Recursively fetch the JSON directory listing for `dirUrl` and populate - * `parentNode.entries` with synthetic handles. Returns folder/file counts. - * Uses the same Caddy/zddc-server JSON shape archive's source.js consumes. - */ -async function readServerDirectory(dirUrl, parentNode, depth) { - if (depth > 10) return { folderCount: 0, fileCount: 0 }; - - let items; - try { - const resp = await fetch(dirUrl, { headers: { 'Accept': 'application/json' }, cache: 'no-cache' }); - if (!resp.ok) throw new Error(`HTTP ${resp.status}`); - items = await resp.json(); - if (!Array.isArray(items)) throw new Error('Expected JSON array'); - } catch (err) { - if (DEBUG) console.warn(`Server listing failed for ${dirUrl}:`, err); - return { folderCount: 0, fileCount: 0 }; - } - - const stats = { folderCount: 0, fileCount: 0 }; - const subdirPromises = []; - - for (const item of items) { - const rawName = item.name.endsWith('/') ? item.name.slice(0, -1) : item.name; - if (rawName.startsWith('.') || rawName.startsWith('_')) continue; - - const base = dirUrl.endsWith('/') ? dirUrl : dirUrl + '/'; - const childUrl = base + encodeURIComponent(rawName) + (item.is_dir ? '/' : ''); - - if (item.is_dir) { - const dirNode = { - name: rawName, - type: 'directory', - handle: createServerDirectoryHandle(rawName, childUrl), - entries: {}, - }; - parentNode.entries[rawName] = dirNode; - stats.folderCount++; - subdirPromises.push( - readServerDirectory(childUrl, dirNode, depth + 1).then((s) => { - stats.folderCount += s.folderCount; - stats.fileCount += s.fileCount; - }) - ); - } else { - parentNode.entries[rawName] = { - name: rawName, - type: 'file', - handle: createServerFileHandle(rawName, childUrl), - }; - stats.fileCount++; - } - } - - await Promise.all(subdirPromises); - return stats; -} - -/** - * Detect HTTP context, fetch the directory the page lives under, and render - * the resulting subtree in the file pane. Idempotent — safe to re-call. - */ -async function loadServerDirectory() { - if (!(location.protocol === 'http:' || location.protocol === 'https:')) return; - - // Compute the directory URL the file tree should be rooted at. - // - // /working/ → root = /working/ - // /working/x/y/ → root = /working/x/y/ - // /working → root = /working/ (no-slash - // canonical-folder URL — the dispatcher - // routes mdedit here directly without - // a redirect, so we infer "directory" - // from the absence of a `.` in the - // last segment rather than stripping - // back to the parent.) - // /x/y/mdedit.html → root = /x/y/ (the leaf - // segment IS a file; strip to parent.) - // - // The rule: if the last path segment contains a "." it's a file, - // strip it; otherwise treat the whole path as the directory. - let href = window.location.href.split('?')[0].split('#')[0]; - let baseUrl; - if (href.endsWith('/')) { - baseUrl = href; - } else { - const lastSlash = href.lastIndexOf('/'); - const lastSeg = lastSlash >= 0 ? href.substring(lastSlash + 1) : href; - if (lastSeg.indexOf('.') !== -1) { - // Looks like a file (has an extension) — strip to parent. - baseUrl = lastSlash >= 0 ? href.substring(0, lastSlash + 1) : href + '/'; - } else { - // Looks like a directory — append the trailing slash so all - // subsequent listing URLs are computed correctly. - baseUrl = href + '/'; - } - } - - // Only enter server-source mode if the host actually serves JSON directory - // listings (zddc-server / Caddy). On a plain static host the probe fails - // and we must leave "Add Local Directory" visible so the user can still - // load local files. - // - // 403 means the host is a zddc-server but the user lacks `r` on this - // directory (a "no list" permission posture). Show a clear message so - // the user understands why the tree is empty. - try { - const resp = await fetch(baseUrl, { headers: { 'Accept': 'application/json' }, cache: 'no-cache' }); - if (resp.status === 403) { - showServerForbiddenMessage(); - return; - } - if (!resp.ok) return; - const items = await resp.json(); - if (!Array.isArray(items)) return; - } catch (_) { - return; - } - - serverSourceMode = true; - - const rootName = (() => { - const path = baseUrl.replace(/\/$/, ''); - const seg = path.substring(path.lastIndexOf('/') + 1); - return seg || baseUrl; - })(); - - fileTree = { - name: rootName, - type: 'directory', - handle: createServerDirectoryHandle(rootName, baseUrl), - entries: {}, - }; - - // Surface refresh. The server now exposes a CRUD file API, so write - // controls (new file, save, delete) stay enabled — the polyfill - // routes their writes through PUT/DELETE/POST. "Add Local Directory" - // is de-emphasized so the user can still load a local folder if they - // want, but server-mode is now the default working mode. - const refreshBtn = document.getElementById('refreshHeaderBtn'); - if (refreshBtn) refreshBtn.classList.remove('hidden'); - const addDirBtn = document.getElementById('addDirectoryBtn'); - if (addDirBtn) { - addDirBtn.classList.remove('btn-primary'); - addDirBtn.classList.add('btn--subtle'); - } - - const stats = await readServerDirectory(baseUrl, fileTree, 0); - renderFileTree(); - updateStatusCounts(stats.folderCount, stats.fileCount); -} - -/** - * Start monitoring files for external changes - */ -function startFileChangeMonitoring() { - setInterval(async () => { - for (const [filePath, editorInstance] of editorInstances) { - try { - const fileHandle = editorInstance.fileHandle; - if (!fileHandle) continue; - if (fileHandle._readOnly) continue; - - const file = await fileHandle.getFile(); - const currentLastModified = file.lastModified; - const storedLastModified = editorInstance.lastModified; - - if (storedLastModified && currentLastModified !== storedLastModified) { - if (DEBUG) console.log(`File ${filePath} changed externally`); - - const action = confirm( - `File "${filePath}" has been modified by another application.\n\n` + - 'Click OK to reload from disk (discards unsaved changes)\n' + - 'Click Cancel to keep current version' - ); - - if (action) { - await reloadFileFromDisk(filePath); - } else { - editorInstance.lastModified = currentLastModified; - } - } - } catch (error) { - if (DEBUG) console.debug(`Error checking file ${filePath}:`, error.message); - } - } - }, 3000); -} diff --git a/mdedit/js/file-tree.js b/mdedit/js/file-tree.js deleted file mode 100644 index 83cb4b7..0000000 --- a/mdedit/js/file-tree.js +++ /dev/null @@ -1,868 +0,0 @@ -/** - * File tree rendering and navigation - */ - -// Cache for lazily loaded CDN libraries -const loadedLibraries = new Map(); - -/** - * Lazily load a script from CDN. Returns a promise that resolves when loaded. - * Caches the promise so subsequent calls return immediately. - */ -function loadLibrary(url) { - if (loadedLibraries.has(url)) return loadedLibraries.get(url); - const promise = new Promise((resolve, reject) => { - const script = document.createElement('script'); - script.src = url; - script.onload = resolve; - script.onerror = () => reject(new Error(`Failed to load library: ${url}`)); - document.head.appendChild(script); - }); - loadedLibraries.set(url, promise); - return promise; -} - -/** - * Render the file tree in the UI - */ -/** - * Create action buttons for file/directory items - * @param {string} filePath - Full path of the file/dir - * @param {string} type - 'file' or 'directory' - */ -function createActionButtons(filePath, type) { - const actionsDiv = document.createElement('div'); - actionsDiv.className = 'tree-actions'; - - // Server mode now supports full CRUD via the file API — drop the - // legacy short-circuit that hid the rename/delete/new-file actions. - - if (type === 'directory') { - // Directory: + (new file) + ✕ (delete) - const newFileBtn = document.createElement('button'); - newFileBtn.className = 'tree-btn'; - newFileBtn.setAttribute('title', 'New file'); - newFileBtn.innerHTML = ''; - newFileBtn.onclick = (e) => { - e.stopPropagation(); - createNewFile(filePath); - }; - - const deleteBtn = document.createElement('button'); - deleteBtn.className = 'tree-btn tree-btn--danger'; - deleteBtn.setAttribute('title', 'Delete'); - deleteBtn.innerHTML = ''; - deleteBtn.onclick = (e) => { - e.stopPropagation(); - deleteEntry(filePath, true); - }; - - actionsDiv.appendChild(newFileBtn); - actionsDiv.appendChild(deleteBtn); - } else { - // File: ✎ (rename) + ✕ (delete) - const renameBtn = document.createElement('button'); - renameBtn.className = 'tree-btn'; - renameBtn.setAttribute('title', 'Rename'); - renameBtn.innerHTML = ''; - renameBtn.onclick = (e) => { - e.stopPropagation(); - renameEntry(filePath, false); - }; - - const deleteBtn = document.createElement('button'); - deleteBtn.className = 'tree-btn tree-btn--danger'; - deleteBtn.setAttribute('title', 'Delete'); - deleteBtn.innerHTML = ''; - deleteBtn.onclick = (e) => { - e.stopPropagation(); - deleteEntry(filePath, false); - }; - - actionsDiv.appendChild(renameBtn); - actionsDiv.appendChild(deleteBtn); - } - - return actionsDiv; -} - -function renderFileTree() { - const fileTreeElement = document.getElementById('file-tree'); - if (!fileTreeElement) return; - - fileTreeElement.innerHTML = ''; - - // Always show scratchpad at top - const scratchpadElement = document.createElement('div'); - scratchpadElement.className = 'file-item tree-row px-2 py-1 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-gray-800 border-b border-gray-200 dark:border-gray-700 mb-2'; - scratchpadElement.dataset.type = 'file'; - scratchpadElement.dataset.path = SCRATCHPAD_ID; - scratchpadElement.dataset.name = 'Scratchpad'; - - const scratchLabel = document.createElement('span'); - scratchLabel.className = 'tree-row__label'; - scratchLabel.innerHTML = '
📝 Scratchpad
Quick notes — no directory needed
'; - scratchpadElement.appendChild(scratchLabel); - - const scratchActions = document.createElement('div'); - scratchActions.className = 'tree-actions tree-actions--always'; - - const scratchDownloadBtn = document.createElement('button'); - scratchDownloadBtn.id = 'scratchpad-download-btn'; - scratchDownloadBtn.className = 'tree-btn'; - scratchDownloadBtn.title = 'Download scratchpad as a Markdown file'; - scratchDownloadBtn.setAttribute('aria-label', 'Download scratchpad'); - scratchDownloadBtn.innerHTML = ''; - scratchDownloadBtn.disabled = true; - scratchDownloadBtn.classList.add('is-disabled'); - scratchDownloadBtn.onclick = (e) => { - e.stopPropagation(); - if (scratchDownloadBtn.disabled) return; - downloadScratchpad(); - }; - scratchActions.appendChild(scratchDownloadBtn); - scratchpadElement.appendChild(scratchActions); - - scratchpadElement.addEventListener('click', (event) => { - event.stopPropagation(); - openScratchpad(); - document.querySelectorAll('.file-item').forEach(el => el.classList.remove('active-file')); - scratchpadElement.classList.add('active-file'); - updateScratchpadDownloadState(); - }); - - fileTreeElement.appendChild(scratchpadElement); - // Sync button state with current scratchpad content (re-renders preserve it) - updateScratchpadDownloadState(); - - function createFileTreeHTML(directory, parentElement, path = '') { - if (!directory || !directory.entries) return; - - // Sort entries: files first, then directories, alphabetically - const sortedEntries = Object.entries(directory.entries).sort((a, b) => { - const [nameA, itemA] = a; - const [nameB, itemB] = b; - - if (itemA.type !== itemB.type) { - return itemA.type === 'file' ? -1 : 1; - } - - return nameA.localeCompare(nameB); - }); - - for (const [name, item] of sortedEntries) { - if (item.type === 'directory') { - const dirElement = document.createElement('div'); - dirElement.className = 'directory-item tree-row px-2 py-1 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-gray-800 collapsed'; - dirElement.dataset.type = 'directory'; - const currentPath = path ? `${path}/${name}` : name; - dirElement.dataset.path = currentPath; - - const dirIcon = document.createElement('span'); - dirIcon.className = 'dir-icon mr-1'; - dirIcon.innerHTML = ''; - - const dirName = document.createElement('span'); - dirName.className = 'tree-row__name'; - const parsedFolder = zddc.parseFolder(name); - if (parsedFolder && parsedFolder.valid) { - const meta = `${parsedFolder.trackingNumber} (${parsedFolder.status}) — ${parsedFolder.date}`; - dirName.innerHTML = `
📁 ${escapeHtml(parsedFolder.title)}
${escapeHtml(meta)}
`; - } else { - // Non-ZDDC folder: still wrap in filename-main so - // typography matches the two-line entries (same font - // size + weight; just no secondary line). - dirName.innerHTML = `
📁 ${escapeHtml(name)}
`; - } - - const dirLabel = document.createElement('span'); - dirLabel.className = 'tree-row__label'; - dirLabel.appendChild(dirIcon); - dirLabel.appendChild(dirName); - - const dirActions = createActionButtons(currentPath, 'directory'); - - dirElement.appendChild(dirLabel); - dirElement.appendChild(dirActions); - parentElement.appendChild(dirElement); - - const contentsElement = document.createElement('div'); - contentsElement.className = 'directory-contents ml-4'; - contentsElement.style.display = 'none'; - parentElement.appendChild(contentsElement); - - dirElement.addEventListener('click', (event) => { - event.stopPropagation(); - dirElement.classList.toggle('collapsed'); - - const contents = dirElement.nextElementSibling; - if (contents && contents.classList.contains('directory-contents')) { - contents.style.display = dirElement.classList.contains('collapsed') ? 'none' : 'block'; - } - }); - - createFileTreeHTML(item, contentsElement, currentPath); - } else if (item.type === 'file') { - const fileElement = document.createElement('div'); - fileElement.className = 'file-item tree-row px-2 py-1 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-gray-800'; - fileElement.dataset.type = 'file'; - const filePath = path ? `${path}/${name}` : name; - fileElement.dataset.path = filePath; - fileElement.dataset.name = name; - - const fileIcon = getFileTypeIcon(name); - - // Build the inner two-line text inside a tree-row__name - // wrapper (column-flex). ZDDC-conforming filenames split - // into title + meta; "Title - filename.ext" pattern uses - // the dash as the same split. Plain names get a single - // line via filename-main only — same wrapper, just no - // secondary div, so the layout stays consistent. - let fileNameInner; - const parsed = zddc.parseFilename(name); - if (parsed && parsed.valid) { - const titleDisplay = escapeHtml(parsed.title); - const metaDisplay = escapeHtml(`${parsed.trackingNumber}_${parsed.revision} (${parsed.status})`); - fileNameInner = `
${fileIcon} ${titleDisplay}
${metaDisplay}
`; - } else if (name.includes(' - ')) { - const dashIdx = name.lastIndexOf(' - '); - const secondary = escapeHtml(name.substring(0, dashIdx)); - const primary = escapeHtml(name.substring(dashIdx + 3).replace(/\.[^.]+$/, '')); - fileNameInner = `
${fileIcon} ${primary}
${secondary}
`; - } else { - fileNameInner = `
${fileIcon} ${escapeHtml(name)}
`; - } - - const fileLabel = document.createElement('span'); - fileLabel.className = 'tree-row__label'; - fileLabel.innerHTML = `${fileNameInner}`; - - const fileActions = createActionButtons(filePath, 'file'); - - fileElement.innerHTML = ''; - fileElement.appendChild(fileLabel); - fileElement.appendChild(fileActions); - - fileElement.addEventListener('click', (event) => { - event.stopPropagation(); - handleFileClick(item.handle, filePath, fileElement); - }); - - parentElement.appendChild(fileElement); - } - } - } - - createFileTreeHTML(fileTree, fileTreeElement); -} - -/** - * Handle click on a file in the file tree - * @param {FileSystemFileHandle} fileHandle - The file handle - * @param {string} filePath - Path of the file - * @param {HTMLElement} fileElement - The clicked element - */ -async function handleFileClick(fileHandle, filePath, fileElement) { - try { - currentFileHandle = fileHandle; - - // Remove active class from all file items - const allFileItems = document.querySelectorAll('.file-item'); - allFileItems.forEach(item => { - item.classList.remove('active-file'); - item.style.backgroundColor = ''; - item.style.color = ''; - }); - - // Add active class to clicked file - fileElement.classList.add('active-file'); - fileElement.style.backgroundColor = '#3b82f6'; - fileElement.style.color = 'white'; - - await displayFileContent(fileHandle, filePath); - - } catch (error) { - console.error('Error handling file click:', error); - alert(`Error opening file: ${error.message}`); - } -} - -/** - * Display file content in main area - * @param {FileSystemFileHandle} fileHandle - File handle - * @param {string} filePath - Path of the file - */ -async function displayFileContent(fileHandle, filePath) { - try { - currentFileHandle = fileHandle; - - const file = await fileHandle.getFile(); - const fileName = file.name; - const lastModified = file.lastModified; - - document.getElementById('welcome-screen').classList.add('hidden'); - document.getElementById('content-container').classList.remove('hidden'); - - const lower = fileName.toLowerCase(); - const lastDot = lower.lastIndexOf('.'); - const ext = lastDot >= 0 ? lower.substring(lastDot + 1) : ''; - - const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg']; - const isImage = imageExtensions.some(e => lower.endsWith(e)); - const isTiff = window.zddc && window.zddc.preview && window.zddc.preview.isTiff(ext); - const isZip = lower.endsWith('.zip'); - const isHtml = lower.endsWith('.html') || lower.endsWith('.htm'); - const isDocx = lower.endsWith('.docx'); - const isXlsx = lower.endsWith('.xlsx') || lower.endsWith('.xls'); - const isPdf = lower.endsWith('.pdf'); - - if (isImage) { - displayImagePreview(file, filePath, fileName, fileHandle, lastModified); - } else if (isTiff) { - displayTiffPreview(file, filePath, fileName, fileHandle, lastModified); - } else if (isZip) { - displayZipPreview(file, filePath, fileName, fileHandle, lastModified); - } else if (isHtml) { - displayHtmlPreview(file, filePath, fileName, fileHandle, lastModified); - } else if (isDocx) { - displayDocxPreview(file, filePath, fileName, fileHandle, lastModified); - } else if (isXlsx) { - displayXlsxPreview(file, filePath, fileName, fileHandle, lastModified); - } else if (isPdf) { - displayPdfPreview(file, filePath, fileName, fileHandle, lastModified); - } else { - const content = await file.text(); - - if (fileName.toLowerCase().endsWith('.md')) { - initializeEditor(content, true, filePath, fileName, fileHandle, lastModified); - } else { - initializeEditor(content, false, filePath, fileName, fileHandle, lastModified); - } - } - } catch (error) { - console.error('Error displaying file content:', error); - alert(`Error opening file: ${error.message}`); - } -} - -/** - * Display image preview - */ -async function displayImagePreview(file, filePath, fileName, fileHandle, lastModified) { - const contentContainer = document.getElementById('content-container'); - if (!contentContainer) { - alert('Error: content-container element not found!'); - return; - } - - document.querySelectorAll('.file-view-container').forEach(container => { - container.style.display = 'none'; - }); - - if (editorInstances.has(filePath)) { - const existingInstance = editorInstances.get(filePath); - if (existingInstance.fileViewContainer) { - existingInstance.fileViewContainer.style.display = 'flex'; - } - return; - } - - const fileViewContainer = document.createElement('div'); - fileViewContainer.className = 'file-view-container flex flex-col h-full'; - - const fileHeader = document.createElement('div'); - fileHeader.className = 'file-header flex justify-between items-center px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 font-medium border-b border-gray-200 dark:border-gray-700'; - - const fileTitle = document.createElement('span'); - fileTitle.textContent = fileName || 'No file selected'; - fileHeader.appendChild(fileTitle); - - fileViewContainer.appendChild(fileHeader); - - const imageContainer = document.createElement('div'); - imageContainer.className = 'image-preview-container flex-1 overflow-auto p-4'; - - const imageElement = document.createElement('img'); - imageElement.className = 'image-preview'; - imageElement.alt = fileName; - - const objectUrl = URL.createObjectURL(file); - imageElement.src = objectUrl; - - imageContainer.appendChild(imageElement); - fileViewContainer.appendChild(imageContainer); - - contentContainer.appendChild(fileViewContainer); - - const instanceData = { - fileViewContainer: fileViewContainer, - fileHandle: fileHandle, - lastModified: lastModified, - isDirty: false, - objectUrl: objectUrl - }; - - editorInstances.set(filePath, instanceData); -} - -/** - * Display TIFF preview using shared zddc.preview.renderTiff (UTIF.js + canvas). - */ -async function displayTiffPreview(file, filePath, fileName, fileHandle, lastModified) { - const contentContainer = document.getElementById('content-container'); - if (!contentContainer) return; - - document.querySelectorAll('.file-view-container').forEach(c => { c.style.display = 'none'; }); - - if (editorInstances.has(filePath)) { - const existing = editorInstances.get(filePath); - if (existing.fileViewContainer) existing.fileViewContainer.style.display = 'flex'; - return; - } - - const fileViewContainer = document.createElement('div'); - fileViewContainer.className = 'file-view-container flex flex-col h-full'; - - const fileHeader = document.createElement('div'); - fileHeader.className = 'file-header flex justify-between items-center px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 font-medium border-b border-gray-200 dark:border-gray-700'; - const fileTitle = document.createElement('span'); - fileTitle.textContent = fileName || 'No file selected'; - fileHeader.appendChild(fileTitle); - fileViewContainer.appendChild(fileHeader); - - const tiffContainer = document.createElement('div'); - tiffContainer.className = 'flex-1 min-h-0'; - tiffContainer.style.display = 'flex'; - tiffContainer.style.flexDirection = 'column'; - fileViewContainer.appendChild(tiffContainer); - - contentContainer.appendChild(fileViewContainer); - - try { - const arrayBuffer = await file.arrayBuffer(); - await window.zddc.preview.renderTiff(document, tiffContainer, arrayBuffer, { fileName: fileName }); - } catch (err) { - console.error('Error rendering TIFF:', err); - tiffContainer.textContent = 'Error rendering TIFF: ' + (err.message || err); - } - - editorInstances.set(filePath, { fileViewContainer, fileHandle, lastModified, isDirty: false }); -} - -/** - * Display ZIP listing using shared zddc.preview.renderZipListing. - */ -async function displayZipPreview(file, filePath, fileName, fileHandle, lastModified) { - const contentContainer = document.getElementById('content-container'); - if (!contentContainer) return; - - document.querySelectorAll('.file-view-container').forEach(c => { c.style.display = 'none'; }); - - if (editorInstances.has(filePath)) { - const existing = editorInstances.get(filePath); - if (existing.fileViewContainer) existing.fileViewContainer.style.display = 'flex'; - return; - } - - const fileViewContainer = document.createElement('div'); - fileViewContainer.className = 'file-view-container flex flex-col h-full'; - - const fileHeader = document.createElement('div'); - fileHeader.className = 'file-header flex justify-between items-center px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 font-medium border-b border-gray-200 dark:border-gray-700'; - const fileTitle = document.createElement('span'); - fileTitle.textContent = fileName || 'No file selected'; - fileHeader.appendChild(fileTitle); - fileViewContainer.appendChild(fileHeader); - - const zipContainer = document.createElement('div'); - zipContainer.className = 'flex-1 min-h-0'; - zipContainer.style.display = 'flex'; - zipContainer.style.flexDirection = 'column'; - fileViewContainer.appendChild(zipContainer); - - contentContainer.appendChild(fileViewContainer); - - try { - const arrayBuffer = await file.arrayBuffer(); - await window.zddc.preview.renderZipListing(document, zipContainer, arrayBuffer, { fileName: fileName }); - } catch (err) { - console.error('Error rendering ZIP listing:', err); - zipContainer.textContent = 'Error reading ZIP: ' + (err.message || err); - } - - editorInstances.set(filePath, { fileViewContainer, fileHandle, lastModified, isDirty: false }); -} - -/** - * Display HTML preview in sandboxed iframe - */ -async function displayHtmlPreview(file, filePath, fileName, fileHandle, lastModified) { - const contentContainer = document.getElementById('content-container'); - if (!contentContainer) { - alert('Error: content-container element not found!'); - return; - } - - document.querySelectorAll('.file-view-container').forEach(container => { - container.style.display = 'none'; - }); - - if (editorInstances.has(filePath)) { - const existingInstance = editorInstances.get(filePath); - if (existingInstance.fileViewContainer) { - existingInstance.fileViewContainer.style.display = 'flex'; - } - return; - } - - const htmlContent = await file.text(); - - const fileViewContainer = document.createElement('div'); - fileViewContainer.className = 'file-view-container flex flex-col h-full'; - - const fileHeader = document.createElement('div'); - fileHeader.className = 'file-header flex justify-between items-center px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 font-medium border-b border-gray-200 dark:border-gray-700'; - - const fileTitle = document.createElement('span'); - fileTitle.textContent = fileName || 'No file selected'; - fileHeader.appendChild(fileTitle); - - fileViewContainer.appendChild(fileHeader); - - const htmlContainer = document.createElement('div'); - htmlContainer.className = 'html-preview-container flex-1 overflow-hidden'; - - const iframe = document.createElement('iframe'); - iframe.className = 'html-preview-iframe w-full h-full border-0'; - - iframe.setAttribute('sandbox', 'allow-same-origin allow-scripts allow-forms allow-popups allow-modals'); - iframe.setAttribute('loading', 'lazy'); - - iframe.srcdoc = htmlContent; - - htmlContainer.appendChild(iframe); - fileViewContainer.appendChild(htmlContainer); - - contentContainer.appendChild(fileViewContainer); - - const instanceData = { - fileViewContainer: fileViewContainer, - fileHandle: fileHandle, - lastModified: lastModified, - isDirty: false, - iframe: iframe - }; - - editorInstances.set(filePath, instanceData); - - iframe.addEventListener('load', () => { - try { - const iframeDoc = iframe.contentDocument || iframe.contentWindow.document; - if (iframeDoc) { - iframeDoc.addEventListener('click', function (e) { - const link = e.target.closest('a'); - if (link && link.getAttribute('href')) { - const href = link.getAttribute('href'); - if (href.startsWith('#')) { - e.preventDefault(); - const targetId = href.substring(1); - const targetElement = iframeDoc.getElementById(targetId); - if (targetElement) { - targetElement.scrollIntoView({ behavior: 'smooth' }); - } - } - } - }); - } - } catch (error) { - if (DEBUG) console.log('Cannot access iframe content for navigation handling:', error); - } - }); -} - -/** - * Display DOCX preview in main content area - */ -async function displayDocxPreview(file, filePath, fileName, fileHandle, lastModified) { - const contentContainer = document.getElementById('content-container'); - if (!contentContainer) { - alert('Error: content-container element not found!'); - return; - } - - document.querySelectorAll('.file-view-container').forEach(container => { - container.style.display = 'none'; - }); - - if (editorInstances.has(filePath)) { - const existingInstance = editorInstances.get(filePath); - if (existingInstance.fileViewContainer) { - existingInstance.fileViewContainer.style.display = 'flex'; - } - return; - } - - const fileViewContainer = document.createElement('div'); - fileViewContainer.className = 'file-view-container flex flex-col h-full'; - - const fileHeader = document.createElement('div'); - fileHeader.className = 'file-header flex justify-between items-center px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 font-medium border-b border-gray-200 dark:border-gray-700'; - - const fileTitle = document.createElement('span'); - fileTitle.textContent = fileName || 'No file selected'; - fileHeader.appendChild(fileTitle); - - fileViewContainer.appendChild(fileHeader); - - const docxContainer = document.createElement('div'); - docxContainer.className = 'flex-1 overflow-auto p-4'; - docxContainer.innerHTML = '
Loading preview...
'; - fileViewContainer.appendChild(docxContainer); - - contentContainer.appendChild(fileViewContainer); - - const instanceData = { - fileViewContainer: fileViewContainer, - fileHandle: fileHandle, - lastModified: lastModified, - isDirty: false - }; - editorInstances.set(filePath, instanceData); - - try { - // jszip + docx-preview bundled into the dist HTML; window.JSZip - // and window.docx are available synchronously. - const arrayBuffer = await file.arrayBuffer(); - docxContainer.innerHTML = ''; - await window.docx.renderAsync(arrayBuffer, docxContainer); - } catch (err) { - console.error('Error rendering DOCX:', err); - docxContainer.innerHTML = `
Error rendering DOCX: ${err.message}
`; - } -} - -/** - * Display XLSX/XLS preview in main content area - */ -async function displayXlsxPreview(file, filePath, fileName, fileHandle, lastModified) { - const contentContainer = document.getElementById('content-container'); - if (!contentContainer) { - alert('Error: content-container element not found!'); - return; - } - - document.querySelectorAll('.file-view-container').forEach(container => { - container.style.display = 'none'; - }); - - if (editorInstances.has(filePath)) { - const existingInstance = editorInstances.get(filePath); - if (existingInstance.fileViewContainer) { - existingInstance.fileViewContainer.style.display = 'flex'; - } - return; - } - - const fileViewContainer = document.createElement('div'); - fileViewContainer.className = 'file-view-container flex flex-col h-full'; - - const fileHeader = document.createElement('div'); - fileHeader.className = 'file-header flex justify-between items-center px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 font-medium border-b border-gray-200 dark:border-gray-700'; - - const fileTitle = document.createElement('span'); - fileTitle.textContent = fileName || 'No file selected'; - fileHeader.appendChild(fileTitle); - - fileViewContainer.appendChild(fileHeader); - - const xlsxContainer = document.createElement('div'); - xlsxContainer.className = 'flex-1 overflow-auto'; - xlsxContainer.innerHTML = '
Loading preview...
'; - fileViewContainer.appendChild(xlsxContainer); - - contentContainer.appendChild(fileViewContainer); - - const instanceData = { - fileViewContainer: fileViewContainer, - fileHandle: fileHandle, - lastModified: lastModified, - isDirty: false - }; - editorInstances.set(filePath, instanceData); - - try { - // XLSX bundled into the dist HTML; window.XLSX is available - // synchronously, no runtime load needed. - const arrayBuffer = await file.arrayBuffer(); - const workbook = XLSX.read(arrayBuffer, { type: 'array' }); - - xlsxContainer.innerHTML = ''; - - if (workbook.SheetNames.length > 1) { - const tabs = document.createElement('div'); - tabs.style.cssText = 'display:flex;gap:0;border-bottom:1px solid #ddd;background:#f5f5f5;'; - const tableArea = document.createElement('div'); - tableArea.className = 'flex-1 overflow-auto'; - - workbook.SheetNames.forEach((name, i) => { - const tab = document.createElement('button'); - tab.textContent = name; - tab.style.cssText = 'padding:0.4rem 1rem;cursor:pointer;border:1px solid transparent;border-bottom:none;font-size:0.85rem;background:transparent;'; - if (i === 0) tab.style.cssText += 'background:white;border-color:#ddd;border-bottom-color:white;margin-bottom:-1px;font-weight:500;'; - tab.onclick = () => { - tabs.querySelectorAll('button').forEach(t => { t.style.background = 'transparent'; t.style.borderColor = 'transparent'; t.style.fontWeight = 'normal'; }); - tab.style.cssText = 'padding:0.4rem 1rem;cursor:pointer;border:1px solid #ddd;border-bottom-color:white;font-size:0.85rem;background:white;margin-bottom:-1px;font-weight:500;'; - renderXlsxSheet(workbook, name, tableArea); - }; - tabs.appendChild(tab); - }); - - xlsxContainer.appendChild(tabs); - xlsxContainer.appendChild(tableArea); - renderXlsxSheet(workbook, workbook.SheetNames[0], tableArea); - } else { - renderXlsxSheet(workbook, workbook.SheetNames[0], xlsxContainer); - } - } catch (err) { - console.error('Error rendering XLSX:', err); - xlsxContainer.innerHTML = `
Error rendering spreadsheet: ${err.message}
`; - } -} - -/** - * Render a single XLSX sheet as an HTML table - */ -function renderXlsxSheet(workbook, sheetName, container) { - const sheet = workbook.Sheets[sheetName]; - const html = XLSX.utils.sheet_to_html(sheet, { editable: false }); - container.innerHTML = html; - const table = container.querySelector('table'); - if (table) { - table.style.cssText = 'border-collapse:collapse;width:100%;font-size:0.85rem;'; - table.querySelectorAll('th,td').forEach(cell => { - cell.style.cssText = 'border:1px solid #ddd;padding:0.35rem 0.5rem;text-align:left;white-space:nowrap;'; - }); - table.querySelectorAll('th').forEach(th => { - th.style.background = '#f0f0f0'; - th.style.fontWeight = '600'; - }); - } -} - -/** - * Display PDF preview using browser's built-in PDF viewer - */ -async function displayPdfPreview(file, filePath, fileName, fileHandle, lastModified) { - const contentContainer = document.getElementById('content-container'); - if (!contentContainer) return; - - document.querySelectorAll('.file-view-container').forEach(container => { - container.style.display = 'none'; - }); - - if (editorInstances.has(filePath)) { - const existingInstance = editorInstances.get(filePath); - if (existingInstance.fileViewContainer) { - existingInstance.fileViewContainer.style.display = 'flex'; - } - return; - } - - const fileViewContainer = document.createElement('div'); - fileViewContainer.className = 'file-view-container flex flex-col h-full'; - - const fileHeader = document.createElement('div'); - fileHeader.className = 'file-header flex justify-between items-center px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 font-medium border-b border-gray-200 dark:border-gray-700'; - - const fileTitle = document.createElement('span'); - fileTitle.textContent = fileName; - fileHeader.appendChild(fileTitle); - fileViewContainer.appendChild(fileHeader); - - const pdfContainer = document.createElement('div'); - pdfContainer.className = 'flex-1 overflow-hidden'; - - const objectUrl = URL.createObjectURL(file); - - const iframe = document.createElement('iframe'); - iframe.className = 'w-full h-full border-0'; - iframe.src = objectUrl; - iframe.setAttribute('title', fileName); - - pdfContainer.appendChild(iframe); - fileViewContainer.appendChild(pdfContainer); - contentContainer.appendChild(fileViewContainer); - - editorInstances.set(filePath, { - fileViewContainer, - fileHandle, - lastModified, - isDirty: false, - objectUrl - }); -} - -/** - * Update status bar counts - */ -function updateStatusCounts(folderCount, fileCount) { - const folderCountElement = document.getElementById('folder-count'); - const fileCountElement = document.getElementById('file-count'); - - if (folderCountElement) { - folderCountElement.textContent = `${folderCount} folder${folderCount !== 1 ? 's' : ''}`; - } - - if (fileCountElement) { - fileCountElement.textContent = `${fileCount} file${fileCount !== 1 ? 's' : ''}`; - } - - updateUnsavedCount(); -} - -/** - * Update unsaved count in status bar - */ -function updateUnsavedCount() { - const unsavedCountElement = document.getElementById('unsaved-count'); - if (!unsavedCountElement) return; - - let dirtyCount = 0; - editorInstances.forEach(instance => { - if (instance.isDirty) { - dirtyCount++; - } - }); - - unsavedCountElement.textContent = `${dirtyCount} unsaved`; - - if (dirtyCount > 0) { - unsavedCountElement.classList.add('text-amber-500', 'font-medium'); - } else { - unsavedCountElement.classList.remove('text-amber-500', 'font-medium'); - } -} - -/** - * Update file dirty status indicator in tree - */ -function updateFileDirtyStatus(filePath, isDirty) { - const fileElement = document.querySelector(`.file-item[data-path="${filePath}"]`); - if (!fileElement) return; - - if (isDirty) { - if (!fileElement.querySelector('.dirty-indicator')) { - const indicator = document.createElement('span'); - indicator.className = 'dirty-indicator ml-1 text-amber-500 font-bold'; - indicator.textContent = '●'; - fileElement.appendChild(indicator); - } - fileElement.classList.add('is-dirty'); - } else { - const indicator = fileElement.querySelector('.dirty-indicator'); - if (indicator) { - fileElement.removeChild(indicator); - } - fileElement.classList.remove('is-dirty'); - } -} diff --git a/mdedit/js/front-matter.js b/mdedit/js/front-matter.js deleted file mode 100644 index e38ab11..0000000 --- a/mdedit/js/front-matter.js +++ /dev/null @@ -1,106 +0,0 @@ -/** - * YAML front matter parsing and stringification - */ - -/** - * Parse YAML front matter from markdown content - * @param {string} content - Full markdown content with potential front matter - * @returns {{data: Object, content: string}} Parsed front matter data and remaining content - */ -function parseFrontMatter(content) { - if (!content || !content.startsWith('---\n')) { - return { - data: {}, - content: content || '' - }; - } - - const endMatch = content.indexOf('\n---\n', 4); - if (endMatch === -1) { - return { - data: {}, - content: content - }; - } - - const frontMatterText = content.substring(4, endMatch); - const markdownBody = content.substring(endMatch + 5); - - // Parse YAML front matter (basic key: value parsing) - const frontMatterData = {}; - const lines = frontMatterText.split('\n'); - - for (const line of lines) { - const trimmedLine = line.trim(); - if (!trimmedLine || trimmedLine.startsWith('#')) continue; - - const colonIndex = trimmedLine.indexOf(':'); - if (colonIndex > 0) { - const key = trimmedLine.substring(0, colonIndex).trim(); - let value = trimmedLine.substring(colonIndex + 1).trim(); - - // Remove quotes - value = value.replace(/^["']|["']$/g, ''); - - // Handle arrays (basic support for [item1, item2]) - if (value.startsWith('[') && value.endsWith(']')) { - value = value.slice(1, -1).split(',').map(item => item.trim().replace(/^["']|["']$/g, '')); - } - - frontMatterData[key] = value; - } - } - - return { - data: frontMatterData, - content: markdownBody - }; -} - -/** - * Stringify front matter data and combine with markdown content - * @param {string} content - Markdown content - * @param {Object} data - Front matter data object - * @returns {string} Combined YAML front matter and markdown - */ -function stringifyFrontMatter(content, data) { - if (!data || Object.keys(data).length === 0) { - return content; - } - - let yamlString = '---\n'; - - for (const [key, value] of Object.entries(data)) { - if (Array.isArray(value)) { - yamlString += `${key}: [${value.map(v => `"${v}"`).join(', ')}]\n`; - } else { - yamlString += `${key}: "${value}"\n`; - } - } - - yamlString += '---\n'; - - return yamlString + content; -} - -/** - * Convert front matter data to YAML string for textarea display (without delimiters) - * @param {Object} data - Front matter data - * @returns {string} YAML string for textarea - */ -function stringifyFrontMatterToTextarea(data) { - if (!data || Object.keys(data).length === 0) { - return ''; - } - - let yamlString = ''; - for (const [key, value] of Object.entries(data)) { - if (Array.isArray(value)) { - yamlString += `${key}: [${value.map(v => `"${v}"`).join(', ')}]\n`; - } else { - yamlString += `${key}: "${value}"\n`; - } - } - - return yamlString.trim(); -} diff --git a/mdedit/js/main.js b/mdedit/js/main.js deleted file mode 100644 index 79c5446..0000000 --- a/mdedit/js/main.js +++ /dev/null @@ -1,50 +0,0 @@ -/** - * Application initialization - */ - -// Initialize when DOM is loaded -document.addEventListener('DOMContentLoaded', function () { - // Check File System API availability and update UI - initializeApiAvailability(); - - setupEventListeners(); - initializeFileNavResizer(); - setupTocDepthSelector(); - startFileChangeMonitoring(); - - // Show scratchpad in file tree on startup - renderFileTree(); - - // Always start with scratchpad selected and loaded - openScratchpad(); - const scratchpadEl = document.querySelector(`.file-item[data-path="${SCRATCHPAD_ID}"]`); - if (scratchpadEl) scratchpadEl.classList.add('active-file'); - - // In server (HTTP) mode, fetch and render the current directory subtree. - if (location.protocol === 'http:' || location.protocol === 'https:') { - loadServerDirectory().catch((err) => { - if (DEBUG) console.warn('Server directory load failed:', err); - }); - } -}); - -/** - * Initialize UI based on File System API availability - */ -function initializeApiAvailability() { - const selectDirectoryBtn = document.getElementById('addDirectoryBtn'); - const welcomeHint = document.getElementById('welcome-hint'); - const welcomeFirefox = document.getElementById('welcome-firefox'); - - if (!hasFileSystemAccess) { - // Disable file system buttons in Firefox and other unsupported browsers - if (selectDirectoryBtn) { - selectDirectoryBtn.disabled = true; - selectDirectoryBtn.title = 'File System API not supported in this browser'; - } - // Show Firefox warning, hide normal hint - if (welcomeHint) welcomeHint.classList.add('hidden'); - if (welcomeFirefox) welcomeFirefox.classList.remove('hidden'); - - } -} diff --git a/mdedit/js/resizer.js b/mdedit/js/resizer.js deleted file mode 100644 index 2aee4b4..0000000 --- a/mdedit/js/resizer.js +++ /dev/null @@ -1,137 +0,0 @@ -/** - * Pane resizing functionality - */ - -/** - * Make an element resizable by dragging its resizer - * @param {HTMLElement} resizer - The resizer element - * @param {HTMLElement} pane - The pane to resize - */ -function makeResizable(resizer, pane) { - const initialWidth = pane.offsetWidth; - - let x = 0; - let paneWidth = initialWidth; - - const mouseDownHandler = function (e) { - x = e.clientX; - paneWidth = pane.offsetWidth; - - document.addEventListener('mousemove', mouseMoveHandler); - document.addEventListener('mouseup', mouseUpHandler); - - resizer.classList.add('active'); - document.body.style.cursor = 'col-resize'; - document.body.style.userSelect = 'none'; - }; - - const mouseMoveHandler = function (e) { - const dx = e.clientX - x; - const newWidth = Math.max(150, paneWidth + dx); - - pane.style.width = `${newWidth}px`; - }; - - const mouseUpHandler = function () { - document.removeEventListener('mousemove', mouseMoveHandler); - document.removeEventListener('mouseup', mouseUpHandler); - - resizer.classList.remove('active'); - document.body.style.cursor = ''; - document.body.style.userSelect = ''; - }; - - resizer.addEventListener('mousedown', mouseDownHandler); -} - -/** - * Make a horizontal split height-adjustable: the resizer drags the height - * of `topPane` while it remains a sibling of the bottom section inside `container`. - * - * @param {HTMLElement} resizer - The horizontal resizer between the panes - * @param {HTMLElement} topPane - The pane whose height is set - * @param {HTMLElement} container - The flex column containing both panes - */ -function makeHeightResizable(resizer, topPane, container) { - let y = 0; - let topHeight = 0; - let containerHeight = 0; - - const mouseDownHandler = (e) => { - y = e.clientY; - topHeight = topPane.offsetHeight; - containerHeight = container.offsetHeight; - document.addEventListener('mousemove', mouseMoveHandler); - document.addEventListener('mouseup', mouseUpHandler); - resizer.classList.add('active'); - document.body.style.cursor = 'row-resize'; - document.body.style.userSelect = 'none'; - }; - - const mouseMoveHandler = (e) => { - const dy = e.clientY - y; - // Reserve at least 80px for the bottom pane (TOC); cap top at containerHeight - 80. - const minTop = 60; - const maxTop = Math.max(minTop, containerHeight - 100); - const newHeight = Math.max(minTop, Math.min(maxTop, topHeight + dy)); - topPane.style.height = `${newHeight}px`; - }; - - const mouseUpHandler = () => { - document.removeEventListener('mousemove', mouseMoveHandler); - document.removeEventListener('mouseup', mouseUpHandler); - resizer.classList.remove('active'); - document.body.style.cursor = ''; - document.body.style.userSelect = ''; - }; - - resizer.addEventListener('mousedown', mouseDownHandler); -} - -/** - * Initialize the file navigation pane resizer - */ -function initializeFileNavResizer() { - const fileNavResizer = document.querySelector('.pane-resizer[data-resizer-for="file-nav"]'); - - if (fileNavResizer && !fileNavResizer.hasAttribute('data-resizer-initialized')) { - fileNavResizer.setAttribute('data-resizer-initialized', 'true'); - - let x = 0; - let navWidth = 0; - - const mouseDownHandler = function (e) { - x = e.clientX; - - const navPane = document.getElementById('file-nav'); - navWidth = navPane.getBoundingClientRect().width; - - document.addEventListener('mousemove', mouseMoveHandler); - document.addEventListener('mouseup', mouseUpHandler); - - fileNavResizer.classList.add('bg-blue-500'); - }; - - const mouseMoveHandler = function (e) { - const dx = e.clientX - x; - - const navPane = document.getElementById('file-nav'); - - const newWidth = navWidth + dx; - - if (newWidth >= 200) { - navPane.style.width = `${newWidth}px`; - } - }; - - const mouseUpHandler = function () { - document.removeEventListener('mousemove', mouseMoveHandler); - document.removeEventListener('mouseup', mouseUpHandler); - - fileNavResizer.classList.remove('bg-blue-500'); - }; - - fileNavResizer.addEventListener('mousedown', mouseDownHandler); - } -} - diff --git a/mdedit/js/toc.js b/mdedit/js/toc.js deleted file mode 100644 index c2b77b3..0000000 --- a/mdedit/js/toc.js +++ /dev/null @@ -1,254 +0,0 @@ -/** - * Table of Contents generation and scroll functionality - */ - -/** - * Scroll to header service - uses line numbers for reliable targeting - */ -const ScrollToHeaderService = { - /** - * Scroll to a specific header in the editor by line number - * @param {Object} editorInstance - Toast UI Editor instance - * @param {string} headerText - Text content of the header (for highlighting) - * @param {number} lineIndex - 0-based line index of the header in markdown - */ - scrollToHeader(editorInstance, headerText, lineIndex) { - if (!editorInstance) { - console.warn('Editor instance not available for scrolling'); - return; - } - - try { - const editorElements = editorInstance.getEditorElements(); - const isWysiwygMode = editorInstance.isWysiwygMode(); - - if (isWysiwygMode) { - // In WYSIWYG mode, find header by text (no line numbers available) - const wysiwygEditor = editorElements.wwEditor; - if (wysiwygEditor) { - const headers = wysiwygEditor.querySelectorAll('h1, h2, h3, h4, h5, h6'); - for (const header of headers) { - if (header.textContent.trim() === headerText.trim()) { - // Scroll the editor container directly with 10px offset - const headerPosition = header.getBoundingClientRect().top - wysiwygEditor.getBoundingClientRect().top; - const offset = 10; // Account for fixed headers or padding - wysiwygEditor.scrollTop = headerPosition - offset; - this._highlightHeader(header); - break; - } - } - } - } else { - // In markdown mode, use line number to position cursor, then scroll preview - const lineNumber = lineIndex + 1; // Convert to 1-based - - // Move cursor to the heading line in the editor - try { - editorInstance.setSelection([lineNumber, 1], [lineNumber, 1]); - } catch (e) { - if (DEBUG) console.debug('Could not set selection:', e); - } - - // Scroll preview to matching header - const previewElement = editorElements.mdPreview; - if (previewElement) { - const headers = previewElement.querySelectorAll('h1, h2, h3, h4, h5, h6'); - - for (const header of headers) { - if (header.textContent.trim() === headerText.trim()) { - // Scroll the preview container directly with 10px offset - const headerPosition = header.getBoundingClientRect().top - previewElement.getBoundingClientRect().top; - const offset = 10; // Account for fixed headers or padding - previewElement.scrollTop = headerPosition - offset; - this._highlightHeader(header); - break; - } - } - } - } - } catch (error) { - console.error('Error scrolling to header:', error); - } - }, - - /** - * Highlight header briefly for visual feedback - * @param {HTMLElement} headerElement - Header to highlight - */ - _highlightHeader(headerElement) { - if (!headerElement) return; - - headerElement.style.transition = 'background-color 0.3s ease'; - headerElement.style.backgroundColor = '#fef3c7'; - - setTimeout(() => { - headerElement.style.backgroundColor = ''; - setTimeout(() => { - headerElement.style.transition = ''; - }, 300); - }, 1500); - } -}; - -/** - * Generate and update the TOC from markdown content - * @param {string} content - Markdown content - * @param {HTMLElement} tocContainer - Container for the TOC - * @param {Object} editorInstance - Toast UI Editor instance - * @param {number} maxDepth - Maximum heading level (1-6) - */ -function updateToc(content, tocContainer, editorInstance, maxDepth = 6) { - if (content === undefined || content === null || !tocContainer) { - console.warn('Missing required params for updateToc'); - return; - } - - tocContainer.innerHTML = ''; - - const tocList = document.createElement('ul'); - tocList.className = 'toc-list pl-0 text-sm'; - - if (!content.trim()) { - const emptyMessage = document.createElement('p'); - emptyMessage.className = 'text-gray-500 p-4'; - emptyMessage.textContent = 'This file is empty.'; - tocContainer.appendChild(emptyMessage); - return; - } - - const headings = []; - const lines = content.split('\n'); - - lines.forEach((line, index) => { - const match = line.match(/^(#{1,6})\s+(.+)$/); - if (match) { - const level = match[1].length; - let text = match[2].trim(); - - // Clean markdown formatting - text = text - .replace(/\\(.)/g, '$1') - .replace(/\*\*(.*?)\*\*/g, '$1') - .replace(/\*(.*?)\*/g, '$1') - .replace(/`(.*?)`/g, '$1') - .replace(/\[(.*?)\]\(.*?\)/g, '$1') - .replace(/~~(.*?)~~/g, '$1') - .trim(); - - const id = text.toLowerCase().replace(/\s+/g, '-').replace(/[^\w-]/g, ''); - - headings.push({ - level, - text, - id, - lineIndex: index - }); - } - }); - - let currentList = tocList; - let currentLevel = 0; - let listsStack = [tocList]; - - const filteredHeadings = headings.filter(heading => heading.level <= maxDepth); - - if (filteredHeadings.length === 0) { - const noHeadings = document.createElement('p'); - noHeadings.className = 'text-gray-500 p-4'; - noHeadings.textContent = maxDepth === 6 ? 'No headings found in this document.' : - 'No headings at or below level H' + maxDepth + ' found.'; - tocContainer.appendChild(noHeadings); - return; - } - - filteredHeadings.forEach(heading => { - const li = document.createElement('li'); - li.className = `toc-item toc-level-${heading.level} py-1`; - - const a = document.createElement('a'); - a.innerHTML = heading.text; - a.href = '#'; - a.className = 'text-blue-600 hover:text-blue-800 hover:underline cursor-pointer'; - a.dataset.headerText = heading.text; - a.dataset.lineIndex = heading.lineIndex; - - a.addEventListener('click', function(e) { - e.preventDefault(); - - if (editorInstance && ScrollToHeaderService) { - try { - ScrollToHeaderService.scrollToHeader( - editorInstance, - heading.text, - parseInt(heading.lineIndex) - ); - } catch (error) { - console.error('Error in ScrollToHeaderService.scrollToHeader:', error); - } - } - }); - - li.appendChild(a); - - if (heading.level > currentLevel) { - const nestedUl = document.createElement('ul'); - nestedUl.className = 'pl-4 mt-1'; - listsStack[listsStack.length - 1].appendChild(nestedUl); - listsStack.push(nestedUl); - currentList = nestedUl; - currentLevel = heading.level; - } else if (heading.level < currentLevel) { - while (heading.level < currentLevel && listsStack.length > 1) { - listsStack.pop(); - currentLevel--; - } - currentList = listsStack[listsStack.length - 1]; - } - - currentList.appendChild(li); - }); - - tocContainer.appendChild(tocList); - clearActiveTocItem(tocContainer); -} - -/** - * Clear active TOC item from all items within the container - * @param {HTMLElement} tocContainer - Container element holding the TOC - */ -function clearActiveTocItem(tocContainer) { - if (!tocContainer) return; - - const activeItems = tocContainer.querySelectorAll('.toc-active'); - activeItems.forEach(item => { - item.classList.remove('toc-active'); - }); -} - -/** - * Set active TOC item by finding the link matching the header text - * @param {HTMLElement} tocContainer - Container element holding the TOC - * @param {string} headerText - Text of the header to match and activate - */ -function setActiveTocItem(tocContainer, headerText) { - if (!tocContainer || !headerText) return; - - // First clear any existing active items - clearActiveTocItem(tocContainer); - - // Find the link matching the header text - const links = tocContainer.querySelectorAll('a[data-header-text]'); - for (const link of links) { - if (link.dataset.headerText === headerText) { - // Add toc-active class to the parent li element - const li = link.parentElement; - if (li) { - li.classList.add('toc-active'); - } - break; - } - } -} - -// Reachable at top-level scope to other concatenated mdedit JS files via the -// build's flat-IIFE-less module pattern; no window.* exports needed. diff --git a/mdedit/js/utils.js b/mdedit/js/utils.js deleted file mode 100644 index 3879c48..0000000 --- a/mdedit/js/utils.js +++ /dev/null @@ -1,113 +0,0 @@ -/** - * Utility functions - */ - -/** - * HTML-escape a string for safe insertion into innerHTML. - */ -function escapeHtml(text) { - const div = document.createElement('div'); - div.textContent = text == null ? '' : String(text); - return div.innerHTML; -} - -/** - * Debounce function calls - * @param {Function} func - Function to debounce - * @param {number} wait - Wait time in milliseconds - * @returns {Function} Debounced function - */ -function debounce(func, wait) { - let timeout; - return function () { - const context = this; - const args = arguments; - clearTimeout(timeout); - timeout = setTimeout(() => func.apply(context, args), wait); - }; -} - -/** - * Get file type icon based on file extension - * @param {string} fileName - Name of the file - * @returns {string} Emoji icon for the file type - */ -function getFileTypeIcon(fileName) { - const extension = zddc.splitExtension(fileName).extension; - - const iconMap = { - // Documents - 'md': '📝', - 'markdown': '📝', - 'txt': '📄', - 'rtf': '📄', - 'doc': '📘', - 'docx': '📘', - 'odt': '📘', - - // Web files - 'html': '🌐', - 'htm': '🌐', - 'css': '🎨', - 'js': '⚡', - 'json': '📋', - 'xml': '📊', - 'yaml': '⚙️', - 'yml': '⚙️', - - // PDFs and presentations - 'pdf': '📕', - 'ppt': '📊', - 'pptx': '📊', - 'odp': '📊', - - // Spreadsheets - 'xls': '📗', - 'xlsx': '📗', - 'csv': '📊', - 'ods': '📗', - - // Images - 'png': '🖼️', - 'jpg': '🖼️', - 'jpeg': '🖼️', - 'gif': '🖼️', - 'svg': '🖼️', - 'webp': '🖼️', - 'bmp': '🖼️', - - // Archives - 'zip': '📦', - 'rar': '📦', - 'tar': '📦', - 'gz': '📦', - '7z': '📦', - - // Code files - 'py': '🐍', - 'java': '☕', - 'cpp': '⚙️', - 'c': '⚙️', - 'h': '⚙️', - 'php': '🔧', - 'rb': '💎', - 'go': '🔵', - 'rs': '🦀', - 'swift': '🧡', - 'kt': '💜', - - // Configuration - 'ini': '⚙️', - 'conf': '⚙️', - 'cfg': '⚙️', - 'env': '⚙️', - - // Other - 'log': '📃', - 'sql': '🗄️', - 'db': '🗄️', - 'sqlite': '🗄️', - }; - - return iconMap[extension] || '📄'; -} diff --git a/mdedit/template.html b/mdedit/template.html deleted file mode 100644 index 135e6b9..0000000 --- a/mdedit/template.html +++ /dev/null @@ -1,170 +0,0 @@ - - - - - - ZDDC Markdown - - - - - - - - -
-
-
- -
- ZDDC Markdown - {{BUILD_LABEL}} -
- - -
-
- - -
-
- -
-
- - -
- -
- - - -
-
-
- -
-
- - 0 folders - 0 files - 0 unsaved -
- -
- - - - - - -
- - - - diff --git a/playwright.config.js b/playwright.config.js index 7cf7e8c..8216626 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -47,10 +47,6 @@ export default defineConfig({ name: 'classifier', testMatch: 'classifier.spec.js', }, - { - name: 'mdedit', - testMatch: 'mdedit.spec.js', - }, { name: 'browse', testMatch: 'browse.spec.js', diff --git a/shared/build-lib.sh b/shared/build-lib.sh index 962b2d6..d4a8167 100755 --- a/shared/build-lib.sh +++ b/shared/build-lib.sh @@ -301,7 +301,7 @@ _emit_build_label_sidecar() { # Tools that participate in the lockstep release. Source of truth — used # by helpers that enumerate "all release artifacts" (matrix render, # coordinated next-stable, channel-link verifier). -ZDDC_RELEASE_TOOLS="archive transmittal classifier mdedit landing form tables browse zddc-server" +ZDDC_RELEASE_TOOLS="archive transmittal classifier landing form tables browse zddc-server" # Compute the next-stable target for a single tool — patch-bump of its own # latest -vX.Y.Z tag. Used by compute_build_label so a tool's @@ -742,7 +742,7 @@ verify_channel_links() { _missing=0 _verified=0 - for _t in archive transmittal classifier mdedit landing form tables browse; do + for _t in archive transmittal classifier landing form tables browse; do for _ch in stable beta alpha; do _f="$_rdir/${_t}_${_ch}.html" if [ -e "$_f" ]; then diff --git a/shared/preview-lib.js b/shared/preview-lib.js index 1b6afb9..afb8837 100644 --- a/shared/preview-lib.js +++ b/shared/preview-lib.js @@ -7,7 +7,7 @@ * * Renderers operate on any document (parent window or popup window), so the * same code works for tools whose preview opens in a popup (classifier, - * archive, transmittal) and tools that render inline (mdedit). + * archive, transmittal) and tools that render inline (browse). * * Public API on window.zddc.preview: * loadLibrary(url) → Promise diff --git a/tests/build-label.spec.js b/tests/build-label.spec.js index b8ed962..8275e59 100644 --- a/tests/build-label.spec.js +++ b/tests/build-label.spec.js @@ -16,7 +16,7 @@ import { test, expect } from '@playwright/test'; import * as path from 'path'; import * as fs from 'fs'; -const tools = ['archive', 'transmittal', 'classifier', 'mdedit']; +const tools = ['archive', 'transmittal', 'classifier', 'browse']; for (const tool of tools) { const distPath = path.resolve(`${tool}/dist/${tool}.html`); @@ -31,7 +31,8 @@ for (const tool of tools) { }); test(`dist file: .build-timestamp element is visible in browser`, async ({ page }) => { - const waitUntil = tool === 'mdedit' ? 'load' : 'domcontentloaded'; + // browse may load Toast UI lazily; wait for full load. + const waitUntil = tool === 'browse' ? 'load' : 'domcontentloaded'; await page.goto(`file://${distPath}`, { waitUntil }); const el = page.locator('.build-timestamp'); await expect(el).toBeVisible({ timeout: 10000 }); diff --git a/tests/mdedit.spec.js b/tests/mdedit.spec.js deleted file mode 100644 index 80361d4..0000000 --- a/tests/mdedit.spec.js +++ /dev/null @@ -1,77 +0,0 @@ -import { test, expect } from '@playwright/test'; -import { MOCK_FS_INIT_SCRIPT } from './fixtures/mock-fs-api.js'; -import * as path from 'path'; - -const HTML_PATH = path.resolve('mdedit/dist/mdedit.html'); - -test.describe('Markdown Editor', () => { - test.beforeEach(async ({ page }) => { - await page.addInitScript(MOCK_FS_INIT_SCRIPT); - }); - - test('loads without errors', async ({ page }) => { - // Use 'load' rather than 'networkidle' — the bundled Toast UI/Tailwind - // scripts run inline so there is no external network activity to wait for. - await page.goto(`file://${HTML_PATH}`, { waitUntil: 'load' }); - await page.waitForSelector('#app', { timeout: 15000 }); - - // Scratchpad opens by default with welcome content seeded into the editor. - await expect(page.locator(`.file-item[data-path="__scratchpad__"]`)).toBeVisible(); - await expect(page.locator('#content-container')).toBeVisible(); - - // Add Local Directory button is present and enabled - const addDirBtn = page.locator('#addDirectoryBtn'); - await expect(addDirBtn).toBeVisible(); - await expect(addDirBtn).not.toBeDisabled(); - }); - - test('renders a file tree from a mock directory', async ({ page }) => { - await page.goto(`file://${HTML_PATH}`, { waitUntil: 'load' }); - await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 }); - - // Set up mock directory before triggering the picker - await page.evaluate(() => { - window.__setMockDirectory('notes', [ - { name: 'readme.md', content: '# Hello\n\nWelcome.', size: 30 }, - { name: 'notes.md', content: '# Notes\n\nSome notes.', size: 25 }, - ]); - }); - - await page.locator('#addDirectoryBtn').click(); - - // File tree should populate with the two files - await page.waitForFunction( - () => document.querySelector('#file-tree')?.children.length > 0, - { timeout: 10000 } - ); - - const items = await page.locator('#file-tree *').count(); - expect(items).toBeGreaterThanOrEqual(2); - }); - - test('DEBUG flag is defined and console.log calls are gated', async ({ page }) => { - const logs = []; - page.on('console', msg => msg.type() === 'log' && logs.push(msg.text())); - - await page.goto(`file://${HTML_PATH}`, { waitUntil: 'load' }); - await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 }); - - const probe = await page.evaluate(() => ({ - debugDefined: typeof DEBUG !== 'undefined', - debugValue: typeof DEBUG !== 'undefined' ? DEBUG : null, - })); - - expect(probe.debugDefined).toBe(true); - expect(probe.debugValue).toBe(false); - - // With DEBUG=false, no console.log should fire from app code on load. - // (Browser/Toast-UI may still log; we only check none of the gated lines fired.) - const ourLogs = logs.filter(l => - l.startsWith('Opened scratchpad') || - l.startsWith('Directory selected') || - l.startsWith('File ') || - l.startsWith('Created new file') - ); - expect(ourLogs).toEqual([]); - }); -}); diff --git a/tests/nav.spec.js b/tests/nav.spec.js index eefc610..4ae0fa3 100644 --- a/tests/nav.spec.js +++ b/tests/nav.spec.js @@ -57,8 +57,8 @@ test.describe('shared/nav.js stage strip', () => { await expect(active).toHaveAttribute('aria-current', 'page'); }); - test('renders for /working/foo/mdedit.html with working active', async ({ page }) => { - await page.goto(`${baseUrl}/projA/working/casey/mdedit.html`, { waitUntil: 'load' }); + test('renders for /working/foo/browse.html with working active', async ({ page }) => { + await page.goto(`${baseUrl}/projA/working/casey/browse.html`, { waitUntil: 'load' }); const active = page.locator('.zddc-stage-strip .zddc-stage--active'); await expect(active).toHaveText('Working'); }); diff --git a/zddc/README.md b/zddc/README.md index 5e749a3..272b4da 100644 --- a/zddc/README.md +++ b/zddc/README.md @@ -519,7 +519,7 @@ The keys that drive built-in behaviour: | Key | Effect | |---|---| -| `default_tool` | tool served at `` (no trailing slash) — the "specialized app": `archive` under `archive/`, `transmittal` under `staging/`, `mdedit` under `working/`, `classifier` under `incoming/`, `tables` at `archive//mdl`, `landing` at root. Cascades leaf→root. | +| `default_tool` | tool served at `` (no trailing slash) — the "specialized app": `archive` under `archive/`, `transmittal` under `staging/`, `browse` under `working/`+`reviewing/` (`browse` hosts the markdown editor plugin), `classifier` under `incoming/`, `tables` at `archive//mdl`, `landing` at root. Cascades leaf→root. | | `dir_tool` | tool served at `/` (trailing slash) — the directory view; floors at `browse`. Cascades leaf→root. (JSON listing requests ignore both keys — the raw listing is always served, so the browse SPA can enumerate entries regardless.) | | `auto_own` / `auto_own_fenced` | mkdir here writes a creator-owned `.zddc` (`created_by: ` + `permissions: { : rwcda }` — the same direct form an operator would write; the creator can edit it later to add collaborators; `created_by` is an audit field, not consulted by the evaluator). `auto_own_fenced` additionally sets `acl.inherit: false` (private to creator). Defaults: `auto_own` on `working`/`staging`/`archive/`/`incoming`; fenced on the per-user `working//` homes. | | `worm` | `worm: [principal…]` marks a **write-once-read-many** zone: `w`/`d`/`a` are stripped for everyone non-admin; `c` survives only for the listed principals (who get read + write-once-create); `r` for outsiders is whatever the normal cascade ACL granted; admins (root / subtree) bypass entirely — the escape hatch for misfiled documents. Defaults: `worm: [document_controller]` on `archive//{received,issued}` — so filing into the archive is write-once for the doc controller and immutable for everyone else (same effect as the old hardcoded "WORM split", but the operator can rename `received`/`issued`, mark any path WORM, or add more controllers, without a code change). | @@ -1531,10 +1531,11 @@ fsnotify watcher's debounce window (~2 s) — no service restart needed. ## Apps: virtual tool HTMLs -`zddc-server` virtually serves the five tool HTMLs (archive, transmittal, -classifier, mdedit, landing) at the appropriate paths. The current-stable -build of each tool is **baked into the binary at compile time** via -`//go:embed`; that's the default. No fetch happens out of the box. +`zddc-server` virtually serves the tool HTMLs (archive, transmittal, +classifier, landing, browse, form, tables) at the appropriate paths. +The current-stable build of each tool is **baked into the binary at +compile time** via `//go:embed`; that's the default. No fetch happens +out of the box. ### Where each tool is served @@ -1542,7 +1543,7 @@ build of each tool is **baked into the binary at compile time** via |---------------|-------------------------------------------------------------------------| | `archive` | every directory (multi-project, project, archive, vendor) | | `classifier` | any `Incoming`, `Working`, or `Staging` directory and its subtree | -| `mdedit` | any `Working` directory and its subtree | +| `browse` | every directory (hosts the in-place markdown editor) | | `transmittal` | any `Staging` directory and its subtree | | `landing` | only at the deployment root (the project picker) | @@ -1581,7 +1582,7 @@ to the embedded copy and emits a one-time WARN log per source. The apps: classifier: alpha # track alpha for this project archive: https://my-mirror.internal/zddc/archive_v0.0.4.html # custom mirror, pinned - mdedit: ./our-mdedit.html # local fork + browse: ./our-browse.html # local fork ``` ### Env vars diff --git a/zddc/internal/apps/apps.go b/zddc/internal/apps/apps.go index 7f9c7e0..a3e0e82 100644 --- a/zddc/internal/apps/apps.go +++ b/zddc/internal/apps/apps.go @@ -1,7 +1,8 @@ -// Package apps serves the five ZDDC tool HTML files (archive, transmittal, -// classifier, mdedit, landing) on virtual paths in the file tree. Each tool -// is "available" only at directories whose name matches a folder convention -// (Incoming/Working/Staging) — see availability.go. +// Package apps serves the ZDDC tool HTML files (archive, transmittal, +// classifier, landing, browse, form, tables) on virtual paths in the +// file tree. Each tool is "available" only at directories whose name +// matches a folder convention (Incoming/Working/Staging) — see +// availability.go. The markdown editor lives as a plugin inside browse. // // Resolution priority for an enabled /.html request: // diff --git a/zddc/internal/apps/availability.go b/zddc/internal/apps/availability.go index fc809e1..bbb9f36 100644 --- a/zddc/internal/apps/availability.go +++ b/zddc/internal/apps/availability.go @@ -43,8 +43,9 @@ func AppAvailableAt(root, requestDir, app string) bool { // - /archive/ → "archive" // - /archive//... → "archive" // - /staging/... → "transmittal" -// - /working/... → "mdedit" -// - /reviewing/... → "mdedit" (operates on the +// - /working/... → "browse" (hosts the +// markdown editor plugin) +// - /reviewing/... → "browse" (operates on the // virtual aggregator listing) // - any other directory → "" (no default) // diff --git a/zddc/internal/apps/embed.go b/zddc/internal/apps/embed.go index 5acbe2b..32c473c 100644 --- a/zddc/internal/apps/embed.go +++ b/zddc/internal/apps/embed.go @@ -7,9 +7,9 @@ import ( "sync" ) -// Embedded fallback: the five tool HTMLs from the time the binary was -// built. Used as a last-resort served-bytes when (cache miss) AND -// (upstream unreachable) AND (no operator override) — see handler.go. +// Embedded fallback: tool HTMLs from the time the binary was built. +// Used as a last-resort served-bytes when (cache miss) AND (upstream +// unreachable) AND (no operator override) — see handler.go. // // The files are populated by the top-level build.sh, which copies the // freshly-built dist/.html into ./embedded/ before `go build` runs. @@ -26,9 +26,6 @@ var embeddedTransmittal []byte //go:embed embedded/classifier.html var embeddedClassifier []byte -//go:embed embedded/mdedit.html -var embeddedMdedit []byte - //go:embed embedded/index.html var embeddedLanding []byte @@ -47,8 +44,6 @@ func EmbeddedBytes(app string) []byte { b = embeddedTransmittal case "classifier": b = embeddedClassifier - case "mdedit": - b = embeddedMdedit case "landing": b = embeddedLanding case "browse": diff --git a/zddc/internal/apps/embedded/mdedit.html b/zddc/internal/apps/embedded/mdedit.html deleted file mode 100644 index 7abae2b..0000000 --- a/zddc/internal/apps/embedded/mdedit.html +++ /dev/null @@ -1,8606 +0,0 @@ - - - - - - ZDDC Markdown - - - - - - - -
-
-
- -
- ZDDC Markdown - v0.0.17-beta · 2026-05-12 · candle-mast-pearl -
- - -
-
- - -
-
- -
-
- - -
- -
- - - -
-
-
- -
-
- - 0 folders - 0 files - 0 unsaved -
- -
- - - - - - -
- - - - diff --git a/zddc/internal/apps/handler.go b/zddc/internal/apps/handler.go index 5237122..da0b0be 100644 --- a/zddc/internal/apps/handler.go +++ b/zddc/internal/apps/handler.go @@ -60,8 +60,6 @@ func MatchAppHTML(requestPath string) (app string, requestDirRel string) { return "transmittal", dir case "classifier.html": return "classifier", dir - case "mdedit.html": - return "mdedit", dir case "browse.html": return "browse", dir } diff --git a/zddc/internal/apps/handler_test.go b/zddc/internal/apps/handler_test.go index 2cb0bb9..f00fc9a 100644 --- a/zddc/internal/apps/handler_test.go +++ b/zddc/internal/apps/handler_test.go @@ -50,7 +50,7 @@ func TestMatchAppHTML(t *testing.T) { {"/index.html", "landing", ""}, {"/archive.html", "archive", ""}, {"/Project-X/archive.html", "archive", "Project-X"}, - {"/Project-X/Working/mdedit.html", "mdedit", "Project-X/Working"}, + {"/Project-X/Working/browse.html", "browse", "Project-X/Working"}, {"/foo.html", "", ""}, } for _, tc := range cases { diff --git a/zddc/internal/handler/reviewinghandler.go b/zddc/internal/handler/reviewinghandler.go index 619e992..02ce49a 100644 --- a/zddc/internal/handler/reviewinghandler.go +++ b/zddc/internal/handler/reviewinghandler.go @@ -233,8 +233,8 @@ func computePending(ctx context.Context, decider policy.Decider, // ServeReviewing emits the aggregator JSON listing for any depth under // /reviewing/. The HTML branch is handled separately by the -// apps subsystem (mdedit served at the URL); only requests that accept -// JSON reach here. +// apps subsystem (browse served at the URL — its markdown editor plugin +// renders responses); only requests that accept JSON reach here. // // Depths: // diff --git a/zddc/internal/handler/zddchandler_test.go b/zddc/internal/handler/zddchandler_test.go index 06270d1..8571a26 100644 --- a/zddc/internal/handler/zddchandler_test.go +++ b/zddc/internal/handler/zddchandler_test.go @@ -276,7 +276,7 @@ func TestServeZddcEditorRendersAppsSection(t *testing.T) { `data-apps-key="default"`, `data-apps-key="archive"`, `data-apps-key="classifier"`, - `data-apps-key="mdedit"`, + `data-apps-key="browse"`, `data-apps-key="transmittal"`, `data-apps-key="landing"`, `value=":beta"`, diff --git a/zddc/internal/zddc/lookups.go b/zddc/internal/zddc/lookups.go index 90266a1..b407e68 100644 --- a/zddc/internal/zddc/lookups.go +++ b/zddc/internal/zddc/lookups.go @@ -12,8 +12,8 @@ import ( // Lookup walks chain.Levels from leaf toward root, returning the // first non-empty value. This implements the "parent applies to // descendants unless overridden" cascade rule: a working/ folder's -// default_tool=mdedit propagates to working/alice/notes/ even when -// no .zddc declares mdedit at the deeper levels. +// default_tool=browse propagates to working/alice/notes/ even when +// no .zddc declares browse at the deeper levels. // // Used by the URL dispatcher to route no-slash directory URLs. // Replaces apps.DefaultAppAt once consumers are migrated (Phase 3b). diff --git a/zddc/internal/zddc/validate.go b/zddc/internal/zddc/validate.go index 60bf81e..4c97357 100644 --- a/zddc/internal/zddc/validate.go +++ b/zddc/internal/zddc/validate.go @@ -9,11 +9,14 @@ import ( // via the apps fetch+cache subsystem. Order is stable for reproducible // admin-UI rendering. // -// All eight HTML tools belong here — including browse, form, and tables. +// All seven HTML tools belong here — including browse, form, and tables. // Omitting any of them means the apps cascade (.zddc apps:) silently // short-circuits to embedded for that name, defeating live-dev // path-source overrides. -var AppNames = []string{"archive", "transmittal", "classifier", "mdedit", "landing", "browse", "form", "tables"} +// +// Markdown editing used to be a dedicated tool ("mdedit"); it now +// lives as a plugin inside browse (browse/js/preview-markdown.js). +var AppNames = []string{"archive", "transmittal", "classifier", "landing", "browse", "form", "tables"} // AppsDefaultKey is the special apps-map key that provides the baseline // URL prefix and channel for any app not overridden per-name. Cascades @@ -237,7 +240,7 @@ func ValidateFile(zf ZddcFile) []FieldError { if !IsValidAppsKey(app) { errs = append(errs, FieldError{ Field: fmt.Sprintf("apps.%s", app), - Message: fmt.Sprintf("unknown app %q (known: default, archive, transmittal, classifier, mdedit, landing, browse, form, tables)", app), + Message: fmt.Sprintf("unknown app %q (known: default, archive, transmittal, classifier, landing, browse, form, tables)", app), }) continue } diff --git a/zddc/internal/zddc/validate_test.go b/zddc/internal/zddc/validate_test.go index 1cba46d..9013f60 100644 --- a/zddc/internal/zddc/validate_test.go +++ b/zddc/internal/zddc/validate_test.go @@ -139,7 +139,7 @@ func TestIsValidAppsKey(t *testing.T) { {"archive", true}, {"transmittal", true}, {"classifier", true}, - {"mdedit", true}, + {"browse", true}, {"landing", true}, {"unknown", false}, {"", false}, @@ -161,7 +161,7 @@ func TestValidateFile_Apps(t *testing.T) { "classifier": "v0.0.4", // ok "default": "https://zddc.varasys.io/releases:stable", // ok (default key + URL+channel) "transmittal": ":beta", // ok (channel-only) - "mdedit": "https://my-mirror.example/releases", // ok (URL-prefix only) + "browse": "https://my-mirror.example/releases", // ok (URL-prefix only) "unknown": "stable", // unknown app "landing": "what is this", // bad spec },