Skip to content

Commit 40c5ff7

Browse files
committed
Render hacky screenshot of current sheet
It won't work in many browsers, nor with many cells contents. But it works with some, and that is enough to be sort of useful. It works particularly well in Chrome, and it works fine with text cells.
1 parent 094eb02 commit 40c5ff7

File tree

3 files changed

+122
-2
lines changed

3 files changed

+122
-2
lines changed

TODO.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@
2828
- Highlight referenced cells when in insert mode
2929
- Cells can run functions in a destructor
3030
- Context menu as last child of body, gets moved by right click
31-
- Render tables to images to save and share screenshots
3231
- Settings input with numeric input and buttons on either side
3332
- CSP to restrict `worker-src` (except that we don't want to block web
3433
workers, only service workers)
@@ -165,3 +164,4 @@
165164
vertical scrolling
166165
- Accidentally typing "RC" while typing another formula can cause everything
167166
to hang
167+
- Tons of issues with screenshot rendering

src/App.svelte

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,12 @@
116116
import { State, Sheet } from "./classes.svelte.js";
117117
import { compressText } from "./compress.js";
118118
import { evalDebounced, functions } from "./formula-functions.svelte.js";
119-
import { debounce, replaceValues, nextZIndex } from "./helpers.js";
119+
import {
120+
debounce,
121+
replaceValues,
122+
nextZIndex,
123+
domToImage,
124+
} from "./helpers.js";
120125
import { actions, keyboardHandler, keybindings } from "./keyboard.js";
121126
122127
let { urlData } = $props();
@@ -127,6 +132,8 @@
127132
let startHeight = $state(0);
128133
let scrollArea = $state();
129134
let imageData = $state();
135+
let tableImage = $state();
136+
let showScreenshot = $state(false);
130137
131138
// svelte-ignore state_referenced_locally
132139
if (!urlData) {
@@ -174,6 +181,9 @@
174181
// HTMLElements)
175182
window.history.pushState(undefined, "", "#" + compressed);
176183
}
184+
if (showScreenshot) {
185+
tableImage = domToImage(table);
186+
}
177187
}, 1000);
178188
$effect(() => {
179189
// Allow cell changes with get or update to trigger save. Those updates
@@ -200,6 +210,12 @@
200210
formulaCode: globals.formulaCode,
201211
});
202212
});
213+
$effect(() => {
214+
globals.currentSheetIndex;
215+
if (showScreenshot) {
216+
tableImage = domToImage(table);
217+
}
218+
});
203219
204220
Object.entries(window.localStorage)
205221
.filter(([k, _]) => k.startsWith("settings."))
@@ -351,6 +367,23 @@
351367
<div
352368
style="display: flex; flex-direction: column; gap: 0.5em; padding: 0.5em;"
353369
>
370+
<Details bind:open={showScreenshot}>
371+
{#snippet summary()}Screenshot{/snippet}
372+
<p>
373+
This feature is likely to not work correctly for some sheet cells and
374+
some browsers.
375+
</p>
376+
{#await tableImage}
377+
<p>Loading...</p>
378+
{:then src}
379+
<img
380+
style="display: block; width: 100%; min-width: 0px; min-height: 0px; object-fit: contain;"
381+
{src}
382+
/>
383+
{:catch err}
384+
<p>Error {err}</p>
385+
{/await}
386+
</Details>
354387
<SaveLoad bind:globals {imageData} />
355388
</div>
356389
</Dialog>

src/helpers.js

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,3 +81,90 @@ export function nextZIndex(increment = 1) {
8181
nextIndex += increment;
8282
return result;
8383
}
84+
85+
const elementProperties = Object.getOwnPropertyNames(HTMLElement.prototype);
86+
const objectProperties = Object.getOwnPropertyNames(Object.prototype);
87+
function cloneNode(node) {
88+
const result = node.cloneNode(false);
89+
let style;
90+
try {
91+
style = window.getComputedStyle(node);
92+
} catch {
93+
return result;
94+
}
95+
for (const s of style) {
96+
result.style[s] = style.getPropertyValue(s);
97+
}
98+
for (const prop of Object.getOwnPropertyNames(node.__proto__)) {
99+
if (typeof node[prop] == "function") continue;
100+
if (objectProperties.includes(prop)) continue;
101+
if (elementProperties.includes(prop)) continue;
102+
const attribute = node[prop];
103+
if (attribute == null || attribute == "") continue;
104+
result.setAttribute(prop, attribute);
105+
}
106+
for (const child of node.childNodes) {
107+
result.appendChild(cloneNode(child));
108+
}
109+
return result;
110+
}
111+
112+
export async function domToImage(node) {
113+
if (node == null) return undefined;
114+
115+
const { width, height } = node.getBoundingClientRect();
116+
const padding = 10;
117+
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
118+
svg.setAttribute("width", width + padding * 2);
119+
svg.setAttribute("height", width + padding * 2);
120+
Array.from(
121+
document.querySelectorAll("link[rel='stylesheet'], style"),
122+
).forEach((tag) => {
123+
svg.append(cloneNode(tag));
124+
});
125+
const foreignObject = Object.assign(
126+
document.createElementNS("http://www.w3.org/2000/svg", "foreignObject"),
127+
{ style: "width: 100%; height: 100%;" },
128+
);
129+
const root = Object.assign(
130+
document.createElementNS("http://www.w3.org/1999/xhtml", "div"),
131+
{ style: `width: 100%; height: 100%; padding: ${(padding * 3) / 4}px;` },
132+
);
133+
root.appendChild(cloneNode(node));
134+
foreignObject.append(root);
135+
svg.appendChild(foreignObject);
136+
137+
const serializer = new XMLSerializer();
138+
const blob = new Blob([serializer.serializeToString(svg)], {
139+
type: "image/svg+xml",
140+
});
141+
const dataUrl = await new Promise((resolve, reject) => {
142+
const reader = new FileReader();
143+
reader.addEventListener("load", (e) => {
144+
resolve(e.target.result);
145+
});
146+
reader.addEventListener("error", (e) => {
147+
reject(e);
148+
});
149+
reader.readAsDataURL(blob);
150+
});
151+
152+
const canvas = document.createElement("canvas");
153+
canvas.width = width + padding * 2;
154+
canvas.height = height + padding * 2;
155+
const context = canvas.getContext("2d");
156+
context.fillStyle = "white";
157+
context.fillRect(0, 0, canvas.width, canvas.height);
158+
await new Promise((resolve, reject) => {
159+
const image = new Image();
160+
image.addEventListener("load", () => {
161+
context.drawImage(image, 0, 0);
162+
resolve();
163+
});
164+
image.addEventListener("error", (e) => {
165+
reject(e);
166+
});
167+
image.src = dataUrl;
168+
});
169+
return canvas.toDataURL("image/png");
170+
}

0 commit comments

Comments
 (0)