diff --git a/src/css/presenter/base.css b/src/css/presenter/base.css index f2b02548..503513af 100644 --- a/src/css/presenter/base.css +++ b/src/css/presenter/base.css @@ -53,6 +53,7 @@ body { } #statusbar { + position: relative; flex-shrink: 0; background: var(--bg); border-top: 1px solid var(--overlay); @@ -98,11 +99,12 @@ body { /* ── Status-bar tooltips: driven by data-tooltip (not title) so the slow, unstyled native browser tooltip never doubles up with this one. Anchored above the bar, - since #statusbar sits at the bottom of the viewport. ── */ -.hud-btn[data-tooltip] { + since #statusbar sits at the bottom of the viewport. Not scoped to .hud-btn: + plain status spans (#slide-info, #step-info, #ws-dot) use it too. ── */ +[data-tooltip] { position: relative; } -.hud-btn[data-tooltip]::after { +[data-tooltip]::after { content: attr(data-tooltip); position: absolute; bottom: calc(100% + 8px); @@ -121,8 +123,8 @@ body { transition: opacity 0.1s ease; z-index: 40; } -.hud-btn[data-tooltip]:hover::after, -.hud-btn[data-tooltip]:focus-visible::after { +[data-tooltip]:hover::after, +[data-tooltip]:focus-visible::after { opacity: 1; transition-delay: 0.35s; } @@ -131,14 +133,27 @@ body { opacity: 0; transition-delay: 0s; } -/* Edge buttons: keep the bubble on-screen instead of centering off the bar. */ -#btn-theme[data-tooltip]::after { +/* Edge elements: keep the bubble on-screen instead of centering off the bar. */ +#btn-theme[data-tooltip]::after, +#btn-prev[data-tooltip]::after { left: 0; transform: none; } #log-indicator[data-tooltip]::after { left: auto; right: 0; + transform: none; +} +/* #ws-dot is narrower than its tooltip text, so right-aligning to its own + edge (like #log-indicator above) leaves the bubble hanging off to the left. + position:static borrows #statusbar's edge instead. */ +#ws-dot[data-tooltip] { + position: static; +} +#ws-dot[data-tooltip]::after { + left: auto; + right: 0; + transform: none; } .hud-sep { @@ -166,6 +181,10 @@ body { font-size: 0.85rem; } +#step-info { + margin-left: 0.6rem; +} + /* ── Log indicator: present only when the deck has messages (JS sets data-level to the highest level). Static builds export no logs, so it never appears there. The icon shape and colour track that level; clicking re-opens the message banner. ── */ @@ -194,17 +213,28 @@ body { display: block; } +/* The badge X is stroked in --bg, not a contrasting colour, so it reads as + a cutout in the red circle rather than a separate mark. */ #ws-dot { - display: inline-block; - width: 6px; - height: 6px; - border-radius: 50%; - background: var(--red); - transition: background 0.3s; + display: inline-flex; + align-items: center; + color: var(--subtext); vertical-align: middle; } -#ws-dot.connected { - background: var(--green); +#ws-dot .ws-badge { + fill: var(--red); +} +#ws-dot .ws-badge-x { + stroke: var(--bg); +} +#ws-dot .ws-ic-on { + display: none; +} +#ws-dot.connected .ws-ic-off { + display: none; +} +#ws-dot.connected .ws-ic-on { + display: block; } /* ── Media zone theme-swap ────────────────────────────────────────────────── */ diff --git a/src/css/presenter/edit.css b/src/css/presenter/edit.css index ff548d8a..0dbc6535 100644 --- a/src/css/presenter/edit.css +++ b/src/css/presenter/edit.css @@ -70,3 +70,14 @@ text-overflow: ellipsis; white-space: nowrap; } + +/* Keyboard-driven highlight (keyboard.ts, arrows/j/k while the menu is open) — + same accent/bg swap as #picker-list's own [role="option"].active. Declared + last so it reliably overrides the base rules above. */ +.edit-row.active { + background: var(--accent); + color: var(--bg); +} +.edit-row.active .edit-row-name { + opacity: 0.75; +} diff --git a/src/inkflow/bundles/presenter.css b/src/inkflow/bundles/presenter.css index 8a66b738..34c8fceb 100644 --- a/src/inkflow/bundles/presenter.css +++ b/src/inkflow/bundles/presenter.css @@ -78,6 +78,7 @@ body { height: 100%; } #statusbar { + position: relative; flex-shrink: 0; background: var(--bg); border-top: 1px solid var(--overlay); @@ -116,10 +117,10 @@ body { [data-theme=light] .hud-btn:hover { background: rgba(0, 0, 0, 0.08); } -.hud-btn[data-tooltip] { +[data-tooltip] { position: relative; } -.hud-btn[data-tooltip]::after { +[data-tooltip]::after { content: attr(data-tooltip); position: absolute; bottom: calc(100% + 8px); @@ -138,8 +139,8 @@ body { transition: opacity 0.1s ease; z-index: 40; } -.hud-btn[data-tooltip]:hover::after, -.hud-btn[data-tooltip]:focus-visible::after { +[data-tooltip]:hover::after, +[data-tooltip]:focus-visible::after { opacity: 1; transition-delay: 0.35s; } @@ -147,13 +148,23 @@ body { opacity: 0; transition-delay: 0s; } -#btn-theme[data-tooltip]::after { +#btn-theme[data-tooltip]::after, +#btn-prev[data-tooltip]::after { left: 0; transform: none; } #log-indicator[data-tooltip]::after { left: auto; right: 0; + transform: none; +} +#ws-dot[data-tooltip] { + position: static; +} +#ws-dot[data-tooltip]::after { + left: auto; + right: 0; + transform: none; } .hud-sep { width: 1px; @@ -176,6 +187,9 @@ body { color: var(--text); font-size: 0.85rem; } +#step-info { + margin-left: 0.6rem; +} #log-indicator:not([data-level]) { display: none; } @@ -201,16 +215,25 @@ body { display: block; } #ws-dot { - display: inline-block; - width: 6px; - height: 6px; - border-radius: 50%; - background: var(--red); - transition: background 0.3s; + display: inline-flex; + align-items: center; + color: var(--subtext); vertical-align: middle; } -#ws-dot.connected { - background: var(--green); +#ws-dot .ws-badge { + fill: var(--red); +} +#ws-dot .ws-badge-x { + stroke: var(--bg); +} +#ws-dot .ws-ic-on { + display: none; +} +#ws-dot.connected .ws-ic-off { + display: none; +} +#ws-dot.connected .ws-ic-on { + display: block; } [data-inkflow-theme] { display: block; @@ -822,6 +845,13 @@ kbd { text-overflow: ellipsis; white-space: nowrap; } +.edit-row.active { + background: var(--accent); + color: var(--bg); +} +.edit-row.active .edit-row-name { + opacity: 0.75; +} /* src/css/presenter/overview.css */ #overview { diff --git a/src/inkflow/bundles/presenter.js b/src/inkflow/bundles/presenter.js index b7798fbf..d67e4381 100644 --- a/src/inkflow/bundles/presenter.js +++ b/src/inkflow/bundles/presenter.js @@ -21,6 +21,7 @@ _pickerActive: 0, _overviewActive: 0, _overviewCols: 1, + _editActive: 0, ws: null, windowLink: null, _syncingFromServer: false, @@ -323,21 +324,32 @@ editMenu.appendChild(row); } } + function editMenuSetActive(i) { + const rows = Array.from( + editMenu.querySelectorAll(".edit-row") + ); + if (rows.length === 0) return; + state._editActive = Math.max(0, Math.min(rows.length - 1, i)); + rows.forEach((row, idx) => { + row.classList.toggle("active", idx === state._editActive); + }); + rows[state._editActive]?.scrollIntoView({ block: "nearest" }); + } + function editMenuCommit() { + const files = state.slides[state.slideIndex]?.editableFiles ?? []; + const file = files[state._editActive]; + if (file) actOn(file); + closeMenu(); + } 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"); + editMenuSetActive(0); document.addEventListener("click", onDocClick); - document.addEventListener("keydown", onKeydown); menuOpened(closeMenu); } function closeMenu() { @@ -345,7 +357,6 @@ editMenu.classList.remove("open"); btnEdit.setAttribute("aria-expanded", "false"); document.removeEventListener("click", onDocClick); - document.removeEventListener("keydown", onKeydown); menuClosed(closeMenu); } function toggleMenu() { @@ -2989,6 +3000,7 @@ let firstPositionPending = false; state.ws.onopen = () => { wsDot.className = "connected"; + wsDot.dataset.tooltip = "Connected"; const assert = authoritative && sends(); firstPositionPending = assert; if (assert) sendNav(); @@ -3030,6 +3042,7 @@ }; state.ws.onclose = () => { wsDot.className = ""; + wsDot.dataset.tooltip = "Disconnected"; state.ws = null; setTimeout(() => connectWS(wsPort, true), 2e3); }; @@ -3070,7 +3083,7 @@ const t = e.target; if (!btnSync.contains(t) && !syncMenu.contains(t)) closeMenu2(); } - function onKeydown2(e) { + function onKeydown(e) { if (e.key === "Escape") { closeMenu2(); btnSync.focus(); @@ -3080,7 +3093,7 @@ syncMenu.classList.add("open"); btnSync.setAttribute("aria-expanded", "true"); document.addEventListener("click", onDocClick2); - document.addEventListener("keydown", onKeydown2); + document.addEventListener("keydown", onKeydown); menuOpened(closeMenu2); } function closeMenu2() { @@ -3088,7 +3101,7 @@ syncMenu.classList.remove("open"); btnSync.setAttribute("aria-expanded", "false"); document.removeEventListener("click", onDocClick2); - document.removeEventListener("keydown", onKeydown2); + document.removeEventListener("keydown", onKeydown); menuClosed(closeMenu2); } function toggleMenu2() { @@ -3150,16 +3163,19 @@ clearInterval(linkPoll); linkPoll = void 0; } + var _wsPort = null; + function openSyncedWindow() { + if (_wsPort === null && state.windowLink) return; + const child = window.open(location.href); + if (!child) { + showNotify(POPUP_BLOCKED_MESSAGE, "yellow"); + return; + } + if (_wsPort === null) attachLink(child); + } function initWindowSync(wsPort) { - btnPresenterView.addEventListener("click", () => { - if (wsPort === null && state.windowLink) return; - const child = window.open(location.href); - if (!child) { - showNotify(POPUP_BLOCKED_MESSAGE, "yellow"); - return; - } - if (wsPort === null) attachLink(child); - }); + _wsPort = wsPort; + btnPresenterView.addEventListener("click", openSyncedWindow); if (wsPort !== null) return; if (window.opener) attachLink(window.opener, true); } @@ -3740,6 +3756,7 @@ $: { action: gotoLast }, g: { action: openPicker, preventDefault: true }, o: { action: toggleOverview, preventDefault: true }, + e: { action: toggleMenu }, f: { action: toggleFullscreen }, b: { action: () => toggleCurtain("black") }, ".": { action: toggleLaser }, @@ -3756,6 +3773,7 @@ p: { action: togglePv }, d: { action: toggleLogs }, m: { action: toggleNotifyHistory }, + n: { action: openSyncedWindow }, s: { action: cycleSyncMode } }; var helpEl = document.getElementById("help"); @@ -3764,6 +3782,7 @@ var curtainEl = document.getElementById("curtain"); var logBannerEl = document.getElementById("log-banner"); var notifyHistoryEl2 = document.getElementById("notify-history"); + var editMenuEl = document.getElementById("edit-menu"); document.addEventListener("keydown", (e) => { if (helpEl.classList.contains("visible")) { if (e.key === "?" || e.key === "Escape" || e.key === "q") { @@ -3778,6 +3797,28 @@ } return; } + if (editMenuEl.classList.contains("open")) { + if (e.key === "Escape" || e.key === "q" || e.key === "e") { + closeMenu(); + return; + } + if (e.key === "ArrowDown" || e.key === "j") { + e.preventDefault(); + editMenuSetActive(state._editActive + 1); + return; + } + if (e.key === "ArrowUp" || e.key === "k") { + e.preventDefault(); + editMenuSetActive(state._editActive - 1); + return; + } + if (e.key === "Enter") { + e.preventDefault(); + editMenuCommit(); + return; + } + return; + } if (overviewEl2.classList.contains("visible")) { if (e.key === "Escape" || e.key === "q") { closeOverview(); diff --git a/src/inkflow/presenter.html b/src/inkflow/presenter.html index b70999ee..cbf71d69 100644 --- a/src/inkflow/presenter.html +++ b/src/inkflow/presenter.html @@ -57,6 +57,7 @@

KEYBINDINGS

$ EndLast slide gSlide picker oSlide overview + eEdit source Present .Laser pointer Ctrl + scrollZoom · Ctrl + drag to pan @@ -69,6 +70,7 @@

KEYBINDINGS

pPresenter panel dDiagnostics mMessages + nNew window sCycle sync mode ?This help @@ -183,7 +185,7 @@

MESSAGES

- - - - - + | + + + + diff --git a/src/ts/presenter/edit.test.ts b/src/ts/presenter/edit.test.ts index 37bc8e2f..c1f1a813 100644 --- a/src/ts/presenter/edit.test.ts +++ b/src/ts/presenter/edit.test.ts @@ -14,6 +14,8 @@ import { afterEach, beforeEach, expect, test, vi } from "vitest"; let initEditMenu: typeof import("./edit").initEditMenu; let renderEditButton: typeof import("./edit").renderEditButton; +let editMenuSetActive: typeof import("./edit").editMenuSetActive; +let editMenuCommit: typeof import("./edit").editMenuCommit; let menuOpened: typeof import("./menus").menuOpened; let state: typeof import("./state").state; let btnEdit: HTMLButtonElement; @@ -46,7 +48,8 @@ beforeEach(async () => {
`; vi.resetModules(); - ({ initEditMenu, renderEditButton } = await import("./edit")); + ({ initEditMenu, renderEditButton, editMenuSetActive, editMenuCommit } = + await import("./edit")); ({ menuOpened } = await import("./menus")); ({ state } = await import("./state")); btnEdit = document.getElementById("btn-edit") as HTMLButtonElement; @@ -284,3 +287,58 @@ test("the general command also applies to SVG files when no SVG-specific overrid ); expect(navigator.clipboard.writeText).not.toHaveBeenCalled(); }); + +// ── Keyboard navigation (editMenuSetActive / editMenuCommit) ────────────────── +// The arrow/j/k/Enter wiring itself lives in keyboard.ts's global cascade (not +// tested here — no keyboard.test.ts exists in this codebase, matching how +// overview.ts's identically-shaped overviewSetActive/overviewCommit also have +// no dedicated cascade test); this covers the exported units keyboard.ts calls. + +test("opening the menu highlights the first row", () => { + 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(); + const rows = editMenu.querySelectorAll(".edit-row"); + expect(rows[0].classList.contains("active")).toBe(true); + expect(rows[1].classList.contains("active")).toBe(false); + expect(state._editActive).toBe(0); +}); + +test("editMenuSetActive moves the highlight and clamps at both ends", () => { + 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(); + const rows = editMenu.querySelectorAll(".edit-row"); + editMenuSetActive(1); + expect(rows[1].classList.contains("active")).toBe(true); + expect(rows[0].classList.contains("active")).toBe(false); + editMenuSetActive(5); // past the end + expect(state._editActive).toBe(1); + editMenuSetActive(-3); // before the start + expect(state._editActive).toBe(0); +}); + +test("editMenuCommit acts on the highlighted row, not necessarily the first", () => { + 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(); + editMenuSetActive(1); + editMenuCommit(); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith("/deck/b.md"); + expect(editMenu.classList.contains("open")).toBe(false); +}); diff --git a/src/ts/presenter/edit.ts b/src/ts/presenter/edit.ts index 81c77009..5b488ae3 100644 --- a/src/ts/presenter/edit.ts +++ b/src/ts/presenter/edit.ts @@ -92,6 +92,36 @@ export function renderEditButton(): void { } } +// ── Keyboard navigation (mirrors overview.ts's active-index pattern) ─────────── +// Unlike syncmenu.ts's identically-shaped dropdown, this one can't just cycle +// through options on a single keypress — acting on a file has a real side effect +// (a clipboard write or a spawned editor command), so highlighting a row and +// committing it must be separate steps. Ownership of the open-state keys +// (arrows/j/k/Enter/Escape) therefore lives in keyboard.ts's global cascade, not +// a listener local to this module: a local listener registered only while the +// menu is open would run *after* keyboard.ts's (registered once at boot), too +// late to stop it from also treating j/k/arrows as slide navigation underneath +// the open menu. + +export function editMenuSetActive(i: number): void { + const rows = Array.from( + editMenu.querySelectorAll(".edit-row"), + ); + if (rows.length === 0) return; + state._editActive = Math.max(0, Math.min(rows.length - 1, i)); + rows.forEach((row, idx) => { + row.classList.toggle("active", idx === state._editActive); + }); + rows[state._editActive]?.scrollIntoView({ block: "nearest" }); +} + +export function editMenuCommit(): void { + const files = state.slides[state.slideIndex]?.editableFiles ?? []; + const file = files[state._editActive]; + if (file) actOn(file); + closeMenu(); +} + // ── Menu open/close ──────────────────────────────────────────────────────────── function onDocClick(e: MouseEvent): void { @@ -99,31 +129,23 @@ function onDocClick(e: MouseEvent): void { 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"); + editMenuSetActive(0); document.addEventListener("click", onDocClick); - document.addEventListener("keydown", onKeydown); menuOpened(closeMenu); } -function closeMenu(): void { +export 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 { +export function toggleMenu(): void { if (editMenu.classList.contains("open")) closeMenu(); else openMenu(); } diff --git a/src/ts/presenter/keyboard.ts b/src/ts/presenter/keyboard.ts index 21a0bef7..e1d5cec2 100644 --- a/src/ts/presenter/keyboard.ts +++ b/src/ts/presenter/keyboard.ts @@ -1,3 +1,9 @@ +import { + closeMenu as closeEditMenu, + editMenuCommit, + editMenuSetActive, + toggleMenu as toggleEditMenu, +} from "./edit"; import { toggleLaser } from "./laser"; import { advance, @@ -29,6 +35,7 @@ import { toggleNotifyHistory, toggleTheme, } from "./ui"; +import { openSyncedWindow } from "./windowsync"; import { keyZoom, smoothResetCamera } from "./zoom"; // ── Stage click and status bar buttons ── @@ -127,6 +134,7 @@ const KEYBINDINGS: Record< $: { action: gotoLast }, g: { action: openPicker, preventDefault: true }, o: { action: toggleOverview, preventDefault: true }, + e: { action: toggleEditMenu }, f: { action: toggleFullscreen }, b: { action: () => toggleCurtain("black") }, ".": { action: toggleLaser }, @@ -143,6 +151,7 @@ const KEYBINDINGS: Record< p: { action: togglePv }, d: { action: toggleLogs }, m: { action: toggleNotifyHistory }, + n: { action: openSyncedWindow }, s: { action: cycleSyncMode }, }; @@ -153,6 +162,7 @@ const pickerEl = document.getElementById("picker")!; const curtainEl = document.getElementById("curtain")!; const logBannerEl = document.getElementById("log-banner")!; const notifyHistoryEl = document.getElementById("notify-history")!; +const editMenuEl = document.getElementById("edit-menu")!; document.addEventListener("keydown", (e) => { if (helpEl.classList.contains("visible")) { @@ -168,6 +178,28 @@ document.addEventListener("keydown", (e) => { } return; } + if (editMenuEl.classList.contains("open")) { + if (e.key === "Escape" || e.key === "q" || e.key === "e") { + closeEditMenu(); + return; + } + if (e.key === "ArrowDown" || e.key === "j") { + e.preventDefault(); + editMenuSetActive(state._editActive + 1); + return; + } + if (e.key === "ArrowUp" || e.key === "k") { + e.preventDefault(); + editMenuSetActive(state._editActive - 1); + return; + } + if (e.key === "Enter") { + e.preventDefault(); + editMenuCommit(); + return; + } + return; + } if (overviewEl.classList.contains("visible")) { if (e.key === "Escape" || e.key === "q") { closeOverview(); diff --git a/src/ts/presenter/state.ts b/src/ts/presenter/state.ts index ea2d8ff1..516353fc 100644 --- a/src/ts/presenter/state.ts +++ b/src/ts/presenter/state.ts @@ -10,6 +10,7 @@ export const state = { _pickerActive: 0, _overviewActive: 0, _overviewCols: 1, + _editActive: 0, ws: null as WebSocket | null, windowLink: null as Window | null, _syncingFromServer: false, diff --git a/src/ts/presenter/websocket.ts b/src/ts/presenter/websocket.ts index 1c579c74..41b3874f 100644 --- a/src/ts/presenter/websocket.ts +++ b/src/ts/presenter/websocket.ts @@ -179,6 +179,7 @@ export function connectWS(wsPort: number | null, authoritative: boolean): void { state.ws.onopen = () => { wsDot.className = "connected"; + wsDot.dataset.tooltip = "Connected"; const assert = authoritative && sends(); firstPositionPending = assert; if (assert) sendNav(); @@ -226,6 +227,7 @@ export function connectWS(wsPort: number | null, authoritative: boolean): void { state.ws.onclose = () => { wsDot.className = ""; + wsDot.dataset.tooltip = "Disconnected"; state.ws = null; // A reconnecting live window re-asserts its position rather than being // adopted by a possibly-stale server. diff --git a/src/ts/presenter/windowsync.ts b/src/ts/presenter/windowsync.ts index c5cc4e4d..047d38c1 100644 --- a/src/ts/presenter/windowsync.ts +++ b/src/ts/presenter/windowsync.ts @@ -94,22 +94,30 @@ function detachLink(): void { linkPoll = undefined; } +// Set once by initWindowSync; read by openSyncedWindow so the keyboard binding +// (keyboard.ts's "n") can call the same logic as the click listener without +// threading wsPort through a second parameter. +let _wsPort: number | null = null; + +export function openSyncedWindow(): void { + if (_wsPort === null && state.windowLink) return; // already linked (2-window cap) + // No target name: a named target gets reused/refocused by a later + // window.open() call with the same name — including from inside the window + // it names, which would just reload itself. Serve has no window-count cap + // (the WS relay already syncs however many windows connect), so repeat + // clicks must always open a genuinely new window rather than colliding with + // whichever window happens to hold that name. + const child = window.open(location.href); + if (!child) { + showNotify(POPUP_BLOCKED_MESSAGE, "yellow"); + return; + } + if (_wsPort === null) attachLink(child); +} + export function initWindowSync(wsPort: number | null): void { - btnPresenterView.addEventListener("click", () => { - if (wsPort === null && state.windowLink) return; // already linked (2-window cap) - // No target name: a named target gets reused/refocused by a later - // window.open() call with the same name — including from inside the window - // it names, which would just reload itself. Serve has no window-count cap - // (the WS relay already syncs however many windows connect), so repeat - // clicks must always open a genuinely new window rather than colliding with - // whichever window happens to hold that name. - const child = window.open(location.href); - if (!child) { - showNotify(POPUP_BLOCKED_MESSAGE, "yellow"); - return; - } - if (wsPort === null) attachLink(child); - }); + _wsPort = wsPort; + btnPresenterView.addEventListener("click", openSyncedWindow); if (wsPort !== null) return; // serve: WS already syncs any window opened this way