Skip to content
Source
Sign inSign up
Open History to see the last commit

sheet-paint.ts

397 lines12.4 KB

1// Adapted from opencode (MIT). Copyright (c) 2025 opencode. See LICENSE.opencode.
2import { rangeRect, type CellRange, type GridMeta } from "@betteroffice/xlsx/headless";
3import { columnName } from "./sheet-cells";
4import type { SheetFrame, SheetFrameRequest } from "./sheet-protocol";
5​
6/** Excel's light headers and green selection. The sheet keeps them on its white paper in every theme. */
7const colours = {
8 header: "#f5f5f5",
9 label: "#5c5c5c",
10 line: "#d4d4d4",
11 edge: "#bdbdbd",
12 corner: "#c8c8c8",
13 selectedHeader: "#d3ecdd",
14 selectedLabel: "#185c37",
15 accent: "#217346",
16 selection: "rgba(33, 115, 70, 0.12)",
17};
18​
19const font = "11px ui-sans-serif, system-ui, sans-serif";
20​
21/** The height of the column headers, in CSS pixels. */
22export const headerHeight = 20;
23​
24/** The sizes of the header strips, in CSS pixels: the row headers' width and the column headers' height. */
25export type Headers = { readonly width: number; readonly height: number };
26​
27/**
28 * The frozen panes' size in a frame: the offset of the first track past them. Without panes, the first track can start
29 * before the frame, part of it scrolled away.
30 */
31export function frozenSize(
32 grid: GridMeta | undefined,
33 frozen: { readonly rows: number; readonly cols: number },
34) {
35 if (!grid) return { width: 0, height: 0 };
36​
37 const pane = (
38 offsets: readonly number[],
39 start: number,
40 indices: readonly number[] | undefined,
41 count: number,
42 ) => {
43 if (count === 0) return 0;
44​
45 const first = offsets.findIndex(
46 (_, position) =>
47 position === offsets.length - 1 || trackIndex(start, indices, position) >= count,
48 );
49​
50 return Math.max(0, offsets[first] ?? 0);
51 };
52​
53 return {
54 width: pane(grid.colOffsets, grid.startCol, grid.colIndices, frozen.cols),
55 height: pane(grid.rowOffsets, grid.startRow, grid.rowIndices, frozen.rows),
56 };
58​
59/** A frame from the workbook worker and the request it answers, which says where its cells sit. */
60export type Painted = { readonly request: SheetFrameRequest; readonly frame: SheetFrame };
61​
62/**
63 * Draws a frame's cells beside the headers at the current scroll position. A frame painted at another position, as one
64 * is while the next renders, moves with the scroll: the frozen panes stay put and the cells past them shift, leaving
65 * the paper where the frame has no cells yet.
66 */
67export function paintCells(
68 context: CanvasRenderingContext2D,
69 painted: Painted,
70 input: {
71 readonly ratio: number;
72 readonly headers: Headers;
73 readonly x: number;
74 readonly y: number;
75 readonly width: number;
76 readonly height: number;
77 },
78) {
79 const request = painted.request;
80 const bitmap = painted.frame.bitmap;
81 const panes = painted.frame.frozen;
82 const ratio = input.ratio;
83 const scale = ratio / request.ratio;
84 const origin = { x: input.headers.width * ratio, y: input.headers.height * ratio };
85​
86 // Each side splits at the frozen panes: the panes stay put, the cells past them follow the scroll.
87 const columns = [
88 { start: 0, end: Math.min(panes.width, input.width), shift: 0 },
89 { start: panes.width, end: input.width, shift: painted.frame.viewport.x - input.x },
90 ];
91​
92 const rows = [
93 { start: 0, end: Math.min(panes.height, input.height), shift: 0 },
94 { start: panes.height, end: input.height, shift: painted.frame.viewport.y - input.y },
95 ];
96​
97 // The whole canvas clears, headers included: their edges cover part of a device pixel, which would darken with every
98 // paint drawn over the last.
99 context.save();
100 context.setTransform(1, 0, 0, 1, 0, 0);
101 context.clearRect(0, 0, context.canvas.width, context.canvas.height);
102​
103 rows.forEach((row) =>
104 columns.forEach((column) => {
105 if (row.end <= row.start || column.end <= column.start) return;
106​
107 // A shifted bitmap's frozen pixels must never enter a scrolling pane.
108 const left = Math.round(
109 origin.x + Math.max(column.start, column.start + column.shift) * ratio,
110 );
111 const top = Math.round(origin.y + Math.max(row.start, row.start + row.shift) * ratio);
112 const right = Math.round(origin.x + column.end * ratio);
113 const bottom = Math.round(origin.y + row.end * ratio);
114 if (left >= right || top >= bottom) return;
115​
116 context.save();
117 context.beginPath();
118 context.rect(left, top, right - left, bottom - top);
119 context.clip();
120 // Whole device pixels, so a frame at its own position lands exactly where the worker painted its cells.
121 context.drawImage(
122 bitmap,
123 Math.round(origin.x + column.shift * ratio - request.offset.x * scale),
124 Math.round(origin.y + row.shift * ratio - request.offset.y * scale),
125 bitmap.width * scale,
126 bitmap.height * scale,
127 );
128 context.restore();
129 }),
130 );
131​
132 context.restore();
134​
135/**
136 * A frame's grid at another scroll position, moved as `paintCells` moves its cells: the tracks past the frozen panes
137 * shift by `shift`, and never under the panes, so headers, selection and hit tests follow the cells on screen.
138 */
139export function shiftGrid(
140 grid: GridMeta,
141 frozen: { readonly rows: number; readonly cols: number },
142 panes: { readonly width: number; readonly height: number },
143 shift: { readonly x: number; readonly y: number },
144): GridMeta {
145 if (shift.x === 0 && shift.y === 0) return grid;
146​
147 const axis = (
148 offsets: readonly number[],
149 start: number,
150 indices: readonly number[] | undefined,
151 count: number,
152 edge: number,
153 by: number,
154 ) => {
155 // The offsets up to the panes' far edge belong to the panes; without panes, every track moves.
156 const fixed =
157 count === 0
158 ? -1
159 : offsets.findIndex((_, position) => trackIndex(start, indices, position) >= count);
160​
161 return offsets.map((offset, position) => {
162 if (fixed === -1 && count > 0) return offset;
163​
164 if (position <= fixed) return offset;
165​
166 return count === 0 ? offset + by : Math.max(edge, offset + by);
167 });
168 };
169​
170 return {
171 ...grid,
172 colOffsets: axis(
173 grid.colOffsets,
174 grid.startCol,
175 grid.colIndices,
176 frozen.cols,
177 panes.width,
178 shift.x,
179 ),
180 rowOffsets: axis(
181 grid.rowOffsets,
182 grid.startRow,
183 grid.rowIndices,
184 frozen.rows,
185 panes.height,
186 shift.y,
187 ),
188 };
190​
191/** The sheet row or column at a position in a frame's grid: its indices when tracks are skipped, else consecutive. */
192function trackIndex(start: number, indices: readonly number[] | undefined, position: number) {
193 return indices?.[position] ?? start + position;
195​
196/**
197 * Draws the column letters and row numbers of a frame's tracks, frozen panes first, along the top and left edges.
198 * Hidden tracks have no width in the grid, so they get no header.
199 */
200export function paintHeaders(
201 context: CanvasRenderingContext2D,
202 input: {
203 readonly grid: GridMeta;
204 readonly ratio: number;
205 readonly headers: Headers;
206 readonly width: number;
207 readonly height: number;
208 readonly selected: CellRange | undefined;
209 },
210) {
211 const grid = input.grid;
212 const headers = input.headers;
213 const selected = input.selected;
214​
215 context.save();
216 context.setTransform(input.ratio, 0, 0, input.ratio, 0, 0);
217 context.fillStyle = colours.header;
218 context.fillRect(0, 0, input.width, headers.height);
219 context.fillRect(0, 0, headers.width, input.height);
220 context.font = font;
221 context.textAlign = "center";
222 context.textBaseline = "middle";
223​
224 grid.colOffsets.slice(0, -1).forEach((offset, position) => {
225 const index = trackIndex(grid.startCol, grid.colIndices, position);
226​
227 paintHeader(context, {
228 x: headers.width + offset,
229 y: 0,
230 width: grid.colOffsets[position + 1]! - offset,
231 height: headers.height,
232 label: columnName(index),
233 selected: !!selected && index >= selected.left && index <= selected.right,
234 clip: { x: headers.width, width: input.width - headers.width },
235 });
236 });
237 grid.rowOffsets.slice(0, -1).forEach((offset, position) => {
238 const index = trackIndex(grid.startRow, grid.rowIndices, position);
239​
240 paintHeader(context, {
241 x: 0,
242 y: headers.height + offset,
243 width: headers.width,
244 height: grid.rowOffsets[position + 1]! - offset,
245 label: String(index + 1),
246 selected: !!selected && index >= selected.top && index <= selected.bottom,
247 clip: { y: headers.height, height: input.height - headers.height },
248 });
249 });
250​
251 line(context, colours.edge, 0, headers.height, input.width, headers.height);
252 line(context, colours.edge, headers.width, 0, headers.width, input.height);
253 // Excel's select-all corner: a small triangle pointing at the cells.
254 context.fillStyle = colours.corner;
255 context.beginPath();
256 context.moveTo(headers.width - 4, headers.height - 12);
257 context.lineTo(headers.width - 4, headers.height - 4);
258 context.lineTo(headers.width - 12, headers.height - 4);
259 context.closePath();
260 context.fill();
261 context.restore();
263​
264/** Outlines the selected range and tints it, leaving its active cell clear, in the cells beside the headers. */
265export function paintSelection(
266 context: CanvasRenderingContext2D,
267 input: {
268 readonly grid: GridMeta;
269 readonly ratio: number;
270 readonly headers: Headers;
271 readonly width: number;
272 readonly height: number;
273 readonly range: CellRange;
274 readonly active: CellRange;
275 },
276) {
277 const range = rangeRect(input.grid, input.range);
278​
279 if (!range) return;
280​
281 const active = rangeRect(input.grid, input.active);
282​
283 context.save();
284 context.setTransform(
285 input.ratio,
286 0,
287 0,
288 input.ratio,
289 input.headers.width * input.ratio,
290 input.headers.height * input.ratio,
291 );
292 context.beginPath();
293 context.rect(0, 0, input.width, input.height);
294 context.clip();
295​
296 if (!sameRect(range, active)) {
297 context.beginPath();
298 context.rect(range.x, range.y, range.w, range.h);
299​
300 if (active) context.rect(active.x, active.y, active.w, active.h);
301​
302 context.fillStyle = colours.selection;
303 context.fill("evenodd");
304 }
305​
306 context.strokeStyle = colours.accent;
307 context.lineWidth = 2;
308 context.strokeRect(range.x, range.y, range.w, range.h);
309 context.restore();
311​
312function paintHeader(
313 context: CanvasRenderingContext2D,
314 header: {
315 readonly x: number;
316 readonly y: number;
317 readonly width: number;
318 readonly height: number;
319 readonly label: string;
320 readonly selected: boolean;
321 readonly clip: {
322 readonly x?: number;
323 readonly y?: number;
324 readonly width?: number;
325 readonly height?: number;
326 };
327 },
328) {
329 if (header.width <= 0 || header.height <= 0) return;
330​
331 const columns = header.y === 0;
332​
333 context.save();
334 context.beginPath();
335 context.rect(
336 header.clip.x ?? header.x,
337 header.clip.y ?? header.y,
338 header.clip.width ?? header.width,
339 header.clip.height ?? header.height,
340 );
341 context.clip();
342​
343 // A selected header is tinted, with a bar on the edge that meets the cells.
344 if (header.selected) {
345 context.fillStyle = colours.selectedHeader;
346 context.fillRect(header.x, header.y, header.width, header.height);
347 context.fillStyle = colours.accent;
348 context.fillRect(
349 columns ? header.x : header.x + header.width - 2,
350 columns ? header.y + header.height - 2 : header.y,
351 columns ? header.width : 2,
352 columns ? 2 : header.height,
353 );
354 }
355​
356 // A track too small for its label shows none, rather than labels piled over each other.
357 if (columns ? context.measureText(header.label).width + 2 <= header.width : header.height >= 9) {
358 context.fillStyle = header.selected ? colours.selectedLabel : colours.label;
359 context.fillText(header.label, header.x + header.width / 2, header.y + header.height / 2 + 0.5);
360 }
361​
362 context.restore();
363​
364 // The line after the header, where the gridline between its cells and the next ones meets it.
365 const x = columns ? header.x + header.width : header.x;
366 const y = columns ? header.y : header.y + header.height;
367​
368 line(
369 context,
370 colours.line,
371 x,
372 y,
373 columns ? x : x + header.width,
374 columns ? y + header.height : y,
375 );
377​
378/** A one-pixel line centred on its coordinates, as the display list draws gridlines, so the two meet. */
379function line(
380 context: CanvasRenderingContext2D,
381 colour: string,
382 x1: number,
383 y1: number,
384 x2: number,
385 y2: number,
386) {
387 context.strokeStyle = colour;
388 context.lineWidth = 1;
389 context.beginPath();
390 context.moveTo(x1, y1);
391 context.lineTo(x2, y2);
392 context.stroke();
394​
395function sameRect(a: { x: number; y: number; w: number; h: number }, b: typeof a | null) {
396 return !!b && a.x === b.x && a.y === b.y && a.w === b.w && a.h === b.h;