chore: retire mdedit tool — markdown editor lives in browse now

mdedit/ is gone. Its functionality moved into browse's preview plugin
(browse/js/preview-markdown.js) — YAML front matter editing, outline,
and on-demand DOCX/HTML/PDF download all happen there. Browse is the
default_tool for working/ + reviewing/ as of the previous commit, so
existing URLs of the form /<project>/working land on browse without
operator action.

Removed:

  • mdedit/ source tree (Toast UI app, CSS, JS, template, build.sh)
  • zddc/internal/apps/embedded/mdedit.html (//go:embed blob)
  • tests/mdedit.spec.js + the "mdedit" project in playwright.config.js
  • mdedit entries in zddc/internal/apps/embed.go (//go:embed, var,
    switch case in EmbeddedBytes)
  • "mdedit" in zddc/internal/zddc/validate.go AppNames + the matching
    error-message app list
  • "mdedit.html" branch in zddc/internal/apps/handler.go MatchAppHTML
  • mdedit case in tests (handler_test.go, validate_test.go,
    zddchandler_test.go) — test fixtures now use browse/classifier
  • mdedit from build (per-tool build.sh loop, tool-list literals,
    composer cards) and shared/build-lib.sh ZDDC_RELEASE_TOOLS
  • mdedit from freshen-channel's tool list and usage banner
  • mdedit-specific paragraphs in AGENTS.md and ARCHITECTURE.md;
    Markdown Editor section in ARCHITECTURE.md rewritten to point at
    browse/js/preview-markdown.js
  • mdedit from CLAUDE.md, README.md, zddc/README.md tool lists

Historical mdedit_v*.html / mdedit_v*.html.sig files in
/srv/zddc/releases/ on the deploy host are immutable history — they
stay where they are. The next ./build release cut will simply not
produce new mdedit_v* artifacts.
This commit is contained in:
ZDDC 2026-05-13 10:34:31 -05:00
parent 7fbe7867fd
commit e7f6334daa
44 changed files with 120 additions and 13741 deletions

View file

@ -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 [<version>|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 `<name>.table.yaml` next to a sibling `<name>/` 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 `<name>.table.yaml` next to a sibling `<name>/` 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: `<tool>-v<X.Y.Z>` 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: `<tool>-v<X.Y.Z>` 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/<tool>.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 |
|---|---|---|
| `<tool>_v<X.Y.Z>.html` | real, immutable | per-version HTML for each of archive, transmittal, classifier, mdedit, landing, form, tables, browse |
| `<tool>_v<X.Y.Z>.html` | real, immutable | per-version HTML for each of archive, transmittal, classifier, landing, form, tables, browse |
| `<tool>_v<X.Y>.html`, `<tool>_v<X>.html` | symlinks | partial-version pins |
| `<tool>_<channel>.html` | symlink (or real bytes during active channel dev) | mutable channel mirror per tool, channel ∈ {stable, beta, alpha} |
| `zddc-server_v<X.Y.Z>_<platform>` | 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/<party>/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 `<app>.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/<party>/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 `<app>.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 `<app>.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 `<script id="transmittal-data" type="application/json">`
## mdedit-specific
## Markdown editor (inside browse)
- `css/tailwind-utils.css` is a pre-generated static subset (~80 classes). Add new Tailwind classes here; do not re-run Tailwind.
- Toast UI Editor v3.2.2 is bundled in `vendor/`; `template.html` loads it from CDN for dev convenience
- `</` escaping is essential: `sed 's#</#<\\/#g'` runs on both app JS and vendor JS at build time
The markdown editor lives at `browse/js/preview-markdown.js` and is mounted as the preview plugin for `.md`/`.markdown` files by `browse/js/preview.js`. The standalone `mdedit/` tool has been retired — `browse` is the editor.
- Toast UI Editor v3.2.2 is vendored at `shared/vendor/toastui-editor-all.min.js` and concatenated into `browse/dist/browse.html` at build time. No runtime CDN.
- YAML front matter (`---\n…\n---`) is split off on load and edited in a dedicated `<textarea>` in the sidebar; on save it's recombined onto the body. Always present (no "empty pane") so authoring new FM is a single click.
- In server mode (HTTP-backed file handles), three Download buttons appear in the file header — DOCX/HTML/PDF — fetching `?convert=<fmt>` and triggering a browser download. The buttons auto-save the dirty buffer first so the converted bytes reflect what's on screen.
## Server-side document conversion (`zddc/internal/convert`)
zddc-server can convert `.md` → DOCX/HTML/PDF on demand at `GET /<path>/foo.md?convert=docx|html|pdf`. Implementation:
- **Two upstream images, pulled on first use.** No custom image build. Operator just needs `podman` or `docker` installed; the runner passes `--pull=missing` so the first request pulls each image and subsequent requests use the local cache.
- `docker.io/pandoc/latex:latest` — pandoc's official image, entrypoint `pandoc`. Used for MD → DOCX and MD → HTML. Override via `--convert-pandoc-image=` / `ZDDC_CONVERT_PANDOC_IMAGE` (e.g. switch to `docker.io/pandoc/core:latest` for a ~90% size reduction).
- `docker.io/zenika/alpine-chrome:latest` — Zenika's Alpine + Chromium image, entrypoint `chromium-browser`. Used for HTML → PDF (the PDF flow is two-stage: pandoc image emits HTML using viewer-template.html, chromium image prints it). Override via `--convert-chromium-image=` / `ZDDC_CONVERT_CHROMIUM_IMAGE`.
- Engine is podman preferred, docker fallback (`--convert-engine=` / `ZDDC_CONVERT_ENGINE` to override). No host pandoc or chromium needed.
- Each conversion runs in a throw-away container with `--rm --pull=missing --network=none --read-only --tmpfs=/tmp:size=128m,exec --memory --cpus --pids-limit --cap-drop=ALL --security-opt=no-new-privileges --env=HOME=/tmp`. Resource caps via `--convert-mem-mib` (default 512), `--convert-cpus` (default "2"), `--convert-pids` (default 100), `--convert-timeout` (default 30s). `--user` is intentionally not set so each image uses its default (root for pandoc/latex, uid 1000 for alpine-chrome) — the other flags already provide strong isolation and overriding the user would break alpine-chrome's user-data-dir layout.
- I/O via bind mount + stdin/stdout. Pandoc reads markdown from stdin, writes to stdout. The viewer template is bind-mounted read-only at `/tpl`. Chromium reads HTML from a read-write bind mount at `/pdf` and writes the PDF to the same mount; the host reads it back.
- Output cached at `<dir>/.converted/<base>.<ext>` (hidden by the `.` prefix). mtime synced to source so the fast path is a stat-and-serve with no exec. PUT/DELETE/MOVE on the source `.md` purges the sidecars.
- Per-project template variables (client/project/contractor/project_number) come from `.zddc` `convert:` cascade keys. Title/tracking_number/revision/status are derived from the filename via `zddc.ParseFilename`.
- If neither podman nor docker is present, the endpoint serves 503 with a Retry-After. The rest of the server keeps working.
## Form-data system (`form/` + zddc-server form handler)

View file

@ -62,7 +62,7 @@ Website files (what `zddc.varasys.io` serves) live on a **separate Codeberg repo
releases/ ← rsync'd from ~/src/zddc/dist/release-output/
```
`<tool>` ∈ {archive, transmittal, classifier, mdedit, landing, form, tables, browse}. `<platform>` ∈ {linux-amd64, darwin-amd64, darwin-arm64, windows-amd64.exe}.
`<tool>` ∈ {archive, transmittal, classifier, landing, form, tables, browse}. `<platform>` ∈ {linux-amd64, darwin-amd64, darwin-arm64, windows-amd64.exe}.
Every URL under `/releases/` resolves directly via the symlink chain — no `manifest.json`, no Caddy regex-rewrite, no JavaScript indirection, no third-party mirror. Caddy serves these as plain static files. The Docker-tag pattern: `:1.2.3` is pinned, `:1.2` floats, `:1` floats further, `:stable` floats furthest, and `:beta` / `:alpha` are mutable channel mirrors that overwrite in place.
@ -154,7 +154,7 @@ Two orthogonal axes: how the bytes get there (this section), and what runtime mo
|---------------|-------------------------------------------------------------------------|
| `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 markdown editor as a preview plugin) |
| `transmittal` | any `Staging` directory and its subtree |
| `landing` | only at the deployment root |
@ -170,7 +170,7 @@ The `X-ZDDC-Source` response header always reports what was served: `fetch:URL`,
### Runtime mode detection
Independent of how the tool got installed. `archive` auto-detects from the URL and folder shape (`?projects=` set → multi-project; scan root has an `archive/` child → project-root; otherwise → in-archive). The other tools don't care — `transmittal`, `classifier`, `mdedit` work the same regardless of where they live.
Independent of how the tool got installed. `archive` auto-detects from the URL and folder shape (`?projects=` set → multi-project; scan root has an `archive/` child → project-root; otherwise → in-archive). The other tools don't care — `transmittal`, `classifier`, `browse` work the same regardless of where they live.
### Build Script Requirements
@ -198,7 +198,7 @@ sed 's#</#<\\/#g' "$input_js" > "$safe_js"
Then use `</script>` (not `<\/script>`) to close the `<script>` block, since the content no longer contains any `</` sequences that the parser could misread.
This is already enforced for mdedit's vendor bundling. It is the contributor's responsibility to ensure new tools follow this pattern.
This is already enforced for browse's Toast UI bundling. It is the contributor's responsibility to ensure new tools follow this pattern.
### Vendor Dependencies
@ -208,24 +208,25 @@ Some tools bundle third-party libraries. These live in `tool/vendor/` and are co
| Tool | Library | File | Notes |
|------|---------|------|-------|
| mdedit | Toast UI Editor v3.2.2 | `vendor/toastui-editor-all.min.js` | Markdown editor with live preview |
| mdedit | Toast UI Editor CSS | `vendor/toastui-editor.min.css` | Editor stylesheet |
| browse | Toast UI Editor v3.2.2 | `shared/vendor/toastui-editor-all.min.js` | Markdown editor (loaded by `browse/js/preview-markdown.js`) |
| browse | Toast UI Editor CSS | `shared/vendor/toastui-editor.min.css` | Editor stylesheet |
| shared | jszip | `shared/vendor/jszip.min.js` | ZIP read for previews + classifier hash-export |
| shared | docx-preview | `shared/vendor/docx-preview.min.js` | DOCX preview |
| shared | xlsx (SheetJS) | `shared/vendor/xlsx.full.min.js` | XLSX/XLS preview |
| shared | UTIF | `shared/vendor/utif.min.js` | TIFF preview |
**No runtime CDN loads.** Every external dependency is vendored into
`shared/vendor/` (or, for mdedit's editor, `mdedit/vendor/`) and
concatenated into each tool's bundle at build time. Tools that need a
given library include the vendor path in their `build.sh`'s
`concat_files` JS list. The "ship the record player with the record"
philosophy: a downloaded `.html` file works offline against any file
the user can open, with no network dependency at runtime.
`shared/vendor/` and concatenated into each tool's bundle at build
time. Tools that need a given library include the vendor path in
their `build.sh`'s `concat_files` JS list. The "ship the record
player with the record" philosophy: a downloaded `.html` file works
offline against any file the user can open, with no network
dependency at runtime.
Trade-off accepted: bundle sizes are larger. archive, classifier,
transmittal land around 1.5 MB after gzip; mdedit lands around 2 MB
because it carries Toast UI + jszip + docx-preview + xlsx + UTIF.
transmittal land around 1.5 MB after gzip; browse lands around 2 MB
because it carries Toast UI + jszip + docx-preview + xlsx + UTIF
for the in-place markdown editor and the preview pane.
Justified by the offline-first guarantee: any tool downloaded from
`/releases/` works without network, against air-gapped archives,
forever. See ARCHITECTURE.md § "Why Single-File HTML Applications"
@ -244,7 +245,7 @@ dependency inlined. No CDN URLs survive into the dist.
| Development | CDN (live, from `template.html`) | Open `template.html` directly in Chromium |
| Production | Bundled / Static CSS | Run `bash tool/build.sh`, open `dist/tool.html` |
For mdedit specifically: `template.html` loads Toast UI from CDN and uses Tailwind Play CDN. The build replaces Toast UI with the bundled vendor file and replaces the Tailwind CDN script with the static `css/tailwind-utils.css` subset.
For browse specifically: `template.html` loads Toast UI from CDN for dev convenience. The build replaces it with the bundled vendor file (`shared/vendor/toastui-editor-all.min.js`).
---
@ -290,7 +291,7 @@ main.js ← Initialization (depends on all modules)
### State Management
Three patterns coexist. **For new tools, prefer the first one** — direct mutation on `window.app` with explicit re-render. It's debuggable, it's the most common pattern in this codebase (archive, mdedit, browse, form, tables), and it doesn't hide control flow.
Three patterns coexist. **For new tools, prefer the first one** — direct mutation on `window.app` with explicit re-render. It's debuggable, it's the most common pattern in this codebase (archive, browse, form, tables), and it doesn't hide control flow.
**1. Direct mutation on `window.app` + explicit re-render** *(recommended for new tools)*
@ -301,7 +302,7 @@ window.app.files.push(newFile);
window.app.modules.table.render();
```
State is read directly. Mutations trigger explicit `render()` calls — no auto-tracking, no surprise updates. Used by archive, mdedit, browse, form, tables, landing.
State is read directly. Mutations trigger explicit `render()` calls — no auto-tracking, no surprise updates. Used by archive, browse, form, tables, landing.
**2. Pub-sub store on top of #1** (classifier)
@ -392,24 +393,17 @@ Files at the root level are ignored. The grouping folder list and transmittal fo
---
### Markdown Editor (mdedit)
### Markdown Editor (browse preview plugin)
**Pattern:** Global functions (`window.updateToc`), editor instances managed per file-path in a `Map`, File System Access API for direct file read/write.
**Lives at:** `browse/js/preview-markdown.js`, registered on `window.app.modules.markdown` and invoked by `browse/js/preview.js` for `.md`/`.markdown` files. The standalone `mdedit/` tool was retired in favour of this plugin.
**Dependencies:** Toast UI Editor v3.2.2 (bundled), Tailwind utility subset (static CSS).
**Pattern:** Editor instances per-file (constructed by `render(node, container, ctx)`, disposed by `dispose()`). CSS Grid layout for the shell — sidebar (FM textarea on top, outline below) on the left, content (info header + Toast UI editor) on the right.
**Toast UI availability check:**
**Front matter:** Parsed off the file on load by `parseFrontMatter()` (a small `---\n…\n---` parser); the FM body goes into a sidebar `<textarea>`, the markdown body into the Toast UI editor. On save, `assembleContent()` recombines them with the envelope on top. The textarea is always present so authoring brand-new FM is a single click; dirty tracking covers both halves via a SHA-256 hash of the assembled bytes.
```javascript
if (typeof toastui === 'undefined') {
// Graceful degradation — show error message
}
const editor = new toastui.Editor({ el: container, ... });
```
**Dependencies:** Toast UI Editor v3.2.2 (vendored at `shared/vendor/toastui-editor-all.min.js`, concatenated into `browse/dist/browse.html` at build time). No runtime CDN, no Tailwind.
**Key DOM IDs:** `#app`, `#select-directory`, `#welcome-screen`, `#file-tree`, `#content-container`.
**File tree:** Populated after `showDirectoryPicker()` resolves. File items are rendered as DOM children of `#file-tree`. Clicking a file opens it in the editor panel.
**Server-mode features:** When the file handle is an `HttpFileHandle` (so `node.url` is set and `state.source === 'server'`), three Download buttons appear in the file header — DOCX/HTML/PDF — fetching `?convert=<fmt>` via `window.zddc.source.downloadConverted()`. Clicks auto-save first if the buffer is dirty so converted bytes reflect what's on screen. See `zddc/internal/convert` for the server-side engine.
---
@ -709,7 +703,7 @@ The schema keys that drive built-in behavior:
| `roles` | `{ name → { members:[], reset:bool } }` | members union across cascade; `reset:true` stops the walk |
| `paths` | recursive map of child-path → `.zddc` overlay; the engine of the whole convention | replaces (the walker threads ancestor `paths:` to the right level) |
**Slash / no-slash URL routing.** Every directory URL has two forms: `<dir>/` serves `dir_tool` (defaults to `browse`, the file-tree navigator) and `<dir>` serves `default_tool` (the specialized app — `archive` under `archive/`, `transmittal` under `staging/`, `mdedit` under `working/`, `tables` at `archive/<party>/mdl`). A folder with no `default_tool` 302s the no-slash form to the slash form, so you land on `dir_tool`. JSON listing requests ignore both keys — the raw listing is always served, so the browse SPA can enumerate entries regardless. The dispatcher's `serveSpecializedNoSlash` (in `cmd/zddc-server/main.go`) is the single chokepoint for the no-slash side; `handler.ServeDirectory` (via `zddc.DirToolAt`) handles the slash side.
**Slash / no-slash URL routing.** Every directory URL has two forms: `<dir>/` serves `dir_tool` (defaults to `browse`, the file-tree navigator) and `<dir>` serves `default_tool` (the specialized app — `archive` under `archive/`, `transmittal` under `staging/`, `browse` under `working/`+`reviewing/` (hosts the markdown editor plugin), `tables` at `archive/<party>/mdl`). A folder with no `default_tool` 302s the no-slash form to the slash form, so you land on `dir_tool`. JSON listing requests ignore both keys — the raw listing is always served, so the browse SPA can enumerate entries regardless. The dispatcher's `serveSpecializedNoSlash` (in `cmd/zddc-server/main.go`) is the single chokepoint for the no-slash side; `handler.ServeDirectory` (via `zddc.DirToolAt`) handles the slash side.
**Zip-backed directories.** A `.zip` file is also a navigable directory: `GET …/Foo.zip/` returns a JSON listing of the zip's members (or the browse SPA for an HTML request) and `GET …/Foo.zip/sub/doc.pdf` extracts and streams that one member — so a client navigating a zipped transmittal folder never downloads the whole archive. `GET …/Foo.zip` (no trailing slash) is unchanged: the raw `.zip` download. Read-only: `PUT`/`DELETE`/`POST` to a path inside a `.zip` is rejected (405). ACL is the chain of the directory *containing* the zip — a zip carries no `.zddc` of its own, the same model as the `.archive` virtual surface. Implemented by `internal/zipfs` + `handler.ServeZip`, routed via `splitZipPath` in the dispatcher (before the file-API branch). Offline tools (archive's scanner, browse's tree) get the same capability client-side via `shared/zip-source.js` — a `ZipDirectoryHandle`/`ZipFileHandle` pair over JSZip that mimics the File-System-Access surface. The archive tool treats a `.zip` whose name minus `.zip` parses as a transmittal-folder name as that transmittal folder; the browse tool expands *any* `.zip`.
@ -734,7 +728,7 @@ zddc-server exposes write methods on the same URL space as GET. Each method maps
Writes use `WriteAtomic` (temp file → fsync → rename) for partial-write safety. Move uses `os.Rename` for same-FS atomicity. Body size capped by `--max-write-bytes` (default 256 MiB). Reserved hidden segments (`.`-prefixed, `_app`, `_template`) are 404'd uniformly with the read path. Every write logs a structured `file_write` event (op, path, email, status, bytes) into the same audit stream as access logs.
Browser clients reach the API through `shared/zddc-source.js` — an FS Access API polyfill (`HttpDirectoryHandle`, `HttpFileHandle`) that lets tools written against `showDirectoryPicker()` work unchanged when served by zddc-server. classifier, mdedit, transmittal, and browse auto-detect HTTP mode at startup, build a polyfill handle for `location.pathname`'s directory, and skip the file picker entirely. A 403 on the initial listing surfaces a "no permission to list this directory" message instead of the welcome screen.
Browser clients reach the API through `shared/zddc-source.js` — an FS Access API polyfill (`HttpDirectoryHandle`, `HttpFileHandle`) that lets tools written against `showDirectoryPicker()` work unchanged when served by zddc-server. classifier, transmittal, and browse auto-detect HTTP mode at startup, build a polyfill handle for `location.pathname`'s directory, and skip the file picker entirely. A 403 on the initial listing surfaces a "no permission to list this directory" message instead of the welcome screen.
#### `zddc-source.js` known gaps
@ -770,19 +764,15 @@ out-of-the-box behavior with no per-deployment configuration.
## CSS Architecture
All tools use vanilla CSS. No frameworks at build time (mdedit's Tailwind utilities are pre-generated static CSS).
All tools use vanilla CSS. No frameworks at build time.
**Common conventions:**
- CSS variables for theme colors and spacing in `base.css`
- Component-scoped class names (no global utilities except where Tailwind provides them)
- Component-scoped class names
- `.hidden` class uses `display: none !important` for JavaScript show/hide
- Print styles in a separate `print.css`
**mdedit Tailwind subset:**
`css/tailwind-utils.css` contains only the ~80 Tailwind v3 utility classes actually used in `template.html`. If a new utility class is needed in the template, add it here. Classes follow Tailwind v3 naming and values exactly.
---
## Testing
@ -805,7 +795,7 @@ Each tool has a spec file in `tests/`:
tests/
archive.spec.js ← 2 tests: load + directory scan
classifier.spec.js ← 2 tests: load + store injection
mdedit.spec.js ← 2 tests: load + file tree render
browse.spec.js ← load + file tree render + markdown editor mount
transmittal.spec.js ← 2 tests: paste round-trip + filesystem round-trip
fixtures/
mock-fs-api.js ← Reusable File System Access API mock

View file

@ -21,10 +21,10 @@ If something in this CLAUDE.md conflicts with those, those win — and please up
This is a **monorepo of independent tools**, not one application:
- `archive/`, `transmittal/`, `classifier/`, `mdedit/`, `landing/`, `form/`, `tables/`, `browse/`eight self-contained HTML tools, each compiled to a single inlined HTML file in its own `dist/`. Most output `dist/tool.html`; **`landing/` outputs `dist/index.html`** (it's the project picker served at the root of `zddc-server`). `form/` is the schema-driven renderer for the form-data system (any `<name>.form.yaml` file in the tree becomes an editable form at `<path>/<name>.form.html`); `tables/` is its read/aggregate counterpart, rendering a directory of YAML rows as a sortable table; `browse/` is the file-tree navigator. See AGENTS.md "Form-data system" / "Tables system" and ARCHITECTURE.md "Form Renderer".
- `archive/`, `transmittal/`, `classifier/`, `landing/`, `form/`, `tables/`, `browse/`seven self-contained HTML tools, each compiled to a single inlined HTML file in its own `dist/`. Most output `dist/tool.html`; **`landing/` outputs `dist/index.html`** (it's the project picker served at the root of `zddc-server`). `form/` is the schema-driven renderer for the form-data system (any `<name>.form.yaml` file in the tree becomes an editable form at `<path>/<name>.form.html`); `tables/` is its read/aggregate counterpart, rendering a directory of YAML rows as a sortable table; `browse/` is the file-tree navigator and **also hosts the in-place markdown editor** (`browse/js/preview-markdown.js` — Toast UI Editor + YAML front-matter pane + on-demand server-side MD→DOCX/HTML/PDF download buttons). A dedicated `mdedit/` tool used to live alongside these but has been retired. See AGENTS.md "Form-data system" / "Tables system" and ARCHITECTURE.md "Form Renderer".
- `zddc/` — Go HTTP server (separate sub-project; Go 1.24+). Two deployment shapes from the same binary: (1) **master** — owns a file tree under `ZDDC_ROOT`, applies `.zddc` ACL cascades, serves files / app HTML / archive listings. Two auth paths on master: `Authorization: Bearer <token>` validated against self-issued tokens at `<ZDDC_ROOT>/.zddc.d/tokens/<sha256-hex>` for CLI/scripted callers, or `X-Auth-Request-Email` injected by an upstream proxy for browser sessions. Self-service token UI at `/.tokens` + JSON API at `/.api/tokens`. (2) **client** — when `--upstream <url>` is set, the binary becomes a downstream proxy/cache/mirror (`zddc/internal/cache/`); master-side machinery is bypassed and `--root` becomes the cache directory. Three sub-modes via `--mode proxy|cache|mirror` (mirror is phase 3). Cache layout is a normal ZDDC root, so the cache dir can be served as a plain master if you unset `--upstream`. Marker file `.zddc-upstream` records provenance. `--no-auth` skips ACL enforcement entirely on this instance (distinct from `--insecure` which only relaxes the no-root-`.zddc` startup check); `--skip-tls-verify` is a separate flag for self-signed upstream certs. Cross-compiled binaries are produced by `./build` and live in `dist/release-output/` (gitignored); `./deploy` rsyncs them to `/srv/zddc/releases/` on the deploy host (Caddy serves them at `https://zddc.varasys.io/releases/`). The `helm/` charts in this repo build from source at deploy time.
- `shared/` — CSS (`base.css`, `fonts.css` + base64-inlined woff2 under `fonts/`, `nav.css`, `logo.css`, `toast.css`) plus shared JS modules (`zddc.js`, `hash.js`, `zddc-filter.js`, `zddc-source.js`, `zip-source.js`, `theme.js`, `toast.js`, `nav.js`, `logo.js`, `help.js`, `preview-lib.js`) and vendored libs (`vendor/`: jszip, xlsx, utif, docx-preview, toastui-editor) — each tool's `build.sh` concatenates the subset it needs. Also `build-lib.sh` (POSIX sh helpers sourced by every tool's `build.sh` AND by the top-level `build` for lockstep release helpers). See AGENTS.md "Shared modules" for the full inventory.
- **Two-repo + deploy-host model.** Source code lives here (`codeberg.org/VARASYS/ZDDC`). Hand-edited website content lives in a separate repo (`codeberg.org/VARASYS/ZDDC-website`, typically cloned at `~/src/zddc-website/` — just `index.html`, `reference.html`, `css/`, `js/`, `img/`; no releases, no LFS). The live site at `zddc.varasys.io` is served from `/srv/zddc/` on the deploy host: Caddy bind-mounts that path, and it's populated by `./deploy` from this repo's `dist/release-output/` plus `~/src/zddc-website/`. **Releases are NOT in any git history** — they're reproducible from this repo's `<tool>-vX.Y.Z` tags by checking out the tag and running `./build release X.Y.Z`. Per-version files (`<tool>_v<X.Y.Z>.html`) are immutable; partial-version pins (`<tool>_v<X.Y>.html`, `<tool>_v<X>.html`) and channel mirrors (`<tool>_{stable,beta,alpha}.html`) are symlinks; zddc-server has analogous `zddc-server_v<X.Y.Z>_<platform>` per-version binaries plus channel/partial-version symlinks plus `zddc-server_<X>.html` stub pages that fan out the four-platform download in one cell. **Install model:** local use is a download from `/releases/`. Server use is `zddc-server`, which has the current-stable build of all eight HTML tools baked in via `//go:embed` (compile-time default). **Which tool a directory URL serves is driven by the `.zddc` cascade, not hardcoded folder names** — a baked-in `defaults.zddc.yaml` (dump it: `zddc-server show-defaults`) declares, via a recursive `paths:` tree, per-folder `default_tool` (served at `<dir>``archive` under `archive/`, `transmittal` under `staging/`, `mdedit` under `working/`, `classifier` under `incoming/`, `tables` at `archive/<party>/mdl`, `landing` at root), `dir_tool` (served at `<dir>/`; defaults to `browse`), `available_tools`, plus the canonical-folder behaviour keys (`auto_own`, `worm:`, `virtual`, `drop_target`); operators override at any level. A `.zip` file is also a navigable directory (`GET …/Foo.zip/` → member listing; `…/Foo.zip/m.pdf` → that member); `GET /dir/?zip=1` streams an ACL-filtered zip of a subtree. Override the *tool source* via a `.zddc apps:` entry (channel/version/URL/path) — fetched once, cached at `<ZDDC_ROOT>/_app/`; or drop a real `.html` at any path. See AGENTS.md "URL handling"/"Install model" and ARCHITECTURE.md "Canonical folders, URL routing & the `.zddc` cascade".
- **Two-repo + deploy-host model.** Source code lives here (`codeberg.org/VARASYS/ZDDC`). Hand-edited website content lives in a separate repo (`codeberg.org/VARASYS/ZDDC-website`, typically cloned at `~/src/zddc-website/` — just `index.html`, `reference.html`, `css/`, `js/`, `img/`; no releases, no LFS). The live site at `zddc.varasys.io` is served from `/srv/zddc/` on the deploy host: Caddy bind-mounts that path, and it's populated by `./deploy` from this repo's `dist/release-output/` plus `~/src/zddc-website/`. **Releases are NOT in any git history** — they're reproducible from this repo's `<tool>-vX.Y.Z` tags by checking out the tag and running `./build release X.Y.Z`. Per-version files (`<tool>_v<X.Y.Z>.html`) are immutable; partial-version pins (`<tool>_v<X.Y>.html`, `<tool>_v<X>.html`) and channel mirrors (`<tool>_{stable,beta,alpha}.html`) are symlinks; zddc-server has analogous `zddc-server_v<X.Y.Z>_<platform>` per-version binaries plus channel/partial-version symlinks plus `zddc-server_<X>.html` stub pages that fan out the four-platform download in one cell. **Install model:** local use is a download from `/releases/`. Server use is `zddc-server`, which has the current-stable build of all seven HTML tools baked in via `//go:embed` (compile-time default). **Which tool a directory URL serves is driven by the `.zddc` cascade, not hardcoded folder names** — a baked-in `defaults.zddc.yaml` (dump it: `zddc-server show-defaults`) declares, via a recursive `paths:` tree, per-folder `default_tool` (served at `<dir>``archive` under `archive/`, `transmittal` under `staging/`, `browse` under `working/`+`reviewing/` (hosts the markdown editor), `classifier` under `incoming/`, `tables` at `archive/<party>/mdl`, `landing` at root), `dir_tool` (served at `<dir>/`; defaults to `browse`), `available_tools`, plus the canonical-folder behaviour keys (`auto_own`, `worm:`, `virtual`, `drop_target`); operators override at any level. A `.zip` file is also a navigable directory (`GET …/Foo.zip/` → member listing; `…/Foo.zip/m.pdf` → that member); `GET /dir/?zip=1` streams an ACL-filtered zip of a subtree. Override the *tool source* via a `.zddc apps:` entry (channel/version/URL/path) — fetched once, cached at `<ZDDC_ROOT>/_app/`; or drop a real `.html` at any path. See AGENTS.md "URL handling"/"Install model" and ARCHITECTURE.md "Canonical folders, URL routing & the `.zddc` cascade".
- `helm/` — example Helm charts for zddc-server. Three flavors: `zddc-server-prod/` (production master), `zddc-server-dev/` (development master with OverlayFS isolation), `zddc-server-cache/` (downstream client running in proxy/cache/mirror mode against an upstream master, with bearer token from a Kubernetes Secret). All compile from source via init container. Operators copy `values.yaml.example` and customize. No secrets in repo — the cache chart references a separately-created Secret for the bearer token.
- `tests/` — Playwright specs (Chromium only, requires File System Access API). `tests/schema.spec.js` validates `transmittal.schema.json` against canonical fixtures via `ajv` (only dev dep besides Playwright)

View file

@ -15,13 +15,12 @@ The name "Zero Day Document Control" comes from the convention itself — adopt
| **Archive Browser** | Browse, search, and filter a project archive folder. Group by transmittal, export selections as ZIP. |
| **Transmittal Creator** | Self-contained HTML transmittal records with SHA-256 checksums and optional digital signatures. |
| **Document Classifier** | Spreadsheet-like bulk-renamer that copy/pastes with Excel and writes back to disk. |
| **Markdown Editor** | Browser-based markdown editor with YAML front matter, TOC, and direct local file access. |
| **Form Renderer** | Schema-driven `*.form.yaml` editor — every form spec auto-mounts an editable form at `<name>.form.html`. |
| **Tables** | Sortable, filterable, in-place-editable grid view over a directory of YAML rows; click a row → edit in the form renderer. Auto-mounts on any directory containing a `table.yaml`. |
| **Browse** | File-tree navigator with previews; the everywhere-available companion to the Archive Browser when you want plain folder navigation rather than tracking-number aggregation. |
| **Browse** | File-tree navigator with previews and an in-place markdown editor (YAML front matter, outline, server-side DOCX/HTML/PDF download); the everywhere-available companion to the Archive Browser when you want plain folder navigation rather than tracking-number aggregation. |
| **Landing** | The project picker served at the deployment root of a `zddc-server`. |
Each tool is published in three channels (stable, beta, alpha) as static files served from <https://zddc.varasys.io/releases/>. **Local use:** download a `.html` file from `releases/` and open it in a browser. **Server use:** run `zddc-server` — the current-stable build of every tool is baked into the binary at compile time, so a fresh deployment Just Works with zero config. Which tool a directory URL serves is driven by the `.zddc` cascade: a baked-in `defaults.zddc.yaml` (dump it with `zddc-server show-defaults`) declares, per folder, `default_tool` (the no-slash form — archive under `archive/`, transmittal under `staging/`, mdedit under `working/`, classifier under `incoming/`, tables at `archive/<party>/mdl`, landing at root) and `dir_tool` (the trailing-slash form; defaults to `browse`); operators override at any level. A `.zip` file is also a navigable directory (`GET …/Foo.zip/`), and `GET /dir/?zip=1` streams an ACL-filtered zip of a subtree. Override the *tool source* per-directory by writing an `apps:` entry in any `.zddc` file (channel/version/URL/path) — fetched once and cached in `<ZDDC_ROOT>/_app/` — or drop a real `.html` file at any path.
Each tool is published in three channels (stable, beta, alpha) as static files served from <https://zddc.varasys.io/releases/>. **Local use:** download a `.html` file from `releases/` and open it in a browser. **Server use:** run `zddc-server` — the current-stable build of every tool is baked into the binary at compile time, so a fresh deployment Just Works with zero config. Which tool a directory URL serves is driven by the `.zddc` cascade: a baked-in `defaults.zddc.yaml` (dump it with `zddc-server show-defaults`) declares, per folder, `default_tool` (the no-slash form — archive under `archive/`, transmittal under `staging/`, browse under `working/`+`reviewing/` (browse hosts the in-place markdown editor), classifier under `incoming/`, tables at `archive/<party>/mdl`, landing at root) and `dir_tool` (the trailing-slash form; defaults to `browse`); operators override at any level. A `.zip` file is also a navigable directory (`GET …/Foo.zip/`), and `GET /dir/?zip=1` streams an ACL-filtered zip of a subtree. Override the *tool source* per-directory by writing an `apps:` entry in any `.zddc` file (channel/version/URL/path) — fetched once and cached in `<ZDDC_ROOT>/_app/` — or drop a real `.html` file at any path.
## File-naming convention

30
build
View file

@ -154,7 +154,6 @@ export BUILD_LABELS_DIR
sh "$SCRIPT_DIR/transmittal/build.sh" $TOOL_RELEASE_ARGS
sh "$SCRIPT_DIR/archive/build.sh" $TOOL_RELEASE_ARGS
sh "$SCRIPT_DIR/classifier/build.sh" $TOOL_RELEASE_ARGS
sh "$SCRIPT_DIR/mdedit/build.sh" $TOOL_RELEASE_ARGS
sh "$SCRIPT_DIR/landing/build.sh" $TOOL_RELEASE_ARGS
sh "$SCRIPT_DIR/form/build.sh" $TOOL_RELEASE_ARGS
sh "$SCRIPT_DIR/tables/build.sh" $TOOL_RELEASE_ARGS
@ -162,27 +161,27 @@ sh "$SCRIPT_DIR/browse/build.sh" $TOOL_RELEASE_ARGS
echo ""
echo "=== Assembling zddc/dist/web/ ==="
# Six tool HTMLs ship inside the server bundle. landing and archive call
# Tool HTMLs ship inside the server bundle. landing and archive call
# server APIs (GET / for the project list, directory listings for archive) and
# are useless without zddc-server. transmittal, classifier, and mdedit are
# pure client-side tools but are still bundled — the server uses these copies
# are useless without zddc-server. transmittal and classifier are pure
# client-side tools but are still bundled — the server uses these copies
# as the embedded fallback (//go:embed in internal/apps/embedded/) when both
# the cache is empty AND the upstream is unreachable. form is the schema-
# driven form renderer used by the form-data system; it's embedded into the
# handler package directly (not the apps cascade) since it isn't subject to
# per-folder version overrides.
# per-folder version overrides. browse hosts the in-place markdown editor
# (no separate mdedit tool — retired in favor of browse's preview plugin).
mkdir -p "$SCRIPT_DIR/zddc/dist/web"
cp "$SCRIPT_DIR/landing/dist/index.html" "$SCRIPT_DIR/zddc/dist/web/index.html"
cp "$SCRIPT_DIR/archive/dist/archive.html" "$SCRIPT_DIR/zddc/dist/web/archive.html"
cp "$SCRIPT_DIR/transmittal/dist/transmittal.html" "$SCRIPT_DIR/zddc/dist/web/transmittal.html"
cp "$SCRIPT_DIR/classifier/dist/classifier.html" "$SCRIPT_DIR/zddc/dist/web/classifier.html"
cp "$SCRIPT_DIR/mdedit/dist/mdedit.html" "$SCRIPT_DIR/zddc/dist/web/mdedit.html"
cp "$SCRIPT_DIR/form/dist/form.html" "$SCRIPT_DIR/zddc/dist/web/form.html"
cp "$SCRIPT_DIR/tables/dist/tables.html" "$SCRIPT_DIR/zddc/dist/web/tables.html"
cp "$SCRIPT_DIR/browse/dist/browse.html" "$SCRIPT_DIR/zddc/dist/web/browse.html"
echo "Wrote zddc/dist/web/{index,archive,transmittal,classifier,mdedit,form,tables,browse}.html"
echo "Wrote zddc/dist/web/{index,archive,transmittal,classifier,form,tables,browse}.html"
# Mirror the five cascade-served HTMLs into the apps embed source dir so the
# Mirror the cascade-served HTMLs into the apps embed source dir so the
# next `go build` of zddc-server picks them up via //go:embed. ONLY happens
# on a beta or stable cut — that's the project invariant: alpha labels are
# never baked into the binary, beta labels go to the dev image (which builds
@ -196,7 +195,6 @@ if [ "$RELEASE_CHANNEL" = "beta" ] || [ "$RELEASE_CHANNEL" = "stable" ]; then
cp "$SCRIPT_DIR/archive/dist/archive.html" "$EMBED_DIR/archive.html"
cp "$SCRIPT_DIR/transmittal/dist/transmittal.html" "$EMBED_DIR/transmittal.html"
cp "$SCRIPT_DIR/classifier/dist/classifier.html" "$EMBED_DIR/classifier.html"
cp "$SCRIPT_DIR/mdedit/dist/mdedit.html" "$EMBED_DIR/mdedit.html"
cp "$SCRIPT_DIR/browse/dist/browse.html" "$EMBED_DIR/browse.html"
echo "Populated $EMBED_DIR/ for //go:embed"
fi
@ -221,7 +219,7 @@ if [ "$RELEASE_CHANNEL" = "beta" ] || [ "$RELEASE_CHANNEL" = "stable" ]; then
VERSIONS_FILE="$EMBED_DIR/versions.txt"
{
echo "# Generated by build.sh — do not edit. One <app>=<build label> per line."
for _tool in archive transmittal classifier mdedit landing form tables browse; do
for _tool in archive transmittal classifier landing form tables browse; do
_label_file="$BUILD_LABELS_DIR/${_tool}.label"
if [ -f "$_label_file" ]; then
_label=$(cat "$_label_file")
@ -436,7 +434,7 @@ build_releases_index() {
_all_versions=$(
find "$RELEASES_DIR" -maxdepth 1 -type f \( \
-name 'archive_v*.html' -o -name 'transmittal_v*.html' \
-o -name 'classifier_v*.html' -o -name 'mdedit_v*.html' \
-o -name 'classifier_v*.html' -o -name 'browse_v*.html' \
-o -name 'landing_v*.html' \
-o -name 'zddc-server_v*_linux-amd64' \
\) 2>/dev/null \
@ -618,7 +616,7 @@ PATH_B_OPEN
for _entry in "archive|Archive Browser|Browse and download from a ZDDC archive." \
"transmittal|Transmittal Creator|Build, sign, and verify transmittal packages." \
"classifier|Classifier|Rename loose files to ZDDC convention." \
"mdedit|Markdown Editor|Edit project markdown files in place." \
"browse|File Browser|Browse the project tree; includes the markdown editor." \
"landing|Landing|Project picker for multi-project servers."; do
_t="${_entry%%|*}"
_rest="${_entry#*|}"
@ -685,8 +683,8 @@ PIN_MID
<select class="composer-select" data-app="classifier" style="min-width: 140px;"></select>
</label>
<label class="composer-row" style="display: flex; align-items: center; justify-content: space-between; gap: var(--spacing-sm);">
<span style="flex: 1;"><code class="inline">mdedit</code> <span style="color: var(--color-text-muted); font-size: 0.85rem;">— Markdown Editor</span></span>
<select class="composer-select" data-app="mdedit" style="min-width: 140px;"></select>
<span style="flex: 1;"><code class="inline">browse</code> <span style="color: var(--color-text-muted); font-size: 0.85rem;">— File Browser (with markdown editor)</span></span>
<select class="composer-select" data-app="browse" style="min-width: 140px;"></select>
</label>
<label class="composer-row" style="display: flex; align-items: center; justify-content: space-between; gap: var(--spacing-sm);">
<span style="flex: 1;"><code class="inline">landing</code> <span style="color: var(--color-text-muted); font-size: 0.85rem;">— Landing</span></span>
@ -1012,7 +1010,7 @@ if [ "$RELEASE_CHANNEL" = "stable" ]; then
# Tag the nine artifacts at HEAD. Pre-flight already validated that
# any pre-existing tag is in HEAD's history, so this is safe.
_head=$(git -C "$SCRIPT_DIR" rev-parse HEAD)
for _t in archive transmittal classifier mdedit landing form tables browse zddc-server; do
for _t in archive transmittal classifier landing form tables browse zddc-server; do
_tag="${_t}-v${RELEASE_VERSION}"
if git -C "$SCRIPT_DIR" rev-parse -q --verify "refs/tags/$_tag" >/dev/null; then
_existing=$(git -C "$SCRIPT_DIR" rev-list -n 1 "$_tag")
@ -1046,7 +1044,7 @@ else
echo "Version: v$RELEASE_VERSION"
echo ""
echo "Tags created locally on main (push when ready):"
for _t in archive transmittal classifier mdedit landing form tables browse zddc-server; do
for _t in archive transmittal classifier landing form tables browse zddc-server; do
echo " ${_t}-v${RELEASE_VERSION}"
done
echo " git push origin main && git push origin --tags"

View file

@ -6,7 +6,7 @@
#
# Usage:
# ./freshen-channel <tool> <channel>
# tool archive | transmittal | classifier | mdedit | landing
# tool archive | transmittal | classifier | browse | landing | form | tables
# channel alpha | beta
#
# Why this exists:
@ -41,10 +41,10 @@ TOOL="${1:-}"
CHANNEL="${2:-}"
case "$TOOL" in
archive | transmittal | classifier | mdedit | landing) ;;
archive | transmittal | classifier | browse | landing | form | tables) ;;
*)
echo "usage: $0 <tool> <channel>" >&2
echo " tool: archive | transmittal | classifier | mdedit | landing" >&2
echo " tool: archive | transmittal | classifier | browse | landing | form | tables" >&2
exit 1
;;
esac

View file

@ -650,9 +650,9 @@
// Render the project-workspace view: title, four stage links, MDL
// section. Stage hrefs use the no-trailing-slash form so the server
// routes them to each canonical default tool (mdedit for working/,
// transmittal for staging/, etc.). Browse-all and the archive deep
// link use the slash form to land on the directory listing.
// routes them to each canonical default tool (browse for working/+
// reviewing/, transmittal for staging/, etc.). Browse-all and the
// archive deep link use the slash form to land on the directory listing.
async function renderProjectMode() {
var project = projectFromPath();
if (!project) return;

View file

@ -1,131 +0,0 @@
# ZDDC Markdown Editor
[← Back to ZDDC](../README.md)
A lightweight, browser-based markdown editor with YAML front matter support.
**[🔗 Open Markdown Editor](dist/mdedit.html)** - Click to use online, or right-click → "Save Link As" to keep your own copy.
## Reliability
This tool follows the "record player with the record" philosophy - the application and your data travel together. The single HTML file contains everything needed to edit markdown files locally in your browser.
## Quick Start
1. Open the editor in your browser
2. Click **Add Local Directory** to choose a folder with markdown files
3. Navigate the file tree on the left
4. Click any `.md` file to edit it
5. Click **Save File** or **Save All** to save changes
## Features
### 📂 File Navigation
- Browse directories using the File System Access API
- Collapsible folder tree with file type icons
- Files sorted alphabetically with directories grouped
### ✏️ Markdown Editing
- Toast UI Editor with live preview
- Split view (markdown + preview)
- Full toolbar for formatting
### 📋 YAML Front Matter
- Separate front matter section at top of editor
- Auto-parsed and preserved on save
- Collapsible for more editing space
### 📑 Table of Contents
- Auto-generated from headings
- Adjustable depth (H1 only through H6)
- Click to jump to heading in preview
### 💾 File Operations
- Save individual files or Save All
- Reload from disk (discards unsaved changes)
- External change detection with reload prompt
- Unsaved change warnings before leaving
### 🖼️ File Previews
- Image preview for common formats
- HTML preview in sandboxed iframe
- Plain text editing for non-markdown files
## Build
The editor is built from modular source files using a bash script:
```bash
cd mdedit
./build.sh
```
This concatenates CSS and JS files into `dist/mdedit.html`.
## Project Structure
```
mdedit/
├── css/
│ ├── base.css # Core styles and layout
│ ├── editor.css # Toast UI Editor overrides
│ ├── toc.css # Table of Contents styles
│ └── markdown.css # Markdown rendering styles
├── js/
│ ├── app.js # Global state
│ ├── utils.js # Utility functions
│ ├── front-matter.js # YAML parsing
│ ├── file-system.js # File operations
│ ├── file-tree.js # Tree rendering
│ ├── editor.js # Toast UI setup
│ ├── toc.js # TOC generation
│ ├── resizer.js # Pane resizing
│ ├── events.js # Event listeners
│ └── main.js # Initialization
├── vendor/
│ ├── toastui-editor-all.min.js # Toast UI Editor JS (bundled)
│ └── toastui-editor.min.css # Toast UI Editor CSS (bundled)
├── template.html # HTML structure (uses CDN for local dev convenience)
├── build.sh # Build script (inlines vendor files, strips CDN refs)
└── dist/
└── mdedit.html # Built self-contained file
```
## Technical Details
- **No server required** - runs entirely in browser
- **File System Access API** - direct local file access
- **Toast UI Editor v3.2.2** - bundled from `vendor/` into the built output (no CDN required)
- **Tailwind CSS** - replaced at build time by `css/tailwind-utils.css`, a hand-written static subset containing only the ~80 utility classes actually used in `template.html` (no runtime overhead, no console warnings)
- **Fully self-contained** - `dist/mdedit.html` (~850 KB) works offline with no external dependencies
> **Development note**: `template.html` loads Toast UI and Tailwind from CDN for a faster local development
> experience (open `template.html` directly in a browser). The `build.sh` script replaces the Tailwind CDN
> `<script>` tag with nothing (utilities come from `css/tailwind-utils.css` instead) and replaces the Toast UI
> CDN tags with the locally bundled `vendor/` files when producing `dist/mdedit.html`.
### Modules
CSS and JS modules live under `css/` and `js/`. The canonical load order is in `build.sh`. See the root `ARCHITECTURE.md` for the build/module pattern and `AGENTS.md` for shared helpers.
mdedit-specific notes:
- `css/tailwind-utils.css` is a hand-curated static subset of Tailwind v3 — there is no Tailwind build step. Add a class here when adding it to `template.html`.
- Toast UI Editor v3.2.2 ships pre-bundled in `vendor/`. `template.html` loads it from CDN for dev convenience; `build.sh` swaps the CDN tag for the bundled file.
- File operations (create, rename, delete) live in `js/file-ops.js`.
### Build Process
The build script (`build.sh`):
1. Concatenates all local CSS and JS files in dependency order
2. **Replaces** the CDN `<script>`/`<link>` tags for Tailwind and Toast UI with the locally bundled files from `vendor/`
3. Injects everything into `template.html` to produce `dist/mdedit.html`
The final HTML file (~850 KB) is fully self-contained and works offline.
### Architecture Notes
- All local CSS/JS files are inlined into the output HTML
- Vendor dependencies (Toast UI, Tailwind) are bundled from `vendor/` — no runtime CDN access
- `template.html` loads dependencies from CDN for convenient local development, but `build.sh` replaces these
- No npm dependencies required at runtime
- File System Access API requires Chromium-based browsers

View file

@ -1,146 +0,0 @@
#!/bin/sh
set -eu
root_dir=$(cd "$(dirname "$0")" && pwd)
. "$root_dir/../shared/build-lib.sh"
src_html="$root_dir/template.html"
output_dir="$root_dir/dist"
output_html="$output_dir/mdedit.html"
# Vendor files (bundled dependencies — no CDN required at runtime)
# Note: Tailwind is NOT a vendor file — it's replaced by css/tailwind-utils.css,
# a hand-written subset of only the utility classes used in template.html.
toastui_js="$root_dir/../shared/vendor/toastui-editor-all.min.js"
toastui_css="$root_dir/../shared/vendor/toastui-editor.min.css"
mkdir -p "$output_dir"
ensure_exists "$src_html"
ensure_exists "$toastui_js"
ensure_exists "$toastui_css"
css_temp=$(mktemp)
js_raw=$(mktemp)
js_temp=$(mktemp)
toastui_js_safe=$(mktemp)
cleanup() { rm -f "$css_temp" "$js_raw" "$js_temp" "$toastui_js_safe"; }
trap cleanup EXIT
# CSS files to concatenate in order
concat_files \
"css/tailwind-utils.css" \
"../shared/fonts.css" \
"../shared/base.css" \
"../shared/toast.css" \
"../shared/nav.css" \
"../shared/logo.css" \
"css/base.css" \
"css/editor.css" \
"css/toc.css" \
"css/markdown.css" \
> "$css_temp"
# JavaScript files to concatenate in order
concat_files \
"../shared/vendor/jszip.min.js" \
"../shared/vendor/docx-preview.min.js" \
"../shared/vendor/xlsx.full.min.js" \
"../shared/vendor/utif.min.js" \
"../shared/zddc.js" \
"../shared/zddc-source.js" \
"../shared/theme.js" \
"../shared/toast.js" \
"../shared/nav.js" \
"../shared/logo.js" \
"../shared/preview-lib.js" \
"js/app.js" \
"js/utils.js" \
"js/front-matter.js" \
"js/file-ops.js" \
"js/file-system.js" \
"js/file-tree.js" \
"js/editor.js" \
"js/toc.js" \
"js/resizer.js" \
"js/events.js" \
"js/main.js" \
"../shared/help.js" \
> "$js_raw"
# Escape '</' in app JS and the Toast UI vendor JS so neither can prematurely
# close the inline <script> blocks they get embedded in.
escape_js_close_tags "$js_raw" "$js_temp"
escape_js_close_tags "$toastui_js" "$toastui_js_safe"
compute_build_label "mdedit" "${1:-}" "${2:-}"
# Process template:
# - Strip the Tailwind CDN <script> tag (css/tailwind-utils.css replaces it)
# - Replace CDN <link> for Toast UI CSS with inline bundled CSS
# - Replace CDN <script src="...toastui..."> with inline bundled Toast UI JS
# - Inject custom CSS/JS at {{CSS_PLACEHOLDER}} and {{JS_PLACEHOLDER}}
# - Substitute {{BUILD_LABEL}}
awk \
-v css_file="$css_temp" \
-v js_file="$js_temp" \
-v toastui_js="$toastui_js_safe" \
-v toastui_css="$toastui_css" \
-v build_label="$build_label" \
-v is_red="$is_red" \
-v favicon_uri="$favicon_data_uri" \
'
/\{\{CSS_PLACEHOLDER\}\}/ {
while ((getline line < css_file) > 0) print line
close(css_file)
next
}
/\{\{JS_PLACEHOLDER\}\}/ {
while ((getline line < js_file) > 0) print line
close(js_file)
next
}
/\{\{BUILD_LABEL\}\}/ {
if (is_red == "1") {
gsub(/\{\{BUILD_LABEL\}\}/, "<span style=\"color:red;font-weight:bold\">" build_label "</span>")
} else {
gsub(/\{\{BUILD_LABEL\}\}/, build_label)
}
print
next
}
/\{\{FAVICON\}\}/ {
gsub(/\{\{FAVICON\}\}/, favicon_uri)
print
next
}
/<script src="https:\/\/cdn\.tailwindcss\.com"/ {
# Stripped: Tailwind utility classes are in css/tailwind-utils.css instead
next
}
/<link rel="stylesheet" href="https:\/\/uicdn\.toast\.com\/editor\/[^"]*\/toastui-editor\.min\.css"/ {
# Inline the bundled Toast UI CSS
print "<style>"
while ((getline line < toastui_css) > 0) print line
close(toastui_css)
print "</style>"
next
}
/<script src="https:\/\/uicdn\.toast\.com\/editor\/[^"]*\/toastui-editor/ {
# Inline the bundled Toast UI JS (already passed through escape_js_close_tags
# so its content cannot contain a literal </script> sequence). We close with
# the real </script> because only that exact string terminates a script
# block per the HTML5 spec.
print "<script>"
while ((getline line < toastui_js) > 0) print line
close(toastui_js)
print "</script>"
next
}
{ print }
' "$src_html" > "$output_html"
echo "Wrote $output_html ($(wc -c < "$output_html") bytes)"
if [ "$is_release" = "1" ]; then
promote_release "mdedit"
fi

View file

@ -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 <div>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;
}

View file

@ -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);
}
}

View file

@ -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);
}

View file

@ -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; }

View file

@ -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;
}

View file

@ -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');

View file

@ -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 = `
<option value="6">All Levels</option>
<option value="1">H1 Only</option>
<option value="2">H1-H2</option>
<option value="3" selected>H1-H3</option>
<option value="4">H1-H4</option>
<option value="5">H1-H5</option>
`;
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 = '<div style="padding: 20px; background: #ffeeee; color: red;">Error: Toast UI library not loaded!</div>';
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;
}
}

View file

@ -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);
}
}
}
});
}

View file

@ -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);
}

View file

@ -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<FileSystemFileHandle|null>} 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<boolean>} 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<boolean>} 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 =
'<div class="server-forbidden-message" style="padding: 1rem; color: var(--text-muted, #555); font-size: 0.875rem;">' +
'<strong>No permission to list this directory.</strong>' +
'<p style="margin: 0.5rem 0 0;">Your account does not have read access here. ' +
'Contact the document controller if you believe this is wrong.</p>' +
'</div>';
}
/**
* 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.
//
// <project>/working/ → root = <project>/working/
// <project>/working/x/y/ → root = <project>/working/x/y/
// <project>/working → root = <project>/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.)
// <project>/x/y/mdedit.html → root = <project>/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);
}

View file

@ -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 = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14M5 12h14"/></svg>';
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 = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>';
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 = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg>';
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 = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>';
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 = '<span class="tree-row__name"><div class="filename-main">📝 Scratchpad</div><div class="filename-secondary">Quick notes — no directory needed</div></span>';
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 = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12"/><path d="M7 10l5 5 5-5"/><path d="M5 21h14"/></svg>';
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 = '<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"></polyline></svg>';
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 = `<div class="filename-main">📁 ${escapeHtml(parsedFolder.title)}</div><div class="filename-secondary">${escapeHtml(meta)}</div>`;
} 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 = `<div class="filename-main">📁 ${escapeHtml(name)}</div>`;
}
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 = `<div class="filename-main">${fileIcon} ${titleDisplay}</div><div class="filename-secondary">${metaDisplay}</div>`;
} else if (name.includes(' - ')) {
const dashIdx = name.lastIndexOf(' - ');
const secondary = escapeHtml(name.substring(0, dashIdx));
const primary = escapeHtml(name.substring(dashIdx + 3).replace(/\.[^.]+$/, ''));
fileNameInner = `<div class="filename-main">${fileIcon} ${primary}</div><div class="filename-secondary">${secondary}</div>`;
} else {
fileNameInner = `<div class="filename-main">${fileIcon} ${escapeHtml(name)}</div>`;
}
const fileLabel = document.createElement('span');
fileLabel.className = 'tree-row__label';
fileLabel.innerHTML = `<span class="tree-row__name">${fileNameInner}</span>`;
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 = '<div style="text-align:center;padding:2rem;color:#666;">Loading preview...</div>';
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 = `<div style="text-align:center;padding:2rem;color:#c00;">Error rendering DOCX: ${err.message}</div>`;
}
}
/**
* 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 = '<div style="text-align:center;padding:2rem;color:#666;">Loading preview...</div>';
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 = `<div style="text-align:center;padding:2rem;color:#c00;">Error rendering spreadsheet: ${err.message}</div>`;
}
}
/**
* 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');
}
}

View file

@ -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();
}

View file

@ -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');
}
}

View file

@ -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);
}
}

View file

@ -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.

View file

@ -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] || '📄';
}

View file

@ -1,170 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ZDDC Markdown</title>
<link rel="icon" type="image/svg+xml" href="{{FAVICON}}">
<script src="https://cdn.tailwindcss.com"></script>
<!-- Toast UI Editor v3.2.2 -->
<link rel="stylesheet" href="https://uicdn.toast.com/editor/3.2.2/toastui-editor.min.css">
<script src="https://uicdn.toast.com/editor/3.2.2/toastui-editor-all.min.js"></script>
<style>
{{CSS_PLACEHOLDER}}
</style>
</head>
<body>
<div id="app" class="flex flex-col h-screen w-full overflow-hidden">
<header class="app-header">
<div class="header-left">
<svg class="app-header__logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" aria-hidden="true">
<rect width="64" height="64" rx="12" fill="#1e3a5f"/>
<g fill="#fff">
<rect x="14" y="18" width="36" height="7"/>
<polygon points="43,25 50,25 21,43 14,43"/>
<rect x="14" y="43" width="36" height="7"/>
</g>
</svg>
<div class="header-title-group">
<span class="app-header__title">ZDDC Markdown</span>
<span class="build-timestamp">{{BUILD_LABEL}}</span>
</div>
<button id="addDirectoryBtn" class="btn btn-primary" title="Add a local directory">Add Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh directory" aria-label="Refresh"></button>
</div>
<div class="header-right">
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)"></button>
<button id="help-btn" class="btn btn-secondary" title="Help">?</button>
</div>
</header>
<main class="flex-1 overflow-hidden relative">
<div class="resizable-pane horizontal flex flex-row relative w-full h-full overflow-hidden" id="root-pane" data-pane-type="root">
<div class="pane nav-pane relative flex flex-col bg-white dark:bg-gray-900 overflow-hidden" id="file-nav" data-pane-type="file-nav">
<div class="pane-header flex flex-col 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 select-none">
<div class="flex justify-between items-center w-full">
<span>Files</span>
<div class="flex items-center gap-1">
<button id="new-file-root" class="btn btn-secondary btn-sm hidden" title="New file in root directory">+</button>
</div>
</div>
</div>
<div class="pane-content flex-1 overflow-auto p-4">
<div id="file-tree" class="file-tree py-2">
</div>
</div>
</div>
<div class="pane-resizer bg-gray-200 dark:bg-gray-700 transition-colors relative z-10 w-1 cursor-col-resize hover:bg-blue-500" data-resizer-for="file-nav"></div>
<div class="pane content-pane flex-1 relative flex flex-col bg-white dark:bg-gray-900 overflow-hidden" id="main-content">
<div id="welcome-screen" class="welcome-screen hidden flex-col items-center justify-center h-full text-gray-500 dark:text-gray-400 text-center p-6">
<div id="welcome-banner" style="background:var(--bg-secondary);padding:0.75rem 1rem;border-left:3px solid var(--warning);text-align:left;font-size:0.9rem;color:var(--text-muted);margin-bottom:1rem;border-radius:var(--radius);max-width:36rem">
<strong>The Browse app now opens markdown files in this same editor.</strong>
Browse provides a unified file tree + per-file-type preview where
<code>.md</code> files render in this Toast UI editor. The
standalone Markdown Editor remains available for offline single-file
editing and air-gapped environments.
</div>
<p id="welcome-hint" class="text-sm">Click <strong>Scratchpad</strong> in the file list to start editing,<br>or <strong>Add Local Directory</strong> to work with files.</p>
<p id="welcome-firefox" class="text-sm text-amber-600 hidden mt-2">Your browser doesn't support the File System API.<br>Use <strong>Scratchpad</strong> to edit markdown and download as a file.</p>
</div>
<div id="content-container" class="content-container flex flex-col h-full hidden">
</div>
</div>
</div>
</main>
<footer class="status-bar flex justify-between items-center px-4 h-6 text-xs bg-gray-100 dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700">
<div class="status-left flex items-center gap-6 py-1">
<button id="save-all" class="btn inline-flex items-center gap-2 px-3 py-1 text-sm bg-transparent border border-gray-300 dark:border-gray-600 rounded text-gray-800 dark:text-gray-200 cursor-pointer transition-all hover:bg-gray-200 dark:hover:bg-gray-700 h-6 leading-none" title="Save All">
<svg class="btn-icon w-3.5 h-3.5 fill-current opacity-80" viewBox="0 0 16 16" fill="currentColor">
<path d="M11.5 1H4.5a1.5 1.5 0 0 0-1.5 1.5v11a1.5 1.5 0 0 0 1.5 1.5h7a1.5 1.5 0 0 0 1.5-1.5v-11a1.5 1.5 0 0 0-1.5-1.5zm-7 1h7a.5.5 0 0 1 .5.5V9H4V2.5a.5.5 0 0 1 .5-.5zM4 10h8v3.5a.5.5 0 0 1-.5.5h-7a.5.5 0 0 1-.5-.5V10z"></path>
<path d="M6.5 0a.5.5 0 0 1 .5.5V2h2V.5a.5.5 0 0 1 1 0V2h1.5a.5.5 0 0 1 0 1H10v2.5a.5.5 0 0 1-1 0V3H7v2.5a.5.5 0 0 1-1 0V3H4.5a.5.5 0 0 1 0-1H6V.5a.5.5 0 0 1 .5-.5z"></path>
</svg>
Save All
</button>
<span id="folder-count" class="status-message text-sm text-gray-800 dark:text-gray-200 opacity-80">0 folders</span>
<span id="file-count" class="status-message text-sm text-gray-800 dark:text-gray-200 opacity-80">0 files</span>
<span id="unsaved-count" class="status-message text-sm text-gray-800 dark:text-gray-200 opacity-80">0 unsaved</span>
</div>
<div class="status-right flex items-center gap-4">
<a href="https://codeberg.org/VARASYS/ZDDC" target="_blank" rel="noopener noreferrer" class="source-link" title="View source code">
<svg class="source-icon fill-current transition-opacity hover:opacity-80" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">
<path d="M5.5 11.5L1 8l4.5-3.5L4.4 3 0 8l4.4 5 1.1-1.5zm5 0L15 8l-4.5-3.5L11.6 3 16 8l-4.4 5-1.1-1.5z"></path>
</svg>
</a>
</div>
</footer>
<!-- Help Panel -->
<aside id="help-panel" class="help-panel" hidden aria-labelledby="help-panel-title">
<div class="help-panel__header">
<h2 id="help-panel-title" class="help-panel__title">Help — ZDDC Markdown</h2>
<button type="button" class="help-panel__close" id="help-panel-close" aria-label="Close">&times;</button>
</div>
<div class="help-panel__body">
<h3>What is ZDDC Markdown?</h3>
<p>ZDDC Markdown is a browser-based Markdown editor that reads and writes files directly on your local file system. Everything runs locally — no data is sent to any server.</p>
<h3>Getting Started</h3>
<ol>
<li>Click <strong>Add Local Directory</strong> to open a folder. The file tree on the left will populate with all files in that folder.</li>
<li>Click any Markdown file (<code>.md</code>) in the tree to open it in the editor.</li>
<li>Use the <strong>Scratchpad</strong> entry (always visible at the top of the tree) for temporary notes without saving to disk.</li>
</ol>
<h3>Editor Modes</h3>
<dl>
<dt>WYSIWYG</dt>
<dd>A rich-text view where formatting is rendered live. Good for composing content.</dd>
<dt>Markdown</dt>
<dd>A plain-text view showing raw Markdown syntax. Good for precise control.</dd>
</dl>
<p>Switch between modes using the toolbar buttons at the top-right of the editor.</p>
<h3>Saving Files</h3>
<dl>
<dt>Auto-save indicator</dt>
<dd>A bullet (•) next to the filename in the tree indicates unsaved changes.</dd>
<dt>Save (Ctrl+S)</dt>
<dd>Saves the currently active file.</dd>
<dt>Save All</dt>
<dd>Saves all files that have unsaved changes in one operation.</dd>
</dl>
<h3>Table of Contents</h3>
<p>When a Markdown file is open, a table of contents is generated from its headings and shown on the right side. Use the depth selector to control how many heading levels appear.</p>
<h3>Browser Compatibility</h3>
<p>File system access requires a Chromium-based browser (Chrome, Edge, Brave). In Firefox and other browsers, the <strong>Scratchpad</strong> is available for editing, and files can be saved via download.</p>
<h3>Keyboard Shortcuts</h3>
<dl>
<dt><kbd>Ctrl+S</kbd></dt>
<dd>Save the current file.</dd>
<dt><kbd>Escape</kbd></dt>
<dd>Close this help panel.</dd>
</dl>
</div>
</aside>
<!-- New-file modal -->
<div id="new-file-modal" class="modal-overlay hidden" role="dialog" aria-modal="true" aria-labelledby="new-file-modal-title">
<div class="modal-box">
<h3 id="new-file-modal-title" class="modal-title">New file name</h3>
<input id="new-file-input" type="text" class="modal-input" value="untitled.md" autocomplete="off" spellcheck="false">
<div class="modal-actions">
<button id="new-file-cancel" class="btn btn-secondary">Cancel</button>
<button id="new-file-confirm" class="btn btn-primary">Create</button>
</div>
</div>
</div>
</div>
<script>
{{JS_PLACEHOLDER}}
</script>
</body>
</html>

View file

@ -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',

View file

@ -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 <tool>-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

View file

@ -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<void>

View file

@ -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 });

View file

@ -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([]);
});
});

View file

@ -57,8 +57,8 @@ test.describe('shared/nav.js stage strip', () => {
await expect(active).toHaveAttribute('aria-current', 'page');
});
test('renders for <project>/working/foo/mdedit.html with working active', async ({ page }) => {
await page.goto(`${baseUrl}/projA/working/casey/mdedit.html`, { waitUntil: 'load' });
test('renders for <project>/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');
});

View file

@ -519,7 +519,7 @@ The keys that drive built-in behaviour:
| Key | Effect |
|---|---|
| `default_tool` | tool served at `<dir>` (no trailing slash) — the "specialized app": `archive` under `archive/`, `transmittal` under `staging/`, `mdedit` under `working/`, `classifier` under `incoming/`, `tables` at `archive/<party>/mdl`, `landing` at root. Cascades leaf→root. |
| `default_tool` | tool served at `<dir>` (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/<party>/mdl`, `landing` at root. Cascades leaf→root. |
| `dir_tool` | tool served at `<dir>/` (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: <email>` + `permissions: { <email>: 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/<party>`/`incoming`; fenced on the per-user `working/<email>/` 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/<party>/{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

View file

@ -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 <dir>/<app>.html request:
//

View file

@ -43,8 +43,9 @@ func AppAvailableAt(root, requestDir, app string) bool {
// - <project>/archive/ → "archive"
// - <project>/archive/<party>/... → "archive"
// - <project>/staging/... → "transmittal"
// - <project>/working/... → "mdedit"
// - <project>/reviewing/... → "mdedit" (operates on the
// - <project>/working/... → "browse" (hosts the
// markdown editor plugin)
// - <project>/reviewing/... → "browse" (operates on the
// virtual aggregator listing)
// - any other directory → "" (no default)
//

View file

@ -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/<tool>.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":

File diff suppressed because one or more lines are too long

View file

@ -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
}

View file

@ -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 {

View file

@ -233,8 +233,8 @@ func computePending(ctx context.Context, decider policy.Decider,
// ServeReviewing emits the aggregator JSON listing for any depth under
// <project>/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:
//

View file

@ -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"`,

View file

@ -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).

View file

@ -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
}

View file

@ -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
},