From ad870f678b527dbbb05989c1e216101d2a6ab07a Mon Sep 17 00:00:00 2001 From: Sangjoon Han Date: Mon, 27 Jul 2026 22:32:46 +0900 Subject: [PATCH 1/7] feat: support footnotes in the full-screen table view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A footnote marker in a table cell survived into the full-screen view as a dead "[1]": the definitions live in the note's `.footnotes` list, outside the `` the overlay clones, so there was nothing to show and nothing to jump to. The overlay now carries in a copy of every definition the clone's markers actually point at, lists it under the table, and re-points each footnote id and `#`-anchor into an overlay-local namespace — so the marker jumps to its definition and the "↩︎" back-reference jumps back, without leaving full screen and without disturbing the note's own footnote navigation. The numbering is carried over explicitly, so a table referencing only footnote 3 still reads "3.", and the section is laid out at a readable measure instead of the table's width. Every failure path degrades to the overlay as it was built before: no markers, no note root, no rendered definition list (Live Preview renders a table in isolation; Reading view virtualises off-screen sections away), or nothing that resolves. An individually unresolvable marker in an otherwise resolved note stays visible but is made inert — the same thing the note itself shows in those modes. Clone hygiene also drops the `id` attributes the clone copied over, keeping only the footnote identity nodes that get re-minted. A table carrying a `^block-id` previously appeared twice under the same id while the overlay was open. Closes #28 Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 7 + src/main.ts | 337 +++++++++++++++++++++++++- styles.css | 69 ++++++ tests/e2e/fixtures/table-harness.html | 30 +++ tests/e2e/table-fullscreen.test.mjs | 65 ++++- 5 files changed, 500 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 18e158c..28ba6d6 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,13 @@ button (no zoom) pinned to the visible top-right corner. Full screen shows the table in a maximized scroll area where its full width fits far better than in the note's narrow reading column. +If a cell carries a **footnote** — inline (`^[note]`) or referenced (`[^1]`) — +its definition is carried into full screen and listed under the table, and the +marker jumps to it (and the "↩︎" back-reference jumps back) without leaving the +full-screen view. Where the note itself cannot render a definition — Live +Preview, which renders a table in isolation — the marker stays visible but +inert, exactly as it reads in the note. + Tables are enhanced in both **Reading view** and **Live Preview**; the table you are actively editing is left untouched. diff --git a/src/main.ts b/src/main.ts index 59225e9..b06ae56 100644 --- a/src/main.ts +++ b/src/main.ts @@ -747,6 +747,67 @@ interface TableViewOptions { const TABLE_EDITOR_CHROME = ".table-col-btn, .table-row-btn, .table-col-drag-handle, .table-row-drag-handle"; +/* + * Footnotes in the full-screen table clone. + * + * The clone lives outside the note's rendered container, so everything the + * footnote mechanism relies on beyond the
subtree — the note's + * `.footnotes` definition list, Obsidian's own scoped click handlers, the + * `#fn-…`/`#fnref-…` id anchors — is unreachable from the overlay. We therefore + * clone the *definitions* in next to the table and re-point every footnote id + * and `#`-href at overlay-local copies. Everything below feeds that. + */ + +/** A footnote reference marker inside a cell — never the definition's back-ref. */ +const FN_REF = 'a.footnote-link:not(.footnote-backref)[href^="#"]'; +/** Every footnote anchor in the overlay (refs *and* back-refs) needs re-pointing. */ +const FN_ANCHOR = 'a.footnote-link[href^="#"]'; +/** + * The note's rendered definitions. Reading view only: in Live Preview the table + * widget renders its slice in isolation and no definition list exists anywhere + * in the DOM, so this legitimately finds nothing (see the degradation path in + * `_buildFootnotes`). + */ +const FN_DEF = ".footnotes li"; +/** + * Nodes carrying a footnote identity that must be re-namespaced on the clone. + * Deliberately narrow — a blanket id rewrite would also hit `url(#…)` / + * `clip-path` targets inside any SVG a cell happens to contain. + * + * Obsidian itself stamps `data-footnote-id` on the `` + * and on the definition `
  • ` — never on the anchors, which carry only the + * `#fn-…`/`#fnref-…` href. Anchors are nevertheless excluded here: should a + * renderer stamp one, the marker and its containing `` would mint the same + * id twice (invalid DOM) and, since the jump map is built in document order, + * the map would point at the nested "↩︎" glyph instead of the definition. + * Anchors are re-pointed by the FN_ANCHOR pass below instead. + */ +const FN_IDENTIFIED = + "[data-footnote-id]:not(a), .footnotes li[id], sup.footnote-ref[id]"; +/** + * Lookout's own chrome, for the case where a cloned footnote definition happens + * to contain an already-processed table or diagram. Buttons/toolbars in a clone + * are dead (their listeners belong to the live view), so they are removed; the + * wrappers are unwrapped so the content inside them survives as plain markup. + */ +const LOOKOUT_CHROME = ".lookout-btn, .lookout-toolbar"; +const LOOKOUT_WRAPPERS = + ".lookout-table-host, .lookout-table-scroll, .lookout-viewport, .lookout-stage"; +/** + * Nearest note-ish root to read definitions from — never `activeDocument`. A + * second pane on the same note renders its own copy with a different `docId`, + * so a document-wide search could silently pick up the *other* pane's list. + */ +const NOTE_ROOT = + ".markdown-embed, .markdown-preview-view, .markdown-rendered, .markdown-source-view"; + +/** + * Monotonic across every overlay ever opened, so the ids minted for one + * full-screen view can never collide with another's (two overlays at once, or a + * re-open of the same table). + */ +let fsSeq = 0; + /** * Wide tables get a single full-screen button (no zoom). Inline, the table * keeps its normal horizontal scroll inside our own scroll wrapper so the @@ -762,6 +823,11 @@ class TableView { host!: HTMLDivElement; scroll!: HTMLDivElement; overlay: HTMLDivElement | null = null; + /** the inline trigger, kept so focus can return to it when the overlay closes */ + fsBtn!: HTMLButtonElement; + /** namespaced footnote id → the node in the overlay it now identifies */ + fsTargets: Map | null = null; + fsHighlightTimer: number | null = null; constructor(table: HTMLTableElement, options: TableViewOptions) { this.table = table; @@ -790,6 +856,185 @@ class TableView { this.openFullscreen(); }); this.host.appendChild(btn); + this.fsBtn = btn; + } + + /* + * The note container this table belongs to. Scoped on purpose: reading the + * definitions from `activeDocument` would let a second pane showing the same + * note donate its (differently-id'd, non-matching) list. + */ + _noteRoot(): HTMLElement | null { + return ( + this.table.closest(NOTE_ROOT) ?? + this.table.closest(".workspace-leaf") + ); + } + + /* + * Index the note's rendered footnote definitions by the id its references + * point at. Strictly read-only over live, Obsidian-owned DOM: nothing here + * moves, re-ids or otherwise touches the note. + */ + _collectDefs(): Map { + const map = new Map(); + const root = this._noteRoot(); + if (!root) return map; + root.querySelectorAll(FN_DEF).forEach((li) => { + if (!li.instanceOf(HTMLElement)) return; + const key = li.id || li.getAttribute("data-footnote-id") || ""; + if (!key || map.has(key)) return; + map.set(key, li); + }); + return map; + } + + /* + * Clone hygiene: strip the Live Preview editor chrome and any of Lookout's + * own controls that rode along, then unwrap Lookout's wrappers so their + * contents survive as plain rendered markup. Idempotent, and safe to run on + * anything — outer wrappers are unwrapped before inner ones (the NodeList is + * static and in document order) and children move into the still-attached + * grandparent, so nothing is orphaned. + * + * It also drops every `id` the clone carried over, except on the footnote + * identity nodes `_namespace` is about to re-mint. A table can legitimately + * own one (Obsidian stamps a `^block-id` onto the element) and a cell can + * contain anything, so without this the overlay would put a second copy of + * those ids in the document for as long as it is open — invalid DOM, and + * enough to send an in-note `#…` link to the clone instead of the note. + */ + _scrub(root: HTMLElement) { + root.querySelectorAll(TABLE_EDITOR_CHROME).forEach((node) => node.remove()); + root.querySelectorAll(LOOKOUT_CHROME).forEach((node) => node.remove()); + root.querySelectorAll(LOOKOUT_WRAPPERS).forEach((wrap) => { + const parent = wrap.parentElement; + if (!parent) return; + while (wrap.firstChild) parent.insertBefore(wrap.firstChild, wrap); + wrap.remove(); + }); + if (root.id && !root.matches(FN_IDENTIFIED)) root.removeAttribute("id"); + root.querySelectorAll("[id]").forEach((node) => { + if (!node.matches(FN_IDENTIFIED)) node.removeAttribute("id"); + }); + } + + /* + * Build the `
    ` that goes under the cloned table, + * containing a copy of every definition the clone's markers actually point + * at. Returns `null` — the "behave exactly as before" path — whenever there + * is nothing to show: no markers at all, no note root, no rendered `.footnotes` + * (Live Preview, and reading-view sections Obsidian has virtualised away), or + * no marker whose target resolves. On every one of those paths the clone is + * returned untouched, so the overlay really is byte-identical to the one built + * before footnote support existed — hence the strict split below between a + * read-only resolution pass and the mutating pass that runs only once the + * section is certain to be emitted. Individual unresolvable markers (a *mixed* + * note, where some definitions did resolve) stay visible but are made inert; + * there is deliberately no `Notice`, because in those modes the note itself + * cannot show the text either. + */ + _buildFootnotes( + clone: HTMLElement, + defs: Map + ): HTMLElement | null { + const refs = clone.querySelectorAll(FN_REF); + if (refs.length === 0) return null; + // Nothing can resolve, so nothing may be marked up: Live Preview never + // renders a definition list, and neither does a reading-view note whose + // `.footnotes` section is currently virtualised away. + if (defs.size === 0) return null; + + // Pass 1 — strictly read-only over the clone. Keyed by href fragment, so a + // footnote referenced twice is emitted once, in first-reference order. + const picked = new Map(); + const resolved: HTMLAnchorElement[] = []; + const unresolved: HTMLAnchorElement[] = []; + refs.forEach((a) => { + // The raw attribute, never `a.hash`: an unresolved href is not a URL. + const href = a.getAttribute("href") ?? ""; + const key = href.slice(1); + const def = key ? defs.get(key) : undefined; + if (!def) { + unresolved.push(a); + return; + } + resolved.push(a); + if (!picked.has(key)) picked.set(key, def); + }); + if (picked.size === 0) return null; + + const section = el("section", "footnotes lookout-fs-footnotes"); + const list = el("ol"); + picked.forEach((def) => { + const li = def.cloneNode(true); + if (!li.instanceOf(HTMLLIElement)) return; + // The list is a *subset* of the note's definitions, so the numbering has + // to be carried over explicitly — otherwise a table referencing only + // footnote 3 would render it as "1." under a marker reading "[3]". + const ol = def.parentElement; + if (ol && ol.instanceOf(HTMLOListElement)) { + const idx = Array.prototype.indexOf.call(ol.children, def); + if (idx >= 0) li.value = (ol.start || 1) + idx; + } + li.setAttribute("tabindex", "-1"); + list.appendChild(li); + }); + if (!list.firstChild) return null; + + // Pass 2 — the section is going to be emitted, so the clone may be touched. + resolved.forEach((a) => { + // Never let a footnote marker open a browser window from the overlay. + a.removeAttribute("target"); + a.removeAttribute("rel"); + }); + unresolved.forEach((a) => { + a.removeAttribute("href"); + a.removeAttribute("target"); + a.removeAttribute("rel"); + a.setAttribute("aria-disabled", "true"); + a.addClass("lookout-fn-unresolved"); + a.setAttribute("aria-label", "각주 내용을 찾을 수 없습니다"); + a.title = "각주 내용을 찾을 수 없습니다"; + }); + + section.appendChild(list); + this._scrub(section); + return section; + } + + /* + * Re-point every footnote identity and `#`-href inside `root` at an + * overlay-local namespace, and return the resulting id → node map used for + * jumps. Must run while `root` is still detached, so the document never holds + * duplicate ids — not even for a frame. + */ + _namespace(root: HTMLElement, prefix: string): Map { + const targets = new Map(); + root.querySelectorAll(FN_IDENTIFIED).forEach((node) => { + const base = node.id || node.getAttribute("data-footnote-id") || ""; + if (!base) return; + const id = prefix + base; + node.id = id; + if (node.hasAttribute("data-footnote-id")) { + node.setAttribute("data-footnote-id", id); + } + // Programmatic focus target only; never in the tab order. + node.setAttribute("tabindex", "-1"); + targets.set(id, node); + }); + root.querySelectorAll(FN_ANCHOR).forEach((a) => { + const href = a.getAttribute("href") ?? ""; + if (href.startsWith("#")) a.setAttribute("href", "#" + prefix + href.slice(1)); + // The anchor gets no id of its own (see FN_IDENTIFIED), but its stale + // `data-footnote-id` still names a node in the note; re-point it so the + // overlay never advertises a note-scoped footnote identity. + const fid = a.getAttribute("data-footnote-id"); + if (fid) a.setAttribute("data-footnote-id", prefix + fid); + a.removeAttribute("target"); + a.removeAttribute("rel"); + }); + return targets; } openFullscreen() { @@ -804,10 +1049,29 @@ class TableView { const scroll = el("div", "lookout-table-fs-scroll markdown-rendered"); const clone = this.table.cloneNode(true) as HTMLTableElement; clone.classList.add("lookout-table-fs-table"); - // Drop the Live Preview editor's drag handles/menu buttons that came along - // with the clone — they would otherwise show as stray ":::"/"⋮" marks. - clone.querySelectorAll(TABLE_EDITOR_CHROME).forEach((node) => node.remove()); - scroll.appendChild(clone); + // Drops the Live Preview editor's drag handles/menu buttons that came along + // with the clone (stray ":::"/"⋮" marks) plus any Lookout chrome. + this._scrub(clone); + + // Footnote definitions live outside the
  • , so they have to be carried + // in explicitly. `notes === null` is the common case and every failure case: + // the overlay is then assembled exactly as it was before footnote support + // existed — no wrapper, no namespacing, no click handling. + let docEl: HTMLElement | null = null; + const notes = this._buildFootnotes(clone, this._collectDefs()); + if (!notes) { + scroll.appendChild(clone); + } else { + docEl = el("div", "lookout-fs-doc"); + docEl.appendChild(clone); + docEl.appendChild(notes); + scroll.appendChild(docEl); + // Namespace while still detached — see `_namespace`. + this.fsTargets = this._namespace(docEl, `lookout-fs-${++fsSeq}-`); + overlay.addEventListener("click", this.onFsAnchorClick); + overlay.addEventListener("auxclick", this.onFsAnchorClick); + } + overlay.appendChild(scroll); const close = el("button", "lookout-btn lookout-fs-close"); @@ -818,9 +1082,13 @@ class TableView { close.addEventListener("click", this.onCloseFs); overlay.appendChild(close); - // Click on the empty backdrop (not the table) closes the view. + // Click on the empty backdrop (not the table, not the footnotes) closes the + // view. The wrapper counts as backdrop: it hugs the table, so anything to + // the side of it is empty space. overlay.addEventListener("pointerdown", (e) => { - if (e.target === overlay || e.target === scroll) this.onCloseFs(); + if (e.target === overlay || e.target === scroll || (docEl && e.target === docEl)) { + this.onCloseFs(); + } }); activeDocument.body.appendChild(overlay); @@ -828,6 +1096,52 @@ class TableView { close.focus({ preventScroll: true }); } + /* + * Delegated on the overlay, so it dies with it. Only the anchors we + * namespaced are ours to handle: the fragment is looked up in `fsTargets` by + * string equality — no selector is ever built from a user-authored label, so + * a malformed one cannot throw — and anything that does not resolve there + * (an internal link or an external URL sitting in a cell) is left with + * exactly the behaviour it had before footnote support existed. Registering + * `auxclick` too covers middle-click; the modifier variants are cancelled + * here before Obsidian's own delegated handlers ever see them. + */ + onFsAnchorClick = (e: MouseEvent) => { + const target = e.target as Element | null; + if (!target || !target.instanceOf(HTMLElement)) return; + const a = target.closest("a"); + if (!a || !a.instanceOf(HTMLAnchorElement)) return; + if (!this.overlay || !this.overlay.contains(a)) return; + + const href = a.getAttribute("href") ?? ""; + const id = href.startsWith("#") ? href.slice(1) : ""; + if (!id || !this.fsTargets || !this.fsTargets.has(id)) return; + + e.preventDefault(); + e.stopPropagation(); + this._jumpTo(id); + } + + /* Scroll a namespaced footnote node into view and flag it briefly. */ + _jumpTo(id: string) { + const node = this.fsTargets?.get(id); + if (!node) return; + node.scrollIntoView({ + block: "center", + behavior: REDUCED_MOTION ? "auto" : "smooth", + }); + node.focus({ preventScroll: true }); + if (this.fsHighlightTimer !== null) window.clearTimeout(this.fsHighlightTimer); + this.overlay + ?.querySelectorAll(".is-lookout-target") + .forEach((n) => n.removeClass("is-lookout-target")); + node.addClass("is-lookout-target"); + this.fsHighlightTimer = window.setTimeout(() => { + this.fsHighlightTimer = null; + node.removeClass("is-lookout-target"); + }, 1400); + } + onFsKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") { e.preventDefault(); @@ -837,9 +1151,20 @@ class TableView { onCloseFs = () => { if (!this.overlay) return; + if (this.fsHighlightTimer !== null) { + window.clearTimeout(this.fsHighlightTimer); + this.fsHighlightTimer = null; + } activeDocument.removeEventListener("keydown", this.onFsKeyDown, true); + // Removing the overlay takes the click/auxclick/pointerdown listeners with it. this.overlay.remove(); this.overlay = null; + this.fsTargets = null; + // Return focus to the trigger — but not on the `destroy()` path, which + // closes first and then detaches the host the button lives in. + if (!this.destroyed && this.fsBtn && this.fsBtn.isConnected) { + this.fsBtn.focus({ preventScroll: true }); + } } destroy() { diff --git a/styles.css b/styles.css index 3db38ca..5da8511 100644 --- a/styles.css +++ b/styles.css @@ -336,6 +336,72 @@ height: max-content; } +/* + * The full-screen table together with its footnote definitions. This wrapper + * only exists when at least one footnote resolves; otherwise the table stays + * the scroll container's single flex child exactly as before. + * + * Its width drives the footnote measure, so it must not simply hug the table: + * - `width: max-content` lets it span a table *wider* than the viewport, so + * the footnotes stay aligned with the table while panning right (it is + * deliberately not capped at `max-width: 100%`, which would stop the section + * and its divider at the viewport edge); + * - `min-width` gives it a readable floor, so a *narrow* table cannot squeeze + * the footnote text into a one-word-per-line ribbon — the note renders that + * same text at full note width. + * The table is centered within that floor, so the container's + * `justify-content: safe center` still centers small tables as before. + */ +.lookout-table-fs-scroll .lookout-fs-doc { + display: flex; + flex-direction: column; + align-items: center; + gap: 20px; + /* flex-shrink would otherwise clamp a wide wrapper back to the viewport. */ + flex: none; + width: max-content; + min-width: min(100%, 60ch); +} + +/* + * `width: 0` + `min-width: 100%` makes the section fill the wrapper without + * contributing to its max-content width — a long footnote must not widen (and + * so off-center) the table above it. Same two-class-under-container trick as + * the table rule above: enough specificity to outrank the theme's bare + * `.footnotes` styles without !important. + */ +.lookout-table-fs-scroll .lookout-fs-footnotes { + width: 0; + min-width: 100%; + margin: 0; + padding-top: 14px; + border-top: 1px solid var(--background-modifier-border); + color: var(--text-muted); +} + +.lookout-table-fs-scroll .lookout-fs-footnotes ol { + margin: 0; + padding-inline-start: 1.6em; +} + +.lookout-table-fs-scroll .lookout-fs-footnotes li { + scroll-margin-top: 72px; /* clear the fixed close button when jumped to */ + outline: none; /* focused programmatically on jump, never via Tab */ +} + +.lookout-table-fs-scroll .is-lookout-target { + background-color: color-mix(in srgb, var(--lookout-accent) 14%, transparent); + border-radius: 4px; + transition: background-color 0.18s ease; +} + +/* A marker whose definition could not be resolved: visible, but plainly inert. */ +.lookout-table-fs-scroll .lookout-fn-unresolved { + color: var(--text-muted); + cursor: default; + text-decoration: none; +} + .lookout-fs-close { position: fixed; top: 16px; @@ -363,4 +429,7 @@ .lookout-fs { animation: none; } + .lookout-table-fs-scroll .is-lookout-target { + transition: none; + } } diff --git a/tests/e2e/fixtures/table-harness.html b/tests/e2e/fixtures/table-harness.html index 9cc2206..06a7c68 100644 --- a/tests/e2e/fixtures/table-harness.html +++ b/tests/e2e/fixtures/table-harness.html @@ -47,6 +47,36 @@
    4fivesix
    + + + + + + + + + + + + +
    AB
    1x[1]
    +
    +
    +
      +
    1. A perfectly ordinary one-sentence inline footnote. ↩︎
    2. +
    +
    diff --git a/tests/e2e/table-fullscreen.test.mjs b/tests/e2e/table-fullscreen.test.mjs index 568a075..9ce6b50 100644 --- a/tests/e2e/table-fullscreen.test.mjs +++ b/tests/e2e/table-fullscreen.test.mjs @@ -64,9 +64,9 @@ try { await page.addStyleTag({ content: STYLES }); await page.addScriptTag({ content: bootstrap }); - // The plugin enhanced the table on load; open it full screen. + // The plugin enhanced the tables on load; open the first one full screen. await page.waitForSelector(".lookout-table-btn"); - await page.click(".lookout-table-btn", { force: true }); + await page.locator(".lookout-table-btn").first().click({ force: true }); await page.waitForSelector(".lookout-table-fs-table"); const r = await page.evaluate(() => { @@ -106,6 +106,67 @@ try { check("override styles intact (margin 0, max-width none)", r.overrideMargin === "0px" && r.overrideMaxWidth === "none", `margin=${r.overrideMargin} maxWidth=${r.overrideMaxWidth}`); check("full-screen table is visible", r.tableVisible); check("small table stays centered", Math.abs(r.leftGap - r.rightGap) <= 2, `gaps ${r.leftGap}/${r.rightGap}`); + + // ---- Footnotes: the second (narrow) table carries an inline-footnote marker. + await page.keyboard.press("Escape"); + await page.waitForSelector(".lookout-fs", { state: "detached" }); + await page.locator(".lookout-table-btn").nth(1).click({ force: true }); + await page.waitForSelector(".lookout-fs-footnotes li"); + + const f = await page.evaluate(() => { + const box = (el) => { + if (!el) return null; + const r = el.getBoundingClientRect(); + return { w: Math.round(r.width), h: Math.round(r.height) }; + }; + const noteLi = document.querySelector(".markdown-rendered > .footnotes li"); + const fsLi = document.querySelector(".lookout-fs-footnotes li"); + const fsTable = document.querySelector(".lookout-table-fs-table"); + const sc = document.querySelector(".lookout-table-fs-scroll"); + const tb = fsTable.getBoundingClientRect(); + const sb = sc.getBoundingClientRect(); + return { + noteLi: box(noteLi), + fsLi: box(fsLi), + fsTableWidth: Math.round(tb.width), + leftGap: Math.round(tb.left - sb.left), + rightGap: Math.round(sb.right - tb.right), + // The definition text must survive the clone. + fsText: (fsLi?.textContent || "").trim().slice(0, 20), + markerHref: document + .querySelector(".lookout-table-fs-table a.footnote-link") + ?.getAttribute("href"), + // The clone must never mint an id the note already uses. + dupeIds: (() => { + const seen = new Set(); + const dupes = []; + for (const node of document.querySelectorAll("[id]")) { + if (seen.has(node.id)) dupes.push(node.id); + seen.add(node.id); + } + return dupes; + })(), + // The note's own definition is Obsidian-owned: read, never rewritten. + noteLiUntouched: + !!noteLi && + !noteLi.id && + noteLi.getAttribute("data-footnote-id") === "fn-1", + }; + }); + + check("footnote definition is carried into full screen", f.fsText.startsWith("A perfectly ordinary"), `text="${f.fsText}"`); + check("footnote marker points at an overlay-local anchor", !!f.markerHref?.startsWith("#lookout-fs-"), `href=${f.markerHref}`); + // The regression this guards: sizing the section to the narrow table's own + // width squeezed the text into a one-word-per-line ribbon. + check( + "footnote text reads at a note-like measure, not the table's width", + f.fsLi.w >= Math.min(f.noteLi.w, 400) && f.fsLi.w > f.fsTableWidth * 2, + `note=${f.noteLi.w}x${f.noteLi.h} fs=${f.fsLi.w}x${f.fsLi.h} table=${f.fsTableWidth}` + ); + check("footnote text is not stacked into a sliver", f.fsLi.h <= f.noteLi.h * 2, `note h=${f.noteLi.h} fs h=${f.fsLi.h}`); + check("narrow table with footnotes stays centered", Math.abs(f.leftGap - f.rightGap) <= 2, `gaps ${f.leftGap}/${f.rightGap}`); + check("no duplicate ids introduced by the clone", f.dupeIds.length === 0, `dupes=${f.dupeIds.join(",")}`); + check("the note's own footnote definition is left untouched", f.noteLiUntouched); } finally { await browser.close(); } From f36e5c68cb88d78ad37c4c677bd6190b378a155b Mon Sep 17 00:00:00 2001 From: Sangjoon Han Date: Mon, 27 Jul 2026 22:32:53 +0900 Subject: [PATCH 2/7] chore(release): 1.2.0 manifest.json, versions.json and CHANGELOG.md in lockstep, per scripts/validate.mjs. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 34 +++++++++++++++++++++++++++++++++- manifest.json | 2 +- versions.json | 3 ++- 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4709b74..82424ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.2.0] - 2026-07-27 + +### Added + +- Full-screen tables now support **footnotes** ([#28]). A cell's footnote marker + used to survive into the full-screen view as a dead `[1]`: the definitions + live in the note's `.footnotes` list, outside the `` the overlay + clones, so there was nothing to show and nothing to jump to. The definitions a + table actually references are now carried into the overlay and listed under + the clone — numbering preserved, so a table referencing only footnote 3 still + reads "3." — and every footnote id and `#`-anchor is re-pointed into an + overlay-local namespace so the marker jumps to its definition and the "↩︎" + back-reference jumps back, all without leaving full screen or touching the + note's own footnote navigation. The footnote text is laid out at a readable + measure rather than at the table's width, so a narrow table no longer squeezes + it into a one-word-per-line ribbon. +- Where a definition cannot be resolved — Live Preview renders a table in + isolation and produces no definition list, and Reading view virtualises + off-screen sections away — the marker stays visible but is made plainly inert, + which is the same thing the note itself shows in those modes. A table with no + footnotes at all builds exactly the overlay it did before. + +### Fixed + +- The full-screen clone no longer copies the live table's `id` attributes into + the document. A table carrying a `^block-id`, or anything id-bearing inside a + cell, previously appeared twice under the same id for as long as the overlay + was open — invalid DOM, and enough to send an in-note `#…` link to the clone + instead of the note. + ## [1.1.7] - 2026-06-24 ### Fixed @@ -128,7 +158,9 @@ Initial public release. mode), not only in Reading view. The table being actively edited is left untouched. -[Unreleased]: https://github.com/Post-Math/Lookout/compare/1.1.3...HEAD +[Unreleased]: https://github.com/Post-Math/Lookout/compare/1.2.0...HEAD +[1.2.0]: https://github.com/Post-Math/Lookout/compare/1.1.7...1.2.0 +[#28]: https://github.com/Post-Math/Lookout/issues/28 [1.1.3]: https://github.com/Post-Math/Lookout/compare/1.1.2...1.1.3 [1.1.2]: https://github.com/Post-Math/Lookout/compare/1.1.1...1.1.2 [1.1.1]: https://github.com/Post-Math/Lookout/releases/tag/1.1.1 diff --git a/manifest.json b/manifest.json index e4bce76..1ab7c25 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "lookout", "name": "Lookout", - "version": "1.1.7", + "version": "1.2.0", "minAppVersion": "1.0.0", "description": "Survey wide content instead of scrolling sideways. Pan and zoom Mermaid diagrams (wheel, Ctrl+wheel, or buttons), fit them to the frame, and open diagrams or wide tables full-screen.", "author": "Post-Math", diff --git a/versions.json b/versions.json index d95db98..efbfde8 100644 --- a/versions.json +++ b/versions.json @@ -5,5 +5,6 @@ "1.1.4": "1.0.0", "1.1.5": "1.0.0", "1.1.6": "1.0.0", - "1.1.7": "1.0.0" + "1.1.7": "1.0.0", + "1.2.0": "1.0.0" } From 1a97b01156f35e4cbd0d3585feef3037629e744b Mon Sep 17 00:00:00 2001 From: Sangjoon Han Date: Tue, 28 Jul 2026 00:22:37 +0900 Subject: [PATCH 3/7] fix: harden the full-screen footnote view against the review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A workflow-backed review at xhigh effort raised 15 findings against the footnote work. Eleven were adjudicated real and are fixed here; four were rejected with evidence (see below). Clone hygiene - `_scrub` no longer strips ids inside an SVG subtree. Removing them broke id-scoped ` -
    +
    @@ -54,11 +84,10 @@ section — in particular that its text is laid out at a readable measure rather than at the narrow table's own width. - The markup below mirrors Obsidian's own renderer exactly: the `` - and the definition `
  • ` carry `data-footnote-id` and *no* `id`, the - anchors carry only the `#fn-…`/`#fnref-…` href, and the marker's href - names the definition's `data-footnote-id`. Keep it that way — resolving - a marker to its definition depends on that pairing. + The same footnote is referenced a second time from the paragraph BELOW + the table, so the definition carries two back-refs: one whose `` + travels into the overlay inside the table, and one whose `` does + not and is therefore dead in the overlay. -->
  • ABC
    @@ -67,14 +96,156 @@ - + + + +
    1x[1]x[1]
    +

    Prose referring to the same footnote[1-1].

    +
    +
    +
      +
    1. A perfectly ordinary one-sentence inline footnote. ↩︎ ↩︎
    2. +
    +
    + + + +
    + + + + + + + + + + +
    AB
    1y[1]
    +
    + + +
    + + + + + + + + + + +
    AB
    ok[1]gone[9]
    +
    +
    +
      +
    1. The only definition this note rendered. ↩︎
    2. +
    +
    +
    + + +
    + + + + + + + + + + +
    AB
    first[2]second[1]
    +
    +
    +
      +
    1. Definition one. ↩︎
    2. +
    3. Definition two. ↩︎
    4. +
    +
    +
    + + +
    + + + + + + + + + +
    A
    + + + + +
    stray
    +
    +
    + + +
    + + + + + + + + + +
    A
    d[1]
    +
    +
    +
      +
    1. + See the diagram: +
      + + + +
      + ↩︎ +
    2. +
    +
    +
    + + +
    + + + + + + + + + + + + + + +
    c1c2c3c4c5c6c7c8c9c10
    w[1]bcdefghij

      -
    1. A perfectly ordinary one-sentence inline footnote. ↩︎
    2. +
    3. A single sentence that has no business being laid out across the full width of a very wide table. ↩︎
    diff --git a/tests/e2e/table-fullscreen.test.mjs b/tests/e2e/table-fullscreen.test.mjs index 9ce6b50..0352c1f 100644 --- a/tests/e2e/table-fullscreen.test.mjs +++ b/tests/e2e/table-fullscreen.test.mjs @@ -1,10 +1,10 @@ /* * E2E regression test: the full-screen table must match the inline table's - * design (theme styling) and layout (centering / override). + * design (theme styling) and layout (centering / override), and must carry a + * cell's footnotes with it without leaking anything into the note behind it. * * It drives the REAL bundled plugin (main.js) in a headless browser with a tiny - * `obsidian` stub, opens a table full screen, and compares computed styles - * against the inline table. + * `obsidian` stub, opens tables full screen, and inspects the result. * * Run: npm run build && npm run test:e2e * Needs a Chromium once: npx playwright install chromium @@ -33,6 +33,12 @@ const bootstrap = ` HTMLElement.prototype.setCssProps = _setCssProps; SVGElement.prototype.setCssStyles = _setCssStyles; SVGElement.prototype.setCssProps = _setCssProps; + // Obsidian's own DOM helpers. src/main.ts uses classList throughout, so + // nothing needs these today; they are stubbed because they are real in the + // app, and a stub that lacks them fails code the app would run fine — which + // is exactly how the footnote jump path came to throw only under test. + Element.prototype.addClass = function (c) { this.classList.add(c); }; + Element.prototype.removeClass = function (c) { this.classList.remove(c); }; Node.prototype.instanceOf = function (t) { return this instanceof t; }; const __obsidian = { Plugin: class { constructor(app){ this.app = app; } registerEvent(){} registerMarkdownPostProcessor(){} addCommand(){} }, @@ -60,14 +66,50 @@ const browser = await chromium.launch(exe ? { executablePath: exe } : {}); try { const page = await browser.newPage(); await page.setViewportSize({ width: 1280, height: 800 }); + + // Nothing the plugin does may throw. A silent TypeError used to abort + // openFullscreen() mid-build with every assertion below still passing. + const pageErrors = []; + page.on("pageerror", (e) => pageErrors.push("pageerror: " + e.message)); + page.on("console", (m) => { + if (m.type() === "error") pageErrors.push("console.error: " + m.text()); + }); + await page.goto(HARNESS); await page.addStyleTag({ content: STYLES }); await page.addScriptTag({ content: bootstrap }); - - // The plugin enhanced the tables on load; open the first one full screen. await page.waitForSelector(".lookout-table-btn"); - await page.locator(".lookout-table-btn").first().click({ force: true }); - await page.waitForSelector(".lookout-table-fs-table"); + + // The dupe-id invariant, checked after every open. SVG-namespaced nodes are + // skipped on purpose: `_scrub` preserves svg-internal ids because id-scoped + // -
    + +
    @@ -78,17 +93,6 @@
    ABC
    - @@ -96,21 +100,15 @@ - +
    AB
    1x[1]x[1]
    -

    Prose referring to the same footnote[1-1].

    -
    -
    -
      -
    1. A perfectly ordinary one-sentence inline footnote. ↩︎ ↩︎
    2. -
    -
    +

    Prose referring to the same footnote[1-1].

    - -
    + +
    @@ -118,60 +116,51 @@ - +
    AB
    1y[1]y[1]
    - -
    + +
    - - + +
    AB
    ok[1]gone[9]ok[1]gone[9]
    -
    -
    -
      -
    1. The only definition this note rendered. ↩︎
    2. -
    -
    - -
    + +
    - - + +
    AB
    first[2]second[1]higher first[5]lower second[3]
    -
    -
    -
      -
    1. Definition one. ↩︎
    2. -
    3. Definition two. ↩︎
    4. -
    -
    -
    +
    @@ -191,39 +180,31 @@ -
    +
    A
    - +
    A
    d[1] +
    + + + +
    +
    -
    -
    -
      -
    1. - See the diagram: -
      - - - -
      - ↩︎ -
    2. -
    -
    -
    +
    @@ -235,19 +216,545 @@ - +
    w[1]w[1] bcd efg hij
    +
    + + +
    + + + + + + + + + +
    흐름
    배포 절차[1]
    +
    + + +
    + + + + + + + + + + +
    왼쪽오른쪽
    첫 번째[3]두 번째[3-1]
    +
    + + +
    + + + + + + + + + +
    6[6]
    +
    + + +
    + + + + + + + + + +
    A
    decoy[1]

      -
    1. A single sentence that has no business being laid out across the full width of a very wide table. ↩︎
    2. +
    3. DOM에서 읽으면 안 되는 옛 본문. ↩︎
    + + +
    +
    + + + + + + + + + + +
    항목
    응답률62%[1]
    +
    +
    + + +
    +
    +
    + + + + + + + + + +
    A
    emb[1]
    +
    +
    +
    + + +
    +
    +
    + + + + + + + + + +
    A
    gone[1]
    +
    +
    +
    + + +
    +
    +
    + + + + + + + + + +
    A
    frag[1]
    +
    +
    +
    + + +
    + + + + + + + + + + + +
    ABC
    a[1]b[2]c[없음]
    +
    + + +
    + + + + + + + + + +
    A
    seven[7]
    +
    + + +
    + + + + + + + + + + + + + +
    ABCDE
    정상[1]여백[2]어긋남[3]인라인[4][5]
    +
    + + +
    + + + + + + + + + +
    A
    oob[1]
    +
    + + +
    + + + + + + + + + +
    A
    rich[1]
    +

    이 문단이 노트 안의 blk-1이다.

    +
    + + +
    + + + + + + + + + +
    A
    link[1]
    +
    + + diff --git a/tests/e2e/table-fullscreen.test.mjs b/tests/e2e/table-fullscreen.test.mjs index 0352c1f..2aed905 100644 --- a/tests/e2e/table-fullscreen.test.mjs +++ b/tests/e2e/table-fullscreen.test.mjs @@ -1,11 +1,18 @@ /* * E2E regression test: the full-screen table must match the inline table's * design (theme styling) and layout (centering / override), and must carry a - * cell's footnotes with it without leaking anything into the note behind it. + * cell's footnotes with it — resolved from the note's SOURCE, not from rendered + * DOM — without leaking anything into the note behind it. * * It drives the REAL bundled plugin (main.js) in a headless browser with a tiny * `obsidian` stub, opens tables full screen, and inspects the result. * + * The stub deliberately fakes the whole resolution chain (metadataCache → + * cachedRead → MarkdownRenderer) rather than the rendered `.footnotes` list the + * previous suite assumed: a real vault does not have that list in the DOM for + * any table but the note's last one, and encoding the assumption in the harness + * is exactly how the original bug shipped green. + * * Run: npm run build && npm run test:e2e * Needs a Chromium once: npx playwright install chromium * (or set CHROMIUM_PATH to an existing binary) @@ -40,16 +47,221 @@ const bootstrap = ` Element.prototype.addClass = function (c) { this.classList.add(c); }; Element.prototype.removeClass = function (c) { this.classList.remove(c); }; Node.prototype.instanceOf = function (t) { return this instanceof t; }; + + window.__componentsLoaded = 0; + window.__componentsUnloaded = 0; + window.__renderCalls = []; + window.__linkResolutions = []; + window.__readCalls = []; + window.__gateRead = false; + window.__pageErrors = []; + window.addEventListener("unhandledrejection", (e) => { + window.__pageErrors.push("unhandledrejection: " + (e.reason && e.reason.message || e.reason)); + }); + + // --- Component: load/unload with counters, plus child bookkeeping. --- + class Component { + constructor() { this._children = []; this._cbs = []; this._loaded = false; } + load() { + if (this._loaded) return; + this._loaded = true; + window.__componentsLoaded++; + this.onload(); + } + onload() {} + unload() { + if (!this._loaded) return; + this._loaded = false; + window.__componentsUnloaded++; + for (const c of this._children.splice(0)) c.unload(); + for (const cb of this._cbs.splice(0)) cb(); + this.onunload(); + } + onunload() {} + register(cb) { this._cbs.push(cb); } + registerEvent() {} + addChild(c) { this._children.push(c); c.load(); return c; } + removeChild(c) { + const i = this._children.indexOf(c); + if (i >= 0) this._children.splice(i, 1); + c.unload(); + return c; + } + } + + class TFile { constructor(path) { this.path = path; } } + class View { constructor(containerEl, file) { this.containerEl = containerEl; this.file = file; } } + class MarkdownView extends View {} + + /* + * A deliberately small markdown renderer with just enough fidelity for the + * properties under test: + * - blank-line separated paragraphs become

    , so back-ref placement is + * exercised against a real block child; + * - a fence at COLUMN 0 becomes

    , while a
    +   *     4-space/tab indented block becomes a plain 
    . Without that
    +   *     distinction the dedent regression would be invisible;
    +   *   - [text](target) becomes an internal link, so the "a link inside a
    +   *     footnote closes full screen and navigates" path is reachable;
    +   *   - "### 제목" becomes 

    and a trailing " ^block-id" stamps that + * id on the paragraph. A renderer that never emits an id would make the + * clone's id hygiene unobservable — the same "the stub returns only what + * the implementation handles" trap that let the original bug ship green. + */ + const inline = (node, text) => { + const re = /\\[([^\\]]+)\\]\\(([^)]+)\\)/g; + let last = 0; + let m; + while ((m = re.exec(text)) !== null) { + if (m.index > last) node.appendChild(document.createTextNode(text.slice(last, m.index))); + const a = document.createElement("a"); + a.className = "internal-link"; + a.setAttribute("href", m[2]); + a.textContent = m[1]; + node.appendChild(a); + last = re.lastIndex; + } + if (last < text.length) node.appendChild(document.createTextNode(text.slice(last))); + }; + const codeBlock = (elm, body, lang) => { + const pre = document.createElement("pre"); + const code = document.createElement("code"); + if (lang) code.className = "language-" + lang; + code.textContent = body; + pre.appendChild(code); + elm.appendChild(pre); + }; + const isIndented = (line) => line.startsWith(" ") || line.startsWith("\\t"); + + class MarkdownRenderer { + static async render(app, markdown, elm, sourcePath, component) { + window.__renderCalls.push({ sourcePath, markdown, component: !!component }); + await Promise.resolve(); + const lines = String(markdown).split("\\n"); + let i = 0; + while (i < lines.length) { + const line = lines[i]; + if (line.startsWith("\`\`\`")) { + const lang = line.slice(3).trim(); + const body = []; + i++; + while (i < lines.length && !lines[i].startsWith("\`\`\`")) { body.push(lines[i]); i++; } + i++; + codeBlock(elm, body.join("\\n"), lang); + continue; + } + if (isIndented(line)) { + const body = []; + while (i < lines.length && (isIndented(lines[i]) || lines[i].trim() === "")) { + body.push(lines[i].replace(/^( |\\t)/, "")); + i++; + } + while (body.length && body[body.length - 1].trim() === "") body.pop(); + codeBlock(elm, body.join("\\n"), ""); + continue; + } + if (line.trim() === "") { i++; continue; } + if (/^#{1,6} /.test(line)) { + const level = line.match(/^#+/)[0].length; + const text = line.slice(level + 1).trim(); + const h = document.createElement("h" + level); + h.id = "h-" + text.replace(/\\s+/g, "-"); + h.textContent = text; + elm.appendChild(h); + i++; + continue; + } + const buf = []; + while (i < lines.length && lines[i].trim() !== "" && !lines[i].startsWith("\`\`\`") && !isIndented(lines[i]) && !/^#{1,6} /.test(lines[i])) { + buf.push(lines[i]); + i++; + } + const p = document.createElement("p"); + let body = buf.join(" "); + const block = /\\s\\^([A-Za-z0-9-]+)$/.exec(body); + if (block) { + p.id = block[1]; + body = body.slice(0, block.index); + } + inline(p, body); + elm.appendChild(p); + } + } + } + const __obsidian = { Plugin: class { constructor(app){ this.app = app; } registerEvent(){} registerMarkdownPostProcessor(){} addCommand(){} }, Notice: class { constructor(m){ this.message = m; } }, + Component, + MarkdownRenderer, + MarkdownView, + TFile, }; const module = { exports: {} }; const exports = module.exports; const require = (id) => { if (id === "obsidian") return __obsidian; throw new Error("unknown module: " + id); }; ${MAIN} const LookoutPlugin = module.exports.default || module.exports; - const app = { workspace: { onLayoutReady: (cb) => cb(), on: () => ({}) } }; + + const files = new Map(); + const fileFor = (path) => { + if (!files.has(path)) files.set(path, new TFile(path)); + return files.get(path); + }; + const app = { + workspace: { + onLayoutReady: (cb) => cb(), + on: () => ({}), + // A real leaf per note root, so the actual leaf -> file walk is exercised. + // \`getActiveFile\` is deliberately NOT stubbed: if the implementation ever + // reaches for it, this suite must break. + getLeavesOfType: (type) => { + if (type !== "markdown") return []; + return Array.from(document.querySelectorAll("[data-note-file]")).map((rootEl) => ({ + view: new MarkdownView(rootEl, fileFor(rootEl.getAttribute("data-note-file"))), + })); + }, + }, + metadataCache: { + getFileCache: (file) => { + const note = window.__NOTES[file.path]; + if (!note || note.cache === null) return null; + return { + footnotes: (note.footnotes || []).map((f) => { + // Offsets derived from the fixture's own source by indexOf, so they + // can never drift from the text — unless the fixture asks them to. + // \`shift\`/\`endShift\` reproduce a metadata index lagging the file + // content, the one case where the cache names a region that is not + // the footnote (or is not in the file at all). + const at = note.source.indexOf(f.slice); + if (at < 0) throw new Error("fixture: slice for " + f.id + " is not in " + file.path); + const start = at + (f.shift || 0); + return { + id: f.id, + position: { + start: { line: 0, col: 0, offset: start }, + end: { line: 0, col: 0, offset: start + f.slice.length + (f.endShift || 0) }, + }, + }; + }), + }; + }, + getFirstLinkpathDest: (linkpath, sourcePath) => { + window.__linkResolutions.push({ linkpath, sourcePath }); + const path = linkpath.endsWith(".md") ? linkpath : linkpath + ".md"; + return window.__NOTES[path] ? fileFor(path) : null; + }, + }, + vault: { + cachedRead: (file) => { + window.__readCalls.push(file.path); + const note = window.__NOTES[file.path] || {}; + const src = note.source || ""; + if (!window.__gateRead) return Promise.resolve(src); + return new Promise((res) => { window.__releaseRead = () => res(src); }); + }, + }, + }; const plugin = new LookoutPlugin(app); plugin.onload(); })(); @@ -68,7 +280,9 @@ try { await page.setViewportSize({ width: 1280, height: 800 }); // Nothing the plugin does may throw. A silent TypeError used to abort - // openFullscreen() mid-build with every assertion below still passing. + // openFullscreen() mid-build with every assertion below still passing; with an + // async resolution path a *rejected promise* is the same hazard, so in-page + // unhandled rejections are drained into the same list. const pageErrors = []; page.on("pageerror", (e) => pageErrors.push("pageerror: " + e.message)); page.on("console", (m) => { @@ -98,27 +312,38 @@ try { }; }); + const SETTLED = '.lookout-fs[data-lookout-fn="ready"], .lookout-fs[data-lookout-fn="none"]'; const openFs = async (sel) => { await page.locator(sel).click({ force: true }); await page.waitForSelector(".lookout-table-fs-table"); }; + // Every footnote assertion must be gated on the SETTLED state: the table + // clone is in the DOM before the definitions are resolved, so an assertion + // made on `.lookout-table-fs-table` alone would read pre-settle DOM and pass + // no matter what the resolution did. + const openFsSettled = async (sel) => { + await openFs(sel); + await page.waitForSelector(SETTLED); + }; const closeFs = async () => { await page.keyboard.press("Escape"); await page.waitForSelector(".lookout-fs", { state: "detached" }); }; const dupes = () => page.evaluate(() => window.__dupeIds()); + const fnState = () => + page.evaluate(() => document.querySelector(".lookout-fs")?.getAttribute("data-lookout-fn")); /* ================= note-a, table 1: theme + layout parity ================= */ await openFs("#note-a .lookout-table-btn >> nth=0"); const r = await page.evaluate(() => { - const g = (el, p) => (el ? getComputedStyle(el)[p] : null); - const cell = (el) => - el && { - borderTopWidth: g(el, "borderTopWidth"), - borderTopStyle: g(el, "borderTopStyle"), - paddingTop: g(el, "paddingTop"), - paddingLeft: g(el, "paddingLeft"), + const g = (elm, p) => (elm ? getComputedStyle(elm)[p] : null); + const cell = (elm) => + elm && { + borderTopWidth: g(elm, "borderTopWidth"), + borderTopStyle: g(elm, "borderTopStyle"), + paddingTop: g(elm, "paddingTop"), + paddingLeft: g(elm, "paddingLeft"), }; const inlineTd = document.querySelector("#note-a .lookout-table-scroll tbody td"); const inlineTh = document.querySelector("#note-a .lookout-table-scroll thead th"); @@ -126,6 +351,7 @@ try { const fsTh = document.querySelector(".lookout-table-fs-table thead th"); const fsTable = document.querySelector(".lookout-table-fs-table"); const sc = document.querySelector(".lookout-table-fs-scroll"); + const overlay = document.querySelector(".lookout-fs"); const tb = fsTable.getBoundingClientRect(); const sb = sc.getBoundingClientRect(); const cs = getComputedStyle(fsTable); @@ -139,6 +365,10 @@ try { tableVisible: tb.width > 0 && tb.height > 0, leftGap: Math.round(tb.left - sb.left), rightGap: Math.round(sb.right - tb.right), + // (xiv) byte-identical overlay for a table with no footnote anchors. + hasSettleAttr: overlay.hasAttribute("data-lookout-fn"), + hasDoc: !!document.querySelector(".lookout-fs-doc"), + tableIsDirectChild: fsTable.parentElement === sc, }; }); @@ -148,61 +378,81 @@ try { check("override styles intact (margin 0, max-width none)", r.overrideMargin === "0px" && r.overrideMaxWidth === "none", `margin=${r.overrideMargin} maxWidth=${r.overrideMaxWidth}`); check("full-screen table is visible", r.tableVisible); check("small table stays centered", Math.abs(r.leftGap - r.rightGap) <= 2, `gaps ${r.leftGap}/${r.rightGap}`); + check( + "a table with no footnotes builds the pre-footnote overlay exactly", + !r.hasSettleAttr && !r.hasDoc && r.tableIsDirectChild, + JSON.stringify(r) + ); check("no duplicate ids (plain table)", (await dupes()).length === 0); await closeFs(); - /* ============ note-a, table 2: footnote section, measure, jumps ============ */ - await openFs("#note-a .lookout-table-btn >> nth=1"); - await page.waitForSelector(".lookout-fs-footnotes li"); + /* ===== (i) THE REGRESSION: a note with NO rendered .footnotes anywhere ===== */ + await openFsSettled("#note-a .lookout-table-btn >> nth=1"); - const f = await page.evaluate(() => { - const box = (el) => { - if (!el) return null; - const r = el.getBoundingClientRect(); - return { w: Math.round(r.width), h: Math.round(r.height) }; + const reg = await page.evaluate(() => { + const box = (elm) => { + if (!elm) return null; + const rect = elm.getBoundingClientRect(); + return { w: Math.round(rect.width), h: Math.round(rect.height) }; }; - const noteLi = document.querySelector("#note-a > .footnotes li"); - const fsLi = document.querySelector(".lookout-fs-footnotes li"); + const lis = Array.from(document.querySelectorAll(".lookout-fs-footnotes li")); + const fsLi = lis[0]; const fsTable = document.querySelector(".lookout-table-fs-table"); const sc = document.querySelector(".lookout-table-fs-scroll"); + const marker = document.querySelector(".lookout-table-fs-table a.footnote-link"); + const liveMarker = document.querySelector("#note-a #fn-table a.footnote-link"); const tb = fsTable.getBoundingClientRect(); const sb = sc.getBoundingClientRect(); return { - noteLi: box(noteLi), + state: document.querySelector(".lookout-fs").getAttribute("data-lookout-fn"), + count: lis.length, + text: (fsLi?.textContent || "").replace("↩︎", "").trim(), + value: fsLi?.value, + liId: fsLi?.id, + liFootnoteId: fsLi?.getAttribute("data-footnote-id"), + markerHref: marker?.getAttribute("href"), + // (xiii) target/rel are real in the vault and must not survive the clone, + // or a click in full screen opens a browser window. The note's own marker + // keeps them: Lookout never touches the live DOM. + cloneTarget: marker?.getAttribute("target"), + cloneRel: marker?.getAttribute("rel"), + liveTarget: liveMarker?.getAttribute("target"), + liveRel: liveMarker?.getAttribute("rel"), fsLi: box(fsLi), fsTableWidth: Math.round(tb.width), leftGap: Math.round(tb.left - sb.left), rightGap: Math.round(sb.right - tb.right), - // The definition text must survive the clone. - fsText: (fsLi?.textContent || "").trim().slice(0, 20), - markerHref: document - .querySelector(".lookout-table-fs-table a.footnote-link") - ?.getAttribute("href"), dupeIds: window.__dupeIds(), - // The note's own definition is Obsidian-owned: read, never rewritten. - noteLiUntouched: - !!noteLi && - noteLi.id === "fn-1-d9" && - noteLi.getAttribute("data-footnote-id") === "fn-1-d9", }; }); - check("footnote definition is carried into full screen", f.fsText.startsWith("A perfectly ordinary"), `text="${f.fsText}"`); - check("footnote marker points at an overlay-local anchor", !!f.markerHref?.startsWith("#lookout-fs-"), `href=${f.markerHref}`); + check( + "a definition is resolved from the note SOURCE with no .footnotes in the DOM", + reg.state === "ready" && reg.count === 1 && reg.text === "표와 문단 양쪽에서 참조되는 각주.", + JSON.stringify({ state: reg.state, count: reg.count, text: reg.text }) + ); + check("the `[^ref]:` label is stripped from the rendered body", !reg.text.includes("[^ref]"), reg.text); + check("the definition keeps the marker's number", reg.value === 1, `value=${reg.value}`); + check("footnote marker points at an overlay-local anchor", !!reg.markerHref?.startsWith("#lookout-fs-"), `href=${reg.markerHref}`); + check("the emitted li keeps its namespaced id through _scrub", reg.liId === reg.markerHref?.slice(1) && reg.liFootnoteId === reg.liId, JSON.stringify({ liId: reg.liId, href: reg.markerHref })); + check( + "target/rel are stripped from the clone but kept on the note's own marker", + reg.cloneTarget === null && reg.cloneRel === null && reg.liveTarget === "_blank" && reg.liveRel === "noopener nofollow", + JSON.stringify(reg) + ); // The regression this guards: sizing the section to the narrow table's own // width squeezed the text into a one-word-per-line ribbon. check( "footnote text reads at a note-like measure, not the table's width", - f.fsLi.w >= Math.min(f.noteLi.w, 400) && f.fsLi.w > f.fsTableWidth * 2, - `note=${f.noteLi.w}x${f.noteLi.h} fs=${f.fsLi.w}x${f.fsLi.h} table=${f.fsTableWidth}` + reg.fsLi.w >= 400 && reg.fsLi.w > reg.fsTableWidth * 2, + `fs=${reg.fsLi.w}x${reg.fsLi.h} table=${reg.fsTableWidth}` ); - check("footnote text is not stacked into a sliver", f.fsLi.h <= f.noteLi.h * 2, `note h=${f.noteLi.h} fs h=${f.fsLi.h}`); - check("narrow table with footnotes stays centered", Math.abs(f.leftGap - f.rightGap) <= 2, `gaps ${f.leftGap}/${f.rightGap}`); - check("no duplicate ids introduced by the clone", f.dupeIds.length === 0, `dupes=${f.dupeIds.join(",")}`); - check("the note's own footnote definition is left untouched", f.noteLiUntouched); + check("footnote text is not stacked into a sliver", reg.fsLi.h <= 60, `fs h=${reg.fsLi.h}`); + check("narrow table with footnotes stays centered", Math.abs(reg.leftGap - reg.rightGap) <= 2, `gaps ${reg.leftGap}/${reg.rightGap}`); + check("no duplicate ids introduced by the clone", reg.dupeIds.length === 0, `dupes=${reg.dupeIds.join(",")}`); - // --- A4: right-click must not jump (auxclick fires for the secondary button). + // --- right-click must not jump (auxclick fires for the secondary button). // (Chromium focuses the link itself on any mouse-down, so what must not // happen is focus landing on the *definition* — that would be our jump.) await page.evaluate(() => { @@ -216,14 +466,14 @@ try { })); check("right-click on a marker does not jump", right.highlighted === 0 && !right.stoleFocus && right.sameScroll, JSON.stringify(right)); - // --- A4: middle-click still jumps. + // --- middle-click still jumps. await page.locator(".lookout-table-fs-table a.footnote-link").click({ button: "middle" }); check( "middle-click on a marker still jumps", (await page.evaluate(() => document.querySelectorAll(".is-lookout-target").length)) === 1 ); - // --- A1/A5/E1: a plain click resolves the marker to its definition. + // --- a plain click resolves the marker to its definition. await page.locator(".lookout-table-fs-table a.footnote-link").click(); const jump = await page.evaluate(() => { const marker = document.querySelector(".lookout-table-fs-table a.footnote-link"); @@ -238,9 +488,30 @@ try { check("marker click highlights exactly one target", jump.highlighted === 1, `count=${jump.highlighted}`); check("marker click focuses the definition li", jump.focusedTarget && jump.isLi, JSON.stringify(jump)); - // --- A2: the in-table back-ref jumps back; the paragraph's back-ref is dead. + // --- one back-ref per occurrence PRESENT IN THE CLONE. The same footnote is + // referenced again from the paragraph below the table; that occurrence's + // never enters the overlay, so it must produce no back-ref at all — a + // dead back-ref is designed out, not merely made inert. const backrefs = page.locator(".lookout-fs-footnotes a.footnote-backref"); - check("definition carries one back-ref per reference occurrence", (await backrefs.count()) === 2, `count=${await backrefs.count()}`); + check("only in-clone occurrences produce a back-ref", (await backrefs.count()) === 1, `count=${await backrefs.count()}`); + const backMeta = await page.evaluate(() => { + const a = document.querySelector(".lookout-fs-footnotes a.footnote-backref"); + const p = a.parentElement; + return { + href: a.getAttribute("href"), + label: a.getAttribute("aria-label"), + inert: a.classList.contains("lookout-fn-unresolved"), + inParagraph: p.tagName === "P", + }; + }); + check( + "the back-ref is live, named and sits with the definition text", + backMeta.href?.startsWith("#lookout-fs-") && + !backMeta.inert && + backMeta.label?.includes("각주 1") && + backMeta.inParagraph, + JSON.stringify(backMeta) + ); await backrefs.nth(0).click(); const backJump = await page.evaluate(() => { @@ -248,45 +519,12 @@ try { return { count: document.querySelectorAll(".is-lookout-target").length, tag: t?.tagName, cls: t?.className }; }); check( - "in-table back-ref jumps to the marker's ", + "the back-ref jumps to the marker's ", backJump.count === 1 && backJump.tag === "SUP" && String(backJump.cls).includes("footnote-ref"), JSON.stringify(backJump) ); - const deadBefore = await page.evaluate(() => { - const a = document.querySelectorAll(".lookout-fs-footnotes a.footnote-backref")[1]; - return { - href: a.getAttribute("href"), - role: a.getAttribute("role"), - disabled: a.getAttribute("aria-disabled"), - label: a.getAttribute("aria-label"), - unresolved: a.classList.contains("lookout-fn-unresolved"), - hash: location.hash, - history: history.length, - }; - }); - check( - "out-of-table back-ref is inert and explained", - deadBefore.href === null && - deadBefore.role === "link" && - deadBefore.disabled === "true" && - // A back-ref's visible text is the "↩︎" glyph, so its name says what is - // missing rather than echoing a label that names nothing. - deadBefore.label === "돌아갈 각주 참조가 이 화면에 없습니다" && - deadBefore.unresolved, - JSON.stringify(deadBefore) - ); - // `force`: Playwright refuses to click an `aria-disabled` element, which is - // exactly what the hardening made it — the click still has to be delivered. - await backrefs.nth(1).click({ force: true }); - const deadAfter = await page.evaluate(() => ({ hash: location.hash, history: history.length })); - check( - "clicking a dead back-ref changes neither the URL nor history", - deadAfter.hash === deadBefore.hash && deadAfter.history === deadBefore.history, - `${JSON.stringify(deadBefore)} -> ${JSON.stringify(deadAfter)}` - ); - - // --- D2: keyboard jump keeps a visible focus ring after the tint expires. + // --- keyboard jump keeps a visible focus ring after the tint expires. await page.locator(".lookout-table-fs-table a.footnote-link").focus(); await page.keyboard.press("Enter"); await page.waitForTimeout(1700); @@ -307,19 +545,23 @@ try { await closeFs(); - /* ================= note-b: no definitions anywhere (A3) ================= */ + /* ================= (vii) note-b: the file has no cache ================= */ const scrollBefore = await page.evaluate(() => document.scrollingElement.scrollTop); const hashBefore = await page.evaluate(() => location.hash); - await openFs("#note-b .lookout-table-btn"); - const degraded = await page.evaluate(() => { + const historyBefore = await page.evaluate(() => history.length); + await openFsSettled("#note-b .lookout-table-btn"); + const nocache = await page.evaluate(() => { const fsTable = document.querySelector(".lookout-table-fs-table"); - const anchors = [...fsTable.querySelectorAll("a.footnote-link")]; + const anchors = Array.from(fsTable.querySelectorAll("a.footnote-link")); return { - hasDoc: !!document.querySelector(".lookout-fs-doc"), + state: document.querySelector(".lookout-fs").getAttribute("data-lookout-fn"), + section: document.querySelectorAll(".lookout-fs-footnotes").length, anchors: anchors.length, allInert: anchors.every( (a) => !a.hasAttribute("href") && + !a.hasAttribute("target") && + !a.hasAttribute("rel") && a.getAttribute("aria-disabled") === "true" && a.getAttribute("role") === "link" && // The accessible name must CONTAIN the visible "[1]", not replace it. @@ -327,33 +569,32 @@ try { (a.getAttribute("aria-label") || "").includes("찾을 수 없습니다") && a.classList.contains("lookout-fn-unresolved") ), - leftovers: fsTable.querySelectorAll("[id], [data-footnote-id]").length, dupeIds: window.__dupeIds(), }; }); - check("no footnote wrapper when nothing resolves", !degraded.hasDoc); - check("every marker is inert when nothing resolves", degraded.anchors === 1 && degraded.allInert, JSON.stringify(degraded)); - check("no footnote identity survives in the degraded clone", degraded.leftovers === 0, `leftovers=${degraded.leftovers}`); - check("no duplicate ids in the degraded clone", degraded.dupeIds.length === 0, `dupes=${degraded.dupeIds.join(",")}`); + check("a file with no metadata cache settles to 'none'", nocache.state === "none" && nocache.section === 0, JSON.stringify(nocache)); + check("every marker is inert when nothing resolves", nocache.anchors === 1 && nocache.allInert, JSON.stringify(nocache)); + check("no duplicate ids in the degraded clone", nocache.dupeIds.length === 0, `dupes=${nocache.dupeIds.join(",")}`); await page.locator(".lookout-table-fs-table a.footnote-link").click({ force: true }); const after = await page.evaluate(() => ({ hash: location.hash, scroll: document.scrollingElement.scrollTop, + history: history.length, })); check( - "clicking a degraded marker moves neither the URL nor the note behind it", - after.hash === hashBefore && after.scroll === scrollBefore, - `hash=${after.hash} scroll=${after.scroll} (was ${scrollBefore})` + "clicking a degraded marker moves neither the URL, the history nor the note behind it", + after.hash === hashBefore && after.scroll === scrollBefore && after.history === historyBefore, + `hash=${after.hash} scroll=${after.scroll} history=${after.history}` ); await closeFs(); - /* ================= note-c: mixed note still builds (A1/F9) ================= */ - await openFs("#note-c .lookout-table-btn"); - await page.waitForSelector(".lookout-fs-footnotes li"); + /* ============ (vi) note-c: one label unknown, the rest still builds ======== */ + await openFsSettled("#note-c .lookout-table-btn"); const mixed = await page.evaluate(() => { - const [ok, gone] = [...document.querySelectorAll(".lookout-table-fs-table a.footnote-link")]; + const [ok, gone] = Array.from(document.querySelectorAll(".lookout-table-fs-table a.footnote-link")); return { + state: document.querySelector(".lookout-fs").getAttribute("data-lookout-fn"), okHref: ok?.getAttribute("href"), goneHref: gone?.getAttribute("href"), goneRole: gone?.getAttribute("role"), @@ -361,10 +602,15 @@ try { goneLabel: gone?.getAttribute("aria-label"), goneUnresolved: gone?.classList.contains("lookout-fn-unresolved"), defs: document.querySelectorAll(".lookout-fs-footnotes li").length, + text: (document.querySelector(".lookout-fs-footnotes li")?.textContent || "").replace("↩︎", "").trim(), dupeIds: window.__dupeIds(), }; }); - check("mixed note still builds its footnote section", mixed.defs === 1 && !!mixed.okHref?.startsWith("#lookout-fs-"), JSON.stringify(mixed)); + check( + "one unknown label does not collapse the section", + mixed.state === "ready" && mixed.defs === 1 && mixed.text === "이 노트가 정의한 유일한 각주." && !!mixed.okHref?.startsWith("#lookout-fs-"), + JSON.stringify(mixed) + ); check( "the unresolvable marker is inert, named and role-restored", mixed.goneHref === null && @@ -384,24 +630,27 @@ try { check("no duplicate ids in a mixed note", mixed.dupeIds.length === 0, `dupes=${mixed.dupeIds.join(",")}`); await closeFs(); - /* ================= note-d: definitions emitted in note order (C1) ========= */ - await openFs("#note-d .lookout-table-btn"); - await page.waitForSelector(".lookout-fs-footnotes li"); + /* ======== (x) note-d: a non-1-based subset, sorted ascending ============== */ + await openFsSettled("#note-d .lookout-table-btn"); const order = await page.evaluate(() => ({ - values: [...document.querySelectorAll(".lookout-fs-footnotes li")].map((li) => li.value), - // `li.value` is 0 without an explicit attribute, so the note's own list is - // read as its ordinal positions — which is what the overlay must reproduce. - noteValues: [...document.querySelectorAll("#note-d > .footnotes li")].map((li, i) => li.value || i + 1), - markers: [...document.querySelectorAll(".lookout-table-fs-table a.footnote-link")].map((a) => a.textContent), + values: Array.from(document.querySelectorAll(".lookout-fs-footnotes li")).map((li) => li.value), + texts: Array.from(document.querySelectorAll(".lookout-fs-footnotes li")).map((li) => + li.textContent.replace("↩︎", "").trim() + ), + markers: Array.from(document.querySelectorAll(".lookout-table-fs-table a.footnote-link")).map((a) => a.textContent), dupeIds: window.__dupeIds(), })); - check("footnote list ascends in the note's document order", JSON.stringify(order.values) === "[1,2]", JSON.stringify(order.values)); - check("the emitted order matches the note's own list", JSON.stringify(order.values) === JSON.stringify(order.noteValues), JSON.stringify(order.noteValues)); - check("markers keep their own numbering", JSON.stringify(order.markers) === JSON.stringify(["[2]", "[1]"]), JSON.stringify(order.markers)); - check("no duplicate ids with reordered footnotes", order.dupeIds.length === 0, `dupes=${order.dupeIds.join(",")}`); + check("the emitted list is the marker's own numbering, ascending", JSON.stringify(order.values) === "[3,5]", JSON.stringify(order.values)); + check("markers keep their own numbering and reference order", JSON.stringify(order.markers) === JSON.stringify(["[5]", "[3]"]), JSON.stringify(order.markers)); + check( + "each number carries its own definition", + order.texts[0] === "앞선 문단이 먼저 참조해 낮은 번호를 받는다." && order.texts[1] === "표 안에서만 참조되므로 더 높은 번호를 받는다.", + JSON.stringify(order.texts) + ); + check("no duplicate ids with a reordered subset", order.dupeIds.length === 0, `dupes=${order.dupeIds.join(",")}`); await closeFs(); - /* ================= note-e: svg-internal ids survive (B1) ================= */ + /* ================= note-e: svg-internal ids survive ================= */ await openFs("#note-e .lookout-table-btn"); const svgIds = await page.evaluate(() => { const fsTable = document.querySelector(".lookout-table-fs-table"); @@ -422,35 +671,27 @@ try { check("no duplicate non-svg ids with an svg in a cell", svgIds.dupeIds.length === 0, `dupes=${svgIds.dupeIds.join(",")}`); await closeFs(); - /* ========= note-f: a processed diagram in a definition is reset (B2) ====== */ + /* ====== note-f: a processed diagram in a CELL loses its inline sizing ===== */ await openFs("#note-f .lookout-table-btn"); - await page.waitForSelector(".lookout-fs-footnotes li"); const diagram = await page.evaluate(() => { - const notes = document.querySelector(".lookout-fs-footnotes"); - const svg = notes.querySelector("svg"); + const fsTable = document.querySelector(".lookout-table-fs-table"); + const svg = fsTable.querySelector("svg"); const sc = document.querySelector(".lookout-table-fs-scroll"); return { w: svg?.style.width, h: svg?.style.height, mw: svg?.style.maxWidth, - svgWidth: svg ? Math.round(svg.getBoundingClientRect().width) : null, - notesWidth: Math.round(notes.getBoundingClientRect().width), - scrollWidth: sc.scrollWidth, - clientWidth: sc.clientWidth, - chrome: notes.querySelectorAll(".lookout-viewport, .lookout-stage, .lookout-btn").length, + chrome: fsTable.querySelectorAll(".lookout-viewport, .lookout-stage, .lookout-btn").length, dupeIds: window.__dupeIds(), }; }); check("cloned diagram loses the inline sizing DiagramView stamped on it", diagram.w === "" && diagram.h === "" && diagram.mw === "", JSON.stringify(diagram)); - check("cloned diagram fits inside the footnote section", diagram.svgWidth <= diagram.notesWidth, `svg=${diagram.svgWidth} notes=${diagram.notesWidth}`); - check("a footnoted diagram adds no horizontal scroll", diagram.scrollWidth <= diagram.clientWidth + 1, `scrollWidth=${diagram.scrollWidth} clientWidth=${diagram.clientWidth}`); check("Lookout's own chrome is unwrapped out of the clone", diagram.chrome === 0, `chrome=${diagram.chrome}`); - check("no duplicate ids with a diagram in a definition", diagram.dupeIds.length === 0, `dupes=${diagram.dupeIds.join(",")}`); + check("no duplicate ids with a diagram in a cell", diagram.dupeIds.length === 0, `dupes=${diagram.dupeIds.join(",")}`); await closeFs(); - /* ========= note-g: a very wide table keeps a readable measure (D3) ======== */ - await openFs("#note-g .lookout-table-btn"); - await page.waitForSelector(".lookout-fs-footnotes li"); + /* ====== note-g: a very wide table keeps a readable footnote measure ======= */ + await openFsSettled("#note-g .lookout-table-btn"); const wide = await page.evaluate(() => { const li = document.querySelector(".lookout-fs-footnotes li"); const notes = document.querySelector(".lookout-fs-footnotes"); @@ -467,7 +708,435 @@ try { check("no duplicate ids on a wide table", wide.dupeIds.length === 0, `dupes=${wide.dupeIds.join(",")}`); await closeFs(); - /* ================= D1: focus restoration is keyboard-only ================= */ + /* ============ (ii) note-h: the 4-space dedent, observably ================= */ + await openFsSettled("#note-h .lookout-table-btn"); + const dedent = await page.evaluate(() => { + const notes = document.querySelector(".lookout-fs-footnotes"); + const fenced = notes.querySelector("code.language-mermaid"); + const plain = Array.from(notes.querySelectorAll("pre code")).filter((c) => !c.className); + return { + state: document.querySelector(".lookout-fs").getAttribute("data-lookout-fn"), + hasFence: !!fenced, + code: fenced?.textContent || "", + plainBlocks: plain.length, + prose: (notes.querySelector("li > p")?.textContent || "").trim(), + dupeIds: window.__dupeIds(), + }; + }); + check( + "a fenced mermaid block inside a definition stays a fence, not indented code", + dedent.state === "ready" && dedent.hasFence && dedent.plainBlocks === 0, + JSON.stringify({ state: dedent.state, hasFence: dedent.hasFence, plainBlocks: dedent.plainBlocks }) + ); + check( + "dedent removes exactly one indent unit, preserving relative indentation", + dedent.code.startsWith("graph LR") && dedent.code.includes("\n A[빌드] --> B[검증]"), + JSON.stringify(dedent.code) + ); + check("the definition's prose survives alongside the fence", dedent.prose === "배포는 아래 순서로 진행한다.", dedent.prose); + check("no duplicate ids with a fenced block in a definition", dedent.dupeIds.length === 0, `dupes=${dedent.dupeIds.join(",")}`); + await closeFs(); + + /* ====== (v) note-i: the same footnote referenced twice in one table ======= */ + await openFsSettled("#note-i .lookout-table-btn"); + const dup = await page.evaluate(() => { + const backs = Array.from(document.querySelectorAll(".lookout-fs-footnotes a.footnote-backref")); + return { + state: document.querySelector(".lookout-fs").getAttribute("data-lookout-fn"), + defs: document.querySelectorAll(".lookout-fs-footnotes li").length, + value: document.querySelector(".lookout-fs-footnotes li")?.value, + backs: backs.length, + hrefs: backs.map((a) => a.getAttribute("href")), + anyInert: backs.some((a) => a.classList.contains("lookout-fn-unresolved")), + // (xiii) every surviving footnote id in the overlay is overlay-local. + strayIds: Array.from(document.querySelectorAll(".lookout-fs [id]")) + .filter((n) => n.namespaceURI !== "http://www.w3.org/2000/svg") + .map((n) => n.id) + .filter((id) => !id.startsWith("lookout-fs-")), + dupeIds: window.__dupeIds(), + }; + }); + check("a footnote referenced twice is listed once", dup.state === "ready" && dup.defs === 1 && dup.value === 3, JSON.stringify(dup)); + check( + "each in-table occurrence gets its own live back-ref", + dup.backs === 2 && !dup.anyInert && dup.hrefs[0] !== dup.hrefs[1] && dup.hrefs.every((h) => h.startsWith("#lookout-fs-")), + JSON.stringify(dup.hrefs) + ); + check("no note-scoped footnote id survives in the overlay", dup.strayIds.length === 0, `stray=${dup.strayIds.join(",")}`); + check("no duplicate ids with a repeated reference", dup.dupeIds.length === 0, `dupes=${dup.dupeIds.join(",")}`); + + await page.locator(".lookout-fs-footnotes a.footnote-backref").nth(0).click(); + const firstBack = await page.evaluate(() => document.querySelector(".is-lookout-target")?.id); + await page.locator(".lookout-fs-footnotes a.footnote-backref").nth(1).click(); + const secondBack = await page.evaluate(() => document.querySelector(".is-lookout-target")?.id); + check("the two back-refs land on different markers", !!firstBack && !!secondBack && firstBack !== secondBack, `${firstBack} vs ${secondBack}`); + await closeFs(); + + /* ===== (iv)/(xi) note-j: an inline footnote with a regex-hostile id ======= */ + await openFsSettled("#note-j .lookout-table-btn"); + const inlineFn = await page.evaluate(() => ({ + state: document.querySelector(".lookout-fs").getAttribute("data-lookout-fn"), + defs: document.querySelectorAll(".lookout-fs-footnotes li").length, + text: (document.querySelector(".lookout-fs-footnotes li")?.textContent || "").replace("↩︎", "").trim(), + value: document.querySelector(".lookout-fs-footnotes li")?.value, + dupeIds: window.__dupeIds(), + })); + check( + "an inline footnote renders verbatim (nothing stripped) under a `[inline5` label", + inlineFn.state === "ready" && inlineFn.defs === 1 && inlineFn.text === "넓은 표에 붙은 짧은 각주 한 문장." && inlineFn.value === 6, + JSON.stringify(inlineFn) + ); + check("no duplicate ids with an inline footnote", inlineFn.dupeIds.length === 0, `dupes=${inlineFn.dupeIds.join(",")}`); + await closeFs(); + + /* ======== (iv-LP) note-lp: the Live Preview table widget shape ============ */ + // Live Preview parses each block on its own, so the widget's inline marker is + // `[inline0` while the metadata cache — which parses the whole file — calls + // that same footnote `[inline1`. Joining the two would render the PARAGRAPH's + // footnote under the TABLE's marker: wrong text under a right marker, the one + // outcome the design forbids outright. The marker must go inert instead. + await openFsSettled("#note-lp .lookout-table-btn"); + const lp = await page.evaluate(() => { + const a = document.querySelector(".lookout-table-fs-table a.footnote-link"); + return { + state: document.querySelector(".lookout-fs").getAttribute("data-lookout-fn"), + sections: document.querySelectorAll(".lookout-fs-footnotes").length, + inert: + !a.hasAttribute("href") && + a.getAttribute("aria-disabled") === "true" && + a.classList.contains("lookout-fn-unresolved"), + overlayText: document.querySelector(".lookout-fs").textContent, + dupeIds: window.__dupeIds(), + }; + }); + check( + "a Live Preview inline marker is refused, not joined to a block-local index", + lp.state === "none" && lp.sections === 0 && lp.inert, + JSON.stringify({ state: lp.state, sections: lp.sections, inert: lp.inert }) + ); + check( + "no other footnote's text is ever shown under the Live Preview marker", + !lp.overlayText.includes("문단에 붙은 각주") && !lp.overlayText.includes("표본 정리 전 기준"), + lp.overlayText.slice(0, 120) + ); + check("no duplicate ids from the Live Preview shape", lp.dupeIds.length === 0, `dupes=${lp.dupeIds.join(",")}`); + await closeFs(); + + /* ============ (ix) note-m / note-n: embeds resolve to the EMBED =========== */ + await page.evaluate(() => { window.__renderCalls.length = 0; }); + await openFsSettled("#note-m .lookout-table-btn"); + const embed = await page.evaluate(() => ({ + state: document.querySelector(".lookout-fs").getAttribute("data-lookout-fn"), + text: (document.querySelector(".lookout-fs-footnotes li")?.textContent || "").replace("↩︎", "").trim(), + sourcePaths: window.__renderCalls.map((c) => c.sourcePath), + resolutions: window.__linkResolutions.slice(-1), + dupeIds: window.__dupeIds(), + })); + check( + "an embedded table's footnote comes from the EMBEDDED file, never the host", + embed.state === "ready" && embed.text === "임베드된 노트의 각주 본문.", + JSON.stringify(embed) + ); + check("the renderer is given the embedded file's sourcePath", JSON.stringify(embed.sourcePaths) === '["embedded.md"]', JSON.stringify(embed.sourcePaths)); + check("the embed link is resolved against the host note", embed.resolutions[0]?.linkpath === "embedded" && embed.resolutions[0]?.sourcePath === "note-m.md", JSON.stringify(embed.resolutions)); + check("no duplicate ids from an embedded table", embed.dupeIds.length === 0, `dupes=${embed.dupeIds.join(",")}`); + await closeFs(); + + // The host note defines the very label this embedded marker carries, so a + // fallback to the host would put the host's text under it. Asserting the + // STATE alone cannot see that — the assertion has to be on the text. + await openFsSettled("#note-n .lookout-table-btn"); + const badEmbed = await page.evaluate(() => { + const anchors = Array.from(document.querySelectorAll(".lookout-table-fs-table a.footnote-link")); + return { + state: document.querySelector(".lookout-fs").getAttribute("data-lookout-fn"), + section: document.querySelectorAll(".lookout-fs-footnotes").length, + allInert: anchors.every((a) => !a.hasAttribute("href") && a.getAttribute("aria-disabled") === "true"), + hostLeak: document.querySelector(".lookout-fs").textContent.includes("호스트 노트의 각주"), + dupeIds: window.__dupeIds(), + }; + }); + check( + "an embed whose src does not resolve degrades cleanly instead of guessing", + badEmbed.state === "none" && badEmbed.section === 0 && badEmbed.allInert, + JSON.stringify(badEmbed) + ); + check("a broken embed never falls back to the HOST note's footnote text", !badEmbed.hostLeak); + check("no duplicate ids from an unresolved embed", badEmbed.dupeIds.length === 0, `dupes=${badEmbed.dupeIds.join(",")}`); + await closeFs(); + + // An embed whose src is a bare `#heading`: no linkpath to resolve at all. + await page.evaluate(() => { window.__linkResolutions.length = 0; }); + await openFsSettled("#note-p .lookout-table-btn"); + const fragEmbed = await page.evaluate(() => ({ + state: document.querySelector(".lookout-fs").getAttribute("data-lookout-fn"), + section: document.querySelectorAll(".lookout-fs-footnotes").length, + hostLeak: document.querySelector(".lookout-fs").textContent.includes("호스트 노트의 각주"), + resolutions: window.__linkResolutions.length, + dupeIds: window.__dupeIds(), + })); + check( + "an embed with no linkpath resolves to no file, never to the host", + fragEmbed.state === "none" && fragEmbed.section === 0 && !fragEmbed.hostLeak && fragEmbed.resolutions === 0, + JSON.stringify(fragEmbed) + ); + check("no duplicate ids from a fragment-only embed", fragEmbed.dupeIds.length === 0, `dupes=${fragEmbed.dupeIds.join(",")}`); + await closeFs(); + + /* ===== §4(a) note-q: markers carrying no usable join key ================== */ + // Nothing to resolve means no promise is started at all: the overlay settles + // to "none" synchronously and the note's source is never read. + await page.evaluate(() => { window.__readCalls.length = 0; }); + await openFs("#note-q .lookout-table-btn"); + const unusable = await page.evaluate(() => { + const anchors = Array.from(document.querySelectorAll(".lookout-table-fs-table a.footnote-link")); + return { + state: document.querySelector(".lookout-fs").getAttribute("data-lookout-fn"), + anchors: anchors.length, + allInert: anchors.every( + (a) => + !a.hasAttribute("href") && + a.getAttribute("aria-disabled") === "true" && + a.classList.contains("lookout-fn-unresolved") + ), + section: document.querySelectorAll(".lookout-fs-footnotes").length, + reads: window.__readCalls.length, + leak: document.querySelector(".lookout-fs").textContent.includes("회귀다"), + dupeIds: window.__dupeIds(), + }; + }); + check( + "markers with no usable join key settle to 'none' synchronously, with no read", + unusable.state === "none" && unusable.reads === 0 && unusable.section === 0, + JSON.stringify({ state: unusable.state, reads: unusable.reads, section: unusable.section }) + ); + check("all three unusable markers are inert", unusable.anchors === 3 && unusable.allInert, JSON.stringify(unusable)); + check("a marker that cannot be numbered emits no definition at all", !unusable.leak); + check("no duplicate ids with unusable markers", unusable.dupeIds.length === 0, `dupes=${unusable.dupeIds.join(",")}`); + await closeFs(); + + /* ===== note-r: the number comes from the link text, not the href ========== */ + await openFsSettled("#note-r .lookout-table-btn"); + const oddHref = await page.evaluate(() => { + const li = document.querySelector(".lookout-fs-footnotes li"); + return { + state: document.querySelector(".lookout-fs").getAttribute("data-lookout-fn"), + defs: document.querySelectorAll(".lookout-fs-footnotes li").length, + value: li?.value, + text: (li?.textContent || "").replace("↩︎", "").trim(), + markerHref: document.querySelector(".lookout-table-fs-table a.footnote-link")?.getAttribute("href"), + dupeIds: window.__dupeIds(), + }; + }); + check( + "a marker with a non-`fn-N` href is numbered from its visible text", + oddHref.state === "ready" && + oddHref.defs === 1 && + oddHref.value === 7 && + oddHref.text === "링크 주소로는 번호를 알 수 없는 각주." && + !!oddHref.markerHref?.startsWith("#lookout-fs-"), + JSON.stringify(oddHref) + ); + check("no duplicate ids with a non-standard href", oddHref.dupeIds.length === 0, `dupes=${oddHref.dupeIds.join(",")}`); + await closeFs(); + + /* ===== §4(d) note-s: a stale metadata index drops, never guesses ========== */ + await openFsSettled("#note-s .lookout-table-btn"); + const stale = await page.evaluate(() => { + const inertOf = (n) => { + const a = document.querySelectorAll(".lookout-table-fs-table a.footnote-link")[n]; + return !a.hasAttribute("href") && a.getAttribute("aria-disabled") === "true"; + }; + const overlay = document.querySelector(".lookout-fs"); + return { + state: overlay.getAttribute("data-lookout-fn"), + values: Array.from(document.querySelectorAll(".lookout-fs-footnotes li")).map((li) => li.value), + texts: Array.from(document.querySelectorAll(".lookout-fs-footnotes li")).map((li) => + li.textContent.replace("↩︎", "").trim() + ), + staleInert: inertOf(2), + staleInlineInert: inertOf(3), + blankInert: inertOf(4), + // Any fragment of the note's prose that a shifted slice would drag in. + leak: /어긋난 오프셋|다른 인라인 각주|\[\^/.test(overlay.textContent), + dupeIds: window.__dupeIds(), + }; + }); + check( + "honest definitions still build beside stale ones (padding inside `^[ ]` included)", + stale.state === "ready" && + JSON.stringify(stale.values) === "[1,2]" && + stale.texts[0] === "온전히 살아남아야 하는 정의." && + stale.texts[1] === "여백을 낀 인라인 각주.", + JSON.stringify({ state: stale.state, values: stale.values, texts: stale.texts }) + ); + check( + "a stale offset drops its definition and inerts its marker — inline included", + stale.staleInert && stale.staleInlineInert && stale.blankInert, + JSON.stringify(stale) + ); + check("no note prose is ever rendered as a footnote body", !stale.leak, JSON.stringify(stale.texts)); + check("no duplicate ids with a stale index", stale.dupeIds.length === 0, `dupes=${stale.dupeIds.join(",")}`); + await closeFs(); + + /* ===== note-t: an end offset past EOF swallows the next definition ======== */ + await openFsSettled("#note-t .lookout-table-btn"); + const oob = await page.evaluate(() => ({ + state: document.querySelector(".lookout-fs").getAttribute("data-lookout-fn"), + section: document.querySelectorAll(".lookout-fs-footnotes").length, + leak: document.querySelector(".lookout-fs").textContent.includes("뒤따르는 다른 정의"), + dupeIds: window.__dupeIds(), + })); + check( + "an end offset past EOF is dropped, not clamped into the next definition", + oob.state === "none" && oob.section === 0 && !oob.leak, + JSON.stringify(oob) + ); + check("no duplicate ids with an out-of-range offset", oob.dupeIds.length === 0, `dupes=${oob.dupeIds.join(",")}`); + await closeFs(); + + /* ===== note-u: ids a rendered definition body brings with it ============== */ + await openFsSettled("#note-u .lookout-table-btn"); + const rich = await page.evaluate(() => { + const li = document.querySelector(".lookout-fs-footnotes li"); + return { + state: document.querySelector(".lookout-fs").getAttribute("data-lookout-fn"), + heading: li?.querySelector("h3")?.textContent, + prose: (li?.querySelector("p")?.textContent || "").replace("↩︎", "").trim(), + liId: li?.id, + // Every id the overlay carries must be one WE minted. + strayIds: Array.from(document.querySelectorAll(".lookout-fs [id]")) + .filter((n) => n.namespaceURI !== "http://www.w3.org/2000/svg") + .map((n) => n.id) + .filter((id) => !id.startsWith("lookout-fs-")), + dupeIds: window.__dupeIds(), + }; + }); + check( + "a definition body with a heading and a block id renders in full", + rich.state === "ready" && rich.heading === "각주 안 제목" && rich.prose === "본문 문단이다.", + JSON.stringify(rich) + ); + check( + "ids the rendered body brought along are scrubbed, the li's own id is not", + rich.strayIds.length === 0 && !!rich.liId?.startsWith("lookout-fs-"), + `stray=${rich.strayIds.join(",")} li=${rich.liId}` + ); + check("no duplicate ids from a rendered definition body", rich.dupeIds.length === 0, `dupes=${rich.dupeIds.join(",")}`); + await closeFs(); + + /* ========== (xii) note-k: the rendered .footnotes section is a decoy ====== */ + await openFsSettled("#note-k .lookout-table-btn"); + const decoy = await page.evaluate(() => { + const noteLi = document.querySelector("#note-k > .footnotes li"); + return { + state: document.querySelector(".lookout-fs").getAttribute("data-lookout-fn"), + text: (document.querySelector(".lookout-fs-footnotes li")?.textContent || "").replace("↩︎", "").trim(), + noteText: noteLi.textContent.replace("↩︎", "").trim(), + noteUntouched: noteLi.id === "fn-1-dk" && noteLi.getAttribute("data-footnote-id") === "fn-1-dk", + dupeIds: window.__dupeIds(), + }; + }); + check( + "the overlay shows the SOURCE text, not the note's rendered definition", + decoy.state === "ready" && decoy.text === "소스에서 읽어야 하는 진짜 본문." && decoy.noteText === "DOM에서 읽으면 안 되는 옛 본문.", + JSON.stringify(decoy) + ); + check("the note's own footnote definition is left untouched", decoy.noteUntouched, JSON.stringify(decoy)); + check("no duplicate ids next to a rendered .footnotes section", decoy.dupeIds.length === 0, `dupes=${decoy.dupeIds.join(",")}`); + await closeFs(); + + /* ===== a link inside a footnote body: navigate AND close full screen ====== */ + // Stand-in for Obsidian's own delegated navigation handler: it must still see + // the click (we neither preventDefault nor stopPropagation on it). + await page.evaluate(() => { + window.__navigated = null; + document.addEventListener("click", (e) => { + const a = e.target instanceof Element ? e.target.closest("a[href]") : null; + if (!a) return; + e.preventDefault(); + window.__navigated = a.getAttribute("href"); + }); + }); + await openFsSettled("#note-o .lookout-table-btn"); + check("no duplicate ids with a link inside a definition", (await dupes()).length === 0); + await page.locator(".lookout-fs-footnotes a.internal-link").click(); + await page.waitForSelector(".lookout-fs", { state: "detached" }); + const nav = await page.evaluate(() => ({ + navigated: window.__navigated, + overlays: document.querySelectorAll(".lookout-fs").length, + })); + check( + "a link inside a footnote reaches Obsidian's handler and closes full screen", + nav.navigated === "other.md" && nav.overlays === 0, + JSON.stringify(nav) + ); + // A footnote marker, by contrast, is ours: the delegated handler must not see it. + await page.evaluate(() => { window.__navigated = null; }); + await openFsSettled("#note-a .lookout-table-btn >> nth=1"); + await page.locator(".lookout-table-fs-table a.footnote-link").click(); + check( + "a footnote marker click never reaches the note's own click handling", + (await page.evaluate(() => window.__navigated)) === null + ); + await closeFs(); + + /* ============ (viii) async ordering: close before the read lands ========== */ + const beforeCounts = await page.evaluate(() => ({ + loaded: window.__componentsLoaded, + unloaded: window.__componentsUnloaded, + })); + await page.evaluate(() => { window.__gateRead = true; }); + await openFs("#note-a .lookout-table-btn >> nth=1"); + check( + "a footnote overlay advertises 'pending' before its source read lands", + (await fnState()) === "pending" + ); + await page.keyboard.press("Escape"); + await page.waitForSelector(".lookout-fs", { state: "detached" }); + await page.evaluate(() => { window.__releaseRead(); }); + await page.waitForTimeout(120); + const late = await page.evaluate(() => ({ + overlays: document.querySelectorAll(".lookout-fs").length, + sections: document.querySelectorAll(".lookout-fs-footnotes").length, + loaded: window.__componentsLoaded, + unloaded: window.__componentsUnloaded, + })); + check( + "a read landing after Escape appends nothing and leaks no Component", + late.overlays === 0 && late.sections === 0 && late.loaded - beforeCounts.loaded === late.unloaded - beforeCounts.unloaded, + JSON.stringify(late) + ); + + await page.evaluate(() => { window.__gateRead = false; }); + await openFsSettled("#note-a .lookout-table-btn >> nth=1"); + const reopened = await page.evaluate(() => { + const li = document.querySelector(".lookout-fs-footnotes li"); + return { + state: document.querySelector(".lookout-fs").getAttribute("data-lookout-fn"), + id: li?.id, + text: (li?.textContent || "").replace("↩︎", "").trim(), + dupeIds: window.__dupeIds(), + }; + }); + check( + "re-opening immediately rebuilds the section under a FRESH namespace", + reopened.state === "ready" && + reopened.text === "표와 문단 양쪽에서 참조되는 각주." && + reopened.id !== reg.liId && + reopened.id.startsWith("lookout-fs-"), + JSON.stringify({ now: reopened.id, before: reg.liId }) + ); + check("no duplicate ids after a re-open", reopened.dupeIds.length === 0, `dupes=${reopened.dupeIds.join(",")}`); + await closeFs(); + + const balance = await page.evaluate(() => ({ + loaded: window.__componentsLoaded, + unloaded: window.__componentsUnloaded, + })); + check("every Component created for an overlay is unloaded with it", balance.loaded === balance.unloaded, JSON.stringify(balance)); + + /* ================= focus restoration is keyboard-only ================= */ // Mouse route: open and close with the pointer. await page.locator("#note-a .lookout-table-host").first().hover(); await page.locator("#note-a .lookout-table-btn >> nth=0").click(); @@ -513,7 +1182,8 @@ try { JSON.stringify(kbd) ); - check("no page errors", pageErrors.length === 0, pageErrors.join(" | ")); + const inPage = await page.evaluate(() => window.__pageErrors); + check("no page errors", pageErrors.length === 0 && inPage.length === 0, [...pageErrors, ...inPage].join(" | ")); } finally { await browser.close(); } diff --git a/tests/manual/footnote-vault-check.md b/tests/manual/footnote-vault-check.md new file mode 100644 index 0000000..0038bb8 --- /dev/null +++ b/tests/manual/footnote-vault-check.md @@ -0,0 +1,294 @@ +# Lookout — 전체 화면 표 각주 수동 검증 노트 + +이 노트는 **실제 vault에서** 손으로 확인하기 위한 것이다. 헤드리스 e2e는 Obsidian의 +DOM을 흉내 낸 스텁 위에서 돌기 때문에, 여기서만 드러나는 것들이 있다 — 특히 +**Live Preview**는 표를 CodeMirror 위젯으로 따로 렌더하므로 Reading view와 갈라진 +전력이 있다. + +> [!important] +> 이 파일은 플러그인 폴더 안에 있어 vault 파일 탐색기에 뜨지 않는다. +> **vault 안 아무 폴더로 복사한 뒤 그 사본을 열 것.** +> +> ```bash +> cp tests/manual/footnote-vault-check.md /path/to/vault/ +> ``` + +준비: `npm run build` (또는 `npm run dev`) 후 플러그인을 껐다 켠다. +**아래 각주 항목은 Reading view 기준이다.** Live Preview는 각주를 표시하지 않는 것이 +설계된 동작이므로 §0·§10에서 따로 확인한다. 각주와 무관한 항목(§5, §9-2)은 양쪽에서 +반복한다 (`Ctrl/Cmd+E`로 전환). + +표 위에 마우스를 올리면 오른쪽 위에 전체 화면 버튼이 나타난다. + +이 구현은 각주 정의를 **렌더된 DOM이 아니라 노트 소스**에서 읽는다 +(`metadataCache.getFileCache().footnotes` + `vault.cachedRead()` → `MarkdownRenderer`). +따라서 각주 정의 영역이 화면 밖이어도, 표가 노트 어디에 있어도 동작해야 한다. + +--- + +## 0. Live Preview 인라인 각주 — 이미 결론이 난 항목 (측정 불필요) + +인라인 각주의 조인 키 `[inline`에서 ``은 **그 마커를 만들어 낸 파싱 단위 안에서의 +순번**이다 (Obsidian 1.12.7 번들에서 확인: `e.identifier = "[inline" + t`, `t`는 이번 +파싱의 인덱스). Reading view는 노트 전체를 한 번에 파싱하므로 `metadataCache`의 번호와 +일치한다. **Live Preview는 블록마다 따로 파싱하므로 위젯 안에서 `n`이 0부터 다시 +시작한다** — 표 위 문단에 인라인 각주가 하나라도 있으면 캐시가 `[inline1`이라 부르는 +각주를 위젯은 `[inline0`으로 내보낸다. 조인하면 **다른 각주의 본문이 옳은 마커 아래** +찍힌다. 위젯 DOM에는 블록의 파일 오프셋이 실려 있지 않아 번호를 보정할 방법이 없다. + +→ **LP에서 인라인 각주는 조인하지 않고 비활성으로 떨어뜨린다.** 참조형은 애초에 마커가 +만들어지지 않으므로, **LP에서는 각주가 전혀 표시되지 않는다.** §10에서 그 상태를 확인한다. + +--- + +## 1. 인라인 각주 — 이슈 #28의 원래 신고 내용 + +전체 화면에서 `[1]`이 죽은 글자가 아니라, 표 아래에 정의가 딸려 와야 한다. +**Reading view에서 확인한다** (Live Preview는 §10). + +| 항목 | 값 | +| --- | --- | +| 응답률 | 62% ^[표본 정리 전 기준이라 최종 수치와 다를 수 있다.] | +| 표본 | 1,204 | + +- [ ] 전체 화면에 각주 정의가 표 아래에 보인다 +- [ ] `[1]`을 클릭하면 정의로 이동하고 잠깐 강조된다 +- [ ] 정의의 `↩︎`를 누르면 표 안의 `[1]`로 되돌아온다 +- [ ] 이동해도 전체 화면이 닫히지 않고, 노트 자체는 움직이지 않는다 + +--- + +## 2. 참조형 각주 — 표 밖에서도 참조되는 경우 + +`[^ref]`는 아래 문단에서도 한 번 더 참조된다. 정의 본문은 소스에서 새로 렌더되고 +`↩︎`는 **전체 화면 안에 실제로 들어온 마커에 대해서만** 만들어지므로, 문단 참조 +몫의 `↩︎`는 애초에 생기지 않는다 (죽은 `↩︎`가 존재할 수 없다). + +| 지표 | 값 | +| --- | --- | +| 이탈률 | 18% [^ref] | + +이 문단도 같은 각주를 참조한다 [^ref]. + +- [ ] 정의에 `↩︎`가 **표 안 참조 개수만큼만** 붙는다 (문단 참조 몫은 아예 만들어지지 않는다) +- [ ] 모든 `↩︎`가 살아 있고, 각각 자기 마커로 돌아간다 +- [ ] 노트 **맨 위**에서 (각주 정의 영역이 화면 밖일 때) 열어도 정의가 보인다 + ← **이것이 이번 재설계의 핵심 회귀** + +--- + +## 3. 같은 각주를 표 안에서 두 번 참조 + +두 마커가 하나의 정의를 가리킨다 (두 번째 마커는 `[n-1]` 꼴로 붙는다). +정의는 **한 번만** 나와야 한다. + +| 왼쪽 | 오른쪽 | +| --- | --- | +| 첫 번째 [^dup] | 두 번째 [^dup] | + +- [ ] 각주 목록에 정의가 중복 없이 한 줄만 나온다 +- [ ] 두 마커 모두 같은 정의로 이동한다 +- [ ] 정의의 두 `↩︎`가 각각 자기 마커로 되돌아간다 (서로 다른 위치, e2e (v)와 같은 케이스) + +--- + +## 4. 표 안 참조 순서가 번호 순서와 어긋나는 경우 + +Obsidian은 각주 번호를 **노트 전체의 참조 순서**로 매긴다. 그래서 아래 문단이 +`[^early]`를 먼저 참조해 낮은 번호를 선점하게 해 두면, 표는 **높은 번호를 먼저** +참조하게 된다. 각주 목록이 표의 참조 순서를 그대로 따르면 번호가 내림차순으로 +찍히는데, 그건 노트의 각주 영역과 다르므로 회귀다. + +이 문단이 먼저 참조한다 [^early]. + +| A | B | +| --- | --- | +| 높은 번호 먼저 [^late] | 낮은 번호 나중 [^early] | + +- [ ] 각주 목록이 번호 **오름차순**으로 나온다 (노트 하단 각주 영역과 같은 순서) +- [ ] 목록 번호(`3.`, `5.` 등)가 표 안 마커의 번호와 정확히 일치한다 (e2e (x)) +- [ ] 표 안 마커의 번호는 그대로다 (정렬 때문에 바뀌지 않는다) +- [ ] 각 마커가 자기 번호의 정의로 이동한다 + +--- + +## 5. 각주가 없는 표 — 회귀 확인 + +전체 화면이 각주 기능이 생기기 **이전과 완전히 동일**해야 한다. + +| 도시 | 인구 | 면적 | +| --- | --- | --- | +| 서울 | 938만 | 605 | +| 부산 | 330만 | 770 | + +- [ ] 표 아래에 각주 영역이나 구분선이 **생기지 않는다** (e2e (xiv)) +- [ ] 표가 가운데 정렬되고 여백이 좌우 같다 + +--- + +## 6. 좁은 표에 긴 각주 — 읽을 수 있는 폭 + +각주 문장이 표 너비에 눌려 한 단어씩 줄바꿈되면 안 된다. + +| N | +| --- | +| 1 ^[이 문장은 일부러 길다. 표는 한 칸뿐이라 아주 좁은데, 각주 본문이 표 너비를 따라가면 한 줄에 한 단어씩 끊겨 세로로 길게 늘어진다. 노트에서 읽을 때와 비슷한 폭으로 나와야 한다.] | + +- [ ] 각주 문장이 정상적인 문단처럼 읽힌다 (한 단어씩 끊기지 않는다) +- [ ] 좁은 표는 여전히 가운데 정렬이다 + +--- + +## 7. 아주 넓은 표 — 각주 본문 폭과 구분선 + +가로 스크롤이 생기는 표다. 각주 본문은 표 너비(수천 px)로 늘어나면 안 되지만, +구분선은 표 너비를 그대로 따라간다. + +| 컬럼 하나 | 컬럼 둘 | 컬럼 셋 | 컬럼 넷 | 컬럼 다섯 | 컬럼 여섯 | 컬럼 일곱 | 컬럼 여덟 | 컬럼 아홉 | 컬럼 열 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 값 하나 | 값 둘 | 값 셋 | 값 넷 | 값 다섯 | 값 여섯 | 값 일곱 | 값 여덟 | 값 아홉 | 값 열 ^[넓은 표에 붙은 짧은 각주 한 문장.] | + +- [ ] 각주 한 문장이 **한 줄로 수천 px 늘어나지 않는다** +- [ ] 각주 위 구분선은 표 오른쪽 끝까지 이어진다 +- [ ] 오른쪽으로 스크롤해도 레이아웃이 깨지지 않는다 + +--- + +## 8. 블록 ID를 가진 표 — 중복 id + +표에 블록 ID가 붙어 있다. 전체 화면 클론이 같은 id를 문서에 하나 더 만들면 +잘못된 DOM이고, 노트 안의 `#…` 링크가 클론으로 끌려간다. + +| 키 | 값 | +| --- | --- | +| alpha | 1 ^[블록 ID가 붙은 표의 각주.] | + +^lookout-check-table + +아래 링크는 위 표를 가리킨다: [[#^lookout-check-table]] + +- [ ] 전체 화면을 **연 상태에서** 위 링크를 클릭하면 노트의 표로 이동한다 + (전체 화면 안 클론으로 끌려가지 않는다) +- [ ] 개발자 도구가 열려 있다면, 오버레이가 **settle된 뒤**에 콘솔에서 확인: + `document.querySelectorAll('[id="lookout-check-table"]').length` → **1** + (settle 여부는 `document.querySelector('.lookout-fs').dataset.lookoutFn` 가 + `ready` 또는 `none` 인지로 판단한다 — `pending`이면 아직 각주를 읽는 중이다) + +--- + +## 9. 마우스·키보드 동작 + +### 9-1. 우클릭 / 가운데 클릭 + +1번 표를 전체 화면으로 연 뒤 `[1]` 마커에서: + +- [ ] **우클릭** → 컨텍스트 메뉴만 뜨고, 화면이 각주로 스크롤되지 **않는다** +- [ ] **가운데 클릭** → 각주로 이동한다 (새 창이 열리지 않는다) + +### 9-2. 닫은 뒤 Space + +이 항목은 각주와 무관하게 **모든 전체 화면 표**에 해당한다. + +- [ ] 전체 화면을 열고 **마우스로** ✕ 또는 배경을 클릭해 닫은 뒤, + `Space`를 누르면 **노트가 스크롤된다** (전체 화면이 다시 열리면 회귀) +- [ ] 전체 화면을 열고 **`Esc`로** 닫으면, 표 위 버튼에 포커스가 돌아오고 + 그 버튼이 **눈에 보인다** (안 보이는 채로 포커스만 가 있으면 회귀) + +### 9-3. 키보드 이동과 포커스 표시 + +- [ ] 전체 화면에서 `Tab`으로 각주 마커까지 간 뒤 `Enter` → 정의로 이동 +- [ ] 강조 색이 사라진 **뒤에도** 정의에 포커스 테두리가 남아 있다 + +--- + +## 10. Live Preview — 각주는 표시되지 않는다 (설계된 동작) + +§0의 이유로 **LP에서는 각주가 표시되지 않는다.** 인라인 마커는 조인 키를 믿을 수 없어 +비활성으로 떨어지고, 참조형은 Obsidian이 마커 자체를 만들지 않는다. 확인할 것은 +"정의가 보이는가"가 아니라 **"엉뚱한 본문이 절대 보이지 않는가"** 이다. + +`Ctrl/Cmd+E`로 Live Preview로 바꾼 뒤 확인한다. + +- [ ] LP에서 **인라인 각주** `^[…]`가 붙은 표(§1, §6, §7)를 전체 화면으로 열면 + 표 아래에 **각주 영역이 생기지 않는다** +- [ ] 그 마커는 보이되 **비활성**이다 (점선 밑줄·흐린 색, 눌러도 아무 일이 없다) +- [ ] **다른 각주의 본문이 절대 나오지 않는다** — 무엇이든 각주 본문이 보이면 + 즉시 보고 (이것이 이 항목의 유일한 회귀 조건이다) +- [ ] LP에서 **참조형 각주** `[^ref]`는 노트 본문에서도 마커가 아니라 `ref` 라는 + 평문으로 보인다. 전체 화면도 노트와 같은 평문을 보여준다 — 비활성 마커도, + 오류도 없다 +- [ ] 오류 알림(Notice)이 뜨지 않는다 +- [ ] Reading view로 전환하면 §1–§8 전부가 그대로 동작한다 + +--- + +## 11. 각주 안의 Mermaid 다이어그램 — **필수** + +정의를 소스에서 **다시 렌더**하므로, 이어지는 줄의 4칸 dedent가 빠지면 펜스가 +들여쓰기 코드 블록으로 파싱돼 다이어그램이 아니라 회색 코드 덩어리가 된다. + +| 흐름 | +| --- | +| 배포 절차 [^flow] | + +- [ ] 각주 안의 ` ```mermaid ` 펜스가 **다이어그램으로** 렌더된다 (회색 코드 덩어리면 회귀) +- [ ] 전체 화면의 각주 안 다이어그램이 각주 폭 안에 들어온다 +- [ ] 가로 스크롤바가 새로 생기지 않는다 +- [ ] 다이어그램에 Lookout의 확대/축소 툴바가 따라붙지 않는다 (읽기 전용 오버레이이므로 정상) + +--- + +## 12. 파일 해석 — 임베드 / 캔버스 / 다중 페인 + +정의는 **이 표가 속한 파일**의 소스에서 읽어야 한다. 활성 노트가 아니라. + +- [ ] 이 노트를 **임베드**한 다른 노트에서 표를 전체 화면으로 열면 + **임베드된 노트의** 각주 본문이 나온다 (호스트 노트의 것이 아니다) +- [ ] 존재하지 않는 노트를 임베드했을 때는 마커가 비활성이고 오류가 없다 +- [ ] **Canvas**에 올린 노트의 표를 열면 마커가 비활성이고 오류가 없다 +- [ ] 같은 노트를 두 페인에 띄운 상태에서, **비활성 페인**의 표를 열어도 정의가 + 정상이다 (docId가 달라도 무관 — 소스에서 읽으므로) + +--- + +## 13. 각주 본문 안의 링크 + +| 참고 | +| --- | +| 링크가 든 각주 [^linkcheck] | + +`[^linkcheck]` 정의의 `[[...]]`를 이 vault에 실제로 있는 노트 이름으로 바꾼 뒤 확인한다. + +- [ ] 각주 본문에 내부 링크가 살아 있다 +- [ ] 전체 화면에서 그 링크를 클릭하면 **전체 화면이 닫히고** 링크한 노트로 이동한다 + (오버레이가 열린 채 뒤에서 노트만 바뀌면 회귀) + +--- + +## 스크롤 여유 + +`Space` 스크롤을 확인하려면 노트가 화면보다 길어야 한다. + +문단 하나. 문단 둘. 문단 셋. 문단 넷. 문단 다섯. +문단 여섯. 문단 일곱. 문단 여덟. 문단 아홉. 문단 열. + +문단 열하나. 문단 열둘. 문단 열셋. 문단 열넷. 문단 열다섯. +문단 열여섯. 문단 열일곱. 문단 열여덟. 문단 열아홉. 문단 스물. + +문단 스물하나. 문단 스물둘. 문단 스물셋. 문단 스물넷. 문단 스물다섯. +문단 스물여섯. 문단 스물일곱. 문단 스물여덟. 문단 스물아홉. 문단 서른. + +--- + +[^ref]: 표와 문단 양쪽에서 참조되는 각주. +[^dup]: 표 안에서 두 번 참조되는 각주. +[^early]: 표보다 앞선 문단에서 먼저 참조되므로 낮은 번호를 받는다. +[^late]: 표 안에서만 참조되므로 더 높은 번호를 받는다. +[^linkcheck]: 자세한 내용은 [[테스트 노트]]를 참고한다. (이 vault에 있는 노트 이름으로 바꿀 것) +[^flow]: 배포는 아래 순서로 진행한다. + + ```mermaid + graph LR + A[빌드] --> B[검증] + B --> C[배포] + ``` diff --git a/versions.json b/versions.json index efbfde8..7da1bbd 100644 --- a/versions.json +++ b/versions.json @@ -6,5 +6,5 @@ "1.1.5": "1.0.0", "1.1.6": "1.0.0", "1.1.7": "1.0.0", - "1.2.0": "1.0.0" + "1.2.0": "1.6.6" } From b43042b7b4f3fe557ba1a52c3096a4b753936e39 Mon Sep 17 00:00:00 2001 From: Sangjoon Han Date: Tue, 28 Jul 2026 20:40:51 +0900 Subject: [PATCH 5/7] feat: preview a footnote's definition on hover in full screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Obsidian gives a footnote no hover affordance of its own — its only footnote handler is a click delegated on the note's preview sizer, which never sees this overlay — and in full screen the definition can sit far below a long table, so a marker was a number with nothing behind it until clicked. The marker now carries the rendered definition as a `title`: native, no listener, nothing to clean up on close. Read before the back-references are appended so the preview does not trail a "↩︎" that means nothing out of context, and capped at 300 characters because a native tooltip does not scroll — the full text is one click away in the list below the table. Only the clone is touched; the note's own markers keep no tooltip. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 4 ++++ src/main.ts | 23 +++++++++++++++++++++++ tests/e2e/table-fullscreen.test.mjs | 15 +++++++++++++++ tests/manual/footnote-vault-check.md | 3 +++ 4 files changed, 45 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d4a495..f4827b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 note's own footnote navigation. The footnote text is laid out at a readable measure rather than at the table's width, so a narrow table no longer squeezes it into a one-word-per-line ribbon. +- Hovering a marker in full screen previews its definition. Obsidian gives a + footnote no hover affordance of its own, and in full screen the definition can + sit far below a long table, so the marker would otherwise be a number with + nothing behind it until clicked. The note's own markers are untouched. - Scope: footnotes resolve in **Reading view**. **Live Preview is not supported**, because Obsidian parses each block there on its own: a reference-style marker (`[^id]`) is not rendered inside a table widget at all diff --git a/src/main.ts b/src/main.ts index 27842fd..817f149 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1122,6 +1122,17 @@ class TableView { * indented 8 spaces (a nested list) must keep 4, and a lazily continued line * with 0–3 spaces must be left alone. */ + /* + * A definition's rendered text, flattened to one line for a `title` tooltip. + * Capped because a native tooltip has no scroll and a long footnote would + * paint a wall across the overlay; the full text is always one click away in + * the list below the table. + */ + _previewText(li: HTMLElement): string { + const text = (li.textContent || "").replace(/\s+/g, " ").trim(); + return text.length > 300 ? text.slice(0, 299).trimEnd() + "…" : text; + } + _dedent(text: string): string { const lines = text.split("\n"); for (let i = 1; i < lines.length; i++) { @@ -1280,6 +1291,10 @@ class TableView { li.value = def.num; targets.set(li.id, li); + // Read before the back-refs below are appended, so the preview does not + // trail a "↩︎" that means nothing out of context. + const preview = this._previewText(li); + // One back-ref per marker occurrence *present in the clone*, keyed to // that occurrence's own rather than to the href the occurrences // share — so `[3]` and `[3-1]` get one back-ref each, landing on @@ -1287,6 +1302,14 @@ class TableView { // dead back-ref cannot exist by construction. for (const ref of refs) { if (ref.fragment !== def.fragment) continue; + // Hovering a marker shows what it points at. Obsidian gives a footnote + // no hover affordance of its own — its only footnote handler is a click + // delegated on the note's preview sizer, which never sees this overlay + // — and in full screen the definition can sit far below a long table, + // so the marker is otherwise a number with nothing behind it until you + // click. A plain `title` buys that: native, no listener, nothing to + // clean up on close. + if (preview) ref.a.title = preview; const sup = ref.sup; if (!sup || !sup.id) continue; const back = el("a", "footnote-backref footnote-link"); diff --git a/tests/e2e/table-fullscreen.test.mjs b/tests/e2e/table-fullscreen.test.mjs index 2aed905..c7a5fb3 100644 --- a/tests/e2e/table-fullscreen.test.mjs +++ b/tests/e2e/table-fullscreen.test.mjs @@ -419,6 +419,11 @@ try { cloneRel: marker?.getAttribute("rel"), liveTarget: liveMarker?.getAttribute("target"), liveRel: liveMarker?.getAttribute("rel"), + // Obsidian gives a footnote no hover affordance of its own, and the + // definition can sit far below a long table, so the marker carries the + // definition text as a native tooltip — without the back-ref glyph. + markerTitle: marker?.getAttribute("title"), + liveMarkerTitle: liveMarker?.getAttribute("title"), fsLi: box(fsLi), fsTableWidth: Math.round(tb.width), leftGap: Math.round(tb.left - sb.left), @@ -434,6 +439,16 @@ try { ); check("the `[^ref]:` label is stripped from the rendered body", !reg.text.includes("[^ref]"), reg.text); check("the definition keeps the marker's number", reg.value === 1, `value=${reg.value}`); + check( + "hovering a resolved marker previews the definition", + reg.markerTitle === "표와 문단 양쪽에서 참조되는 각주." && !reg.markerTitle.includes("↩︎"), + `title=${JSON.stringify(reg.markerTitle)}` + ); + check( + "the note's own marker is left without a tooltip", + reg.liveMarkerTitle === null, + `liveTitle=${JSON.stringify(reg.liveMarkerTitle)}` + ); check("footnote marker points at an overlay-local anchor", !!reg.markerHref?.startsWith("#lookout-fs-"), `href=${reg.markerHref}`); check("the emitted li keeps its namespaced id through _scrub", reg.liId === reg.markerHref?.slice(1) && reg.liFootnoteId === reg.liId, JSON.stringify({ liId: reg.liId, href: reg.markerHref })); check( diff --git a/tests/manual/footnote-vault-check.md b/tests/manual/footnote-vault-check.md index 0038bb8..7051e53 100644 --- a/tests/manual/footnote-vault-check.md +++ b/tests/manual/footnote-vault-check.md @@ -52,6 +52,9 @@ DOM을 흉내 낸 스텁 위에서 돌기 때문에, 여기서만 드러나는 | 표본 | 1,204 | - [ ] 전체 화면에 각주 정의가 표 아래에 보인다 +- [ ] **마커 위에 마우스를 올리면 각주 본문이 툴팁으로 뜬다** (본문 끝에 `↩︎`가 + 따라붙지 않는다). 노트 본문의 마커에는 툴팁이 붙지 않는다 — Obsidian에는 + 각주 호버 기능이 없고, 이 툴팁은 전체 화면에서만 Lookout이 붙인다 - [ ] `[1]`을 클릭하면 정의로 이동하고 잠깐 강조된다 - [ ] 정의의 `↩︎`를 누르면 표 안의 `[1]`로 되돌아온다 - [ ] 이동해도 전체 화면이 닫히지 않고, 노트 자체는 움직이지 않는다 From aabe6f5d585e8b237f497b2ba517d139b66f60c7 Mon Sep 17 00:00:00 2001 From: Sangjoon Han Date: Tue, 28 Jul 2026 20:45:12 +0900 Subject: [PATCH 6/7] chore: date the 1.2.0 changelog entry to the release day Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f4827b9..8495cc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [1.2.0] - 2026-07-27 +## [1.2.0] - 2026-07-28 ### Added From 3ec6b1b0af2e1aae9f6b26b2bf5f3ba0c3d55dea Mon Sep 17 00:00:00 2001 From: Sangjoon Han Date: Tue, 28 Jul 2026 20:57:00 +0900 Subject: [PATCH 7/7] fix: make both full-screen views modal, and trap Tab inside them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A full-screen overlay covers the workspace, but Tab walked straight out of it onto the note's own links and buttons behind the overlay: focus landed where the reader could not see it, and the next Enter activated it. Measured with the trap disabled — Tab reaches BODY and then the note's own footnote links, underneath a still-open overlay. Tab and Shift+Tab now cycle within the overlay, and it identifies itself as a labelled modal dialog. This applies to the diagram overlay as well as the table one; it was never specific to footnotes. The focusable set is deliberately narrow. Footnote definitions carry `tabindex="-1"` as programmatic jump targets and stay out of the tab order, and an unresolvable marker has had its `href` stripped, which already makes it unfocusable. Visibility is tested with `getClientRects()`, not `offsetParent`, because the close button is `position: fixed` and its `offsetParent` is always null. The table overlay reads its focusables at keypress time rather than at open time, since footnote definitions render asynchronously and arrive after the overlay is already up. Obsidian's own modals set neither `role="dialog"` nor `aria-modal` — verified in the app bundle — but they are small centred dialogs a reader can see past. Ours is the whole screen. Also swept `window` to `activeWindow` where it is correct: `matchMedia` for the reduced-motion probe and `innerHeight` for the inline height cap. The timer functions are deliberately NOT swept: `eslint-plugin-obsidianmd`'s `prefer-window-timers` rule requires `window.setTimeout` / `window.clearTimeout` / `window.requestAnimationFrame`, so the existing calls were already correct and converting them fails lint. Tests: 4 checks per suite covering the dialog attributes, that Tab and Shift+Tab never escape, that Tab genuinely cycles through more than one stop (the footnoted table is used on purpose — a plain table offers only the close button and would pass without testing anything), and that Esc returns focus to the inline frame. Verified by mutation that disabling the trap fails them. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 6 +++ src/main.ts | 66 ++++++++++++++++++++++++++-- tests/e2e/diagram-fit.test.mjs | 48 ++++++++++++++++++++ tests/e2e/table-fullscreen.test.mjs | 58 ++++++++++++++++++++++++ tests/manual/footnote-vault-check.md | 3 ++ 5 files changed, 177 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8495cc7..bbe103d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Both full-screen views — diagrams and tables — are now proper modal surfaces. + They cover the workspace, but `Tab` used to walk straight out of them onto the + note's own links and buttons behind the overlay, so focus landed where the + reader could not see it and the next `Enter` activated it. `Tab` and + `Shift+Tab` now cycle within the overlay, and it identifies itself to + assistive tech as a labelled modal dialog. - The minimum supported Obsidian version is now **1.6.6**, the release that exposes footnotes in the metadata cache — the single source full-screen footnote definitions are read from. diff --git a/src/main.ts b/src/main.ts index 817f149..66c888e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -103,9 +103,49 @@ const clamp = (v: number, lo: number, hi: number): number => Math.min(hi, Math.max(lo, v)); const REDUCED_MOTION = - typeof window !== "undefined" && - !!window.matchMedia && - window.matchMedia("(prefers-reduced-motion: reduce)").matches; + typeof activeWindow !== "undefined" && + !!activeWindow.matchMedia && + activeWindow.matchMedia("(prefers-reduced-motion: reduce)").matches; + +/* + * What Tab may reach inside a full-screen overlay. Deliberately narrow: the + * footnote definitions carry `tabindex="-1"` as programmatic jump targets and + * must stay out of the tab order, and an unresolvable marker has had its `href` + * stripped, which already makes it unfocusable. + */ +const FOCUSABLE = + 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]),' + + ' textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; + +/** + * Keep Tab inside `overlay`. A full-screen overlay covers the workspace, so + * without this the first Tab walks onto the note's own links and buttons behind + * it — focus lands somewhere the reader cannot see, and the next Enter + * activates it. Obsidian's own modals do not do this, but they are small + * centred dialogs the reader can see past; ours is the whole screen. + * + * Visibility is tested with `getClientRects()`, not `offsetParent`: the close + * button is `position: fixed`, for which `offsetParent` is always null. + */ +function trapTab(overlay: HTMLElement, e: KeyboardEvent) { + if (e.key !== "Tab") return; + const items = Array.from( + overlay.querySelectorAll(FOCUSABLE) + ).filter((n) => n.getClientRects().length > 0); + // Nothing to land on — swallow Tab rather than let it escape the overlay. + if (!items.length) { + e.preventDefault(); + return; + } + const first = items[0]; + const last = items[items.length - 1]; + const active = activeDocument.activeElement; + const inside = !!active && overlay.contains(active); + if (e.shiftKey ? active === first || !inside : active === last || !inside) { + e.preventDefault(); + (e.shiftKey ? last : first).focus({ preventScroll: true }); + } +} type ViewMode = "actual" | "fit" | "free"; @@ -246,6 +286,11 @@ class DiagramView { if (this.fs) { this.overlay = el("div", "lookout-fs"); + // A modal surface: it covers the workspace, so say so rather than leave + // assistive tech to announce the note underneath as if it were reachable. + this.overlay.setAttribute("role", "dialog"); + this.overlay.setAttribute("aria-modal", "true"); + this.overlay.setAttribute("aria-label", "Mermaid 다이어그램 전체 화면"); this.overlay.appendChild(this.viewport); activeDocument.body.appendChild(this.overlay); } else { @@ -394,7 +439,7 @@ class DiagramView { // not the live viewport height, so `fit` can contain a tall diagram against // it without the chicken-and-egg of resizing the frame it is measuring. _inlineMaxHeight() { - return Math.round(window.innerHeight * INLINE_MAX_VH); + return Math.round(activeWindow.innerHeight * INLINE_MAX_VH); } // Default / "100%" view: actual size, anchored top-left. @@ -597,6 +642,10 @@ class DiagramView { this.close(); return; } + if (this.fs && this.overlay) { + trapTab(this.overlay, e); + if (e.key === "Tab") return; + } if (!this.fs && activeDocument.activeElement !== this.viewport) return; const step = 60; @@ -1370,6 +1419,11 @@ class TableView { openFullscreen() { if (this.overlay) return; const overlay = el("div", "lookout-fs lookout-fs--table"); + // A modal surface: it covers the workspace, so say so rather than leave + // assistive tech to announce the note underneath as if it were reachable. + overlay.setAttribute("role", "dialog"); + overlay.setAttribute("aria-modal", "true"); + overlay.setAttribute("aria-label", "표 전체 화면"); this.overlay = overlay; // The clone lives outside the note, so Obsidian's table styling (borders, @@ -1606,7 +1660,11 @@ class TableView { e.preventDefault(); this.fsCloseByKeyboard = true; this.onCloseFs(); + return; } + // Footnote definitions can arrive after the overlay opens, so the trap has + // to read the overlay's focusables at keypress time, not at open time. + if (this.overlay) trapTab(this.overlay, e); } onCloseFs = () => { diff --git a/tests/e2e/diagram-fit.test.mjs b/tests/e2e/diagram-fit.test.mjs index fea165d..f3fedfd 100644 --- a/tests/e2e/diagram-fit.test.mjs +++ b/tests/e2e/diagram-fit.test.mjs @@ -152,6 +152,54 @@ try { bFit.vpH <= cap + 1, `vpH=${bFit.vpH.toFixed(1)} cap=${cap}` ); + + // ===== full-screen overlay: modal semantics + focus trap ===== + await page.click(inline('[aria-label="전체 화면으로 보기"]'), { force: true }); + await page.waitForSelector(".lookout-viewport--fs"); + + const modal = await page.evaluate(() => { + const fs = document.querySelector(".lookout-fs"); + return { + role: fs.getAttribute("role"), + ariaModal: fs.getAttribute("aria-modal"), + ariaLabel: fs.getAttribute("aria-label"), + }; + }); + check( + "full screen: the overlay announces itself as a modal dialog", + modal.role === "dialog" && modal.ariaModal === "true" && !!modal.ariaLabel, + JSON.stringify(modal) + ); + + // Without the trap, Tab walks onto the inline view's own toolbar behind the + // overlay — focus lands where the reader cannot see it. + const trap = { escaped: 0, seen: [] }; + for (let i = 0; i < 8; i++) { + await page.keyboard.press("Tab"); + const where = await page.evaluate(() => { + const a = document.activeElement; + return { + inside: !!a && !!a.closest(".lookout-fs"), + tag: a ? a.tagName + (a.className ? "." + String(a.className).split(" ")[0] : "") : "none", + }; + }); + if (!where.inside) trap.escaped++; + trap.seen.push(where.tag); + } + check("full screen: Tab never escapes the overlay", trap.escaped === 0, JSON.stringify(trap)); + check( + "full screen: Tab actually cycles — more than one stop", + new Set(trap.seen).size > 1, + JSON.stringify(trap.seen) + ); + + await page.keyboard.press("Escape"); + await page.waitForSelector(".lookout-fs", { state: "detached" }); + const restored = await page.evaluate( + () => !!document.activeElement && + document.activeElement.matches(".lookout-viewport:not(.lookout-viewport--fs)") + ); + check("full screen: Esc returns focus to the inline frame", restored); } finally { await browser.close(); } diff --git a/tests/e2e/table-fullscreen.test.mjs b/tests/e2e/table-fullscreen.test.mjs index c7a5fb3..d436ab2 100644 --- a/tests/e2e/table-fullscreen.test.mjs +++ b/tests/e2e/table-fullscreen.test.mjs @@ -1197,6 +1197,64 @@ try { JSON.stringify(kbd) ); + /* ===== modal semantics + focus trap ===== */ + // The footnoted table on purpose: its overlay holds a close button, a marker + // and a back-reference, so Tab has somewhere to cycle and wrap-around is + // actually exercised. A plain table offers only the close button, which would + // pass the trap check without ever testing it. + await page.locator(".lookout-table-btn").nth(1).click({ force: true }); + await page.waitForSelector(".lookout-fs-footnotes li"); + + const modal = await page.evaluate(() => { + const fs = document.querySelector(".lookout-fs--table"); + return { + role: fs.getAttribute("role"), + ariaModal: fs.getAttribute("aria-modal"), + ariaLabel: fs.getAttribute("aria-label"), + }; + }); + check( + "the full-screen table overlay announces itself as a modal dialog", + modal.role === "dialog" && modal.ariaModal === "true" && !!modal.ariaLabel, + JSON.stringify(modal) + ); + + // Tab must cycle inside the overlay. Without the trap the first Tab walks + // onto the note's own controls behind it — focus lands somewhere the reader + // cannot see, and the next Enter activates it. + const trap = { escaped: 0, seen: [] }; + for (let i = 0; i < 6; i++) { + await page.keyboard.press("Tab"); + const where = await page.evaluate(() => { + const a = document.activeElement; + return { + inside: !!a && !!a.closest(".lookout-fs--table"), + tag: a ? a.tagName + (a.className ? "." + String(a.className).split(" ")[0] : "") : "none", + }; + }); + if (!where.inside) trap.escaped++; + trap.seen.push(where.tag); + } + check("Tab never escapes the full-screen overlay", trap.escaped === 0, JSON.stringify(trap)); + check( + "Tab actually cycles — the overlay offers more than one stop", + new Set(trap.seen).size > 1, + JSON.stringify(trap.seen) + ); + + const shift = { escaped: 0 }; + for (let i = 0; i < 4; i++) { + await page.keyboard.press("Shift+Tab"); + const inside = await page.evaluate( + () => !!document.activeElement && !!document.activeElement.closest(".lookout-fs--table") + ); + if (!inside) shift.escaped++; + } + check("Shift+Tab never escapes the full-screen overlay", shift.escaped === 0, JSON.stringify(shift)); + + await page.keyboard.press("Escape"); + await page.waitForSelector(".lookout-fs", { state: "detached" }); + const inPage = await page.evaluate(() => window.__pageErrors); check("no page errors", pageErrors.length === 0 && inPage.length === 0, [...pageErrors, ...inPage].join(" | ")); } finally { diff --git a/tests/manual/footnote-vault-check.md b/tests/manual/footnote-vault-check.md index 7051e53..745f8aa 100644 --- a/tests/manual/footnote-vault-check.md +++ b/tests/manual/footnote-vault-check.md @@ -201,6 +201,9 @@ Obsidian은 각주 번호를 **노트 전체의 참조 순서**로 매긴다. - [ ] 전체 화면에서 `Tab`으로 각주 마커까지 간 뒤 `Enter` → 정의로 이동 - [ ] 강조 색이 사라진 **뒤에도** 정의에 포커스 테두리가 남아 있다 +- [ ] `Tab`을 계속 눌러도 **포커스가 오버레이 밖으로 나가지 않는다** (뒤에 있는 + 노트의 링크나 사이드바로 넘어가면 회귀). `Shift+Tab`도 마찬가지 +- [ ] **다이어그램** 전체 화면에서도 동일하다 (표와 무관하게 적용되는 항목) ---