Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ jobs:

- uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0

- name: Run renderer and search regression tests
run: |
node --test scripts/*.test.mjs
node --experimental-default-type=module tests/renderer/math.test.mjs
node --experimental-default-type=module tests/renderer/presentation.test.mjs

- name: Run unit tests
run: ./gradlew test

Expand All @@ -46,3 +52,28 @@ jobs:
# every PR instead of only at tag-cut time.
- name: Build release APK (unsigned, validates R8)
run: ./gradlew assembleRelease

browser:
name: Mobile & desktop renderer interactions
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
- name: Install pinned browser test dependency
run: |
npm install --prefix "$RUNNER_TEMP/renderer-tests" --no-audit --no-fund playwright-core@1.56.1
"$RUNNER_TEMP/renderer-tests/node_modules/.bin/playwright-core" install --with-deps chromium
- name: Verify tables, code, clipboard, wrap and offline fonts
env:
EVIDENCE_DIR: ${{ runner.temp }}/renderer-evidence
run: |
python3 -m http.server 8766 > "$RUNNER_TEMP/renderer-server.log" 2>&1 &
PLAYWRIGHT_MODULE="$RUNNER_TEMP/renderer-tests/node_modules/playwright-core/index.mjs" node --experimental-default-type=module tests/renderer/presentation.e2e.mjs
PLAYWRIGHT_MODULE="$RUNNER_TEMP/renderer-tests/node_modules/playwright-core/index.mjs" node --experimental-default-type=module tests/renderer/math-toc.e2e.mjs
- name: Save visual verification evidence
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: renderer-browser-evidence
path: ${{ runner.temp }}/renderer-evidence
if-no-files-found: warn
32 changes: 32 additions & 0 deletions app/src/main/assets/renderer/android-host.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@
import { getConfig, parseConfig, setConfig, setHostDefaults } from './config.js';
import { DocumentView } from './document.js';
import { NO_CAPABILITIES } from './host.js';
import { createSearchRenderNotifier } from './search.js';
import { createClipboardBridge } from './clipboard.js';
import { createTocController } from './toc.js';
import { asLocalResourceUri, setResourceOrigin } from './util.js';

/**
Expand All @@ -49,11 +52,17 @@ const RESOURCE_PREFIX = '/__mdr';
const NO_BRIDGE = {
ready: () => console.warn('[host] no Kotlin bridge; running detached'),
rendered: path => console.info('[host] rendered', path),
searchRendered: () => {},
openPath: path => console.info('[host] openPath', path),
openExternal: url => console.info('[host] openExternal', url),
};

const bridge = window.__mdrHost ?? NO_BRIDGE;
const clipboard = createClipboardBridge((id, text) => {
if (typeof bridge.copyText !== 'function') throw new Error('Native clipboard unavailable');
bridge.copyText(id, text);
});
window.addEventListener('pagehide', () => clipboard.dispose());

// The host knows its own origin and says so, rather than being sniffed for. Whatever is set
// here has to be reachable under the page's CSP — it is, as `'self'`.
Expand Down Expand Up @@ -114,6 +123,8 @@ const host = {
bridge.openExternal(url);
},

copyText: text => clipboard.copyText(text),

// showInFolder: deliberately absent.
};

Expand Down Expand Up @@ -143,6 +154,17 @@ const view = new DocumentView({
* landed. Two frames after the render, they have. That is the signal a loading state is
* dismissed on, and the one the spike measured first paint with.
*/
const toc = createTocController({
root: document.getElementById('markdown-body'),
notify: json => bridge.tocChanged?.(json),
});
view.on('headings', headings => toc.rendered(headings));
view.on('activeHeading', slug => toc.active(slug));
const searchRender = createSearchRenderNotifier(() => bridge.searchRendered());
// `headings` is emitted on every render, not only when a different document opens.
view.on('headings', () => searchRender.rendered());
window.addEventListener('pagehide', () => searchRender.dispose(), { once: true });

view.on('document', doc => {
if (doc) {
requestAnimationFrame(() => requestAnimationFrame(() => bridge.rendered(doc.path)));
Expand All @@ -160,6 +182,9 @@ view.on('document', doc => {
function applyTypography() {
const config = getConfig();
const style = document.documentElement.style;
document.body.style.setProperty('--vscode-editor-font-family', config.codeFontFamily);
document.body.style.setProperty('--markdown-code-font-family', config.codeFontFamily);
document.body.classList.toggle('source-document', config.sourceCode === true);
style.setProperty('--markdown-font-size', `${config.fontSize}px`);
style.setProperty('--markdown-line-height', `${config.lineHeight}px`);
style.setProperty('--markdown-font-family', config.fontFamily);
Expand All @@ -174,12 +199,19 @@ const RENDER_SETTINGS = ['frontMatter'];
// -------------------------------------------------------------- the page's own surface

const page = {
completeCopy(json) {
clipboard.complete(json);
},
revealHeading(json) {
toc.reveal(json);
},
/**
* @param json {string} a JSON `{ path, text, rootPath?, preserveScroll? }`. Kotlin hands it
* across as a string rather than an expression, so nothing is injected into the page.
*/
showDocument(json) {
const payload = JSON.parse(json);
toc.begin(payload.tocGeneration);
// Root-relative links (`/README.md`) resolve against the folder, when there is one.
view.setRootPath(payload.rootPath ?? undefined);
view.setDocument(
Expand Down
33 changes: 33 additions & 0 deletions app/src/main/assets/renderer/clipboard.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// SPDX-License-Identifier: GPL-3.0-only
// Native clipboard writes are asynchronous and acknowledged; no optimistic success labels.
export function createClipboardBridge(send, { timeoutMs = 5000, maxLength = 2_000_000 } = {}) {
let next = 0;
const pending = new Map();
return {
copyText(text) {
if (typeof text !== 'string' || text.length > maxLength) return Promise.reject(new Error('Clipboard payload too large'));
if (pending.size >= 8) return Promise.reject(new Error('Clipboard busy'));
return new Promise((resolve, reject) => {
const id = String(++next);
const timer = setTimeout(() => { pending.delete(id); reject(new Error('Clipboard timeout')); }, timeoutMs);
pending.set(id, { resolve, reject, timer });
try { send(id, text); } catch (error) {
clearTimeout(timer); pending.delete(id); reject(error);
}
});
},
complete(json) {
let payload;
try { payload = typeof json === 'string' ? JSON.parse(json) : json; } catch { return; }
const entry = pending.get(payload?.id);
if (!entry) return;
clearTimeout(entry.timer); pending.delete(payload.id);
if (payload.ok === true) entry.resolve();
else entry.reject(new Error('Native clipboard write failed'));
},
dispose() {
for (const entry of pending.values()) { clearTimeout(entry.timer); entry.reject(new Error('Page closed')); }
pending.clear();
},
};
}
4 changes: 4 additions & 0 deletions app/src/main/assets/renderer/config.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

179 changes: 179 additions & 0 deletions app/src/main/assets/renderer/css/document-overrides.css
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/* SPDX-License-Identifier: GPL-3.0-only */
/* Copyright (c) 2026 DigiGate */
@import './fonts.css';

/*---------------------------------------------------------------------------------------------
* The document stylesheet MarkReader owns.
Expand Down Expand Up @@ -215,3 +216,181 @@ table.frontmatter td > ul {
border-left: 0;
border-inline-start: 4px solid var(--vscode-editorError-foreground, #f48771);
}

/* ---------------------------------------------------------------- table & code presentation
* Keep VS Code vendor styles byte-identical. These rules also work without desktop app.css.
*/
.markdown-body {
min-width: 0;
}
body.vscode-light {
--mr-table-border: #b8bec6;
--mr-table-head: #edf1f5;
--mr-table-stripe: #f7f9fb;
}
body.vscode-dark {
--mr-table-border: #616975;
--mr-table-head: #303640;
--mr-table-stripe: #252a31;
}
.markdown-body .table-wrapper {
max-width: 100%;
min-width: 0;
overflow: auto;
overscroll-behavior-x: contain;
margin-block: 1em;
border: 1px solid var(--mr-table-border, var(--vscode-widget-border));
border-radius: 6px;
/* Keep focus ring visible; never hide page overflow to disguise a sizing bug. */
}
.markdown-body .table-wrapper > table {
width: max-content;
min-width: 100%;
border-collapse: separate;
border-spacing: 0;
margin: 0;
font-size: 1em;
line-height: 1.55;
}
.markdown-body .table-wrapper :is(th, td) {
min-width: 7ch;
max-width: 30ch;
padding: 10px 12px;
vertical-align: top;
white-space: normal;
overflow-wrap: anywhere;
border: 0;
border-inline-end: 1px solid var(--mr-table-border);
border-block-end: 1px solid var(--mr-table-border);
}
.markdown-body .table-wrapper tr > :last-child { border-inline-end: 0; }
.markdown-body .table-wrapper tbody tr:last-child > * { border-block-end: 0; }
.markdown-body .table-wrapper th {
background: var(--mr-table-head);
color: var(--vscode-editor-foreground);
font-weight: 650;
}
.markdown-body .table-wrapper tbody tr:nth-child(even) { background: var(--mr-table-stripe); }
.markdown-body .table-wrapper td > :first-child { margin-block-start: 0; }
.markdown-body .table-wrapper td > :last-child { margin-block-end: 0; }
.markdown-body :not(pre) > code {
font-family: var(--vscode-editor-font-family, monospace);
font-size: 0.92em;
background: var(--vscode-textCodeBlock-background);
padding: 0.12em 0.3em;
border-radius: 3px;
overflow-wrap: anywhere;
}
.markdown-body .code-block {
max-width: 100%;
min-width: 0;
margin-block: 1em;
border: 1px solid var(--vscode-editorWidget-border);
border-radius: 6px;
background: var(--vscode-textCodeBlock-background);
}
.markdown-body .code-block-toolbar {
display: flex;
align-items: center;
gap: 4px;
padding: 2px 6px 2px 12px;
border-bottom: 1px solid var(--vscode-editorWidget-border);
font: 500 12px/1.4 system-ui, sans-serif;
}
.markdown-body .code-block-language {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.markdown-body .code-block-toolbar button {
position: static;
inset: auto;
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
width: auto;
min-width: 44px;
min-height: 44px;
padding: 6px 10px;
opacity: 1;
border: 1px solid transparent;
border-radius: 4px;
background: transparent;
color: inherit;
font: inherit;
cursor: pointer;
transition: none;
}
.markdown-body .code-block-toolbar button:hover,
.markdown-body .code-block-toolbar button[aria-pressed="true"] {
background: var(--vscode-toolbar-hoverBackground);
}
.markdown-body .code-block-toolbar button:disabled { cursor: wait; }
.markdown-body .code-block > pre {
box-sizing: border-box;
max-width: 100%;
min-width: 0;
margin: 0;
padding: 14px 16px;
border: 0;
border-radius: 0 0 6px 6px;
overflow: auto;
overscroll-behavior-x: contain;
direction: ltr;
text-align: start;
white-space: pre;
font-family: var(--vscode-editor-font-family, monospace);
font-size: 0.9em;
line-height: 1.6;
tab-size: 4;
}
.markdown-body .code-block > pre code {
display: block;
padding: 0;
font: inherit;
line-height: inherit;
white-space: inherit;
overflow-wrap: inherit;
background: none;
}
body.wordWrap .markdown-body .code-block > pre,
.markdown-body .code-block[data-wrap="true"] > pre {
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.markdown-body .code-block[data-wrap="false"] > pre {
white-space: pre;
overflow-wrap: normal;
}
.markdown-body .code-block-status {
display: block;
padding: 0 12px 8px;
font: 12px/1.5 system-ui, sans-serif;
}
.markdown-body .code-block-status:empty { display: none; }
.markdown-body :is(.table-wrapper, .code-block > pre, .code-block-toolbar button):focus-visible {
outline: 2px solid var(--vscode-textLink-foreground);
outline-offset: -2px;
}
/* VS Code's default dark comments are low contrast against our code background. */
.vscode-dark .markdown-body :is(.hljs-comment, .hljs-quote) { color: #a0b889; }
@media print {
.markdown-body .code-block-toolbar { display: none; }
.markdown-body .code-block > pre { white-space: pre-wrap; overflow-wrap: anywhere; }
.markdown-body .table-wrapper { overflow: visible; }
.markdown-body .table-wrapper > table { width: 100%; }
}

/* markdown.css applies the gutter to BOTH html and body (64px on a 390px phone).
* One reading gutter is enough; retain the host's vertical/chrome inset. */
html { padding-inline: 0; }
/* SourceCodeFormat wraps the whole file in a fence; the pre already owns its gutter. */
body.source-document { padding-inline: 0; }
.source-document .markdown-body > .code-block {
margin-block: 0;
border-inline: 0;
border-radius: 0;
}
6 changes: 6 additions & 0 deletions app/src/main/assets/renderer/css/fonts.css

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading