From 71899b99caa95497ea2018538be1aab3090f4e35 Mon Sep 17 00:00:00 2001 From: ll-nick <68419636+ll-nick@users.noreply.github.com> Date: Fri, 18 Sep 2026 07:12:31 +0200 Subject: [PATCH 01/25] Add a module to open an editor if defined via env variable --- src/inkflow/edit.py | 67 +++++++++++++++++++++++++++++++ tests/test_edit.py | 96 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 163 insertions(+) create mode 100644 src/inkflow/edit.py create mode 100644 tests/test_edit.py diff --git a/src/inkflow/edit.py b/src/inkflow/edit.py new file mode 100644 index 00000000..fd85ec04 --- /dev/null +++ b/src/inkflow/edit.py @@ -0,0 +1,67 @@ +"""Launching an external editor on a slide's source file, from the presenter. + +Resolution is env-var only (``INKFLOW_EDIT_CMD_SVG`` / ``INKFLOW_EDIT_CMD_MD``): 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: + svg: str | None + md: str | None + + +NO_EDIT_COMMANDS = EditCommands(svg=None, md=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( + svg=os.environ.get("INKFLOW_EDIT_CMD_SVG"), + md=os.environ.get("INKFLOW_EDIT_CMD_MD"), + ) + + +def command_for(path: Path, commands: EditCommands) -> str | None: + return commands.svg if path.suffix.lower() == ".svg" else commands.md + + +def open_in_editor(path: Path, template: 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 a warning, not 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: + logger.warning(f"failed to launch edit command {template!r}: {e}") + finally: + os.close(devnull) diff --git a/tests/test_edit.py b/tests/test_edit.py new file mode 100644 index 00000000..61bf02d2 --- /dev/null +++ b/tests/test_edit.py @@ -0,0 +1,96 @@ +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_SVG", "INKFLOW_EDIT_CMD_MD") + + +@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(svg=None, md=None) + + +def test_resolve_edit_commands_from_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("INKFLOW_EDIT_CMD_SVG", "code -r {path}") + monkeypatch.setenv("INKFLOW_EDIT_CMD_MD", "nvim {path}") + assert resolve_edit_commands() == EditCommands( + svg="code -r {path}", md="nvim {path}" + ) + + +# ── command_for ─────────────────────────────────────────────────────────────── + + +def test_command_for_svg_suffix() -> None: + commands = EditCommands(svg="edit-svg", md="edit-md") + assert command_for(Path("slide.svg"), commands) == "edit-svg" + + +def test_command_for_svg_suffix_case_insensitive() -> None: + commands = EditCommands(svg="edit-svg", md="edit-md") + assert command_for(Path("slide.SVG"), commands) == "edit-svg" + + +def test_command_for_other_suffix_uses_md() -> None: + commands = EditCommands(svg="edit-svg", md="edit-md") + assert command_for(Path("notes.md"), commands) == "edit-md" + + +def test_command_for_none_configured() -> None: + commands = EditCommands(svg=None, md=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) + open_in_editor(Path("/tmp/slide.svg"), "code -r --goto {path}") + args = popen.call_args[0][0] # pyright: ignore[reportAny] + assert args == ["code", "-r", "--goto", "/tmp/slide.svg"] + + +def test_open_in_editor_appends_path_when_no_placeholder( + monkeypatch: pytest.MonkeyPatch, +) -> None: + popen = MagicMock() + monkeypatch.setattr(subprocess, "Popen", popen) + open_in_editor(Path("/tmp/notes.md"), "nvim") + args = popen.call_args[0][0] # pyright: ignore[reportAny] + assert args == ["nvim", "/tmp/notes.md"] + + +def test_open_in_editor_launch_failure_warns(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: + 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) From 004de0ee3a98fdfe66b1dbce701bbdf28a270fb7 Mon Sep 17 00:00:00 2001 From: ll-nick <68419636+ll-nick@users.noreply.github.com> Date: Fri, 18 Sep 2026 07:27:17 +0200 Subject: [PATCH 02/25] Store dict of editable files in the pipeline --- src/inkflow/pipeline.py | 26 +++++++++++++++- tests/test_pipeline.py | 68 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 1 deletion(-) diff --git a/src/inkflow/pipeline.py b/src/inkflow/pipeline.py index 0f869c96..3ec76448 100644 --- a/src/inkflow/pipeline.py +++ b/src/inkflow/pipeline.py @@ -45,11 +45,17 @@ # ── Slide wire format ──────────────────────────────────────────────────────── +class EditableFile(TypedDict): + label: str + path: str + + class SlideData(TypedDict): id: str svg: str title: str notes: str + editableFiles: list[EditableFile] # ── Path conventions ───────────────────────────────────────────────────────── @@ -657,6 +663,24 @@ 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: list[EditableFile] = [ + {"label": "Layout", "path": str(svg_path)} + ] + if md is not None and md.path is not None: + editable_files.append({"label": "Content", "path": str(md.path)}) + if loaded_notes.path is not None: + editable_files.append({"label": "Notes", "path": str(loaded_notes.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/tests/test_pipeline.py b/tests/test_pipeline.py index a82f77d8..ed6ab92d 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -686,6 +686,74 @@ def test_process_deck_id_collision_resolved(self, tmp_path: Path) -> None: assert results[1]["id"] == "plain-2" +class TestEditableFiles: + def test_svg_only_slide_has_single_layout_entry(self, tmp_path: Path) -> None: + (tmp_path / "slides").mkdir() + slide_path = tmp_path / "slides" / "plain.svg" + slide_path.write_text(_PLAIN_SVG, encoding="utf-8") + deck = Deck(slides=[Slide("slides/plain.svg")]) + results = process_deck(deck, tmp_path) + assert results[0]["editableFiles"] == [ + {"label": "Layout", "path": str(slide_path)} + ] + + def test_file_backed_md_adds_content_entry(self, tmp_path: Path) -> None: + layout = tmp_path / "layouts" / "layout.svg" + layout.parent.mkdir(parents=True, exist_ok=True) + layout.write_text(_LAYOUT_SVG, encoding="utf-8") + slides_dir = tmp_path / "slides" + slides_dir.mkdir() + md_path = slides_dir / "content.md" + md_path.write_text("# Hello\n", encoding="utf-8") + deck = Deck(slides=[Slide("layout", md="content")]) + results = process_deck(deck, tmp_path) + assert results[0]["editableFiles"] == [ + {"label": "Layout", "path": str(layout)}, + {"label": "Content", "path": str(md_path)}, + ] + + def test_inline_md_has_no_content_entry(self, tmp_path: Path) -> None: + layout = tmp_path / "layouts" / "layout.svg" + layout.parent.mkdir(parents=True, exist_ok=True) + layout.write_text(_LAYOUT_SVG, encoding="utf-8") + deck = Deck(slides=[Slide("layout", md=Inline("# Hello"))]) + results = process_deck(deck, tmp_path) + assert results[0]["editableFiles"] == [{"label": "Layout", "path": str(layout)}] + + def test_file_backed_notes_adds_notes_entry(self, tmp_path: Path) -> None: + (tmp_path / "slides").mkdir() + slide_path = tmp_path / "slides" / "plain.svg" + slide_path.write_text(_PLAIN_SVG, encoding="utf-8") + notes_path = tmp_path / "slides" / "plain-notes.md" + notes_path.write_text("Speak slowly.", encoding="utf-8") + deck = Deck(slides=[Slide("slides/plain.svg", notes="slides/plain-notes.md")]) + results = process_deck(deck, tmp_path) + assert results[0]["editableFiles"] == [ + {"label": "Layout", "path": str(slide_path)}, + {"label": "Notes", "path": str(notes_path)}, + ] + + def test_all_three_editable_files_in_order(self, tmp_path: Path) -> None: + layout = tmp_path / "layouts" / "layout.svg" + layout.parent.mkdir(parents=True, exist_ok=True) + layout.write_text(_LAYOUT_SVG, encoding="utf-8") + slides_dir = tmp_path / "slides" + slides_dir.mkdir() + md_path = slides_dir / "content.md" + md_path.write_text("# Hello\n", encoding="utf-8") + notes_path = slides_dir / "content-notes.md" + notes_path.write_text("Notes.", encoding="utf-8") + deck = Deck( + slides=[Slide("layout", md="content", notes="slides/content-notes.md")] + ) + results = process_deck(deck, tmp_path) + assert results[0]["editableFiles"] == [ + {"label": "Layout", "path": str(layout)}, + {"label": "Content", "path": str(md_path)}, + {"label": "Notes", "path": str(notes_path)}, + ] + + class TestParseMarkdownOnce: def test_markdown_parsed_once_per_md_slide( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch From c533e3392d9ad575feb5bcaf858e2426b11f66e7 Mon Sep 17 00:00:00 2001 From: ll-nick <68419636+ll-nick@users.noreply.github.com> Date: Fri, 18 Sep 2026 07:32:28 +0200 Subject: [PATCH 03/25] Set edit commands in html and handle edit requests on the server side --- src/inkflow/server.py | 63 ++++++++++++++++++++++++++++++++++++---- src/ts/presenter/main.ts | 1 + tests/test_fonts.py | 2 +- tests/test_server.py | 56 +++++++++++++++++++++++++++++++++++ 4 files changed, 115 insertions(+), 7 deletions(-) diff --git a/src/inkflow/server.py b/src/inkflow/server.py index 7eaf9502..6159d4ea 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 @@ -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,12 @@ 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: + open_in_editor(*request) finally: _state["ws_clients"].discard(websocket) logger.debug(f"client disconnected ({len(_state['ws_clients'])} total)") @@ -285,13 +323,20 @@ 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( + {"svg": edit_commands.svg is not None, "md": edit_commands.md is not None} + ) html = ( template.replace("/* __CSS__ */", css) .replace("/* __JS__ */", js) @@ -301,6 +346,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 +377,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 +410,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 +524,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 +550,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/presenter/main.ts b/src/ts/presenter/main.ts index 83f0971b..f1e6e465 100644 --- a/src/ts/presenter/main.ts +++ b/src/ts/presenter/main.ts @@ -17,6 +17,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__; 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_server.py b/tests/test_server.py index a026332d..ea52b1d5 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -8,11 +8,14 @@ import pytest from inkflow.assets import AssetRoots +from inkflow.edit import EditCommands from inkflow.enums import ColorMode +from inkflow.pipeline import EditableFile, SlideData from inkflow.server import ( State, _coerce_nav_position, # pyright: ignore[reportPrivateUsage] _resolve_asset, # pyright: ignore[reportPrivateUsage] + _resolve_edit_request, # pyright: ignore[reportPrivateUsage] build_html, ) @@ -44,6 +47,7 @@ def _state(**overrides: object) -> State: "__DATA_THEME__", "__SLIDES_JSON__", "__WS_PORT__", + "__EDIT_COMMANDS_JSON__", "__ERROR_JSON__", "__TRANSITIONS_JSON__", "__LOGS_JSON__", @@ -112,6 +116,18 @@ def test_build_html_transitions_json_embedded() -> None: assert json.dumps([{"type": "fade"}]) in html +def test_build_html_edit_commands_default_both_false() -> None: + html = build_html(_state(), ws_port=7778).decode() + assert json.dumps({"svg": False, "md": False}) in html + + +def test_build_html_edit_commands_reflects_configured() -> None: + html = build_html( + _state(), ws_port=7778, edit_commands=EditCommands(svg="code {path}", md=None) + ).decode() + assert json.dumps({"svg": True, "md": False}) in html + + def test_build_html_logs_json_embedded() -> None: logs = [ {"level": "warning", "message": 'font "Inter" not found in any font directory'} @@ -224,3 +240,43 @@ def test_coerce_nav_empty_deck_pins_index_to_zero() -> None: def test_coerce_nav_defaults_when_fields_absent() -> None: assert _coerce_nav_position({}, 5) == {"slideIndex": 0, "step": 0} + + +# ── _resolve_edit_request ────────────────────────────────────────────────────── + + +def _slide_with_files(*files: EditableFile) -> SlideData: + return { + "id": "s", + "svg": "", + "title": "", + "notes": "", + "editableFiles": list(files), + } + + +def test_resolve_edit_request_valid_path_and_configured_command() -> None: + slides = [_slide_with_files({"label": "Layout", "path": "/deck/slide.svg"})] + commands = EditCommands(svg="code -r {path}", md=None) + assert _resolve_edit_request({"path": "/deck/slide.svg"}, slides, commands) == ( + Path("/deck/slide.svg"), + "code -r {path}", + ) + + +def test_resolve_edit_request_unknown_path_dropped() -> None: + slides = [_slide_with_files({"label": "Layout", "path": "/deck/slide.svg"})] + commands = EditCommands(svg="code -r {path}", md=None) + assert _resolve_edit_request({"path": "/etc/passwd"}, slides, commands) is None + + +def test_resolve_edit_request_missing_path_field_dropped() -> None: + slides = [_slide_with_files({"label": "Layout", "path": "/deck/slide.svg"})] + commands = EditCommands(svg="code -r {path}", md=None) + assert _resolve_edit_request({}, slides, commands) is None + + +def test_resolve_edit_request_no_command_configured_dropped() -> None: + slides = [_slide_with_files({"label": "Layout", "path": "/deck/slide.svg"})] + commands = EditCommands(svg=None, md=None) + assert _resolve_edit_request({"path": "/deck/slide.svg"}, slides, commands) is None From c340a47df45f68b00fb757f99cb8951b2c69dc3f Mon Sep 17 00:00:00 2001 From: ll-nick <68419636+ll-nick@users.noreply.github.com> Date: Fri, 18 Sep 2026 11:54:04 +0200 Subject: [PATCH 04/25] Add the editable files and commands to the type definitions --- src/ts/shared/types.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/ts/shared/types.ts b/src/ts/shared/types.ts index 92102d65..d9c72010 100644 --- a/src/ts/shared/types.ts +++ b/src/ts/shared/types.ts @@ -1,11 +1,24 @@ // 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; + 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 { + svg: boolean; + md: boolean; } // Per-client position-sync mode. Never sent to the server: it only decides, From ad56bdef71fcf283ca181f21178e143463b82ed1 Mon Sep 17 00:00:00 2001 From: ll-nick <68419636+ll-nick@users.noreply.github.com> Date: Fri, 18 Sep 2026 11:51:05 +0200 Subject: [PATCH 05/25] Add a button to the status bar --- src/inkflow/presenter.html | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/inkflow/presenter.html b/src/inkflow/presenter.html index cb22a5de..8fac6e09 100644 --- a/src/inkflow/presenter.html +++ b/src/inkflow/presenter.html @@ -232,6 +232,14 @@

KEYBINDINGS

+ + + + From 09eaa4600eda367f63ee9aa8d7aacf36fd9d42c6 Mon Sep 17 00:00:00 2001 From: ll-nick <68419636+ll-nick@users.noreply.github.com> Date: Fri, 18 Sep 2026 11:51:46 +0200 Subject: [PATCH 06/25] Style the edit button --- src/css/presenter/edit.css | 55 +++++++++++++++++++++++++++++++++++++ src/css/presenter/index.css | 1 + 2 files changed, 56 insertions(+) create mode 100644 src/css/presenter/edit.css diff --git a/src/css/presenter/edit.css b/src/css/presenter/edit.css new file mode 100644 index 00000000..c592a30f --- /dev/null +++ b/src/css/presenter/edit.css @@ -0,0 +1,55 @@ +/* ── Edit-source control ────────────────────────────────────────────────────── */ + +.edit-wrap { + position: relative; + display: inline-flex; +} + +/* Dropdown: anchored above the button (status bar sits at the bottom). */ +#edit-menu { + display: none; + position: absolute; + bottom: calc(100% + 6px); + left: 0; + z-index: 30; + min-width: 9rem; + 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; + width: 100%; + background: transparent; + border: none; + border-radius: 4px; + color: var(--subtext); + cursor: pointer; + font-family: inherit; + font-size: 0.78rem; + 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); +} + +/* Brief confirmation flash after a clipboard copy (button or menu row). */ +#btn-edit.copied, +.edit-row.copied { + color: var(--accent); +} 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"; From caeb1d099a36220bae678e379f39ea56438147ec Mon Sep 17 00:00:00 2001 From: ll-nick <68419636+ll-nick@users.noreply.github.com> Date: Fri, 18 Sep 2026 11:53:00 +0200 Subject: [PATCH 07/25] Add logic behind the edit button --- src/ts/presenter/edit.test.ts | 142 ++++++++++++++++++++++++++++++++++ src/ts/presenter/edit.ts | 121 +++++++++++++++++++++++++++++ 2 files changed, 263 insertions(+) create mode 100644 src/ts/presenter/edit.test.ts create mode 100644 src/ts/presenter/edit.ts diff --git a/src/ts/presenter/edit.test.ts b/src/ts/presenter/edit.test.ts new file mode 100644 index 00000000..28c184e8 --- /dev/null +++ b/src/ts/presenter/edit.test.ts @@ -0,0 +1,142 @@ +// @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 state: typeof import("./state").state; +let btnEdit: HTMLButtonElement; +let editMenu: HTMLElement; +let editWrap: HTMLElement; + +beforeEach(async () => { + document.body.innerHTML = ` + + +
+
+ `; + vi.resetModules(); + ({ initEditMenu, renderEditButton } = await import("./edit")); + ({ state } = await import("./state")); + btnEdit = document.getElementById("btn-edit") as HTMLButtonElement; + editMenu = document.getElementById("edit-menu")!; + editWrap = btnEdit.closest(".edit-wrap") as HTMLElement; + 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; 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, md: false }, null); + expect(editWrap.style.display).toBe("none"); +}); + +test("renderEditButton populates one row per editable file", () => { + state.slides = [ + slideWith( + { label: "Layout", path: "/deck/slide.svg" }, + { label: "Content", path: "/deck/slide.md" }, + ), + ]; + renderEditButton(); + const rows = editMenu.querySelectorAll(".edit-row"); + expect(rows.length).toBe(2); + expect(rows[0].textContent).toBe("Layout"); + expect(rows[1].textContent).toBe("Content"); +}); + +test("a single editable file acts directly on click, no dropdown", () => { + state.slides = [slideWith({ label: "Layout", path: "/deck/slide.svg" })]; + initEditMenu({ svg: false, md: false }, 7778); + btnEdit.click(); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + "/deck/slide.svg", + ); + expect(editMenu.classList.contains("open")).toBe(false); +}); + +test("multiple editable files open a dropdown instead of acting directly", () => { + state.slides = [ + slideWith( + { label: "Layout", path: "/deck/slide.svg" }, + { label: "Content", path: "/deck/slide.md" }, + ), + ]; + initEditMenu({ svg: false, md: false }, 7778); + btnEdit.click(); + expect(editMenu.classList.contains("open")).toBe(true); + expect(navigator.clipboard.writeText).not.toHaveBeenCalled(); +}); + +test("clicking a dropdown row acts on that file and closes the menu", () => { + state.slides = [ + slideWith( + { label: "Layout", path: "/deck/slide.svg" }, + { label: "Content", path: "/deck/slide.md" }, + ), + ]; + initEditMenu({ svg: false, md: false }, 7778); + btnEdit.click(); + const contentRow = Array.from( + editMenu.querySelectorAll(".edit-row"), + ).find((r) => r.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", path: "/deck/slide.svg" })]; + const send = vi.fn(); + state.ws = { send, readyState: WebSocket.OPEN } as unknown as WebSocket; + initEditMenu({ svg: true, md: false }, 7778); + btnEdit.click(); + expect(send).toHaveBeenCalledWith( + JSON.stringify({ type: "edit", path: "/deck/slide.svg" }), + ); + expect(navigator.clipboard.writeText).not.toHaveBeenCalled(); +}); + +test("a configured command falls back to clipboard when the WS is not open", () => { + state.slides = [slideWith({ label: "Layout", path: "/deck/slide.svg" })]; + const send = vi.fn(); + state.ws = { send, readyState: WebSocket.CLOSED } as unknown as WebSocket; + initEditMenu({ svg: true, md: false }, 7778); + btnEdit.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", path: "/deck/slide.svg" })]; + const send = vi.fn(); + state.ws = { send, readyState: WebSocket.OPEN } as unknown as WebSocket; + initEditMenu({ svg: false, md: false }, 7778); + btnEdit.click(); + expect(send).not.toHaveBeenCalled(); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + "/deck/slide.svg", + ); +}); diff --git a/src/ts/presenter/edit.ts b/src/ts/presenter/edit.ts new file mode 100644 index 00000000..8d80e96e --- /dev/null +++ b/src/ts/presenter/edit.ts @@ -0,0 +1,121 @@ +import type { EditableFile, EditCommandsConfig } from "../shared/types"; +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_SVG / +// INKFLOW_EDIT_CMD_MD (server env vars, baked into EditCommandsConfig at page +// load) override that per 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")!; + +let config: EditCommandsConfig = { svg: false, md: false }; + +function isConfigured(file: EditableFile): boolean { + return file.path.toLowerCase().endsWith(".svg") ? config.svg : config.md; +} + +function flashCopied(el: HTMLElement): void { + el.classList.add("copied"); + setTimeout(() => el.classList.remove("copied"), 1200); +} + +function actOn(file: EditableFile, flashTarget: HTMLElement): void { + if ( + isConfigured(file) && + state.ws && + state.ws.readyState === WebSocket.OPEN + ) { + state.ws.send(JSON.stringify({ type: "edit", path: file.path })); + return; + } + try { + void navigator.clipboard.writeText(file.path); + flashCopied(flashTarget); + } 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.textContent = file.label; + row.addEventListener("click", () => { + actOn(file, row); + 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); +} + +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); +} + +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(); + const files = state.slides[state.slideIndex]?.editableFiles ?? []; + if (files.length <= 1) { + if (files.length === 1) actOn(files[0], btnEdit); + return; + } + toggleMenu(); + }); + renderEditButton(); +} From 44fcdb310a6ce5d62011aae05aa84a0872021113 Mon Sep 17 00:00:00 2001 From: ll-nick <68419636+ll-nick@users.noreply.github.com> Date: Fri, 18 Sep 2026 11:53:40 +0200 Subject: [PATCH 08/25] Adjust existing tests that now need to carry editable files or the edit menu --- src/ts/presenter/overview.test.ts | 6 ++++++ src/ts/presenter/picker.test.ts | 14 +++++++++++++- src/ts/presenter/transitions.test.ts | 18 +++++++++++++++++- src/ts/presenter/windowsync.test.ts | 4 ++++ 4 files changed, 40 insertions(+), 2 deletions(-) diff --git a/src/ts/presenter/overview.test.ts b/src/ts/presenter/overview.test.ts index 9f2b2739..e0eecf28 100644 --- a/src/ts/presenter/overview.test.ts +++ b/src/ts/presenter/overview.test.ts @@ -45,6 +45,10 @@ beforeAll(async () => {
+ + +
+
`; // computeStageFlip() divides stage/thumbnail rect sizes; happy-dom has no @@ -87,12 +91,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..8cccda63 100644 --- a/src/ts/presenter/picker.test.ts +++ b/src/ts/presenter/picker.test.ts @@ -39,6 +39,10 @@ beforeAll(async () => {
+ + +
+
`; vi.resetModules(); ({ filterPicker } = await import("./picker")); @@ -53,7 +57,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/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/windowsync.test.ts b/src/ts/presenter/windowsync.test.ts index 2ce194bc..c3d7d901 100644 --- a/src/ts/presenter/windowsync.test.ts +++ b/src/ts/presenter/windowsync.test.ts @@ -37,6 +37,10 @@ beforeAll(async () => {
+ + +
+
`; ({ isSyncPayload } = await import("./windowsync")); }); From eacb88c0a72d11edded71add71c16b12a6c26907 Mon Sep 17 00:00:00 2001 From: ll-nick <68419636+ll-nick@users.noreply.github.com> Date: Fri, 18 Sep 2026 11:54:28 +0200 Subject: [PATCH 09/25] Wire up the edit button --- src/ts/globals.d.ts | 2 ++ src/ts/presenter/main.ts | 2 ++ src/ts/presenter/pv.ts | 2 ++ 3 files changed, 6 insertions(+) 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/main.ts b/src/ts/presenter/main.ts index f1e6e465..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"; @@ -41,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/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 { From 5c1cfb92a19f57c5b690e2322f178eed4d6783e1 Mon Sep 17 00:00:00 2001 From: ll-nick <68419636+ll-nick@users.noreply.github.com> Date: Fri, 18 Sep 2026 15:21:09 +0200 Subject: [PATCH 10/25] Add a test deck that varies the utilized source files --- tests/decks/edit_button.py | 38 ++++++++++++++++++++ tests/decks/slides/edit-content-and-notes.md | 7 ++++ tests/decks/slides/edit-content.md | 6 ++++ tests/decks/slides/edit-notes.md | 2 ++ 4 files changed, 53 insertions(+) create mode 100644 tests/decks/edit_button.py create mode 100644 tests/decks/slides/edit-content-and-notes.md create mode 100644 tests/decks/slides/edit-content.md create mode 100644 tests/decks/slides/edit-notes.md diff --git a/tests/decks/edit_button.py b/tests/decks/edit_button.py new file mode 100644 index 00000000..c3fcbeef --- /dev/null +++ b/tests/decks/edit_button.py @@ -0,0 +1,38 @@ +"""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 (or its dropdown, on slides with more than one entry) in the status +bar on each slide: + +1. **SVG only** — no markdown, no notes. Edit should act directly (no dropdown): + with no ``INKFLOW_EDIT_CMD_SVG`` set, it copies the slide's absolute path to + the clipboard; with it set (e.g. ``INKFLOW_EDIT_CMD_SVG="code -r {path}"``), + it launches that command instead. +2. **Content file** — ``md=`` points at a real file. Edit should offer a + two-item dropdown: Layout, Content. +3. **Content and notes files** — both ``md=`` and ``notes=`` are file-backed. + Edit should offer a three-item dropdown: Layout, Content, Notes. + +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", + ), + ], + ) 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. From 3b4dac604c0b545b6918e415b266d33dd8bb73ec Mon Sep 17 00:00:00 2001 From: ll-nick <68419636+ll-nick@users.noreply.github.com> Date: Sun, 20 Sep 2026 19:04:09 +0200 Subject: [PATCH 11/25] Find and pass also in-project parent layers to the frontend --- src/inkflow/pipeline.py | 44 ++++++++++++++++++++----- src/ts/shared/types.ts | 1 + tests/test_pipeline.py | 71 ++++++++++++++++++++++++++++++++++------- tests/test_server.py | 24 +++++++++++--- 4 files changed, 116 insertions(+), 24 deletions(-) diff --git a/src/inkflow/pipeline.py b/src/inkflow/pipeline.py index 3ec76448..fd75d521 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, @@ -47,6 +47,7 @@ class EditableFile(TypedDict): label: str + name: str path: str @@ -637,6 +638,39 @@ def _source_for(roots: AssetRoots, path: Path | None) -> AssetSource: return AssetSource.for_file(roots, path) +def _editable_files( + ctx: DeckContext, + svg_path: Path, + md: LoadedText | None, + loaded_notes: LoadedText, +) -> 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, and any + file-backed content/notes: what the presenter's Edit button offers. + + 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), + } + ) + return files + + def process_deck(deck: Deck, project_dir: Path) -> list[SlideData]: visible_slides = [s for s in deck.slides if s.visible] assets = AssetRoots(project_dir, deck.theme.asset_dir()) @@ -665,13 +699,7 @@ def process_deck(deck: Deck, project_dir: Path) -> list[SlideData]: notes = "\n".join(filter(None, [explicit_notes, md_notes])) svg_path = resolve_slide_src(slide.src, ctx.project_dir, ctx.theme) - editable_files: list[EditableFile] = [ - {"label": "Layout", "path": str(svg_path)} - ] - if md is not None and md.path is not None: - editable_files.append({"label": "Content", "path": str(md.path)}) - if loaded_notes.path is not None: - editable_files.append({"label": "Notes", "path": str(loaded_notes.path)}) + editable_files = _editable_files(ctx, svg_path, md, loaded_notes) results.append( { diff --git a/src/ts/shared/types.ts b/src/ts/shared/types.ts index d9c72010..80724292 100644 --- a/src/ts/shared/types.ts +++ b/src/ts/shared/types.ts @@ -3,6 +3,7 @@ // defensive, not handling a real absent case. export interface EditableFile { label: string; + name: string; path: string; } diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index ed6ab92d..7c5ec235 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -686,6 +686,16 @@ def test_process_deck_id_collision_resolved(self, tmp_path: Path) -> None: assert results[1]["id"] == "plain-2" +def _svg_with_parent(parent: str) -> str: + return ( + '\n' + "\n" + ) + + class TestEditableFiles: def test_svg_only_slide_has_single_layout_entry(self, tmp_path: Path) -> None: (tmp_path / "slides").mkdir() @@ -694,7 +704,7 @@ def test_svg_only_slide_has_single_layout_entry(self, tmp_path: Path) -> None: deck = Deck(slides=[Slide("slides/plain.svg")]) results = process_deck(deck, tmp_path) assert results[0]["editableFiles"] == [ - {"label": "Layout", "path": str(slide_path)} + {"label": "Layout", "name": "plain.svg", "path": str(slide_path)} ] def test_file_backed_md_adds_content_entry(self, tmp_path: Path) -> None: @@ -708,8 +718,8 @@ def test_file_backed_md_adds_content_entry(self, tmp_path: Path) -> None: deck = Deck(slides=[Slide("layout", md="content")]) results = process_deck(deck, tmp_path) assert results[0]["editableFiles"] == [ - {"label": "Layout", "path": str(layout)}, - {"label": "Content", "path": str(md_path)}, + {"label": "Layout", "name": "layout.svg", "path": str(layout)}, + {"label": "Content", "name": "content.md", "path": str(md_path)}, ] def test_inline_md_has_no_content_entry(self, tmp_path: Path) -> None: @@ -718,7 +728,9 @@ def test_inline_md_has_no_content_entry(self, tmp_path: Path) -> None: layout.write_text(_LAYOUT_SVG, encoding="utf-8") deck = Deck(slides=[Slide("layout", md=Inline("# Hello"))]) results = process_deck(deck, tmp_path) - assert results[0]["editableFiles"] == [{"label": "Layout", "path": str(layout)}] + assert results[0]["editableFiles"] == [ + {"label": "Layout", "name": "layout.svg", "path": str(layout)} + ] def test_file_backed_notes_adds_notes_entry(self, tmp_path: Path) -> None: (tmp_path / "slides").mkdir() @@ -729,14 +741,16 @@ def test_file_backed_notes_adds_notes_entry(self, tmp_path: Path) -> None: deck = Deck(slides=[Slide("slides/plain.svg", notes="slides/plain-notes.md")]) results = process_deck(deck, tmp_path) assert results[0]["editableFiles"] == [ - {"label": "Layout", "path": str(slide_path)}, - {"label": "Notes", "path": str(notes_path)}, + {"label": "Layout", "name": "plain.svg", "path": str(slide_path)}, + {"label": "Notes", "name": "plain-notes.md", "path": str(notes_path)}, ] - def test_all_three_editable_files_in_order(self, tmp_path: Path) -> None: + def test_all_four_editable_files_in_order(self, tmp_path: Path) -> None: + parent = tmp_path / "layouts" / "parent.svg" + parent.parent.mkdir(parents=True, exist_ok=True) + parent.write_text(_LAYOUT_SVG, encoding="utf-8") layout = tmp_path / "layouts" / "layout.svg" - layout.parent.mkdir(parents=True, exist_ok=True) - layout.write_text(_LAYOUT_SVG, encoding="utf-8") + layout.write_text(_svg_with_parent("parent"), encoding="utf-8") slides_dir = tmp_path / "slides" slides_dir.mkdir() md_path = slides_dir / "content.md" @@ -748,9 +762,42 @@ def test_all_three_editable_files_in_order(self, tmp_path: Path) -> None: ) results = process_deck(deck, tmp_path) assert results[0]["editableFiles"] == [ - {"label": "Layout", "path": str(layout)}, - {"label": "Content", "path": str(md_path)}, - {"label": "Notes", "path": str(notes_path)}, + {"label": "Parent", "name": "parent.svg", "path": str(parent)}, + {"label": "Layout", "name": "layout.svg", "path": str(layout)}, + {"label": "Content", "name": "content.md", "path": str(md_path)}, + {"label": "Notes", "name": "content-notes.md", "path": str(notes_path)}, + ] + + def test_ancestor_chain_listed_root_first_next_to_layout( + self, tmp_path: Path + ) -> None: + # Root-first, so the immediate parent sits right next to Layout: reading + # top to bottom is root -> ... -> immediate parent -> layout -> content. + grandparent = tmp_path / "layouts" / "grandparent.svg" + grandparent.parent.mkdir(parents=True, exist_ok=True) + grandparent.write_text(_LAYOUT_SVG, encoding="utf-8") + parent = tmp_path / "layouts" / "parent.svg" + parent.write_text(_svg_with_parent("grandparent"), encoding="utf-8") + layout = tmp_path / "layouts" / "layout.svg" + layout.write_text(_svg_with_parent("parent"), encoding="utf-8") + deck = Deck(slides=[Slide("layout")]) + results = process_deck(deck, tmp_path) + assert results[0]["editableFiles"] == [ + {"label": "Parent", "name": "grandparent.svg", "path": str(grandparent)}, + {"label": "Parent", "name": "parent.svg", "path": str(parent)}, + {"label": "Layout", "name": "layout.svg", "path": str(layout)}, + ] + + def test_theme_ancestor_excluded(self, tmp_path: Path) -> None: + # A layout built on a built-in/theme ancestor: that ancestor lives outside + # the project (often inside an installed package), so it's not offered. + layout = tmp_path / "layouts" / "layout.svg" + layout.parent.mkdir(parents=True, exist_ok=True) + layout.write_text(_svg_with_parent("builtin:base"), encoding="utf-8") + deck = Deck(slides=[Slide("layout")]) + results = process_deck(deck, tmp_path) + assert results[0]["editableFiles"] == [ + {"label": "Layout", "name": "layout.svg", "path": str(layout)} ] diff --git a/tests/test_server.py b/tests/test_server.py index ea52b1d5..8185f81d 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -256,7 +256,11 @@ def _slide_with_files(*files: EditableFile) -> SlideData: def test_resolve_edit_request_valid_path_and_configured_command() -> None: - slides = [_slide_with_files({"label": "Layout", "path": "/deck/slide.svg"})] + slides = [ + _slide_with_files( + {"label": "Layout", "name": "slide.svg", "path": "/deck/slide.svg"} + ) + ] commands = EditCommands(svg="code -r {path}", md=None) assert _resolve_edit_request({"path": "/deck/slide.svg"}, slides, commands) == ( Path("/deck/slide.svg"), @@ -265,18 +269,30 @@ def test_resolve_edit_request_valid_path_and_configured_command() -> None: def test_resolve_edit_request_unknown_path_dropped() -> None: - slides = [_slide_with_files({"label": "Layout", "path": "/deck/slide.svg"})] + slides = [ + _slide_with_files( + {"label": "Layout", "name": "slide.svg", "path": "/deck/slide.svg"} + ) + ] commands = EditCommands(svg="code -r {path}", md=None) assert _resolve_edit_request({"path": "/etc/passwd"}, slides, commands) is None def test_resolve_edit_request_missing_path_field_dropped() -> None: - slides = [_slide_with_files({"label": "Layout", "path": "/deck/slide.svg"})] + slides = [ + _slide_with_files( + {"label": "Layout", "name": "slide.svg", "path": "/deck/slide.svg"} + ) + ] commands = EditCommands(svg="code -r {path}", md=None) assert _resolve_edit_request({}, slides, commands) is None def test_resolve_edit_request_no_command_configured_dropped() -> None: - slides = [_slide_with_files({"label": "Layout", "path": "/deck/slide.svg"})] + slides = [ + _slide_with_files( + {"label": "Layout", "name": "slide.svg", "path": "/deck/slide.svg"} + ) + ] commands = EditCommands(svg=None, md=None) assert _resolve_edit_request({"path": "/deck/slide.svg"}, slides, commands) is None From e845fc633105769bc15449a2878e2e425e85abb9 Mon Sep 17 00:00:00 2001 From: ll-nick <68419636+ll-nick@users.noreply.github.com> Date: Sun, 20 Sep 2026 19:05:29 +0200 Subject: [PATCH 12/25] Add a menu coordinator to prevent multiple drop downs opened at once --- src/ts/presenter/edit.ts | 2 ++ src/ts/presenter/menus.test.ts | 45 ++++++++++++++++++++++++++++++++++ src/ts/presenter/menus.ts | 16 ++++++++++++ src/ts/presenter/syncmenu.ts | 3 +++ 4 files changed, 66 insertions(+) create mode 100644 src/ts/presenter/menus.test.ts create mode 100644 src/ts/presenter/menus.ts diff --git a/src/ts/presenter/edit.ts b/src/ts/presenter/edit.ts index 8d80e96e..0042ed56 100644 --- a/src/ts/presenter/edit.ts +++ b/src/ts/presenter/edit.ts @@ -84,6 +84,7 @@ function openMenu(): void { btnEdit.setAttribute("aria-expanded", "true"); document.addEventListener("click", onDocClick); document.addEventListener("keydown", onKeydown); + menuOpened(closeMenu); } function closeMenu(): void { @@ -92,6 +93,7 @@ function closeMenu(): void { btnEdit.setAttribute("aria-expanded", "false"); document.removeEventListener("click", onDocClick); document.removeEventListener("keydown", onKeydown); + menuClosed(closeMenu); } function toggleMenu(): void { 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/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 { From 857831d720b01f2e83599391f1320ecc4448d4ac Mon Sep 17 00:00:00 2001 From: ll-nick <68419636+ll-nick@users.noreply.github.com> Date: Sun, 20 Sep 2026 19:06:20 +0200 Subject: [PATCH 13/25] Use a bigger icon on the edit button --- src/inkflow/presenter.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/inkflow/presenter.html b/src/inkflow/presenter.html index 8fac6e09..29a52da3 100644 --- a/src/inkflow/presenter.html +++ b/src/inkflow/presenter.html @@ -235,7 +235,7 @@

KEYBINDINGS

From 6275e7aff712ae460ab41e677ff00ca12c556720 Mon Sep 17 00:00:00 2001 From: ll-nick <68419636+ll-nick@users.noreply.github.com> Date: Sun, 20 Sep 2026 19:07:40 +0200 Subject: [PATCH 14/25] Add a toast-style pop-up confirmation when pressing edit --- src/css/presenter/overlays.css | 35 ++++++++++++++++++++++++++++++++++ src/inkflow/presenter.html | 7 +++++++ src/ts/presenter/edit.ts | 22 ++++++++++++++++----- 3 files changed, 59 insertions(+), 5 deletions(-) diff --git a/src/css/presenter/overlays.css b/src/css/presenter/overlays.css index a0ed13db..ae408c9e 100644 --- a/src/css/presenter/overlays.css +++ b/src/css/presenter/overlays.css @@ -205,3 +205,38 @@ kbd { #log-list li.log-error { color: var(--red); } + +/* ── Edit-action toast (non-modal, self-dismissing) ── + Same corner and box treatment as #log-banner (surface/border/shadow), an + accent colour instead of its warning yellow, and no close button — it always + times itself out (edit.ts) rather than needing a dismiss. */ +#edit-toast { + display: none; + position: fixed; + right: 1rem; + bottom: 1rem; + z-index: 40; + align-items: center; + gap: 0.4rem; + max-width: 20rem; + background: var(--surface); + border: 1px solid var(--green); + border-radius: 8px; + padding: 0.5rem 0.75rem; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35); + color: var(--text); + font-size: 0.8rem; + white-space: nowrap; + pointer-events: none; +} +#edit-toast.visible { + display: flex; +} +#edit-toast svg { + color: var(--green); + flex-shrink: 0; +} +#edit-toast-text { + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/src/inkflow/presenter.html b/src/inkflow/presenter.html index 29a52da3..71f81317 100644 --- a/src/inkflow/presenter.html +++ b/src/inkflow/presenter.html @@ -23,6 +23,12 @@

BUILD ERROR

    +
    + + +