From ad870f678b527dbbb05989c1e216101d2a6ab07a Mon Sep 17 00:00:00 2001 From: Sangjoon Han Date: Mon, 27 Jul 2026 22:32:46 +0900 Subject: [PATCH 1/4] 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/4] 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/4] 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" }