diff --git a/CHANGELOG.md b/CHANGELOG.md index 4709b74..2d4a495 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,60 @@ 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 outside the `` the overlay clones, so there was nothing to show + and nothing to jump to. The definitions a table actually references are now + resolved from the note's own **source** through Obsidian's metadata cache and + rendered under the clone, so they appear no matter where the table sits in the + note — 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. +- Scope: footnotes resolve in **Reading view**. **Live Preview is not + supported**, because Obsidian parses each block there on its own: a + reference-style marker (`[^id]`) is not rendered inside a table widget at all + (the definition is outside that block, and the note itself shows the marker as + plain text), and an inline marker (`^[note]`) is numbered within its block + rather than within the note, so it cannot be matched to a definition — a + paragraph's footnote above the table would otherwise be rendered under the + table's marker. Where a definition cannot be resolved — that case, a footnote + referenced but never defined, a table in a surface with no backing file, or a + metadata index that has not caught up with the file — the marker stays visible + but is made plainly inert, and is exposed to assistive tech as a disabled link + carrying the reason, not by colour alone. Wrong footnote text under a right + marker is never shown. + 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. Ids inside an SVG a cell contains are deliberately kept: + id-scoped ` -
+ +
@@ -47,6 +92,669 @@
ABC
4fivesix
+ + + + + + + + + + + +
AB
1x[1]
+

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

+ + + +
+ + + + + + + + + + +
AB
1y[1]
+
+ + +
+ + + + + + + + + + +
AB
ok[1]gone[9]
+
+ + +
+ + + + + + + + + + +
AB
higher first[5]lower second[3]
+
+ + +
+ + + + + + + + + +
A
+ + + + +
stray
+
+
+ + +
+ + + + + + + + + +
A
+
+ + + +
+
+
+ + +
+ + + + + + + + + + + + + + + + + +
c1c2c3c4c5c6c7c8c9c10
w[1]bcdefghij
+
+ + +
+ + + + + + + + + +
흐름
배포 절차[1]
+
+ + +
+ + + + + + + + + + +
왼쪽오른쪽
첫 번째[3]두 번째[3-1]
+
+ + +
+ + + + + + + + + +
6[6]
+
+ + +
+ + + + + + + + + +
A
decoy[1]
+
+
+
    +
  1. DOM에서 읽으면 안 되는 옛 본문. ↩︎
  2. +
+
+
+ + +
+
+ + + + + + + + + + +
항목
응답률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 568a075..2aed905 100644 --- a/tests/e2e/table-fullscreen.test.mjs +++ b/tests/e2e/table-fullscreen.test.mjs @@ -1,10 +1,17 @@ /* * 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 — 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 a table full screen, and compares computed styles - * against the inline table. + * `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 @@ -33,17 +40,228 @@ 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; }; + + 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(); })(); @@ -60,30 +278,80 @@ 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; 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) => { + 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 table on load; open it full screen. await page.waitForSelector(".lookout-table-btn"); - await page.click(".lookout-table-btn", { 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 + //