diff --git a/docs/guides/presenter-view.md b/docs/guides/presenter-view.md index 92f25bc4..10a2dd60 100644 --- a/docs/guides/presenter-view.md +++ b/docs/guides/presenter-view.md @@ -68,6 +68,25 @@ Slide("results", notes=Path("slides/results-notes.md")) See [Authoring slides](slides.md#speaker-notes) and [Manifest reference](../reference/manifest.md) for details. +## Editing a slide + +Click the pencil-icon button in the status bar to edit the current slide's source. +It opens a small dropdown listing, top to bottom: any parent layouts +(a layout built on a project-local `inkflow:parent`), the layout itself, +a file-backed `md=` and/or `notes=` as Content and Notes, +and finally the deck script. +Each entry shows its kind and filename. +A parent layout that lives in the active theme or an installed package +is never listed, since it isn't yours to edit. + +By default this copies the file's absolute path to the clipboard. +With `INKFLOW_EDIT_CMD` set, it launches that command instead +(`INKFLOW_EDIT_CMD_SVG` overrides it just for SVG files). +See [CLI reference](../reference/cli.md#editing-from-the-presenter) for details, +including suggested commands for VS Code, Neovim, and Inkscape. + +This button only appears under `inkflow serve`, not in a static build. + ## Using a second screen To present on a projector while keeping the panel on your laptop screen, diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 37e89832..bdead89b 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -57,3 +57,71 @@ INKFLOW_LOG_LEVEL_FILE=debug inkflow build # same as the first, via the e A fatal build error is shown as a full-screen overlay (and the `serve` TUI error view), separate from these sinks; enabling the file sink also captures its traceback. +## Editing from the presenter + +The presenter's Edit button (see [Presenter panel](../guides/presenter-view.md)) +copies the current slide's source path to the clipboard by default. +Set `INKFLOW_EDIT_CMD` to launch an editor instead, for every file kind, +with `{path}` substituted (appended as a final argument if the template has no +`{path}` placeholder). +`INKFLOW_EDIT_CMD_SVG` overrides it specifically for SVG files, +if you want a different command there +(Inkscape instead of a text editor, say) without losing the general one +for content, notes, and the deck script itself. + +```bash +INKFLOW_EDIT_CMD="code -r --goto {path}" inkflow serve deck.py +``` + +### Suggested editor commands + +These are starting points to copy and adjust, not built-in behavior. + +**VS Code** reuses an already-open window natively: + +```bash +export INKFLOW_EDIT_CMD="code -r --goto {path}" +``` + +**Neovim** needs a fixed socket to connect to, +since its own address is otherwise random per instance. +Launch nvim with that socket, for example via a shell alias: + +```bash +alias vim='nvim --listen /tmp/nvim.sock' +``` + +```bash +export INKFLOW_EDIT_CMD="nvim --server /tmp/nvim.sock --remote {path}" +``` + +If no nvim with that listen address is running, +the command fails silently (logged as a warning server-side). + +**Inkscape** has no comparable built-in remote-control flag, +so this uses its D-Bus interface directly. +Set it as `INKFLOW_EDIT_CMD_SVG`, not the general `INKFLOW_EDIT_CMD`. +Save this as an executable script on your `PATH`: + +```bash +#!/bin/sh +# Reuses a running Inkscape's window (a new tab on 1.5+, a new window on +# older versions) instead of spawning a second process; falls back to a +# plain `inkscape` launch if no instance is running yet. +if gdbus call --session --dest org.inkscape.Inkscape \ + --object-path /org/inkscape/Inkscape \ + --method org.freedesktop.Application.ActivateAction \ + "file-open-window" "[<'$1'>]" "{}" >/dev/null 2>&1; then + exit 0 +fi +exec inkscape "$1" +``` + +```bash +export INKFLOW_EDIT_CMD_SVG="inkflow-edit-svg {path}" +``` + +This is Linux-specific (D-Bus) and depends on Inkscape's own D-Bus interface, +which isn't part of inkflow and could change between Inkscape releases. +It degrades safely on any version, though. + diff --git a/src/css/presenter/edit.css b/src/css/presenter/edit.css new file mode 100644 index 00000000..ff548d8a --- /dev/null +++ b/src/css/presenter/edit.css @@ -0,0 +1,72 @@ +/* ── Edit-source control ────────────────────────────────────────────────────── */ + +.edit-wrap { + position: relative; + display: inline-flex; +} + +/* Dropdown: anchored above the button (status bar sits at the bottom). Sized to + its content (short generic labels) rather than a fixed width like sync-menu's, + whose rows are full sentences; a max-width plus ellipsis on the filename keeps + a long one from stretching the menu. */ +#edit-menu { + display: none; + position: absolute; + bottom: calc(100% + 6px); + left: 0; + z-index: 30; + max-width: 14rem; + padding: 0.25rem; + background: var(--surface); + border: 1px solid var(--overlay); + border-radius: 6px; + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.35); +} +#edit-menu.open { + display: block; +} + +.edit-row { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + background: transparent; + border: none; + border-radius: 4px; + color: var(--subtext); + cursor: pointer; + font-family: inherit; + padding: 0.35rem 0.5rem; + text-align: left; + transition: + background 0.12s, + color 0.12s; +} +.edit-row:hover { + background: rgba(255, 255, 255, 0.08); + color: var(--text); +} +[data-theme="light"] .edit-row:hover { + background: rgba(0, 0, 0, 0.08); +} +.edit-row svg { + flex-shrink: 0; +} + +.edit-row-text { + display: flex; + flex-direction: column; + min-width: 0; + line-height: 1.3; +} +.edit-row-label { + font-size: 0.78rem; +} +.edit-row-name { + font-size: 0.68rem; + opacity: 0.65; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/src/css/presenter/index.css b/src/css/presenter/index.css index a446dcc3..7a5b2f3e 100644 --- a/src/css/presenter/index.css +++ b/src/css/presenter/index.css @@ -4,6 +4,7 @@ @import "./overlays.css"; @import "./picker.css"; @import "./sync.css"; +@import "./edit.css"; @import "./overview.css"; @import "./pv.css"; @import "../shared/animations.css"; diff --git a/src/css/presenter/overlays.css b/src/css/presenter/overlays.css index a0ed13db..0fef9c30 100644 --- a/src/css/presenter/overlays.css +++ b/src/css/presenter/overlays.css @@ -205,3 +205,95 @@ kbd { #log-list li.log-error { color: var(--red); } + +/* ── Edit-action toast (non-modal) ── + Same corner and box treatment as #log-banner (surface/border/shadow), an + accent colour instead of its warning yellow. Times itself out (edit.ts) but + also offers a close button, like #log-banner, plus a bar along the bottom + edge filling left-to-right as it approaches that — purely CSS (an animation + timed to match edit.ts's setTimeout), restarted by re-toggling .visible so a + second edit in quick succession doesn't inherit the first one's progress. */ +#edit-toast { + display: none; + position: fixed; + right: 1rem; + bottom: 1rem; + z-index: 40; + flex-direction: column; + max-width: 24rem; + background: var(--surface); + border: 1px solid var(--green); + border-radius: 8px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35); + color: var(--text); + font-size: 0.8rem; + line-height: 1.4; + overflow: hidden; + pointer-events: none; +} +#edit-toast.visible { + display: flex; +} +#edit-toast-body { + display: flex; + align-items: flex-start; + gap: 0.4rem; + padding: 0.5rem 0.5rem 0.5rem 0.75rem; +} +#edit-toast svg { + color: var(--green); + flex-shrink: 0; + margin-top: 0.15rem; +} +#edit-toast-text { + word-break: break-word; +} +#edit-toast-close { + background: none; + border: none; + color: var(--subtext); + font-size: 1.1rem; + line-height: 1; + cursor: pointer; + padding: 0 0.25rem; + flex-shrink: 0; + pointer-events: auto; +} +#edit-toast-close:hover { + color: var(--text); +} +#edit-toast-progress { + height: 2px; + background: var(--green); + transform-origin: left; + animation: edit-toast-grow 3s linear forwards; +} +@keyframes edit-toast-grow { + from { + transform: scaleX(0); + } + to { + transform: scaleX(1); + } +} +/* Error state: launching the configured command failed (e.g. the binary isn't + on PATH) — red instead of green, the error glyph instead of the checkmark, + and no progress bar, since an error toast has no auto-dismiss timer to show + the progress of. Declared last so it reliably overrides the base rules above + regardless of its own (lower) specificity. */ +.edit-toast-icon-error { + display: none; +} +#edit-toast.error { + border-color: var(--red); +} +#edit-toast.error .edit-toast-icon-ok { + display: none; +} +#edit-toast.error .edit-toast-icon-error { + display: block; + color: var(--red); +} +#edit-toast.error #edit-toast-progress { + display: none; +} diff --git a/src/inkflow/bundles/presenter.css b/src/inkflow/bundles/presenter.css index ba91d245..e7b5cf6f 100644 --- a/src/inkflow/bundles/presenter.css +++ b/src/inkflow/bundles/presenter.css @@ -435,6 +435,85 @@ kbd { #log-list li.log-error { color: var(--red); } +#edit-toast { + display: none; + position: fixed; + right: 1rem; + bottom: 1rem; + z-index: 40; + flex-direction: column; + max-width: 24rem; + background: var(--surface); + border: 1px solid var(--green); + border-radius: 8px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35); + color: var(--text); + font-size: 0.8rem; + line-height: 1.4; + overflow: hidden; + pointer-events: none; +} +#edit-toast.visible { + display: flex; +} +#edit-toast-body { + display: flex; + align-items: flex-start; + gap: 0.4rem; + padding: 0.5rem 0.5rem 0.5rem 0.75rem; +} +#edit-toast svg { + color: var(--green); + flex-shrink: 0; + margin-top: 0.15rem; +} +#edit-toast-text { + word-break: break-word; +} +#edit-toast-close { + background: none; + border: none; + color: var(--subtext); + font-size: 1.1rem; + line-height: 1; + cursor: pointer; + padding: 0 0.25rem; + flex-shrink: 0; + pointer-events: auto; +} +#edit-toast-close:hover { + color: var(--text); +} +#edit-toast-progress { + height: 2px; + background: var(--green); + transform-origin: left; + animation: edit-toast-grow 3s linear forwards; +} +@keyframes edit-toast-grow { + from { + transform: scaleX(0); + } + to { + transform: scaleX(1); + } +} +.edit-toast-icon-error { + display: none; +} +#edit-toast.error { + border-color: var(--red); +} +#edit-toast.error .edit-toast-icon-ok { + display: none; +} +#edit-toast.error .edit-toast-icon-error { + display: block; + color: var(--red); +} +#edit-toast.error #edit-toast-progress { + display: none; +} /* src/css/presenter/picker.css */ #picker { @@ -578,6 +657,69 @@ kbd { flex-shrink: 0; } +/* src/css/presenter/edit.css */ +.edit-wrap { + position: relative; + display: inline-flex; +} +#edit-menu { + display: none; + position: absolute; + bottom: calc(100% + 6px); + left: 0; + z-index: 30; + max-width: 14rem; + padding: 0.25rem; + background: var(--surface); + border: 1px solid var(--overlay); + border-radius: 6px; + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.35); +} +#edit-menu.open { + display: block; +} +.edit-row { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + background: transparent; + border: none; + border-radius: 4px; + color: var(--subtext); + cursor: pointer; + font-family: inherit; + padding: 0.35rem 0.5rem; + text-align: left; + transition: background 0.12s, color 0.12s; +} +.edit-row:hover { + background: rgba(255, 255, 255, 0.08); + color: var(--text); +} +[data-theme=light] .edit-row:hover { + background: rgba(0, 0, 0, 0.08); +} +.edit-row svg { + flex-shrink: 0; +} +.edit-row-text { + display: flex; + flex-direction: column; + min-width: 0; + line-height: 1.3; +} +.edit-row-label { + font-size: 0.78rem; +} +.edit-row-name { + font-size: 0.68rem; + opacity: 0.65; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + /* src/css/presenter/overview.css */ #overview { display: flex; diff --git a/src/inkflow/bundles/presenter.js b/src/inkflow/bundles/presenter.js index c7a10bcc..6b20d4ad 100644 --- a/src/inkflow/bundles/presenter.js +++ b/src/inkflow/bundles/presenter.js @@ -1,5 +1,152 @@ "use strict"; (() => { + // src/ts/presenter/menus.ts + var activeClose = null; + function menuOpened(close) { + if (activeClose && activeClose !== close) activeClose(); + activeClose = close; + } + function menuClosed(close) { + if (activeClose === close) activeClose = null; + } + + // src/ts/presenter/state.ts + var state = { + slides: [], + transitions: [], + slideIndex: 0, + step: 0, + syncMode: "two-way", + _pickerMatches: [], + _pickerActive: 0, + _overviewActive: 0, + _overviewCols: 1, + ws: null, + windowLink: null, + _syncingFromServer: false, + _laserMode: false + }; + + // src/ts/presenter/edit.ts + var btnEdit = document.getElementById("btn-edit"); + var editMenu = document.getElementById("edit-menu"); + var editWrap = btnEdit.closest(".edit-wrap"); + var editToast = document.getElementById("edit-toast"); + var editToastText = document.getElementById("edit-toast-text"); + var editToastClose = document.getElementById("edit-toast-close"); + var TOAST_DURATION_MS = 3e3; + var config = { default: false, svg: false }; + var toastTimeout = null; + editToastClose.addEventListener("click", () => hideToast()); + var ROW_ICONS = { + Layout: ``, + Parent: ``, + Content: ``, + Notes: ``, + Deck: `` + }; + function isConfigured(file) { + if (file.path.toLowerCase().endsWith(".svg")) { + return config.svg || config.default; + } + return config.default; + } + function hideToast() { + if (toastTimeout) clearTimeout(toastTimeout); + editToast.classList.remove("visible"); + toastTimeout = null; + } + function flashToast(message, kind = "success") { + editToastText.textContent = message; + editToast.classList.toggle("error", kind === "error"); + editToast.classList.remove("visible"); + void editToast.offsetWidth; + editToast.classList.add("visible"); + if (toastTimeout) clearTimeout(toastTimeout); + toastTimeout = kind === "error" ? null : setTimeout(hideToast, TOAST_DURATION_MS); + } + function showEditError(message) { + flashToast(message, "error"); + } + function actOn(file) { + if (isConfigured(file) && state.ws && state.ws.readyState === WebSocket.OPEN) { + state.ws.send(JSON.stringify({ type: "edit", path: file.path })); + flashToast(`Opened ${file.name}`); + return; + } + try { + void navigator.clipboard.writeText(file.path); + flashToast(`Copied ${file.path}`); + } catch (_) { + } + } + function renderEditButton() { + const files = state.slides[state.slideIndex]?.editableFiles ?? []; + editMenu.innerHTML = ""; + for (const file of files) { + const row = document.createElement("button"); + row.type = "button"; + row.className = "edit-row"; + row.insertAdjacentHTML("beforeend", ROW_ICONS[file.label] ?? ""); + const text = document.createElement("span"); + text.className = "edit-row-text"; + const label = document.createElement("span"); + label.className = "edit-row-label"; + label.textContent = file.label; + const name = document.createElement("span"); + name.className = "edit-row-name"; + name.textContent = file.name; + text.append(label, name); + row.appendChild(text); + row.addEventListener("click", () => { + actOn(file); + closeMenu(); + }); + editMenu.appendChild(row); + } + } + function onDocClick(e) { + const t = e.target; + if (!btnEdit.contains(t) && !editMenu.contains(t)) closeMenu(); + } + function onKeydown(e) { + if (e.key === "Escape") { + closeMenu(); + btnEdit.focus(); + } + } + function openMenu() { + editMenu.classList.add("open"); + btnEdit.setAttribute("aria-expanded", "true"); + document.addEventListener("click", onDocClick); + document.addEventListener("keydown", onKeydown); + menuOpened(closeMenu); + } + function closeMenu() { + if (!editMenu.classList.contains("open")) return; + editMenu.classList.remove("open"); + btnEdit.setAttribute("aria-expanded", "false"); + document.removeEventListener("click", onDocClick); + document.removeEventListener("keydown", onKeydown); + menuClosed(closeMenu); + } + function toggleMenu() { + if (editMenu.classList.contains("open")) closeMenu(); + else openMenu(); + } + function initEditMenu(cfg, wsPort) { + if (!wsPort) { + editWrap.style.display = "none"; + return; + } + config = cfg; + btnEdit.addEventListener("click", (e) => { + e.stopPropagation(); + toggleMenu(); + }); + renderEditButton(); + } + // src/ts/shared/ring.ts function buildStepRing(current, total) { const size = 20, cx = 10, cy = 10, ro = 9, ri = 5; @@ -270,23 +417,6 @@ return `${round2(vb.x)} ${round2(vb.y)} ${round2(vb.w)} ${round2(vb.h)}`; } - // src/ts/presenter/state.ts - var state = { - slides: [], - transitions: [], - slideIndex: 0, - step: 0, - syncMode: "two-way", - _pickerMatches: [], - _pickerActive: 0, - _overviewActive: 0, - _overviewCols: 1, - ws: null, - windowLink: null, - _syncingFromServer: false, - _laserMode: false - }; - // src/ts/presenter/deck-url.ts var SLIDE = "slide"; var STEPS = "steps"; @@ -651,6 +781,7 @@ updatePvInfo(); renderPvNext(); renderPvNotes(); + renderEditButton(); } function togglePv() { document.body.classList.toggle("pv-open"); @@ -2829,6 +2960,8 @@ renderPv(); } else if (msg.type === "error") { showError(msg.message); + } else if (msg.type === "edit-error") { + showEditError(msg.message); } else if (msg.type === "position") { if (receives() && !msg.snap && firstPositionPending) { firstPositionPending = false; @@ -2869,43 +3002,45 @@ function setSyncMode(mode) { applySyncMode(mode); renderSyncButton(); - closeMenu(); + closeMenu2(); } function cycleSyncMode() { const i = SYNC_ORDER.indexOf(state.syncMode); setSyncMode(SYNC_ORDER[(i + 1) % SYNC_ORDER.length]); } - function onDocClick(e) { + function onDocClick2(e) { const t = e.target; - if (!btnSync.contains(t) && !syncMenu.contains(t)) closeMenu(); + if (!btnSync.contains(t) && !syncMenu.contains(t)) closeMenu2(); } - function onKeydown(e) { + function onKeydown2(e) { if (e.key === "Escape") { - closeMenu(); + closeMenu2(); btnSync.focus(); } } - function openMenu() { + function openMenu2() { syncMenu.classList.add("open"); btnSync.setAttribute("aria-expanded", "true"); - document.addEventListener("click", onDocClick); - document.addEventListener("keydown", onKeydown); + document.addEventListener("click", onDocClick2); + document.addEventListener("keydown", onKeydown2); + menuOpened(closeMenu2); } - function closeMenu() { + function closeMenu2() { if (!syncMenu.classList.contains("open")) return; syncMenu.classList.remove("open"); btnSync.setAttribute("aria-expanded", "false"); - document.removeEventListener("click", onDocClick); - document.removeEventListener("keydown", onKeydown); + document.removeEventListener("click", onDocClick2); + document.removeEventListener("keydown", onKeydown2); + menuClosed(closeMenu2); } - function toggleMenu() { - if (syncMenu.classList.contains("open")) closeMenu(); - else openMenu(); + function toggleMenu2() { + if (syncMenu.classList.contains("open")) closeMenu2(); + else openMenu2(); } function initSyncMenu() { btnSync.addEventListener("click", (e) => { e.stopPropagation(); - toggleMenu(); + toggleMenu2(); }); for (const row of syncMenu.querySelectorAll(".sync-row")) row.addEventListener( @@ -3633,6 +3768,7 @@ var INITIAL_SLIDES = __SLIDES_JSON__; var INITIAL_TRANSITIONS = __TRANSITIONS_JSON__; var WS_PORT = __WS_PORT__; + var EDIT_COMMANDS = __EDIT_COMMANDS_JSON__; var INITIAL_ERROR = __ERROR_JSON__; var INITIAL_LOGS = __LOGS_JSON__; state.slides = INITIAL_SLIDES; @@ -3650,6 +3786,7 @@ loadSyncMode(); initSyncMenu(); initWindowSync(WS_PORT); + initEditMenu(EDIT_COMMANDS, WS_PORT); var deepLinked = readURL(); loadSlide(); renderPv(); diff --git a/src/inkflow/edit.py b/src/inkflow/edit.py new file mode 100644 index 00000000..d4dbd87b --- /dev/null +++ b/src/inkflow/edit.py @@ -0,0 +1,77 @@ +"""Launching an external editor on a slide's source file, from the presenter. + +Resolution is env-var only (``INKFLOW_EDIT_CMD`` / ``INKFLOW_EDIT_CMD_SVG``): no +command is bundled by default because "jump an already-open editor to this file" +is inherently editor- and machine-specific (see the two env vars' docs). When +unset, the presenter falls back to copying the path to the clipboard instead. +""" + +from __future__ import annotations + +import os +import shlex +import subprocess +from dataclasses import dataclass +from pathlib import Path + +from inkflow.logging import logger + + +@dataclass(frozen=True) +class EditCommands: + default: str | None + svg: str | None + + +NO_EDIT_COMMANDS = EditCommands(default=None, svg=None) +"""Shared "nothing configured" value, so callers with no real commands to pass +(export.py's build_html call, default handler args) don't each construct their own +equal-but-distinct instance — and so it can be used as a default argument without +ruff's B008 (no function call in a default value).""" + + +def resolve_edit_commands() -> EditCommands: + return EditCommands( + default=os.environ.get("INKFLOW_EDIT_CMD"), + svg=os.environ.get("INKFLOW_EDIT_CMD_SVG"), + ) + + +def command_for(path: Path, commands: EditCommands) -> str | None: + """``INKFLOW_EDIT_CMD_SVG`` overrides ``INKFLOW_EDIT_CMD`` for SVG files; + every other file kind always uses the general command.""" + if path.suffix.lower() == ".svg" and commands.svg is not None: + return commands.svg + return commands.default + + +def open_in_editor(path: Path, template: str) -> str | None: + """Launch ``template`` with ``path`` substituted, detached from this process. + + ``{path}`` is substituted into every token that contains it; if no token does, + the path is appended as a final argument (the ``$EDITOR file`` convention). + Never raises: a bad template or missing binary is logged and returned as a + message (e.g. so the caller can also report it back to the requesting + browser tab, which is otherwise the only place nothing visibly happens), + not raised as a crash. + """ + args = shlex.split(template) + substituted = [a.replace("{path}", str(path)) for a in args] + if not any("{path}" in a for a in args): + substituted.append(str(path)) + + devnull = os.open(os.devnull, os.O_WRONLY) + try: + subprocess.Popen( + substituted, + stdout=devnull, + stderr=devnull, + stdin=subprocess.DEVNULL, + ) + except OSError as e: + message = f"failed to launch edit command {template!r}: {e}" + logger.warning(message) + return message + finally: + os.close(devnull) + return None diff --git a/src/inkflow/export.py b/src/inkflow/export.py index bcbadece..260af96e 100644 --- a/src/inkflow/export.py +++ b/src/inkflow/export.py @@ -38,7 +38,7 @@ def build_static_html( ) -> None: deck = load_deck(deck_path) project_dir = deck_path.parent - slides = process_deck(deck, project_dir) + slides = process_deck(deck, project_dir, deck_path) transitions = resolve_transitions(deck) styles_css = load_deck_styles(deck, project_dir) if deck.embed_fonts: @@ -213,7 +213,7 @@ def build_pdf( deck = load_deck(deck_path) project_dir = deck_path.parent - slides = process_deck(deck, project_dir) + slides = process_deck(deck, project_dir, deck_path) if not slides: raise RuntimeError("Cannot export a PDF: the deck has no visible slides.") styles_css = load_deck_styles(deck, project_dir) diff --git a/src/inkflow/pipeline.py b/src/inkflow/pipeline.py index 0f869c96..9f184774 100644 --- a/src/inkflow/pipeline.py +++ b/src/inkflow/pipeline.py @@ -23,7 +23,7 @@ resolve_default_zone, resolve_parent_path, ) -from inkflow.loaders import load_md, load_notes, load_style +from inkflow.loaders import LoadedText, load_md, load_notes, load_style from inkflow.logging import logger from inkflow.manifest import ( Deck, @@ -45,11 +45,18 @@ # ── Slide wire format ──────────────────────────────────────────────────────── +class EditableFile(TypedDict): + label: str + name: str + path: str + + class SlideData(TypedDict): id: str svg: str title: str notes: str + editableFiles: list[EditableFile] # ── Path conventions ───────────────────────────────────────────────────────── @@ -631,7 +638,44 @@ def _source_for(roots: AssetRoots, path: Path | None) -> AssetSource: return AssetSource.for_file(roots, path) -def process_deck(deck: Deck, project_dir: Path) -> list[SlideData]: +def _editable_files( + ctx: DeckContext, + svg_path: Path, + md: LoadedText | None, + loaded_notes: LoadedText, + deck_path: Path, +) -> list[EditableFile]: + """The slide's in-project ancestor layouts (root first, so the immediate + parent sits next to the slide's own SVG), that SVG itself, any file-backed + content/notes, and the deck script itself: what the presenter's Edit button + offers. ``deck_path`` is always present, so this list is never a single entry + — every slide's own SVG plus its deck script is the floor. + + A theme/built-in ancestor is skipped: it lives outside the project (often + inside an installed package), so editing it is unlikely to be wanted and may + not even be writable. + """ + files: list[EditableFile] = [] + chain = resolve_chain(svg_path, ctx.project_dir, ctx.theme) + for parent in chain: + if parent.is_relative_to(ctx.project_dir): + files.append({"label": "Parent", "name": parent.name, "path": str(parent)}) + files.append({"label": "Layout", "name": svg_path.name, "path": str(svg_path)}) + if md is not None and md.path is not None: + files.append({"label": "Content", "name": md.path.name, "path": str(md.path)}) + if loaded_notes.path is not None: + files.append( + { + "label": "Notes", + "name": loaded_notes.path.name, + "path": str(loaded_notes.path), + } + ) + files.append({"label": "Deck", "name": deck_path.name, "path": str(deck_path)}) + return files + + +def process_deck(deck: Deck, project_dir: Path, deck_path: Path) -> list[SlideData]: visible_slides = [s for s in deck.slides if s.visible] assets = AssetRoots(project_dir, deck.theme.asset_dir()) ctx = DeckContext( @@ -657,6 +701,18 @@ def process_deck(deck: Deck, project_dir: Path) -> list[SlideData]: md_source = _source_for(assets, md.path if md is not None else None) svg, md_notes = process_slide(slide, ctx, i + 1, parsed, md_source, slide_id) notes = "\n".join(filter(None, [explicit_notes, md_notes])) - results.append({"id": slide_id, "svg": svg, "title": title, "notes": notes}) + + svg_path = resolve_slide_src(slide.src, ctx.project_dir, ctx.theme) + editable_files = _editable_files(ctx, svg_path, md, loaded_notes, deck_path) + + results.append( + { + "id": slide_id, + "svg": svg, + "title": title, + "notes": notes, + "editableFiles": editable_files, + } + ) logger.info(f"processed {len(results)} slide(s)") return results diff --git a/src/inkflow/presenter.html b/src/inkflow/presenter.html index cb22a5de..c33073ad 100644 --- a/src/inkflow/presenter.html +++ b/src/inkflow/presenter.html @@ -23,6 +23,21 @@

BUILD ERROR

+
+
+ + + + +
+
+