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:
parent
7fbe7867fd
commit
e7f6334daa
44 changed files with 120 additions and 13741 deletions
36
AGENTS.md
36
AGENTS.md
|
|
@ -27,7 +27,7 @@
|
||||||
./deploy --releases # only dist/release-output/ → /srv/zddc/releases/
|
./deploy --releases # only dist/release-output/ → /srv/zddc/releases/
|
||||||
|
|
||||||
# Single-tool dev build for testing (does NOT touch dist/release-output/):
|
# 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
|
# Single-tool release (rare; prefer ./build alpha|beta|release so versions
|
||||||
# don't drift between tools). Same flag form as before.
|
# don't drift between tools). Same flag form as before.
|
||||||
|
|
@ -38,7 +38,7 @@ sh tool/build.sh --release [<version>|alpha|beta]
|
||||||
npm test
|
npm test
|
||||||
|
|
||||||
# Test single tool
|
# 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 (cache-busting HTTP, on port 8000)
|
||||||
./dev-server start
|
./dev-server start
|
||||||
|
|
@ -60,7 +60,7 @@ because the bundle is complete, dangling-link errors mean a real bug.
|
||||||
|
|
||||||
## Architecture
|
## 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/
|
tool/
|
||||||
|
|
@ -202,7 +202,7 @@ Format: `trackingNumber_revision (status) - title.extension`
|
||||||
|
|
||||||
- Feature-branch workflow; squash-merge feature branches to `main`
|
- Feature-branch workflow; squash-merge feature branches to `main`
|
||||||
- Conventional commits: `feat(archive): ...`, `fix(transmittal): ...`
|
- 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`
|
- `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
|
- 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
|
- 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 |
|
| 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>_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} |
|
| `<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} |
|
| `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:
|
No install script. Two paths:
|
||||||
|
|
||||||
- **Local** — download a tool `.html` from `https://zddc.varasys.io/releases/` and open it. Done.
|
- **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:
|
To override at any level, either:
|
||||||
1. Drop a real `<app>.html` file at the path → static handler serves it (highest priority).
|
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."
|
- 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">`
|
- 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.
|
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 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
|
- 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)
|
## Form-data system (`form/` + zddc-server form handler)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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/
|
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.
|
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) |
|
| `archive` | every directory (multi-project, project, archive, vendor) |
|
||||||
| `classifier` | any `Incoming`, `Working`, or `Staging` directory and its subtree |
|
| `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 |
|
| `transmittal` | any `Staging` directory and its subtree |
|
||||||
| `landing` | only at the deployment root |
|
| `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
|
### 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
|
### 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.
|
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
|
### Vendor Dependencies
|
||||||
|
|
||||||
|
|
@ -208,24 +208,25 @@ Some tools bundle third-party libraries. These live in `tool/vendor/` and are co
|
||||||
|
|
||||||
| Tool | Library | File | Notes |
|
| Tool | Library | File | Notes |
|
||||||
|------|---------|------|-------|
|
|------|---------|------|-------|
|
||||||
| mdedit | Toast UI Editor v3.2.2 | `vendor/toastui-editor-all.min.js` | Markdown editor with live preview |
|
| browse | Toast UI Editor v3.2.2 | `shared/vendor/toastui-editor-all.min.js` | Markdown editor (loaded by `browse/js/preview-markdown.js`) |
|
||||||
| mdedit | Toast UI Editor CSS | `vendor/toastui-editor.min.css` | Editor stylesheet |
|
| 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 | 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 | docx-preview | `shared/vendor/docx-preview.min.js` | DOCX preview |
|
||||||
| shared | xlsx (SheetJS) | `shared/vendor/xlsx.full.min.js` | XLSX/XLS preview |
|
| shared | xlsx (SheetJS) | `shared/vendor/xlsx.full.min.js` | XLSX/XLS preview |
|
||||||
| shared | UTIF | `shared/vendor/utif.min.js` | TIFF preview |
|
| shared | UTIF | `shared/vendor/utif.min.js` | TIFF preview |
|
||||||
|
|
||||||
**No runtime CDN loads.** Every external dependency is vendored into
|
**No runtime CDN loads.** Every external dependency is vendored into
|
||||||
`shared/vendor/` (or, for mdedit's editor, `mdedit/vendor/`) and
|
`shared/vendor/` and concatenated into each tool's bundle at build
|
||||||
concatenated into each tool's bundle at build time. Tools that need a
|
time. Tools that need a given library include the vendor path in
|
||||||
given library include the vendor path in their `build.sh`'s
|
their `build.sh`'s `concat_files` JS list. The "ship the record
|
||||||
`concat_files` JS list. The "ship the record player with the record"
|
player with the record" philosophy: a downloaded `.html` file works
|
||||||
philosophy: a downloaded `.html` file works offline against any file
|
offline against any file the user can open, with no network
|
||||||
the user can open, with no network dependency at runtime.
|
dependency at runtime.
|
||||||
|
|
||||||
Trade-off accepted: bundle sizes are larger. archive, classifier,
|
Trade-off accepted: bundle sizes are larger. archive, classifier,
|
||||||
transmittal land around 1.5 MB after gzip; mdedit lands around 2 MB
|
transmittal land around 1.5 MB after gzip; browse lands around 2 MB
|
||||||
because it carries Toast UI + jszip + docx-preview + xlsx + UTIF.
|
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
|
Justified by the offline-first guarantee: any tool downloaded from
|
||||||
`/releases/` works without network, against air-gapped archives,
|
`/releases/` works without network, against air-gapped archives,
|
||||||
forever. See ARCHITECTURE.md § "Why Single-File HTML Applications"
|
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 |
|
| 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` |
|
| 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
|
### 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)*
|
**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();
|
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)
|
**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
|
**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.
|
||||||
if (typeof toastui === 'undefined') {
|
|
||||||
// Graceful degradation — show error message
|
|
||||||
}
|
|
||||||
const editor = new toastui.Editor({ el: container, ... });
|
|
||||||
```
|
|
||||||
|
|
||||||
**Key DOM IDs:** `#app`, `#select-directory`, `#welcome-screen`, `#file-tree`, `#content-container`.
|
**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.
|
||||||
|
|
||||||
**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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -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 |
|
| `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) |
|
| `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`.
|
**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.
|
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
|
#### `zddc-source.js` known gaps
|
||||||
|
|
||||||
|
|
@ -770,19 +764,15 @@ out-of-the-box behavior with no per-deployment configuration.
|
||||||
|
|
||||||
## CSS Architecture
|
## 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:**
|
**Common conventions:**
|
||||||
|
|
||||||
- CSS variables for theme colors and spacing in `base.css`
|
- 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
|
- `.hidden` class uses `display: none !important` for JavaScript show/hide
|
||||||
- Print styles in a separate `print.css`
|
- 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
|
## Testing
|
||||||
|
|
@ -805,7 +795,7 @@ Each tool has a spec file in `tests/`:
|
||||||
tests/
|
tests/
|
||||||
archive.spec.js ← 2 tests: load + directory scan
|
archive.spec.js ← 2 tests: load + directory scan
|
||||||
classifier.spec.js ← 2 tests: load + store injection
|
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
|
transmittal.spec.js ← 2 tests: paste round-trip + filesystem round-trip
|
||||||
fixtures/
|
fixtures/
|
||||||
mock-fs-api.js ← Reusable File System Access API mock
|
mock-fs-api.js ← Reusable File System Access API mock
|
||||||
|
|
|
||||||
|
|
@ -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:
|
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.
|
- `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.
|
- `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.
|
- `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)
|
- `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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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. |
|
| **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. |
|
| **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. |
|
| **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`. |
|
| **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`. |
|
| **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`. |
|
| **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
|
## File-naming convention
|
||||||
|
|
||||||
|
|
|
||||||
30
build
30
build
|
|
@ -154,7 +154,6 @@ export BUILD_LABELS_DIR
|
||||||
sh "$SCRIPT_DIR/transmittal/build.sh" $TOOL_RELEASE_ARGS
|
sh "$SCRIPT_DIR/transmittal/build.sh" $TOOL_RELEASE_ARGS
|
||||||
sh "$SCRIPT_DIR/archive/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/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/landing/build.sh" $TOOL_RELEASE_ARGS
|
||||||
sh "$SCRIPT_DIR/form/build.sh" $TOOL_RELEASE_ARGS
|
sh "$SCRIPT_DIR/form/build.sh" $TOOL_RELEASE_ARGS
|
||||||
sh "$SCRIPT_DIR/tables/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 ""
|
||||||
echo "=== Assembling zddc/dist/web/ ==="
|
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
|
# server APIs (GET / for the project list, directory listings for archive) and
|
||||||
# are useless without zddc-server. transmittal, classifier, and mdedit are
|
# are useless without zddc-server. transmittal and classifier are pure
|
||||||
# pure client-side tools but are still bundled — the server uses these copies
|
# client-side tools but are still bundled — the server uses these copies
|
||||||
# as the embedded fallback (//go:embed in internal/apps/embedded/) when both
|
# 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-
|
# 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
|
# 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
|
# 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"
|
mkdir -p "$SCRIPT_DIR/zddc/dist/web"
|
||||||
cp "$SCRIPT_DIR/landing/dist/index.html" "$SCRIPT_DIR/zddc/dist/web/index.html"
|
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/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/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/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/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/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"
|
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
|
# 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
|
# 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
|
# 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/archive/dist/archive.html" "$EMBED_DIR/archive.html"
|
||||||
cp "$SCRIPT_DIR/transmittal/dist/transmittal.html" "$EMBED_DIR/transmittal.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/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"
|
cp "$SCRIPT_DIR/browse/dist/browse.html" "$EMBED_DIR/browse.html"
|
||||||
echo "Populated $EMBED_DIR/ for //go:embed"
|
echo "Populated $EMBED_DIR/ for //go:embed"
|
||||||
fi
|
fi
|
||||||
|
|
@ -221,7 +219,7 @@ if [ "$RELEASE_CHANNEL" = "beta" ] || [ "$RELEASE_CHANNEL" = "stable" ]; then
|
||||||
VERSIONS_FILE="$EMBED_DIR/versions.txt"
|
VERSIONS_FILE="$EMBED_DIR/versions.txt"
|
||||||
{
|
{
|
||||||
echo "# Generated by build.sh — do not edit. One <app>=<build label> per line."
|
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"
|
_label_file="$BUILD_LABELS_DIR/${_tool}.label"
|
||||||
if [ -f "$_label_file" ]; then
|
if [ -f "$_label_file" ]; then
|
||||||
_label=$(cat "$_label_file")
|
_label=$(cat "$_label_file")
|
||||||
|
|
@ -436,7 +434,7 @@ build_releases_index() {
|
||||||
_all_versions=$(
|
_all_versions=$(
|
||||||
find "$RELEASES_DIR" -maxdepth 1 -type f \( \
|
find "$RELEASES_DIR" -maxdepth 1 -type f \( \
|
||||||
-name 'archive_v*.html' -o -name 'transmittal_v*.html' \
|
-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 'landing_v*.html' \
|
||||||
-o -name 'zddc-server_v*_linux-amd64' \
|
-o -name 'zddc-server_v*_linux-amd64' \
|
||||||
\) 2>/dev/null \
|
\) 2>/dev/null \
|
||||||
|
|
@ -618,7 +616,7 @@ PATH_B_OPEN
|
||||||
for _entry in "archive|Archive Browser|Browse and download from a ZDDC archive." \
|
for _entry in "archive|Archive Browser|Browse and download from a ZDDC archive." \
|
||||||
"transmittal|Transmittal Creator|Build, sign, and verify transmittal packages." \
|
"transmittal|Transmittal Creator|Build, sign, and verify transmittal packages." \
|
||||||
"classifier|Classifier|Rename loose files to ZDDC convention." \
|
"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
|
"landing|Landing|Project picker for multi-project servers."; do
|
||||||
_t="${_entry%%|*}"
|
_t="${_entry%%|*}"
|
||||||
_rest="${_entry#*|}"
|
_rest="${_entry#*|}"
|
||||||
|
|
@ -685,8 +683,8 @@ PIN_MID
|
||||||
<select class="composer-select" data-app="classifier" style="min-width: 140px;"></select>
|
<select class="composer-select" data-app="classifier" style="min-width: 140px;"></select>
|
||||||
</label>
|
</label>
|
||||||
<label class="composer-row" style="display: flex; align-items: center; justify-content: space-between; gap: var(--spacing-sm);">
|
<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>
|
<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="mdedit" style="min-width: 140px;"></select>
|
<select class="composer-select" data-app="browse" style="min-width: 140px;"></select>
|
||||||
</label>
|
</label>
|
||||||
<label class="composer-row" style="display: flex; align-items: center; justify-content: space-between; gap: var(--spacing-sm);">
|
<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>
|
<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
|
# Tag the nine artifacts at HEAD. Pre-flight already validated that
|
||||||
# any pre-existing tag is in HEAD's history, so this is safe.
|
# any pre-existing tag is in HEAD's history, so this is safe.
|
||||||
_head=$(git -C "$SCRIPT_DIR" rev-parse HEAD)
|
_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}"
|
_tag="${_t}-v${RELEASE_VERSION}"
|
||||||
if git -C "$SCRIPT_DIR" rev-parse -q --verify "refs/tags/$_tag" >/dev/null; then
|
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")
|
_existing=$(git -C "$SCRIPT_DIR" rev-list -n 1 "$_tag")
|
||||||
|
|
@ -1046,7 +1044,7 @@ else
|
||||||
echo "Version: v$RELEASE_VERSION"
|
echo "Version: v$RELEASE_VERSION"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Tags created locally on main (push when ready):"
|
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}"
|
echo " ${_t}-v${RELEASE_VERSION}"
|
||||||
done
|
done
|
||||||
echo " git push origin main && git push origin --tags"
|
echo " git push origin main && git push origin --tags"
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# ./freshen-channel <tool> <channel>
|
# ./freshen-channel <tool> <channel>
|
||||||
# tool archive | transmittal | classifier | mdedit | landing
|
# tool archive | transmittal | classifier | browse | landing | form | tables
|
||||||
# channel alpha | beta
|
# channel alpha | beta
|
||||||
#
|
#
|
||||||
# Why this exists:
|
# Why this exists:
|
||||||
|
|
@ -41,10 +41,10 @@ TOOL="${1:-}"
|
||||||
CHANNEL="${2:-}"
|
CHANNEL="${2:-}"
|
||||||
|
|
||||||
case "$TOOL" in
|
case "$TOOL" in
|
||||||
archive | transmittal | classifier | mdedit | landing) ;;
|
archive | transmittal | classifier | browse | landing | form | tables) ;;
|
||||||
*)
|
*)
|
||||||
echo "usage: $0 <tool> <channel>" >&2
|
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
|
exit 1
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
|
||||||
|
|
@ -650,9 +650,9 @@
|
||||||
|
|
||||||
// Render the project-workspace view: title, four stage links, MDL
|
// Render the project-workspace view: title, four stage links, MDL
|
||||||
// section. Stage hrefs use the no-trailing-slash form so the server
|
// section. Stage hrefs use the no-trailing-slash form so the server
|
||||||
// routes them to each canonical default tool (mdedit for working/,
|
// routes them to each canonical default tool (browse for working/+
|
||||||
// transmittal for staging/, etc.). Browse-all and the archive deep
|
// reviewing/, transmittal for staging/, etc.). Browse-all and the
|
||||||
// link use the slash form to land on the directory listing.
|
// archive deep link use the slash form to land on the directory listing.
|
||||||
async function renderProjectMode() {
|
async function renderProjectMode() {
|
||||||
var project = projectFromPath();
|
var project = projectFromPath();
|
||||||
if (!project) return;
|
if (!project) return;
|
||||||
|
|
|
||||||
131
mdedit/README.md
131
mdedit/README.md
|
|
@ -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
|
|
||||||
146
mdedit/build.sh
146
mdedit/build.sh
|
|
@ -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
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
|
|
@ -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; }
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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');
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
|
|
@ -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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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();
|
|
||||||
}
|
|
||||||
|
|
@ -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');
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
254
mdedit/js/toc.js
254
mdedit/js/toc.js
|
|
@ -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.
|
|
||||||
|
|
@ -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] || '📄';
|
|
||||||
}
|
|
||||||
|
|
@ -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">×</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>
|
|
||||||
|
|
@ -47,10 +47,6 @@ export default defineConfig({
|
||||||
name: 'classifier',
|
name: 'classifier',
|
||||||
testMatch: 'classifier.spec.js',
|
testMatch: 'classifier.spec.js',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'mdedit',
|
|
||||||
testMatch: 'mdedit.spec.js',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'browse',
|
name: 'browse',
|
||||||
testMatch: 'browse.spec.js',
|
testMatch: 'browse.spec.js',
|
||||||
|
|
|
||||||
|
|
@ -301,7 +301,7 @@ _emit_build_label_sidecar() {
|
||||||
# Tools that participate in the lockstep release. Source of truth — used
|
# Tools that participate in the lockstep release. Source of truth — used
|
||||||
# by helpers that enumerate "all release artifacts" (matrix render,
|
# by helpers that enumerate "all release artifacts" (matrix render,
|
||||||
# coordinated next-stable, channel-link verifier).
|
# 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
|
# 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
|
# latest <tool>-vX.Y.Z tag. Used by compute_build_label so a tool's
|
||||||
|
|
@ -742,7 +742,7 @@ verify_channel_links() {
|
||||||
_missing=0
|
_missing=0
|
||||||
_verified=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
|
for _ch in stable beta alpha; do
|
||||||
_f="$_rdir/${_t}_${_ch}.html"
|
_f="$_rdir/${_t}_${_ch}.html"
|
||||||
if [ -e "$_f" ]; then
|
if [ -e "$_f" ]; then
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
*
|
*
|
||||||
* Renderers operate on any document (parent window or popup window), so the
|
* Renderers operate on any document (parent window or popup window), so the
|
||||||
* same code works for tools whose preview opens in a popup (classifier,
|
* 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:
|
* Public API on window.zddc.preview:
|
||||||
* loadLibrary(url) → Promise<void>
|
* loadLibrary(url) → Promise<void>
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ import { test, expect } from '@playwright/test';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
|
|
||||||
const tools = ['archive', 'transmittal', 'classifier', 'mdedit'];
|
const tools = ['archive', 'transmittal', 'classifier', 'browse'];
|
||||||
|
|
||||||
for (const tool of tools) {
|
for (const tool of tools) {
|
||||||
const distPath = path.resolve(`${tool}/dist/${tool}.html`);
|
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 }) => {
|
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 });
|
await page.goto(`file://${distPath}`, { waitUntil });
|
||||||
const el = page.locator('.build-timestamp');
|
const el = page.locator('.build-timestamp');
|
||||||
await expect(el).toBeVisible({ timeout: 10000 });
|
await expect(el).toBeVisible({ timeout: 10000 });
|
||||||
|
|
|
||||||
|
|
@ -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([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -57,8 +57,8 @@ test.describe('shared/nav.js stage strip', () => {
|
||||||
await expect(active).toHaveAttribute('aria-current', 'page');
|
await expect(active).toHaveAttribute('aria-current', 'page');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('renders for <project>/working/foo/mdedit.html with working active', async ({ page }) => {
|
test('renders for <project>/working/foo/browse.html with working active', async ({ page }) => {
|
||||||
await page.goto(`${baseUrl}/projA/working/casey/mdedit.html`, { waitUntil: 'load' });
|
await page.goto(`${baseUrl}/projA/working/casey/browse.html`, { waitUntil: 'load' });
|
||||||
const active = page.locator('.zddc-stage-strip .zddc-stage--active');
|
const active = page.locator('.zddc-stage-strip .zddc-stage--active');
|
||||||
await expect(active).toHaveText('Working');
|
await expect(active).toHaveText('Working');
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -519,7 +519,7 @@ The keys that drive built-in behaviour:
|
||||||
|
|
||||||
| Key | Effect |
|
| 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.) |
|
| `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. |
|
| `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). |
|
| `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
|
## Apps: virtual tool HTMLs
|
||||||
|
|
||||||
`zddc-server` virtually serves the five tool HTMLs (archive, transmittal,
|
`zddc-server` virtually serves the tool HTMLs (archive, transmittal,
|
||||||
classifier, mdedit, landing) at the appropriate paths. The current-stable
|
classifier, landing, browse, form, tables) at the appropriate paths.
|
||||||
build of each tool is **baked into the binary at compile time** via
|
The current-stable build of each tool is **baked into the binary at
|
||||||
`//go:embed`; that's the default. No fetch happens out of the box.
|
compile time** via `//go:embed`; that's the default. No fetch happens
|
||||||
|
out of the box.
|
||||||
|
|
||||||
### Where each tool is served
|
### 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) |
|
| `archive` | every directory (multi-project, project, archive, vendor) |
|
||||||
| `classifier` | any `Incoming`, `Working`, or `Staging` directory and its subtree |
|
| `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 |
|
| `transmittal` | any `Staging` directory and its subtree |
|
||||||
| `landing` | only at the deployment root (the project picker) |
|
| `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:
|
apps:
|
||||||
classifier: alpha # track alpha for this project
|
classifier: alpha # track alpha for this project
|
||||||
archive: https://my-mirror.internal/zddc/archive_v0.0.4.html # custom mirror, pinned
|
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
|
### Env vars
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
// Package apps serves the five ZDDC tool HTML files (archive, transmittal,
|
// Package apps serves the ZDDC tool HTML files (archive, transmittal,
|
||||||
// classifier, mdedit, landing) on virtual paths in the file tree. Each tool
|
// classifier, landing, browse, form, tables) on virtual paths in the
|
||||||
// is "available" only at directories whose name matches a folder convention
|
// file tree. Each tool is "available" only at directories whose name
|
||||||
// (Incoming/Working/Staging) — see availability.go.
|
// 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:
|
// Resolution priority for an enabled <dir>/<app>.html request:
|
||||||
//
|
//
|
||||||
|
|
|
||||||
|
|
@ -43,8 +43,9 @@ func AppAvailableAt(root, requestDir, app string) bool {
|
||||||
// - <project>/archive/ → "archive"
|
// - <project>/archive/ → "archive"
|
||||||
// - <project>/archive/<party>/... → "archive"
|
// - <project>/archive/<party>/... → "archive"
|
||||||
// - <project>/staging/... → "transmittal"
|
// - <project>/staging/... → "transmittal"
|
||||||
// - <project>/working/... → "mdedit"
|
// - <project>/working/... → "browse" (hosts the
|
||||||
// - <project>/reviewing/... → "mdedit" (operates on the
|
// markdown editor plugin)
|
||||||
|
// - <project>/reviewing/... → "browse" (operates on the
|
||||||
// virtual aggregator listing)
|
// virtual aggregator listing)
|
||||||
// - any other directory → "" (no default)
|
// - any other directory → "" (no default)
|
||||||
//
|
//
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,9 @@ import (
|
||||||
"sync"
|
"sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Embedded fallback: the five tool HTMLs from the time the binary was
|
// Embedded fallback: tool HTMLs from the time the binary was built.
|
||||||
// built. Used as a last-resort served-bytes when (cache miss) AND
|
// Used as a last-resort served-bytes when (cache miss) AND (upstream
|
||||||
// (upstream unreachable) AND (no operator override) — see handler.go.
|
// unreachable) AND (no operator override) — see handler.go.
|
||||||
//
|
//
|
||||||
// The files are populated by the top-level build.sh, which copies the
|
// The files are populated by the top-level build.sh, which copies the
|
||||||
// freshly-built dist/<tool>.html into ./embedded/ before `go build` runs.
|
// freshly-built dist/<tool>.html into ./embedded/ before `go build` runs.
|
||||||
|
|
@ -26,9 +26,6 @@ var embeddedTransmittal []byte
|
||||||
//go:embed embedded/classifier.html
|
//go:embed embedded/classifier.html
|
||||||
var embeddedClassifier []byte
|
var embeddedClassifier []byte
|
||||||
|
|
||||||
//go:embed embedded/mdedit.html
|
|
||||||
var embeddedMdedit []byte
|
|
||||||
|
|
||||||
//go:embed embedded/index.html
|
//go:embed embedded/index.html
|
||||||
var embeddedLanding []byte
|
var embeddedLanding []byte
|
||||||
|
|
||||||
|
|
@ -47,8 +44,6 @@ func EmbeddedBytes(app string) []byte {
|
||||||
b = embeddedTransmittal
|
b = embeddedTransmittal
|
||||||
case "classifier":
|
case "classifier":
|
||||||
b = embeddedClassifier
|
b = embeddedClassifier
|
||||||
case "mdedit":
|
|
||||||
b = embeddedMdedit
|
|
||||||
case "landing":
|
case "landing":
|
||||||
b = embeddedLanding
|
b = embeddedLanding
|
||||||
case "browse":
|
case "browse":
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -60,8 +60,6 @@ func MatchAppHTML(requestPath string) (app string, requestDirRel string) {
|
||||||
return "transmittal", dir
|
return "transmittal", dir
|
||||||
case "classifier.html":
|
case "classifier.html":
|
||||||
return "classifier", dir
|
return "classifier", dir
|
||||||
case "mdedit.html":
|
|
||||||
return "mdedit", dir
|
|
||||||
case "browse.html":
|
case "browse.html":
|
||||||
return "browse", dir
|
return "browse", dir
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ func TestMatchAppHTML(t *testing.T) {
|
||||||
{"/index.html", "landing", ""},
|
{"/index.html", "landing", ""},
|
||||||
{"/archive.html", "archive", ""},
|
{"/archive.html", "archive", ""},
|
||||||
{"/Project-X/archive.html", "archive", "Project-X"},
|
{"/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", "", ""},
|
{"/foo.html", "", ""},
|
||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
|
|
|
||||||
|
|
@ -233,8 +233,8 @@ func computePending(ctx context.Context, decider policy.Decider,
|
||||||
|
|
||||||
// ServeReviewing emits the aggregator JSON listing for any depth under
|
// ServeReviewing emits the aggregator JSON listing for any depth under
|
||||||
// <project>/reviewing/. The HTML branch is handled separately by the
|
// <project>/reviewing/. The HTML branch is handled separately by the
|
||||||
// apps subsystem (mdedit served at the URL); only requests that accept
|
// apps subsystem (browse served at the URL — its markdown editor plugin
|
||||||
// JSON reach here.
|
// renders responses); only requests that accept JSON reach here.
|
||||||
//
|
//
|
||||||
// Depths:
|
// Depths:
|
||||||
//
|
//
|
||||||
|
|
|
||||||
|
|
@ -276,7 +276,7 @@ func TestServeZddcEditorRendersAppsSection(t *testing.T) {
|
||||||
`data-apps-key="default"`,
|
`data-apps-key="default"`,
|
||||||
`data-apps-key="archive"`,
|
`data-apps-key="archive"`,
|
||||||
`data-apps-key="classifier"`,
|
`data-apps-key="classifier"`,
|
||||||
`data-apps-key="mdedit"`,
|
`data-apps-key="browse"`,
|
||||||
`data-apps-key="transmittal"`,
|
`data-apps-key="transmittal"`,
|
||||||
`data-apps-key="landing"`,
|
`data-apps-key="landing"`,
|
||||||
`value=":beta"`,
|
`value=":beta"`,
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,8 @@ import (
|
||||||
// Lookup walks chain.Levels from leaf toward root, returning the
|
// Lookup walks chain.Levels from leaf toward root, returning the
|
||||||
// first non-empty value. This implements the "parent applies to
|
// first non-empty value. This implements the "parent applies to
|
||||||
// descendants unless overridden" cascade rule: a working/ folder's
|
// descendants unless overridden" cascade rule: a working/ folder's
|
||||||
// default_tool=mdedit propagates to working/alice/notes/ even when
|
// default_tool=browse propagates to working/alice/notes/ even when
|
||||||
// no .zddc declares mdedit at the deeper levels.
|
// no .zddc declares browse at the deeper levels.
|
||||||
//
|
//
|
||||||
// Used by the URL dispatcher to route no-slash directory URLs.
|
// Used by the URL dispatcher to route no-slash directory URLs.
|
||||||
// Replaces apps.DefaultAppAt once consumers are migrated (Phase 3b).
|
// Replaces apps.DefaultAppAt once consumers are migrated (Phase 3b).
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,14 @@ import (
|
||||||
// via the apps fetch+cache subsystem. Order is stable for reproducible
|
// via the apps fetch+cache subsystem. Order is stable for reproducible
|
||||||
// admin-UI rendering.
|
// 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
|
// Omitting any of them means the apps cascade (.zddc apps:) silently
|
||||||
// short-circuits to embedded for that name, defeating live-dev
|
// short-circuits to embedded for that name, defeating live-dev
|
||||||
// path-source overrides.
|
// 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
|
// AppsDefaultKey is the special apps-map key that provides the baseline
|
||||||
// URL prefix and channel for any app not overridden per-name. Cascades
|
// URL prefix and channel for any app not overridden per-name. Cascades
|
||||||
|
|
@ -237,7 +240,7 @@ func ValidateFile(zf ZddcFile) []FieldError {
|
||||||
if !IsValidAppsKey(app) {
|
if !IsValidAppsKey(app) {
|
||||||
errs = append(errs, FieldError{
|
errs = append(errs, FieldError{
|
||||||
Field: fmt.Sprintf("apps.%s", app),
|
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
|
continue
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -139,7 +139,7 @@ func TestIsValidAppsKey(t *testing.T) {
|
||||||
{"archive", true},
|
{"archive", true},
|
||||||
{"transmittal", true},
|
{"transmittal", true},
|
||||||
{"classifier", true},
|
{"classifier", true},
|
||||||
{"mdedit", true},
|
{"browse", true},
|
||||||
{"landing", true},
|
{"landing", true},
|
||||||
{"unknown", false},
|
{"unknown", false},
|
||||||
{"", false},
|
{"", false},
|
||||||
|
|
@ -161,7 +161,7 @@ func TestValidateFile_Apps(t *testing.T) {
|
||||||
"classifier": "v0.0.4", // ok
|
"classifier": "v0.0.4", // ok
|
||||||
"default": "https://zddc.varasys.io/releases:stable", // ok (default key + URL+channel)
|
"default": "https://zddc.varasys.io/releases:stable", // ok (default key + URL+channel)
|
||||||
"transmittal": ":beta", // ok (channel-only)
|
"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
|
"unknown": "stable", // unknown app
|
||||||
"landing": "what is this", // bad spec
|
"landing": "what is this", // bad spec
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue