Skip to content

Commit 337e6e2

Browse files
committed
allow root scrollable
1 parent fc286f6 commit 337e6e2

10 files changed

Lines changed: 138 additions & 21 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
A browser extension that exports any web page's rendered DOM to **10 output formats** using the [@node-projects/layout2vector](https://github.com/node-projects/layout2vector) library.
44

5+
Install the Chrome version from the [Chrome Web Store](https://chromewebstore.google.com/detail/web2vector/ojjkecepeobhmpilhdhcjcgpdjhnkjgl).
6+
57
<p align="center">
68
<img src="src/icons/icon.svg" width="128" alt="Web2Vector icon" />
79
</p>

package-lock.json

Lines changed: 4 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
},
1818
"dependencies": {
1919
"@chenglou/pretext": "^0.0.6",
20-
"@node-projects/layout2vector": "^5.18.0",
20+
"@node-projects/layout2vector": "^5.19.0",
2121
"get-box-quads-polyfill": "^4.36.0"
2222
},
2323
"devDependencies": {

src/background/service-worker.js

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ const popupPorts = new Set();
1010
const IMAGE_FETCH_TIMEOUT_MS = 12000;
1111
const FRAME_COLLECTION_TIMEOUT_MS = 45000;
1212
const PRECOMPUTED_IR_TRANSFER_MAX_BYTES = 8 * 1024 * 1024;
13+
const DEFAULT_EXPORT_OPTIONS = Object.freeze({
14+
rootScrollBehavior: 'clip',
15+
});
1316

1417
const FRAME_EXTRACTION_OPTIONS = {
1518
boxType: 'border',
@@ -53,7 +56,7 @@ extensionApi.runtime.onInstalled.addListener(() => {
5356
extensionApi.contextMenus.onClicked.addListener((info, tab) => {
5457
if (!info.menuItemId.startsWith('export-')) return;
5558
const format = info.menuItemId.slice('export-'.length);
56-
startExport(tab.id, format);
59+
startExport(tab.id, format, DEFAULT_EXPORT_OPTIONS);
5760
});
5861

5962
extensionApi.runtime.onConnect?.addListener((port) => {
@@ -69,8 +72,9 @@ extensionApi.runtime.onConnect?.addListener((port) => {
6972
extensionApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
7073
// From popup: start an export
7174
if (message.action === 'export') {
75+
const exportOptions = normalizeExportOptions(message.options);
7276
extensionApi.tabs.query({ active: true, currentWindow: true }).then(([tab]) => {
73-
if (tab) startExport(tab.id, message.format);
77+
if (tab) startExport(tab.id, message.format, exportOptions);
7478
});
7579
return;
7680
}
@@ -114,29 +118,36 @@ extensionApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
114118
});
115119

116120
// ── Export orchestration ──────────────────────────────────
117-
async function startExport(tabId, format) {
121+
async function startExport(tabId, format, exportOptions = DEFAULT_EXPORT_OPTIONS) {
118122
const fmt = FORMATS[format];
119123
if (!fmt) {
120124
notifyPopup('export-error', { error: `Unknown format: ${format}` });
121125
return;
122126
}
123127

124128
try {
125-
const mergedIr = selectPrecomputedIrForTransfer(await collectMergedIrForTab(tabId));
129+
const normalizedOptions = normalizeExportOptions(exportOptions);
130+
const mergedIr = selectPrecomputedIrForTransfer(await collectMergedIrForTab(tabId, normalizedOptions));
126131

127132
// 1. Set the requested format and precomputed IR in the content-script world
128133
await extensionApi.scripting.executeScript({
129134
target: { tabId },
130-
func: (f, ir) => {
135+
func: (f, ir, options) => {
131136
globalThis.__web2vector_format = f;
132137

133138
if (Array.isArray(ir)) {
134139
globalThis.__web2vector_precomputed_ir = ir;
135140
} else {
136141
delete globalThis.__web2vector_precomputed_ir;
137142
}
143+
144+
if (options && typeof options === 'object') {
145+
globalThis.__web2vector_export_options = options;
146+
} else {
147+
delete globalThis.__web2vector_export_options;
148+
}
138149
},
139-
args: [format, mergedIr],
150+
args: [format, mergedIr, normalizedOptions],
140151
});
141152

142153
// 2. Lazy-load writer bundle when required
@@ -162,7 +173,7 @@ async function startExport(tabId, format) {
162173
}
163174
}
164175

165-
async function collectMergedIrForTab(tabId) {
176+
async function collectMergedIrForTab(tabId, exportOptions = DEFAULT_EXPORT_OPTIONS) {
166177
try {
167178
return await withTimeout((async () => {
168179
await extensionApi.scripting.executeScript({
@@ -178,7 +189,10 @@ async function collectMergedIrForTab(tabId) {
178189
const frameResults = await extensionApi.scripting.executeScript({
179190
target: { tabId, allFrames: true },
180191
func: (options) => globalThis.__web2vectorFrameSupport?.collectFrameData?.(options) ?? null,
181-
args: [FRAME_EXTRACTION_OPTIONS],
192+
args: [{
193+
...FRAME_EXTRACTION_OPTIONS,
194+
rootScrollBehavior: normalizeExportOptions(exportOptions).rootScrollBehavior,
195+
}],
182196
});
183197

184198
return mergeFrameExtractionResults(frameResults, { rootFrameId: 0 })?.ir ?? null;
@@ -213,6 +227,12 @@ function estimateSerializedSize(value) {
213227
}
214228
}
215229

230+
function normalizeExportOptions(options) {
231+
return {
232+
rootScrollBehavior: options?.rootScrollBehavior === 'expand' ? 'expand' : 'clip',
233+
};
234+
}
235+
216236
async function withTimeout(promise, timeoutMs, onTimeout) {
217237
let timeoutId = null;
218238

src/content/frame-support.js

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,12 @@ async function collectFrameData(options = {}) {
3939
includeSourceMetadata: options.includeSourceMetadata ?? true,
4040
includeInvisible: options.includeInvisible ?? false,
4141
walkIframes: false,
42+
rootScrollBehavior: options.rootScrollBehavior ?? 'clip',
4243
convertFormControls: options.convertFormControls ?? true,
4344
includePseudoElements: options.includePseudoElements ?? true,
4445
};
4546

46-
const paintOrder = collectPaintOrder(root, extractOptions.includeInvisible ?? false, lib);
47+
const paintOrder = collectPaintOrder(root, extractOptions, lib);
4748
const ir = await lib.extractIR(root, extractOptions);
4849
const childFrames = await collectChildFrameMappings(root, extractOptions.includeInvisible ?? false, lib);
4950

@@ -55,12 +56,14 @@ async function collectFrameData(options = {}) {
5556
};
5657
}
5758

58-
function collectPaintOrder(root, includeInvisible, lib) {
59+
function collectPaintOrder(root, extractOptions, lib) {
5960
if (typeof lib.traverseDOM !== 'function' || typeof lib.flattenStackingOrder !== 'function') {
6061
return [];
6162
}
6263

63-
const stackingTree = lib.traverseDOM(root, includeInvisible, false);
64+
const stackingTree = lib.traverseDOM(root, extractOptions.includeInvisible ?? false, false, {
65+
rootScrollBehavior: extractOptions.rootScrollBehavior,
66+
});
6467
return lib.flattenStackingOrder(stackingTree).map((node) => getElementXPath(node.element));
6568
}
6669

src/content/run-export.js

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/**
22
* Run-export script — injected each time the user requests an export.
33
*
4-
* Reads the requested format from globalThis.__web2vector_format,
4+
* Reads the requested format and export options from globalThis,
55
* performs the export via layout2vector, converts the result to a
66
* data-URL, and sends it to the background service worker which
77
* triggers chrome.downloads.download({ saveAs: true }).
@@ -30,11 +30,13 @@ const PX_TO_MM = 25.4 / 96;
3030
const { extractIR, renderIR, writers } = lib;
3131

3232
const root = document.documentElement;
33+
const exportOptions = normalizeExportOptions(globalThis.__web2vector_export_options);
3334
const precomputedIr = Array.isArray(globalThis.__web2vector_precomputed_ir)
3435
? globalThis.__web2vector_precomputed_ir
3536
: null;
3637

3738
delete globalThis.__web2vector_precomputed_ir;
39+
delete globalThis.__web2vector_export_options;
3840

3941
const BITMAP_FORMATS = ['png', 'jpeg', 'webp'];
4042
const isBitmap = BITMAP_FORMATS.includes(format);
@@ -47,6 +49,7 @@ const PX_TO_MM = 25.4 / 96;
4749
includeImages,
4850
includeSourceMetadata: false,
4951
walkIframes: true,
52+
rootScrollBehavior: exportOptions.rootScrollBehavior,
5053
convertFormControls: true,
5154
});
5255
}
@@ -361,3 +364,9 @@ function truncateForLog(value, maxLength = 240) {
361364
if (typeof value !== 'string' || value.length <= maxLength) return value ?? null;
362365
return `${value.slice(0, maxLength - 3)}...`;
363366
}
367+
368+
function normalizeExportOptions(options) {
369+
return {
370+
rootScrollBehavior: options?.rootScrollBehavior === 'expand' ? 'expand' : 'clip',
371+
};
372+
}

src/popup/popup.css

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,35 @@ body {
8484
font-family: 'SFMono-Regular', Consolas, monospace;
8585
}
8686

87+
/* ── Export options ── */
88+
.options-panel {
89+
margin-top: 10px;
90+
padding: 9px 10px;
91+
border: 1px solid #e0e0e0;
92+
border-radius: 8px;
93+
background: #fff;
94+
}
95+
96+
.option-toggle {
97+
display: flex;
98+
align-items: flex-start;
99+
gap: 8px;
100+
font-size: 12px;
101+
color: #333;
102+
cursor: pointer;
103+
}
104+
105+
.option-toggle input {
106+
margin-top: 1px;
107+
}
108+
109+
.option-hint {
110+
margin-top: 5px;
111+
font-size: 11px;
112+
line-height: 1.4;
113+
color: #777;
114+
}
115+
87116
/* ── Status bar ── */
88117
.status {
89118
margin-top: 10px;

src/popup/popup.html

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,14 @@ <h1>Web2Vector</h1>
1515

1616
<div id="format-list" class="format-list"></div>
1717

18+
<div class="options-panel">
19+
<label class="option-toggle" for="root-scroll-expand">
20+
<input id="root-scroll-expand" type="checkbox">
21+
<span>Expand root scroll content</span>
22+
</label>
23+
<p class="option-hint">Export the full page when the root uses scrollable overflow.</p>
24+
</div>
25+
1826
<div id="status" class="status hidden"></div>
1927
</div>
2028
<script src="popup.js"></script>

src/popup/popup.js

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { extensionApi } from '../shared/extension-api.js';
33
import { POPUP_STATUS_PORT_NAME } from '../shared/popup-status.js';
44

55
const list = document.getElementById('format-list');
6+
const rootScrollExpand = document.getElementById('root-scroll-expand');
67
const status = document.getElementById('status');
78
let statusPort = null;
89

@@ -36,37 +37,49 @@ for (const [cat, label] of Object.entries(CATEGORIES)) {
3637
// ── Export logic ──────────────────────────────────────────
3738
let exporting = false;
3839

39-
function setButtons(enabled) {
40+
function setControlsEnabled(enabled) {
4041
for (const btn of list.querySelectorAll('.format-btn')) {
4142
btn.disabled = !enabled;
4243
}
44+
45+
rootScrollExpand.disabled = !enabled;
4346
}
4447

4548
function showStatus(text, type) {
4649
status.textContent = text;
4750
status.className = `status ${type}`;
4851
}
4952

53+
function getExportOptions() {
54+
return {
55+
rootScrollBehavior: rootScrollExpand.checked ? 'expand' : 'clip',
56+
};
57+
}
58+
5059
function startExport(format) {
5160
if (exporting) return;
5261
exporting = true;
53-
setButtons(false);
62+
setControlsEnabled(false);
5463
showStatus('Exporting\u2026', 'loading');
5564

56-
extensionApi.runtime.sendMessage({ action: 'export', format });
65+
extensionApi.runtime.sendMessage({
66+
action: 'export',
67+
format,
68+
options: getExportOptions(),
69+
});
5770
}
5871

5972
// ── Listen for result from background ─────────────────────
6073
function handleStatusMessage(msg) {
6174
if (msg.action === 'export-complete') {
6275
showStatus('Download started!', 'success');
6376
exporting = false;
64-
setButtons(true);
77+
setControlsEnabled(true);
6578
}
6679
if (msg.action === 'export-error') {
6780
showStatus(msg.error ?? 'Export failed', 'error');
6881
exporting = false;
69-
setButtons(true);
82+
setControlsEnabled(true);
7083
}
7184
}
7285

tests/service-worker.test.js

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,38 @@ describe('service-worker message handling', () => {
9696
}, { timeout: 2000 });
9797
});
9898

99+
it('passes root scroll expand through frame extraction and export setup', async () => {
100+
await import('../src/background/service-worker.js');
101+
chrome._listeners.onMessage?.({
102+
action: 'export',
103+
format: 'svg',
104+
options: { rootScrollBehavior: 'expand' },
105+
}, {});
106+
107+
await vi.waitFor(() => {
108+
const frameCollectionCall = chrome.scripting.executeScript.mock.calls.find((call) =>
109+
call[0]?.target?.allFrames === true
110+
&& typeof call[0]?.func === 'function'
111+
&& Array.isArray(call[0]?.args)
112+
&& call[0].args[0]?.boxType === 'border'
113+
);
114+
115+
expect(frameCollectionCall).toBeTruthy();
116+
expect(frameCollectionCall[0].args[0]).toEqual(expect.objectContaining({
117+
rootScrollBehavior: 'expand',
118+
}));
119+
120+
const exportSetupCall = chrome.scripting.executeScript.mock.calls.find((call) =>
121+
typeof call[0]?.func === 'function'
122+
&& Array.isArray(call[0]?.args)
123+
&& call[0].args[0] === 'svg'
124+
);
125+
126+
expect(exportSetupCall).toBeTruthy();
127+
expect(exportSetupCall[0].args[2]).toEqual({ rootScrollBehavior: 'expand' });
128+
}, { timeout: 2000 });
129+
});
130+
99131
it('skips transferring oversized precomputed IR back into the tab', async () => {
100132
const hugeDataUrl = `data:image/png;base64,${'A'.repeat(9 * 1024 * 1024)}`;
101133
chrome.scripting.executeScript.mockImplementation(async (config) => {

0 commit comments

Comments
 (0)