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
+
@@ -232,6 +247,15 @@
KEYBINDINGS
+
+
+
+
+
1.0×
diff --git a/src/inkflow/server.py b/src/inkflow/server.py
index 7eaf9502..97bcb936 100644
--- a/src/inkflow/server.py
+++ b/src/inkflow/server.py
@@ -27,6 +27,13 @@
from websockets.asyncio.server import serve as ws_serve
from inkflow.assets import MIME_TYPES, AssetRoots
+from inkflow.edit import (
+ NO_EDIT_COMMANDS,
+ EditCommands,
+ command_for,
+ open_in_editor,
+ resolve_edit_commands,
+)
from inkflow.enums import ColorMode
from inkflow.fonts import embed_fonts_css
from inkflow.loaders import load_deck_scripts, load_deck_styles
@@ -112,7 +119,7 @@ async def _animate() -> None:
with collect_logs(min(levels.console, levels.browser)) as entries:
deck = await asyncio.to_thread(load_deck, deck_path)
project_dir = deck_path.parent
- slides = await asyncio.to_thread(process_deck, deck, project_dir)
+ slides = await asyncio.to_thread(process_deck, deck, project_dir, deck_path)
transitions = resolve_transitions(deck)
styles_css = await asyncio.to_thread(load_deck_styles, deck, project_dir)
if deck.embed_fonts:
@@ -209,7 +216,32 @@ def _coerce_nav_position(
return {"slideIndex": slide_index, "step": step}
-def make_ws_handler(ui: LiveUI) -> Callable[[ServerConnection], Awaitable[None]]:
+def _resolve_edit_request(
+ msg: dict[str, object], slides: list[SlideData], edit_commands: EditCommands
+) -> tuple[Path, str] | None:
+ """Validate an `edit` payload and resolve its launch command, if any.
+
+ The path must belong to the *current* build's own `editableFiles` (not just be
+ a well-formed path) — this guards against a stale client message surviving a
+ deck rebuild, the same spirit as `_coerce_nav_position`'s clamping. Returns
+ None when the path is missing/unknown or no command is configured for its kind
+ (the client already handled that case as a clipboard copy; nothing to launch).
+ """
+ path_str = msg.get("path")
+ if not isinstance(path_str, str):
+ return None
+ valid_paths = {f["path"] for slide in slides for f in slide["editableFiles"]}
+ if path_str not in valid_paths:
+ return None
+ template = command_for(Path(path_str), edit_commands)
+ if template is None:
+ return None
+ return Path(path_str), template
+
+
+def make_ws_handler(
+ ui: LiveUI, edit_commands: EditCommands
+) -> Callable[[ServerConnection], Awaitable[None]]:
async def handler(websocket: ServerConnection) -> None:
_state["ws_clients"].add(websocket)
logger.debug(f"client connected ({len(_state['ws_clients'])} total)")
@@ -263,6 +295,16 @@ async def handler(websocket: ServerConnection) -> None:
if msg.get("snap"):
position_msg["snap"] = True
await broadcast(json.dumps(position_msg), sender=websocket)
+ elif msg_type == "edit":
+ request = _resolve_edit_request(
+ msg, _state["slides"], edit_commands
+ )
+ if request is not None:
+ error = open_in_editor(*request)
+ if error is not None:
+ await websocket.send(
+ json.dumps({"type": "edit-error", "message": error})
+ )
finally:
_state["ws_clients"].discard(websocket)
logger.debug(f"client disconnected ({len(_state['ws_clients'])} total)")
@@ -285,13 +327,23 @@ def favicon_data_uri() -> str:
return f"data:image/svg+xml;base64,{b64}"
-def build_html(state: State, ws_port: int | None) -> bytes:
+def build_html(
+ state: State,
+ ws_port: int | None,
+ edit_commands: EditCommands = NO_EDIT_COMMANDS,
+) -> bytes:
pkg = importlib.resources.files("inkflow")
template = pkg.joinpath("presenter.html").read_text(encoding="utf-8")
css = pkg.joinpath("bundles", "presenter.css").read_text(encoding="utf-8")
js = pkg.joinpath("bundles", "presenter.js").read_text(encoding="utf-8")
data_theme = "" if state["mode"] == ColorMode.DARK else "light"
ws_port_js = "null" if ws_port is None else str(ws_port)
+ edit_commands_json = json.dumps(
+ {
+ "default": edit_commands.default is not None,
+ "svg": edit_commands.svg is not None,
+ }
+ )
html = (
template.replace("/* __CSS__ */", css)
.replace("/* __JS__ */", js)
@@ -301,6 +353,7 @@ def build_html(state: State, ws_port: int | None) -> bytes:
.replace("__TRANSITIONS_JSON__", json.dumps(state["transitions"]))
.replace("/* __SCRIPTS__ */", state["scripts_js"])
.replace("__WS_PORT__", ws_port_js)
+ .replace("__EDIT_COMMANDS_JSON__", edit_commands_json)
.replace("__ERROR_JSON__", json.dumps(state["error"]))
.replace("__LOGS_JSON__", json.dumps(state["logs"]))
.replace("__FAVICON__", favicon_data_uri())
@@ -331,7 +384,11 @@ def _resolve_asset(roots: AssetRoots, request_path: str) -> Path | None:
return resolved
-def make_http_handler(ws_port: int, project_dir: Path | None = None) -> _StreamHandler:
+def make_http_handler(
+ ws_port: int,
+ project_dir: Path | None = None,
+ edit_commands: EditCommands = NO_EDIT_COMMANDS,
+) -> _StreamHandler:
async def handler(
reader: asyncio.StreamReader, writer: asyncio.StreamWriter
) -> None:
@@ -360,7 +417,7 @@ async def handler(
await writer.drain()
return
- body = build_html(_state, ws_port)
+ body = build_html(_state, ws_port, edit_commands)
header = (
b"HTTP/1.1 200 OK\r\n"
+ b"Content-Type: text/html; charset=utf-8\r\n"
@@ -474,7 +531,8 @@ async def serve(
uninstall_shutdown_handler = install_shutdown_handler(loop, shutdown)
try:
- http_handler = make_http_handler(ws_port, deck_path.parent)
+ edit_commands = resolve_edit_commands()
+ http_handler = make_http_handler(ws_port, deck_path.parent, edit_commands)
# Bind before the Live UI so port conflicts fail fast with a clean message
try:
http_server = await asyncio.start_server(http_handler, host, http_port)
@@ -499,7 +557,7 @@ async def serve(
try:
async with (
http_server,
- ws_serve(make_ws_handler(ui), host, ws_port),
+ ws_serve(make_ws_handler(ui, edit_commands), host, ws_port),
):
await rebuild(deck_path, ui, levels)
tasks = [
diff --git a/src/ts/globals.d.ts b/src/ts/globals.d.ts
index e4cae0bf..ff5f55e3 100644
--- a/src/ts/globals.d.ts
+++ b/src/ts/globals.d.ts
@@ -1,5 +1,6 @@
import type { Render, TransitionFactory } from "./presenter/transitions";
import type {
+ EditCommandsConfig,
LogEntry,
SlideData,
SyncMode,
@@ -10,6 +11,7 @@ declare global {
const __SLIDES_JSON__: SlideData[];
const __TRANSITIONS_JSON__: TransitionData[];
const __WS_PORT__: number | null;
+ const __EDIT_COMMANDS_JSON__: EditCommandsConfig;
const __ERROR_JSON__: string | null;
const __LOGS_JSON__: LogEntry[];
diff --git a/src/ts/presenter/edit.test.ts b/src/ts/presenter/edit.test.ts
new file mode 100644
index 00000000..4163703b
--- /dev/null
+++ b/src/ts/presenter/edit.test.ts
@@ -0,0 +1,382 @@
+// @vitest-environment happy-dom
+import { afterEach, beforeEach, expect, test, vi } from "vitest";
+
+// edit.ts captures DOM element references at module evaluation time via
+// getElementById, and initEditMenu() is not idempotent (each call adds another
+// click listener to the same button) — production code only ever calls it once
+// per page load. So each test gets a fresh DOM fragment *and* a fresh module
+// instance via vi.resetModules() + dynamic import, mirroring transitions.test.ts,
+// rather than reusing one initEditMenu() call across tests.
+
+let initEditMenu: typeof import("./edit").initEditMenu;
+let renderEditButton: typeof import("./edit").renderEditButton;
+let showEditError: typeof import("./edit").showEditError;
+let menuOpened: typeof import("./menus").menuOpened;
+let state: typeof import("./state").state;
+let btnEdit: HTMLButtonElement;
+let editMenu: HTMLElement;
+let editWrap: HTMLElement;
+let editToast: HTMLElement;
+let editToastText: HTMLElement;
+let editToastClose: HTMLButtonElement;
+
+beforeEach(async () => {
+ document.body.innerHTML = `
+
+
+
+
+
+ `;
+ vi.resetModules();
+ ({ initEditMenu, renderEditButton, showEditError } = await import(
+ "./edit"
+ ));
+ ({ menuOpened } = await import("./menus"));
+ ({ state } = await import("./state"));
+ btnEdit = document.getElementById("btn-edit") as HTMLButtonElement;
+ editMenu = document.getElementById("edit-menu")!;
+ editWrap = btnEdit.closest(".edit-wrap") as HTMLElement;
+ editToast = document.getElementById("edit-toast")!;
+ editToastText = document.getElementById("edit-toast-text")!;
+ editToastClose = document.getElementById(
+ "edit-toast-close",
+ ) as HTMLButtonElement;
+ state.slides = [];
+ state.slideIndex = 0;
+ state.ws = null;
+ vi.stubGlobal("navigator", {
+ clipboard: { writeText: vi.fn().mockResolvedValue(undefined) },
+ });
+});
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+function slideWith(
+ ...editableFiles: { label: string; name: string; path: string }[]
+) {
+ return { id: "s", svg: "", title: "", notes: "", editableFiles };
+}
+
+test("hides the wrap entirely when there is no WS port (static export)", () => {
+ initEditMenu({ svg: false, default: false }, null);
+ expect(editWrap.style.display).toBe("none");
+});
+
+test("renderEditButton populates one row per editable file, with an icon, label and filename", () => {
+ state.slides = [
+ slideWith(
+ { label: "Layout", name: "slide.svg", path: "/deck/slide.svg" },
+ { label: "Content", name: "slide.md", path: "/deck/slide.md" },
+ ),
+ ];
+ renderEditButton();
+ const rows = editMenu.querySelectorAll(".edit-row");
+ expect(rows.length).toBe(2);
+ expect(rows[0].querySelector("svg")).not.toBeNull();
+ expect(rows[0].querySelector(".edit-row-label")?.textContent).toBe(
+ "Layout",
+ );
+ expect(rows[0].querySelector(".edit-row-name")?.textContent).toBe(
+ "slide.svg",
+ );
+ expect(rows[1].querySelector(".edit-row-label")?.textContent).toBe(
+ "Content",
+ );
+ expect(rows[1].querySelector(".edit-row-name")?.textContent).toBe(
+ "slide.md",
+ );
+});
+
+test("distinct Parent rows still read apart via their filename", () => {
+ state.slides = [
+ slideWith(
+ { label: "Layout", name: "child.svg", path: "/deck/child.svg" },
+ { label: "Parent", name: "parent.svg", path: "/deck/parent.svg" },
+ {
+ label: "Parent",
+ name: "grandparent.svg",
+ path: "/deck/grandparent.svg",
+ },
+ ),
+ ];
+ renderEditButton();
+ const names = Array.from(editMenu.querySelectorAll(".edit-row-name")).map(
+ (n) => n.textContent,
+ );
+ expect(names).toEqual(["child.svg", "parent.svg", "grandparent.svg"]);
+});
+
+test("a single editable file still opens a (one-row) dropdown", () => {
+ // Never happens in production (pipeline._editable_files always appends the
+ // deck script, so the real floor is two entries) but edit.ts makes no
+ // assumption about that — it just renders whatever it's given.
+ state.slides = [
+ slideWith({
+ label: "Layout",
+ name: "slide.svg",
+ path: "/deck/slide.svg",
+ }),
+ ];
+ initEditMenu({ svg: false, default: false }, 7778);
+ btnEdit.click();
+ expect(editMenu.classList.contains("open")).toBe(true);
+ expect(editMenu.querySelectorAll(".edit-row").length).toBe(1);
+});
+
+test("clicking the lone row copies the full path and flashes a confirmation", () => {
+ state.slides = [
+ slideWith({
+ label: "Layout",
+ name: "slide.svg",
+ path: "/deck/slide.svg",
+ }),
+ ];
+ initEditMenu({ svg: false, default: false }, 7778);
+ btnEdit.click();
+ editMenu.querySelector
(".edit-row")!.click();
+ expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
+ "/deck/slide.svg",
+ );
+ expect(editMenu.classList.contains("open")).toBe(false);
+ expect(editToast.classList.contains("visible")).toBe(true);
+ // The full path, not just the filename — a bare "slide.svg" would read as
+ // though only the name had been copied.
+ expect(editToastText.textContent).toBe("Copied /deck/slide.svg");
+});
+
+test("clicking the toast's close button dismisses it early", () => {
+ vi.useFakeTimers();
+ state.slides = [
+ slideWith({
+ label: "Layout",
+ name: "slide.svg",
+ path: "/deck/slide.svg",
+ }),
+ ];
+ initEditMenu({ svg: false, default: false }, 7778);
+ btnEdit.click();
+ editMenu.querySelector(".edit-row")!.click();
+ expect(editToast.classList.contains("visible")).toBe(true);
+ editToastClose.click();
+ expect(editToast.classList.contains("visible")).toBe(false);
+ // The pending auto-hide timeout must also be cleared, not just overridden by
+ // the manual dismiss, otherwise it could re-fire (harmlessly, but sloppily)
+ // or clobber a *later* toast's own timer.
+ vi.advanceTimersByTime(3000);
+ expect(editToast.classList.contains("visible")).toBe(false);
+ vi.useRealTimers();
+});
+
+test("a second copy in quick succession restarts the auto-hide timer", () => {
+ vi.useFakeTimers();
+ state.slides = [
+ slideWith(
+ { label: "Layout", name: "a.svg", path: "/deck/a.svg" },
+ { label: "Content", name: "b.md", path: "/deck/b.md" },
+ ),
+ ];
+ initEditMenu({ svg: false, default: false }, 7778);
+ btnEdit.click();
+ editMenu.querySelectorAll(".edit-row")[0].click();
+ vi.advanceTimersByTime(2000); // most of the way to the first toast's timeout
+ btnEdit.click();
+ editMenu.querySelectorAll(".edit-row")[1].click();
+ vi.advanceTimersByTime(2000); // would have closed the first toast by now
+ expect(editToast.classList.contains("visible")).toBe(true);
+ expect(editToastText.textContent).toBe("Copied /deck/b.md");
+ vi.advanceTimersByTime(1000); // completes the second toast's own 3000ms
+ expect(editToast.classList.contains("visible")).toBe(false);
+ vi.useRealTimers();
+});
+
+test("the copy confirmation times itself out", () => {
+ vi.useFakeTimers();
+ state.slides = [
+ slideWith({
+ label: "Layout",
+ name: "slide.svg",
+ path: "/deck/slide.svg",
+ }),
+ ];
+ initEditMenu({ svg: false, default: false }, 7778);
+ btnEdit.click();
+ editMenu.querySelector(".edit-row")!.click();
+ expect(editToast.classList.contains("visible")).toBe(true);
+ vi.advanceTimersByTime(3000);
+ expect(editToast.classList.contains("visible")).toBe(false);
+ vi.useRealTimers();
+});
+
+test("multiple editable files open a dropdown instead of acting directly", () => {
+ state.slides = [
+ slideWith(
+ { label: "Layout", name: "slide.svg", path: "/deck/slide.svg" },
+ { label: "Content", name: "slide.md", path: "/deck/slide.md" },
+ ),
+ ];
+ initEditMenu({ svg: false, default: false }, 7778);
+ btnEdit.click();
+ expect(editMenu.classList.contains("open")).toBe(true);
+ expect(navigator.clipboard.writeText).not.toHaveBeenCalled();
+});
+
+test("opening the edit dropdown closes another open menu (e.g. sync)", () => {
+ state.slides = [
+ slideWith(
+ { label: "Layout", name: "slide.svg", path: "/deck/slide.svg" },
+ { label: "Content", name: "slide.md", path: "/deck/slide.md" },
+ ),
+ ];
+ const closeOther = vi.fn();
+ menuOpened(closeOther);
+ initEditMenu({ svg: false, default: false }, 7778);
+ btnEdit.click();
+ expect(closeOther).toHaveBeenCalledOnce();
+ expect(editMenu.classList.contains("open")).toBe(true);
+});
+
+test("clicking a dropdown row acts on that file and closes the menu", () => {
+ state.slides = [
+ slideWith(
+ { label: "Layout", name: "slide.svg", path: "/deck/slide.svg" },
+ { label: "Content", name: "slide.md", path: "/deck/slide.md" },
+ ),
+ ];
+ initEditMenu({ svg: false, default: false }, 7778);
+ btnEdit.click();
+ const contentRow = Array.from(
+ editMenu.querySelectorAll(".edit-row"),
+ ).find(
+ (r) => r.querySelector(".edit-row-label")?.textContent === "Content",
+ )!;
+ contentRow.click();
+ expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
+ "/deck/slide.md",
+ );
+ expect(editMenu.classList.contains("open")).toBe(false);
+});
+
+test("a configured command with a live WS connection sends an edit message instead of copying", () => {
+ state.slides = [
+ slideWith({
+ label: "Layout",
+ name: "slide.svg",
+ path: "/deck/slide.svg",
+ }),
+ ];
+ const send = vi.fn();
+ state.ws = { send, readyState: WebSocket.OPEN } as unknown as WebSocket;
+ initEditMenu({ svg: true, default: false }, 7778);
+ btnEdit.click();
+ editMenu.querySelector(".edit-row")!.click();
+ expect(send).toHaveBeenCalledWith(
+ JSON.stringify({ type: "edit", path: "/deck/slide.svg" }),
+ );
+ expect(navigator.clipboard.writeText).not.toHaveBeenCalled();
+ expect(editToast.classList.contains("visible")).toBe(true);
+ expect(editToastText.textContent).toBe("Opened slide.svg");
+});
+
+test("a configured command falls back to clipboard when the WS is not open", () => {
+ state.slides = [
+ slideWith({
+ label: "Layout",
+ name: "slide.svg",
+ path: "/deck/slide.svg",
+ }),
+ ];
+ const send = vi.fn();
+ state.ws = { send, readyState: WebSocket.CLOSED } as unknown as WebSocket;
+ initEditMenu({ svg: true, default: false }, 7778);
+ btnEdit.click();
+ editMenu.querySelector(".edit-row")!.click();
+ expect(send).not.toHaveBeenCalled();
+ expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
+ "/deck/slide.svg",
+ );
+});
+
+test("an unconfigured file kind falls back to clipboard even with a live WS", () => {
+ state.slides = [
+ slideWith({
+ label: "Layout",
+ name: "slide.svg",
+ path: "/deck/slide.svg",
+ }),
+ ];
+ const send = vi.fn();
+ state.ws = { send, readyState: WebSocket.OPEN } as unknown as WebSocket;
+ initEditMenu({ svg: false, default: false }, 7778);
+ btnEdit.click();
+ editMenu.querySelector(".edit-row")!.click();
+ expect(send).not.toHaveBeenCalled();
+ expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
+ "/deck/slide.svg",
+ );
+});
+
+test("the general command also applies to SVG files when no SVG-specific override is set", () => {
+ state.slides = [
+ slideWith({
+ label: "Layout",
+ name: "slide.svg",
+ path: "/deck/slide.svg",
+ }),
+ ];
+ const send = vi.fn();
+ state.ws = { send, readyState: WebSocket.OPEN } as unknown as WebSocket;
+ initEditMenu({ svg: false, default: true }, 7778);
+ btnEdit.click();
+ editMenu.querySelector(".edit-row")!.click();
+ expect(send).toHaveBeenCalledWith(
+ JSON.stringify({ type: "edit", path: "/deck/slide.svg" }),
+ );
+ expect(navigator.clipboard.writeText).not.toHaveBeenCalled();
+});
+
+test("showEditError shows a red, non-auto-dismissing toast", () => {
+ vi.useFakeTimers();
+ showEditError("failed to launch edit command 'nope': not found");
+ expect(editToast.classList.contains("visible")).toBe(true);
+ expect(editToast.classList.contains("error")).toBe(true);
+ expect(editToastText.textContent).toBe(
+ "failed to launch edit command 'nope': not found",
+ );
+ // Unlike a success toast, an error stays until dismissed — it's diagnostic
+ // text worth reading, not a transient confirmation.
+ vi.advanceTimersByTime(10_000);
+ expect(editToast.classList.contains("visible")).toBe(true);
+ vi.useRealTimers();
+});
+
+test("the close button dismisses an error toast too", () => {
+ showEditError("something went wrong");
+ editToastClose.click();
+ expect(editToast.classList.contains("visible")).toBe(false);
+});
+
+test("a success toast after an error clears the error styling", () => {
+ state.slides = [
+ slideWith({
+ label: "Layout",
+ name: "slide.svg",
+ path: "/deck/slide.svg",
+ }),
+ ];
+ showEditError("something went wrong");
+ expect(editToast.classList.contains("error")).toBe(true);
+ initEditMenu({ svg: false, default: false }, 7778);
+ btnEdit.click();
+ editMenu.querySelector(".edit-row")!.click();
+ expect(editToast.classList.contains("error")).toBe(false);
+ expect(editToast.classList.contains("visible")).toBe(true);
+});
diff --git a/src/ts/presenter/edit.ts b/src/ts/presenter/edit.ts
new file mode 100644
index 00000000..06aab484
--- /dev/null
+++ b/src/ts/presenter/edit.ts
@@ -0,0 +1,190 @@
+import type { EditableFile, EditCommandsConfig } from "../shared/types";
+import { menuClosed, menuOpened } from "./menus";
+import { state } from "./state";
+
+// Status-bar control for editing the current slide's source file(s). Owns the
+// button + dropdown DOM (structurally mirrors syncmenu.ts).
+//
+// Default action is copying the resolved path to the clipboard — universal,
+// works with any editor via paste-navigate. INKFLOW_EDIT_CMD / INKFLOW_EDIT_CMD_SVG
+// (server env vars, baked into EditCommandsConfig at page load) override that:
+// INKFLOW_EDIT_CMD_SVG overrides INKFLOW_EDIT_CMD for SVG files, which otherwise
+// covers every file kind. When configured *and* there's a live server connection,
+// the path is sent to the server to launch instead. The live-connection check
+// (not just the boot-time config flag) mirrors websocket.ts's postToPeer
+// reasoning — check the real transport, not a static flag — so a disconnected
+// server degrades to clipboard-copy instead of silently dropping the click.
+//
+// Hidden entirely when wsPort is null (a static build/export): there is no server
+// to run a command, and copying the deck author's local dev-machine path to an
+// arbitrary viewer's clipboard is not a sensible default there.
+
+const btnEdit = document.getElementById("btn-edit")!;
+const editMenu = document.getElementById("edit-menu")!;
+const editWrap = btnEdit.closest(".edit-wrap")!;
+const editToast = document.getElementById("edit-toast")!;
+const editToastText = document.getElementById("edit-toast-text")!;
+const editToastClose = document.getElementById("edit-toast-close")!;
+
+// Must match the CSS animation duration on #edit-toast-progress (overlays.css).
+const TOAST_DURATION_MS = 3000;
+
+let config: EditCommandsConfig = { default: false, svg: false };
+let toastTimeout: ReturnType | null = null;
+
+editToastClose.addEventListener("click", () => hideToast());
+
+// One small icon per editableFiles label — a plain signifier, not decoration, so
+// entries with the same generic label (several "Parent" rows) still read apart at
+// a glance alongside their filename. Trusted, fixed markup (never file/user data).
+const ROW_ICONS: Record = {
+ Layout: ``,
+ Parent: ``,
+ Content: ``,
+ Notes: ``,
+ Deck: ``,
+};
+
+function isConfigured(file: EditableFile): boolean {
+ // config.svg overrides config.default for SVG files; every other kind
+ // always uses the general command — mirrors edit.py's command_for.
+ if (file.path.toLowerCase().endsWith(".svg")) {
+ return config.svg || config.default;
+ }
+ return config.default;
+}
+
+function hideToast(): void {
+ if (toastTimeout) clearTimeout(toastTimeout);
+ editToast.classList.remove("visible");
+ toastTimeout = null;
+}
+
+// Flashes a confirmation styled like the #log-banner message boxes (same
+// surface/border/shadow treatment, an accent colour instead of its warning
+// yellow), with its own close button and (for a success message) a shrinking
+// progress bar (pure CSS, see overlays.css) showing time left before it
+// dismisses itself. An error stays until manually dismissed — it's diagnostic
+// text the click that triggered it didn't expect, worth more than a glance.
+function flashToast(
+ message: string,
+ kind: "success" | "error" = "success",
+): void {
+ editToastText.textContent = message;
+ editToast.classList.toggle("error", kind === "error");
+ // Drop .visible and force a reflow before re-adding it, even if the toast is
+ // already showing (two edits in quick succession) — otherwise the browser
+ // never sees the class go away and won't restart the progress-bar animation.
+ editToast.classList.remove("visible");
+ void editToast.offsetWidth;
+ editToast.classList.add("visible");
+ if (toastTimeout) clearTimeout(toastTimeout);
+ toastTimeout =
+ kind === "error" ? null : setTimeout(hideToast, TOAST_DURATION_MS);
+}
+
+// Called from websocket.ts when the server replies with an "edit-error"
+// message: the configured command failed to launch (e.g. the binary isn't on
+// PATH). Without this, a failed launch was only ever logged server-side —
+// invisible from the browser tab where the click actually happened.
+export function showEditError(message: string): void {
+ flashToast(message, "error");
+}
+
+function actOn(file: EditableFile): void {
+ 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);
+ // The full path, not just file.name: a bare filename here would read as
+ // though only the name (not the whole path) had been copied.
+ flashToast(`Copied ${file.path}`);
+ } catch (_) {}
+}
+
+// ── Button / menu rendering ──────────────────────────────────────────────────
+
+export function renderEditButton(): void {
+ 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);
+ }
+}
+
+// ── Menu open/close ────────────────────────────────────────────────────────────
+
+function onDocClick(e: MouseEvent): void {
+ const t = e.target as Node;
+ if (!btnEdit.contains(t) && !editMenu.contains(t)) closeMenu();
+}
+
+function onKeydown(e: KeyboardEvent): void {
+ if (e.key === "Escape") {
+ closeMenu();
+ btnEdit.focus();
+ }
+}
+
+function openMenu(): void {
+ editMenu.classList.add("open");
+ btnEdit.setAttribute("aria-expanded", "true");
+ document.addEventListener("click", onDocClick);
+ document.addEventListener("keydown", onKeydown);
+ menuOpened(closeMenu);
+}
+
+function closeMenu(): void {
+ 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(): void {
+ if (editMenu.classList.contains("open")) closeMenu();
+ else openMenu();
+}
+
+export function initEditMenu(
+ cfg: EditCommandsConfig,
+ wsPort: number | null,
+): void {
+ if (!wsPort) {
+ editWrap.style.display = "none";
+ return;
+ }
+ config = cfg;
+ btnEdit.addEventListener("click", (e) => {
+ e.stopPropagation();
+ toggleMenu();
+ });
+ renderEditButton();
+}
diff --git a/src/ts/presenter/main.ts b/src/ts/presenter/main.ts
index 83f0971b..3ab3125c 100644
--- a/src/ts/presenter/main.ts
+++ b/src/ts/presenter/main.ts
@@ -1,3 +1,4 @@
+import { initEditMenu } from "./edit";
import { renderPv, updatePvClock } from "./pv";
import { state } from "./state";
import { readURL } from "./status";
@@ -17,6 +18,7 @@ import "./keyboard";
const INITIAL_SLIDES = __SLIDES_JSON__;
const INITIAL_TRANSITIONS = __TRANSITIONS_JSON__;
const WS_PORT = __WS_PORT__;
+const EDIT_COMMANDS = __EDIT_COMMANDS_JSON__;
const INITIAL_ERROR = __ERROR_JSON__;
const INITIAL_LOGS = __LOGS_JSON__;
@@ -40,6 +42,7 @@ window.addEventListener("popstate", () => {
loadSyncMode();
initSyncMenu();
initWindowSync(WS_PORT);
+initEditMenu(EDIT_COMMANDS, WS_PORT);
// Capture deep-link authority before loadSlide()/syncURL() rewrites the URL.
const deepLinked = readURL();
loadSlide();
diff --git a/src/ts/presenter/menus.test.ts b/src/ts/presenter/menus.test.ts
new file mode 100644
index 00000000..66f79522
--- /dev/null
+++ b/src/ts/presenter/menus.test.ts
@@ -0,0 +1,45 @@
+import { beforeEach, expect, test, vi } from "vitest";
+import { menuClosed, menuOpened } from "./menus";
+
+// menus.ts holds one bit of module-level state (which close fn is "active"), so
+// tests must reset it between runs rather than relying on module isolation.
+beforeEach(() => {
+ // Drain any leftover active closer from a previous test without invoking it.
+ const noop = () => {};
+ menuOpened(noop);
+ menuClosed(noop);
+});
+
+test("opening a second menu closes the first", () => {
+ const closeA = vi.fn();
+ const closeB = vi.fn();
+ menuOpened(closeA);
+ menuOpened(closeB);
+ expect(closeA).toHaveBeenCalledOnce();
+ expect(closeB).not.toHaveBeenCalled();
+});
+
+test("re-opening the same menu does not close itself", () => {
+ const close = vi.fn();
+ menuOpened(close);
+ menuOpened(close);
+ expect(close).not.toHaveBeenCalled();
+});
+
+test("menuClosed clears the active menu so the next open closes nothing", () => {
+ const closeA = vi.fn();
+ const closeB = vi.fn();
+ menuOpened(closeA);
+ menuClosed(closeA);
+ menuOpened(closeB);
+ expect(closeA).not.toHaveBeenCalled();
+});
+
+test("menuClosed is a no-op when it isn't the active menu", () => {
+ const closeA = vi.fn();
+ const closeB = vi.fn();
+ menuOpened(closeA);
+ menuClosed(closeB); // closeB was never the active one
+ menuOpened(closeB);
+ expect(closeA).toHaveBeenCalledOnce(); // still closed normally by B's open
+});
diff --git a/src/ts/presenter/menus.ts b/src/ts/presenter/menus.ts
new file mode 100644
index 00000000..41ec390b
--- /dev/null
+++ b/src/ts/presenter/menus.ts
@@ -0,0 +1,16 @@
+// Coordinates status-bar dropdown menus (sync, edit, …) so opening one closes
+// any other that's open. Each menu owns its own DOM/open-close logic (syncmenu.ts,
+// edit.ts) and just reports through here — this module holds no menu state of its
+// own beyond "which close function is currently active", so two menus never import
+// each other directly.
+
+let activeClose: (() => void) | null = null;
+
+export function menuOpened(close: () => void): void {
+ if (activeClose && activeClose !== close) activeClose();
+ activeClose = close;
+}
+
+export function menuClosed(close: () => void): void {
+ if (activeClose === close) activeClose = null;
+}
diff --git a/src/ts/presenter/overview.test.ts b/src/ts/presenter/overview.test.ts
index 9f2b2739..8463db36 100644
--- a/src/ts/presenter/overview.test.ts
+++ b/src/ts/presenter/overview.test.ts
@@ -45,6 +45,11 @@ beforeAll(async () => {
+
+
+
+
+
`;
// computeStageFlip() divides stage/thumbnail rect sizes; happy-dom has no
@@ -87,12 +92,14 @@ beforeEach(() => {
svg: '',
title: "A",
notes: "",
+ editableFiles: [],
},
{
id: "b",
svg: '',
title: "B",
notes: "",
+ editableFiles: [],
},
];
state.slideIndex = 0;
diff --git a/src/ts/presenter/picker.test.ts b/src/ts/presenter/picker.test.ts
index 5be2b0a7..1c6e6833 100644
--- a/src/ts/presenter/picker.test.ts
+++ b/src/ts/presenter/picker.test.ts
@@ -39,6 +39,11 @@ beforeAll(async () => {
+
+
+
+
+
`;
vi.resetModules();
({ filterPicker } = await import("./picker"));
@@ -53,7 +58,15 @@ beforeEach(() => {
describe("picker title escaping (F-018)", () => {
test("a malicious title renders inert instead of injecting a node", () => {
const evil = `
`;
- state.slides = [{ id: "a", svg: "", title: evil, notes: "" }];
+ state.slides = [
+ {
+ id: "a",
+ svg: "",
+ title: evil,
+ notes: "",
+ editableFiles: [],
+ },
+ ];
filterPicker("");
diff --git a/src/ts/presenter/pv.ts b/src/ts/presenter/pv.ts
index 3a74a832..662c16cb 100644
--- a/src/ts/presenter/pv.ts
+++ b/src/ts/presenter/pv.ts
@@ -1,6 +1,7 @@
import { buildStepRing } from "../shared/ring";
import { applyStepInstant } from "../shared/step";
import { parseViewBox } from "../shared/viewbox";
+import { renderEditButton } from "./edit";
import { state } from "./state";
import { maxStep } from "./status";
@@ -124,6 +125,7 @@ export function renderPv(): void {
updatePvInfo();
renderPvNext();
renderPvNotes();
+ renderEditButton();
}
export function togglePv(): void {
diff --git a/src/ts/presenter/syncmenu.ts b/src/ts/presenter/syncmenu.ts
index 7afe0eae..ac62501b 100644
--- a/src/ts/presenter/syncmenu.ts
+++ b/src/ts/presenter/syncmenu.ts
@@ -1,4 +1,5 @@
import type { SyncMode } from "../shared/types";
+import { menuClosed, menuOpened } from "./menus";
import { state } from "./state";
import { applySyncMode } from "./websocket";
@@ -59,6 +60,7 @@ function openMenu(): void {
btnSync.setAttribute("aria-expanded", "true");
document.addEventListener("click", onDocClick);
document.addEventListener("keydown", onKeydown);
+ menuOpened(closeMenu);
}
function closeMenu(): void {
@@ -67,6 +69,7 @@ function closeMenu(): void {
btnSync.setAttribute("aria-expanded", "false");
document.removeEventListener("click", onDocClick);
document.removeEventListener("keydown", onKeydown);
+ menuClosed(closeMenu);
}
function toggleMenu(): void {
diff --git a/src/ts/presenter/transitions.test.ts b/src/ts/presenter/transitions.test.ts
index fd4bfa53..348fe714 100644
--- a/src/ts/presenter/transitions.test.ts
+++ b/src/ts/presenter/transitions.test.ts
@@ -42,7 +42,13 @@ beforeAll(async () => {
beforeEach(() => {
state.slides = [
- { id: "a", svg: "", title: "S1", notes: "" },
+ {
+ id: "a",
+ svg: "",
+ title: "S1",
+ notes: "",
+ editableFiles: [],
+ },
];
state.slideIndex = 0;
state.step = 0;
@@ -61,12 +67,14 @@ describe("reverse", () => {
svg: "",
title: "S1",
notes: "",
+ editableFiles: [],
},
{
id: "b",
svg: "",
title: "S2",
notes: "",
+ editableFiles: [],
},
];
state.slideIndex = 1;
@@ -110,12 +118,14 @@ describe("reverse", () => {
svg: "",
title: "A",
notes: "",
+ editableFiles: [],
},
{
id: "b",
svg: ``,
title: "B",
notes: "",
+ editableFiles: [],
},
];
state.transitions = [
@@ -154,12 +164,14 @@ describe("reverse", () => {
svg: "",
title: "A",
notes: "",
+ editableFiles: [],
},
{
id: "b",
svg: "",
title: "B",
notes: "",
+ editableFiles: [],
},
];
state.transitions = [
@@ -201,12 +213,14 @@ describe("reverse", () => {
svg: "",
title: "A",
notes: "",
+ editableFiles: [],
},
{
id: "b",
svg: "",
title: "B",
notes: "",
+ editableFiles: [],
},
];
state.transitions = [
@@ -244,12 +258,14 @@ describe("snap in-flight", () => {
svg: "",
title: "A",
notes: "",
+ editableFiles: [],
},
{
id: "b",
svg: "",
title: "B",
notes: "",
+ editableFiles: [],
},
];
});
diff --git a/src/ts/presenter/websocket.ts b/src/ts/presenter/websocket.ts
index 24269ac8..56a818f7 100644
--- a/src/ts/presenter/websocket.ts
+++ b/src/ts/presenter/websocket.ts
@@ -5,6 +5,7 @@ import type {
TransitionData,
WsMessage,
} from "../shared/types";
+import { showEditError } from "./edit";
import { renderPv, renderPvNext, updatePvInfo } from "./pv";
import { state } from "./state";
import {
@@ -209,6 +210,8 @@ export function connectWS(wsPort: number | null, authoritative: boolean): void {
renderPv();
} else if (msg.type === "error") {
showError(msg.message);
+ } else if (msg.type === "edit-error") {
+ showEditError(msg.message);
} else if (msg.type === "position") {
// Discard exactly the stale connect-time push so an authoritative window
// keeps its own position; later updates apply normally. Mirrors the
diff --git a/src/ts/presenter/windowsync.test.ts b/src/ts/presenter/windowsync.test.ts
index 2ce194bc..e1ae46d2 100644
--- a/src/ts/presenter/windowsync.test.ts
+++ b/src/ts/presenter/windowsync.test.ts
@@ -37,6 +37,11 @@ beforeAll(async () => {
+
+
+
+
+
`;
({ isSyncPayload } = await import("./windowsync"));
});
diff --git a/src/ts/shared/types.ts b/src/ts/shared/types.ts
index 92102d65..d669fa16 100644
--- a/src/ts/shared/types.ts
+++ b/src/ts/shared/types.ts
@@ -1,11 +1,25 @@
// Every field is always emitted by the Python side (pipeline.py process_deck),
// so all are required here. Consumers that still guard with `|| ""` are being
// defensive, not handling a real absent case.
+export interface EditableFile {
+ label: string;
+ name: string;
+ path: string;
+}
+
export interface SlideData {
id: string;
svg: string;
title: string;
notes: string;
+ editableFiles: EditableFile[];
+}
+
+// Whether the server has a configured edit command for each file kind (env vars
+// INKFLOW_EDIT_CMD_SVG / INKFLOW_EDIT_CMD_MD), baked in at page load — see edit.ts.
+export interface EditCommandsConfig {
+ default: boolean;
+ svg: boolean;
}
// Per-client position-sync mode. Never sent to the server: it only decides,
@@ -56,4 +70,5 @@ export type WsMessage =
logs: LogEntry[];
}
| { type: "error"; message: string }
+ | { type: "edit-error"; message: string }
| ({ type: "position" } & SyncPosition);
diff --git a/tests/decks/edit_button.py b/tests/decks/edit_button.py
new file mode 100644
index 00000000..4456a477
--- /dev/null
+++ b/tests/decks/edit_button.py
@@ -0,0 +1,56 @@
+"""Feature-test deck for the presenter's Edit button.
+
+Serve it with ``uv run inkflow serve tests/decks/edit_button.py`` and click the
+Edit button (opens a dropdown on every slide, in the status bar) on each slide.
+Every dropdown ends with a **Deck** entry (this file) — ``pipeline._editable_files``
+always appends the deck script, so no slide ever has just one editable file:
+
+1. **SVG only** — no markdown, no notes. Two-item dropdown: Layout, Deck.
+2. **Content file** — ``md=`` points at a real file. Three-item dropdown:
+ Layout, Content, Deck.
+3. **Content and notes files** — both ``md=`` and ``notes=`` are file-backed.
+ Four-item dropdown: Layout, Content, Notes, Deck.
+4. **Layout with a parent chain** — ``layouts/edit-parent-child.svg`` has
+ ``inkflow:parent="edit-parent-base"``, which itself has
+ ``inkflow:parent="edit-parent-grandparent"``: two project-local ancestors.
+ Four-item dropdown, root ancestor first so the immediate parent sits next
+ to the layout it belongs to: Parent (edit-parent-grandparent.svg), Parent
+ (edit-parent-base.svg), Layout (edit-parent-child.svg), Deck — the two
+ Parent rows told apart by filename since the label repeats. A built-in/theme
+ ancestor is never offered this way (see
+ ``TestEditableFiles.test_theme_ancestor_excluded`` in test_pipeline.py).
+
+With no ``INKFLOW_EDIT_CMD``/``INKFLOW_EDIT_CMD_SVG`` set, every row copies its
+absolute path to the clipboard; with ``INKFLOW_EDIT_CMD`` set (e.g.
+``INKFLOW_EDIT_CMD="code -r {path}"``), every row launches that command
+instead, unless ``INKFLOW_EDIT_CMD_SVG`` overrides it for the Layout/Parent
+rows specifically. Either way a confirmation toast appears bottom-right.
+
+Also built (not served) by ``tests/test_decks.py`` as a compilation smoke test.
+"""
+
+from inkflow import Deck, Slide
+
+
+def main() -> Deck:
+ return Deck(
+ slides=[
+ Slide(
+ "builtin:title",
+ id="svg-only",
+ zones={"title": "SVG only: Edit acts directly, no dropdown"},
+ ),
+ Slide("builtin:content", id="content-file", md="edit-content"),
+ Slide(
+ "builtin:content",
+ id="content-and-notes",
+ md="edit-content-and-notes",
+ notes="slides/edit-notes.md",
+ ),
+ Slide(
+ "layouts/edit-parent-child",
+ id="layout-with-parent",
+ zones={"title": "This layout has a parent: Layout + Parent"},
+ ),
+ ],
+ )
diff --git a/tests/decks/layouts/edit-parent-base.svg b/tests/decks/layouts/edit-parent-base.svg
new file mode 100644
index 00000000..e85e430e
--- /dev/null
+++ b/tests/decks/layouts/edit-parent-base.svg
@@ -0,0 +1,2 @@
+
diff --git a/tests/decks/layouts/edit-parent-child.svg b/tests/decks/layouts/edit-parent-child.svg
new file mode 100644
index 00000000..2a24d199
--- /dev/null
+++ b/tests/decks/layouts/edit-parent-child.svg
@@ -0,0 +1,3 @@
+
diff --git a/tests/decks/layouts/edit-parent-grandparent.svg b/tests/decks/layouts/edit-parent-grandparent.svg
new file mode 100644
index 00000000..54a43753
--- /dev/null
+++ b/tests/decks/layouts/edit-parent-grandparent.svg
@@ -0,0 +1,3 @@
+
diff --git a/tests/decks/slides/edit-content-and-notes.md b/tests/decks/slides/edit-content-and-notes.md
new file mode 100644
index 00000000..c16222e4
--- /dev/null
+++ b/tests/decks/slides/edit-content-and-notes.md
@@ -0,0 +1,7 @@
+# Edit: content and notes
+
+This slide has both a file-backed `md=` content file and a file-backed
+`notes=` file.
+
+Clicking Edit should offer three entries: **Layout**, **Content** (this
+file), and **Notes** (`slides/edit-notes.md`).
diff --git a/tests/decks/slides/edit-content.md b/tests/decks/slides/edit-content.md
new file mode 100644
index 00000000..d1a67036
--- /dev/null
+++ b/tests/decks/slides/edit-content.md
@@ -0,0 +1,6 @@
+# Edit: content file
+
+This slide's content comes from `slides/edit-content.md`.
+
+Clicking Edit should offer two entries: **Layout** (the SVG) and **Content**
+(this file).
diff --git a/tests/decks/slides/edit-notes.md b/tests/decks/slides/edit-notes.md
new file mode 100644
index 00000000..0a1aefec
--- /dev/null
+++ b/tests/decks/slides/edit-notes.md
@@ -0,0 +1,2 @@
+Speaker notes for the "content and notes" slide, living in their own file.
+Open the presenter panel (`p`) to see this rendered in the Notes pane.
diff --git a/tests/test_edit.py b/tests/test_edit.py
new file mode 100644
index 00000000..fa19ea94
--- /dev/null
+++ b/tests/test_edit.py
@@ -0,0 +1,110 @@
+from __future__ import annotations
+
+import logging
+import subprocess
+from pathlib import Path
+from unittest.mock import MagicMock
+
+import pytest
+
+from inkflow.edit import (
+ EditCommands,
+ command_for,
+ open_in_editor,
+ resolve_edit_commands,
+)
+from inkflow.logging import collect_logs
+
+_ENV_VARS = ("INKFLOW_EDIT_CMD", "INKFLOW_EDIT_CMD_SVG")
+
+
+@pytest.fixture(autouse=True)
+def _isolated_env(monkeypatch: pytest.MonkeyPatch): # pyright: ignore[reportUnusedFunction]
+ for name in _ENV_VARS:
+ monkeypatch.delenv(name, raising=False)
+ yield
+
+
+# ── resolve_edit_commands ────────────────────────────────────────────────────
+
+
+def test_resolve_edit_commands_unset() -> None:
+ assert resolve_edit_commands() == EditCommands(default=None, svg=None)
+
+
+def test_resolve_edit_commands_from_env(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setenv("INKFLOW_EDIT_CMD", "nvim {path}")
+ monkeypatch.setenv("INKFLOW_EDIT_CMD_SVG", "code -r {path}")
+ assert resolve_edit_commands() == EditCommands(
+ default="nvim {path}", svg="code -r {path}"
+ )
+
+
+# ── command_for ───────────────────────────────────────────────────────────────
+
+
+def test_command_for_svg_suffix_uses_svg_override() -> None:
+ commands = EditCommands(default="edit-default", svg="edit-svg")
+ assert command_for(Path("slide.svg"), commands) == "edit-svg"
+
+
+def test_command_for_svg_suffix_case_insensitive() -> None:
+ commands = EditCommands(default="edit-default", svg="edit-svg")
+ assert command_for(Path("slide.SVG"), commands) == "edit-svg"
+
+
+def test_command_for_other_suffix_uses_default() -> None:
+ commands = EditCommands(default="edit-default", svg="edit-svg")
+ assert command_for(Path("notes.md"), commands) == "edit-default"
+
+
+def test_command_for_svg_falls_back_to_default_without_override() -> None:
+ # The general command also applies to SVG files when no SVG-specific
+ # override is set — setting only INKFLOW_EDIT_CMD covers everything.
+ commands = EditCommands(default="edit-default", svg=None)
+ assert command_for(Path("slide.svg"), commands) == "edit-default"
+
+
+def test_command_for_none_configured() -> None:
+ commands = EditCommands(default=None, svg=None)
+ assert command_for(Path("slide.svg"), commands) is None
+
+
+# ── open_in_editor ────────────────────────────────────────────────────────────
+
+
+def test_open_in_editor_substitutes_placeholder(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ popen = MagicMock()
+ monkeypatch.setattr(subprocess, "Popen", popen)
+ path = Path("/tmp/slide.svg")
+ result = open_in_editor(path, "code -r --goto {path}")
+ args = popen.call_args[0][0] # pyright: ignore[reportAny]
+ assert args == ["code", "-r", "--goto", str(path)]
+ assert result is None
+
+
+def test_open_in_editor_appends_path_when_no_placeholder(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ popen = MagicMock()
+ monkeypatch.setattr(subprocess, "Popen", popen)
+ path = Path("/tmp/notes.md")
+ open_in_editor(path, "nvim")
+ args = popen.call_args[0][0] # pyright: ignore[reportAny]
+ assert args == ["nvim", str(path)]
+
+
+def test_open_in_editor_launch_failure_warns_and_returns_message(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ def _raise(*_args: object, **_kwargs: object) -> None:
+ raise OSError("no such file or directory")
+
+ monkeypatch.setattr(subprocess, "Popen", _raise)
+ with collect_logs(logging.WARNING) as warnings:
+ result = open_in_editor(Path("/tmp/slide.svg"), "not-a-real-editor {path}")
+ assert any("failed to launch edit command" in w.message for w in warnings)
+ assert result is not None
+ assert "failed to launch edit command" in result
diff --git a/tests/test_fonts.py b/tests/test_fonts.py
index 7e5a9057..5814d8b4 100644
--- a/tests/test_fonts.py
+++ b/tests/test_fonts.py
@@ -32,7 +32,7 @@
def _slide(svg: str) -> SlideData:
- return {"svg": svg, "title": "", "id": "", "notes": ""}
+ return {"svg": svg, "title": "", "id": "", "notes": "", "editableFiles": []}
def _svg(body: str) -> str:
diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py
index a82f77d8..12d61168 100644
--- a/tests/test_pipeline.py
+++ b/tests/test_pipeline.py
@@ -455,7 +455,7 @@ def _write_slide(self, tmp_path: Path, name: str, content: str) -> Path:
def test_foreignobject_replaces_zone_rect(self, tmp_path: Path) -> None:
self._write_slide(tmp_path, "slide.svg", _ZONE_SLIDE_SVG)
deck = Deck(slides=[Slide("slides/slide.svg", zones={"content": "hello"})])
- results = process_deck(deck, tmp_path)
+ results = process_deck(deck, tmp_path, tmp_path / "deck.py")
assert len(results) == 1
assert "foreignObject" in results[0]["svg"]
assert "hello" in results[0]["svg"]
@@ -463,14 +463,14 @@ def test_foreignobject_replaces_zone_rect(self, tmp_path: Path) -> None:
def test_zone_rect_id_inherited_by_foreignobject(self, tmp_path: Path) -> None:
self._write_slide(tmp_path, "slide.svg", _ZONE_SLIDE_SVG)
deck = Deck(slides=[Slide("slides/slide.svg", zones={"content": "hi"})])
- results = process_deck(deck, tmp_path)
+ results = process_deck(deck, tmp_path, tmp_path / "deck.py")
assert 'id="zone-content"' in results[0]["svg"]
def test_unreferenced_zone_rects_removed(self, tmp_path: Path) -> None:
self._write_slide(tmp_path, "slide.svg", _LAYOUT_SVG)
# Only supply content for zone-content, leave zone-title unconsumed
deck = Deck(slides=[Slide("slides/slide.svg", zones={"content": "body"})])
- results = process_deck(deck, tmp_path)
+ results = process_deck(deck, tmp_path, tmp_path / "deck.py")
assert 'id="zone-title"' not in results[0]["svg"]
def test_foreignobject_content_has_inkflow_content_class(
@@ -478,7 +478,7 @@ def test_foreignobject_content_has_inkflow_content_class(
) -> None:
self._write_slide(tmp_path, "slide.svg", _ZONE_SLIDE_SVG)
deck = Deck(slides=[Slide("slides/slide.svg", zones={"content": "x"})])
- results = process_deck(deck, tmp_path)
+ results = process_deck(deck, tmp_path, tmp_path / "deck.py")
assert "inkflow-content" in results[0]["svg"]
@@ -496,7 +496,7 @@ def test_markdown_slide_expands_to_foreignobject(self, tmp_path: Path) -> None:
md = slides_dir / "content.md"
md.write_text("# Hello\n\nBody text here.\n", encoding="utf-8")
deck = Deck(slides=[Slide("layout", md="content")])
- results = process_deck(deck, tmp_path)
+ results = process_deck(deck, tmp_path, tmp_path / "deck.py")
assert len(results) == 1
assert "foreignObject" in results[0]["svg"]
assert "Body text" in results[0]["svg"]
@@ -506,7 +506,7 @@ def test_markdown_slide_title_extracted(self, tmp_path: Path) -> None:
md = slides_dir / "content.md"
md.write_text("# My Title\n\nSome content.\n", encoding="utf-8")
deck = Deck(slides=[Slide("layout", md="content")])
- results = process_deck(deck, tmp_path)
+ results = process_deck(deck, tmp_path, tmp_path / "deck.py")
assert results[0]["title"] == "My Title"
def test_markdown_slide_animations_applied(self, tmp_path: Path) -> None:
@@ -515,7 +515,7 @@ def test_markdown_slide_animations_applied(self, tmp_path: Path) -> None:
deck = Deck(
slides=[Slide("layout", md="content", animations=[FadeIn("zone-title")])]
)
- results = process_deck(deck, tmp_path)
+ results = process_deck(deck, tmp_path, tmp_path / "deck.py")
cues = self._title_cues(results[0]["svg"])
assert [c["name"] for c in cues] == ["fade-in"]
@@ -526,7 +526,7 @@ def _title_cues(svg: str) -> list[_CueDict]:
def test_zones_media_injected(self, tmp_path: Path) -> None:
self._setup(tmp_path)
deck = Deck(slides=[Slide("layout", zones={"content": Image("photo.jpg")})])
- results = process_deck(deck, tmp_path)
+ results = process_deck(deck, tmp_path, tmp_path / "deck.py")
assert "photo.jpg" in results[0]["svg"]
def test_deck_animations_concatenate_after_reveals(self, tmp_path: Path) -> None:
@@ -538,7 +538,7 @@ def test_deck_animations_concatenate_after_reveals(self, tmp_path: Path) -> None
deck = Deck(
slides=[Slide("layout", md="content", animations=[FadeIn("zone-title")])]
)
- svg = process_deck(deck, tmp_path)[0]["svg"]
+ svg = process_deck(deck, tmp_path, tmp_path / "deck.py")[0]["svg"]
root = parse_svg(svg)
reveal = next(
el for el in root.iter() if (el.get("id") or "").startswith("inkflow-step-")
@@ -561,7 +561,7 @@ def test_autoplay_overridden_by_play_video_cue(self, tmp_path: Path) -> None:
]
)
with collect_logs(logging.WARNING) as warnings:
- svg = process_deck(deck, tmp_path)[0]["svg"]
+ svg = process_deck(deck, tmp_path, tmp_path / "deck.py")[0]["svg"]
assert any("autoplay overridden" in w.message for w in warnings)
assert "data-autoplay" not in svg # the cue wins, autoplay stripped
assert not re.search(r"