Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: fix print off-by-one, wire drag-fill handle, copy-as-formula, OG tags

- Fix printSheet() 0-based loops: cell IDs are 1-based, so colToLetter(0)
returns "" and cellId(0,0) returns "0". Loops now start at 1.
- Wire drag-fill.ts into UI: fill handle on selection, vertical drag-to-fill
with pattern detection (numbers, dates, formulas, text repeat), auto-scroll,
undo via ydoc.transact().
- Add "Copy as reference/SUM/AVERAGE/COUNT" to cell context menu when a
multi-cell range is selected.
- Add Open Graph meta tags to all HTML pages for link preview support.
- Rename repo from crypt to tools (Gitea, remote URL, CLAUDE.md).
- Add print indexing regression tests.

Closes #251

+284 -6
+38
src/css/app.css
··· 2706 2706 .sheet-grid td.range-left { border-left: 2px solid var(--color-teal); } 2707 2707 .sheet-grid td.range-right { border-right: 2px solid var(--color-teal); } 2708 2708 2709 + /* --- Drag-to-Fill Handle --- */ 2710 + .sheet-grid td.has-fill-handle { 2711 + overflow: visible; 2712 + } 2713 + .fill-handle { 2714 + position: absolute; 2715 + bottom: -4px; 2716 + right: -4px; 2717 + width: 7px; 2718 + height: 7px; 2719 + background: var(--color-teal); 2720 + cursor: crosshair; 2721 + z-index: 6; 2722 + border: 1px solid #fff; 2723 + pointer-events: auto; 2724 + } 2725 + [data-theme="dark"] .fill-handle { 2726 + border-color: var(--color-bg); 2727 + } 2728 + @media (prefers-color-scheme: dark) { 2729 + :root:not([data-theme="light"]) .fill-handle { 2730 + border-color: var(--color-bg); 2731 + } 2732 + } 2733 + .sheet-grid td.fill-preview { 2734 + background: oklch(0.92 0.04 195); 2735 + outline: 1px dashed var(--color-teal); 2736 + outline-offset: -1px; 2737 + } 2738 + [data-theme="dark"] .sheet-grid td.fill-preview { 2739 + background: oklch(0.25 0.04 195); 2740 + } 2741 + @media (prefers-color-scheme: dark) { 2742 + :root:not([data-theme="light"]) .sheet-grid td.fill-preview { 2743 + background: oklch(0.25 0.04 195); 2744 + } 2745 + } 2746 + 2709 2747 /* Header hover states */ 2710 2748 .sheet-grid th[data-col]:hover, 2711 2749 .sheet-grid th.row-header:hover {
+5
src/docs/index.html
··· 3 3 <head> 4 4 <meta charset="UTF-8"> 5 5 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 + <meta name="description" content="E2EE collaborative document editor. End-to-end encrypted, real-time collaboration."> 7 + <meta property="og:title" content="Tools — Docs"> 8 + <meta property="og:description" content="E2EE collaborative document editor. End-to-end encrypted, real-time collaboration."> 9 + <meta property="og:type" content="website"> 10 + <meta property="og:image" content="/favicon.svg"> 6 11 <title>Tools — Docs</title> 7 12 <link rel="icon" type="image/svg+xml" href="/favicon.svg"> 8 13 <link rel="stylesheet" href="../css/app.css">
+5
src/index.html
··· 3 3 <head> 4 4 <meta charset="UTF-8"> 5 5 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 + <meta name="description" content="E2EE collaborative docs and sheets. End-to-end encrypted, real-time collaboration."> 7 + <meta property="og:title" content="Tools — Encrypted Office"> 8 + <meta property="og:description" content="E2EE collaborative docs and sheets. End-to-end encrypted, real-time collaboration."> 9 + <meta property="og:type" content="website"> 10 + <meta property="og:image" content="/favicon.svg"> 6 11 <title>Tools — Encrypted Office</title> 7 12 <link rel="icon" type="image/svg+xml" href="/favicon.svg"> 8 13 <link rel="stylesheet" href="./css/app.css">
+5
src/sheets/index.html
··· 3 3 <head> 4 4 <meta charset="UTF-8"> 5 5 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 + <meta name="description" content="E2EE collaborative spreadsheet. End-to-end encrypted, real-time collaboration."> 7 + <meta property="og:title" content="Tools — Sheets"> 8 + <meta property="og:description" content="E2EE collaborative spreadsheet. End-to-end encrypted, real-time collaboration."> 9 + <meta property="og:type" content="website"> 10 + <meta property="og:image" content="/favicon.svg"> 6 11 <title>Tools — Sheets</title> 7 12 <link rel="icon" type="image/svg+xml" href="/favicon.svg"> 8 13 <link rel="stylesheet" href="../css/app.css">
+197 -6
src/sheets/main.ts
··· 41 41 import { computeVisibleRows, computeVisibleCols, hiddenRowsSpacerAdjustment, getAdjacentHiddenRows, getAdjacentHiddenCols, isAtHiddenRowBoundary, isAtHiddenColBoundary } from './hidden-rows-cols.js'; 42 42 import { createFindState, findInCells, nextMatch, prevMatch, replaceCurrentMatch, replaceAllMatches, getMatchInfo, isCellMatch, isCurrentMatch } from './sheets-find-replace.js'; 43 43 import { isSparklineResult, drawSparkline } from './sparkline.js'; 44 + import { detectPattern, generateFillValues, adjustFormulaRef, PATTERN_TYPES } from './drag-fill.js'; 44 45 import { buildSheetsPrintHtml } from '../lib/print-layout.js'; 45 46 import type { PrintCell, PrintRow, SheetsPrintData, SheetsPrintOptions } from '../lib/print-layout.js'; 46 47 import { ··· 315 316 let isSelecting = false; 316 317 let formatPainterFormat = null; 317 318 let formatPainterSticky = false; 319 + let isFillDragging = false; 320 + let fillPreviewRange = null; 318 321 319 322 // --- Find & Replace state --- 320 323 let sheetsFindState = createFindState(); ··· 1169 1172 } 1170 1173 return; 1171 1174 } 1175 + // Fill handle drag 1176 + const fillHandle = e.target.closest('.fill-handle'); 1177 + if (fillHandle) { 1178 + e.preventDefault(); 1179 + e.stopPropagation(); 1180 + startFillDrag(e); 1181 + return; 1182 + } 1172 1183 const handle = e.target.closest('.col-resize-handle'); 1173 1184 if (handle) { 1174 1185 e.preventDefault(); ··· 1412 1423 document.addEventListener('mouseup', onMouseUp); 1413 1424 } 1414 1425 1426 + // --- Drag-to-Fill --- 1427 + 1428 + function startFillDrag(e) { 1429 + if (!selectionRange && !selectedCell) return; 1430 + isFillDragging = true; 1431 + 1432 + const sourceRange = selectionRange 1433 + ? normalizeRange(selectionRange) 1434 + : { startCol: selectedCell.col, startRow: selectedCell.row, endCol: selectedCell.col, endRow: selectedCell.row }; 1435 + 1436 + let _fillScrollTimer = null; 1437 + const SCROLL_EDGE = 40; 1438 + const SCROLL_SPEED = 8; 1439 + 1440 + const onMouseMove = (ev) => { 1441 + const moveTd = ev.target.closest('td[data-id]'); 1442 + if (moveTd) { 1443 + const targetRow = parseInt(moveTd.dataset.row); 1444 + // Vertical only: determine fill direction 1445 + if (targetRow > sourceRange.endRow) { 1446 + fillPreviewRange = { startCol: sourceRange.startCol, startRow: sourceRange.endRow + 1, endCol: sourceRange.endCol, endRow: targetRow }; 1447 + } else if (targetRow < sourceRange.startRow) { 1448 + fillPreviewRange = { startCol: sourceRange.startCol, startRow: targetRow, endCol: sourceRange.endCol, endRow: sourceRange.startRow - 1 }; 1449 + } else { 1450 + fillPreviewRange = null; 1451 + } 1452 + updateFillPreviewVisuals(); 1453 + } 1454 + 1455 + // Auto-scroll near edges 1456 + const container = sheetContainer; 1457 + const rect = container.getBoundingClientRect(); 1458 + const nearBottom = ev.clientY > rect.bottom - SCROLL_EDGE; 1459 + const nearTop = ev.clientY < rect.top + SCROLL_EDGE; 1460 + if (nearBottom || nearTop) { 1461 + if (!_fillScrollTimer) { 1462 + _fillScrollTimer = setInterval(() => { 1463 + container.scrollTop += nearBottom ? SCROLL_SPEED : -SCROLL_SPEED; 1464 + }, 16); 1465 + } 1466 + } else if (_fillScrollTimer) { 1467 + clearInterval(_fillScrollTimer); 1468 + _fillScrollTimer = null; 1469 + } 1470 + }; 1471 + 1472 + const onMouseUp = () => { 1473 + isFillDragging = false; 1474 + if (_fillScrollTimer) { clearInterval(_fillScrollTimer); _fillScrollTimer = null; } 1475 + document.removeEventListener('mousemove', onMouseMove); 1476 + document.removeEventListener('mouseup', onMouseUp); 1477 + 1478 + if (fillPreviewRange) { 1479 + const targetRange = { ...fillPreviewRange }; 1480 + clearFillPreviewVisuals(); 1481 + fillPreviewRange = null; 1482 + executeFill(sourceRange, targetRange); 1483 + } else { 1484 + clearFillPreviewVisuals(); 1485 + fillPreviewRange = null; 1486 + } 1487 + }; 1488 + 1489 + document.addEventListener('mousemove', onMouseMove); 1490 + document.addEventListener('mouseup', onMouseUp); 1491 + } 1492 + 1493 + function updateFillPreviewVisuals() { 1494 + clearFillPreviewVisuals(); 1495 + if (!fillPreviewRange) return; 1496 + for (let r = fillPreviewRange.startRow; r <= fillPreviewRange.endRow; r++) { 1497 + for (let c = fillPreviewRange.startCol; c <= fillPreviewRange.endCol; c++) { 1498 + const td = getCellEl(c, r); 1499 + if (td) { 1500 + td.classList.add('fill-preview'); 1501 + prevSelectionEls.push(td); 1502 + } 1503 + } 1504 + } 1505 + } 1506 + 1507 + function clearFillPreviewVisuals() { 1508 + grid.querySelectorAll('.fill-preview').forEach(el => el.classList.remove('fill-preview')); 1509 + } 1510 + 1511 + function executeFill(sourceRange, targetRange) { 1512 + const direction = targetRange.startRow > sourceRange.endRow ? 'forward' : 'backward'; 1513 + const fillCount = targetRange.endRow - targetRange.startRow + 1; 1514 + if (fillCount <= 0) return; 1515 + 1516 + ydoc.transact(() => { 1517 + for (let c = sourceRange.startCol; c <= sourceRange.endCol; c++) { 1518 + // Collect source values for this column 1519 + const sourceValues = []; 1520 + for (let r = sourceRange.startRow; r <= sourceRange.endRow; r++) { 1521 + const id = cellId(c, r); 1522 + const cellData = getCellData(id); 1523 + if (cellData?.f) { 1524 + sourceValues.push({ f: cellData.f, v: cellData.v }); 1525 + } else if (cellData?.v !== undefined && cellData?.v !== '') { 1526 + sourceValues.push(cellData.v); 1527 + } else { 1528 + sourceValues.push(''); 1529 + } 1530 + } 1531 + 1532 + const pattern = detectPattern(sourceValues); 1533 + const fillValues = generateFillValues(sourceValues, pattern, fillCount, direction); 1534 + 1535 + for (let i = 0; i < fillCount; i++) { 1536 + const targetRow = targetRange.startRow + i; 1537 + const id = cellId(c, targetRow); 1538 + 1539 + if (pattern.type === PATTERN_TYPES.FORMULA_ADJUST) { 1540 + // For formulas, adjust references based on row offset 1541 + const sourceIdx = i % (sourceRange.endRow - sourceRange.startRow + 1); 1542 + const sourceRow = sourceRange.startRow + sourceIdx; 1543 + const sourceId = cellId(c, sourceRow); 1544 + const sourceCellData = getCellData(sourceId); 1545 + if (sourceCellData?.f) { 1546 + const dRow = targetRow - sourceRow; 1547 + const newFormula = adjustFormulaRef(sourceCellData.f, 0, dRow); 1548 + setCellData(id, { f: newFormula, v: '' }); 1549 + } 1550 + } else { 1551 + const val = fillValues[i]; 1552 + setCellData(id, { v: val, f: '' }); 1553 + } 1554 + } 1555 + } 1556 + }); 1557 + 1558 + evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine(); 1559 + refreshVisibleCells(); 1560 + 1561 + // Extend selection to include filled area 1562 + const newEndRow = Math.max(sourceRange.endRow, targetRange.endRow); 1563 + const newStartRow = Math.min(sourceRange.startRow, targetRange.startRow); 1564 + selectionRange = { startCol: sourceRange.startCol, startRow: newStartRow, endCol: sourceRange.endCol, endRow: newEndRow }; 1565 + updateSelectionVisuals(); 1566 + updateFormulaBar(); 1567 + showToast(`Filled ${fillCount} cell${fillCount > 1 ? 's' : ''}`); 1568 + } 1569 + 1415 1570 function onCellDblClick(e) { 1416 1571 const td = e.target.closest('td[data-id]'); 1417 1572 if (!td) return; ··· 1988 2143 1989 2144 // --- Visual updates (#18: improved range selection) --- 1990 2145 // Track previously styled elements to avoid full-grid querySelectorAll on every selection change 1991 - const selectionClasses = ['selected', 'in-range', 'range-top', 'range-bottom', 'range-left', 'range-right', 'col-selected', 'row-selected'] as const; 2146 + const selectionClasses = ['selected', 'in-range', 'range-top', 'range-bottom', 'range-left', 'range-right', 'col-selected', 'row-selected', 'has-fill-handle', 'fill-preview'] as const; 1992 2147 let prevSelectionEls: Element[] = []; 1993 2148 1994 2149 function clearPrevSelection() { ··· 2043 2198 cellAddressInput.value = cellId(startCol, startRow) + ':' + cellId(endCol, endRow); 2044 2199 } 2045 2200 } 2201 + // Render fill handle on bottom-right cell of selection 2202 + const existingHandle = grid.querySelector('.fill-handle'); 2203 + if (existingHandle) existingHandle.remove(); 2204 + if (selectionRange) { 2205 + const { endCol: brCol, endRow: brRow } = normalizeRange(selectionRange); 2206 + const brTd = getCellEl(brCol, brRow); 2207 + if (brTd) { 2208 + brTd.classList.add('has-fill-handle'); 2209 + const handle = document.createElement('div'); 2210 + handle.className = 'fill-handle'; 2211 + (brTd as HTMLElement).appendChild(handle); 2212 + } 2213 + } else { 2214 + const curTd = getCellEl(selectedCell.col, selectedCell.row); 2215 + if (curTd) { 2216 + curTd.classList.add('has-fill-handle'); 2217 + const handle = document.createElement('div'); 2218 + handle.className = 'fill-handle'; 2219 + (curTd as HTMLElement).appendChild(handle); 2220 + } 2221 + } 2222 + 2046 2223 updateStatusBar(); 2047 2224 } 2048 2225 ··· 3367 3544 if (ref.col + 1 > maxCol) maxCol = ref.col + 1; 3368 3545 } 3369 3546 }); 3370 - maxRow = Math.max(maxRow, 1); 3371 - maxCol = Math.max(maxCol, 1); 3547 + maxRow = Math.max(maxRow, 2); 3548 + maxCol = Math.max(maxCol, 2); 3372 3549 3373 3550 // Column headers 3374 3551 const headers: string[] = []; 3375 3552 const colWidths: number[] = []; 3376 - for (let c = 0; c < maxCol; c++) { 3553 + for (let c = 1; c < maxCol; c++) { 3377 3554 if (isColHidden(c)) continue; 3378 3555 headers.push(colToLetter(c)); 3379 3556 colWidths.push(getColWidth(c)); ··· 3381 3558 3382 3559 // Build row data 3383 3560 const rows: PrintRow[] = []; 3384 - for (let r = 0; r < maxRow; r++) { 3561 + for (let r = 1; r < maxRow; r++) { 3385 3562 if (isRowHidden(r)) continue; 3386 3563 const rowCells: (PrintCell | null)[] = []; 3387 - for (let c = 0; c < maxCol; c++) { 3564 + for (let c = 1; c < maxCol; c++) { 3388 3565 if (isColHidden(c)) continue; 3389 3566 const id = cellId(c, r); 3390 3567 const mergeInfo = mergeMap.get(id); ··· 5060 5237 { label: 'Paste', icon: '\uD83D\uDCCB', shortcut: '\u2318V', action: () => { navigator.clipboard.readText().then(text => pasteAtSelection(text)).catch(() => {}); } }, 5061 5238 { label: 'Paste Special...', shortcut: '\u2318\u21e7V', action: () => showPasteSpecialDialog() }, 5062 5239 SEPARATOR, 5240 + // Copy range as formula reference (only useful with multi-cell selection) 5241 + ...(selectionRange && (() => { 5242 + const nr = normalizeRange(selectionRange); 5243 + const isMulti = nr.startCol !== nr.endCol || nr.startRow !== nr.endRow; 5244 + if (!isMulti) return []; 5245 + const rangeRef = cellId(nr.startCol, nr.startRow) + ':' + cellId(nr.endCol, nr.endRow); 5246 + return [ 5247 + { label: 'Copy as reference', icon: 'f\u2099', action: () => { navigator.clipboard.writeText(rangeRef); showToast('Copied ' + rangeRef); } }, 5248 + { label: 'Copy as =SUM()', action: () => { navigator.clipboard.writeText('=SUM(' + rangeRef + ')'); showToast('Copied =SUM(' + rangeRef + ')'); } }, 5249 + { label: 'Copy as =AVERAGE()', action: () => { navigator.clipboard.writeText('=AVERAGE(' + rangeRef + ')'); showToast('Copied =AVERAGE(' + rangeRef + ')'); } }, 5250 + { label: 'Copy as =COUNT()', action: () => { navigator.clipboard.writeText('=COUNT(' + rangeRef + ')'); showToast('Copied =COUNT(' + rangeRef + ')'); } }, 5251 + SEPARATOR, 5252 + ]; 5253 + })() || []), 5063 5254 { label: 'Insert Row Above', action: () => doInsertRow(row) }, 5064 5255 { label: 'Insert Row Below', action: () => doInsertRow(row + 1) }, 5065 5256 { label: 'Insert Column Left', action: () => doInsertColumn(col) },
+34
tests/sheets-print.test.ts
··· 5 5 buildSheetsPrintHtml, 6 6 } from '../src/lib/print-layout.js'; 7 7 import type { SheetsPrintData, SheetsPrintOptions } from '../src/lib/print-layout.js'; 8 + import { colToLetter, cellId } from '../src/sheets/formulas.js'; 8 9 9 10 // ---- HTML structure ---- 10 11 ··· 390 391 expect(html).toContain('A &amp; B'); 391 392 }); 392 393 }); 394 + 395 + // ---- Print indexing invariants (1-based cell IDs) ---- 396 + 397 + describe('print indexing invariants', () => { 398 + it('colToLetter(0) returns empty string (invalid)', () => { 399 + expect(colToLetter(0)).toBe(''); 400 + }); 401 + 402 + it('colToLetter(1) returns "A"', () => { 403 + expect(colToLetter(1)).toBe('A'); 404 + }); 405 + 406 + it('colToLetter(26) returns "Z"', () => { 407 + expect(colToLetter(26)).toBe('Z'); 408 + }); 409 + 410 + it('colToLetter(27) returns "AA"', () => { 411 + expect(colToLetter(27)).toBe('AA'); 412 + }); 413 + 414 + it('cellId(1, 1) returns "A1"', () => { 415 + expect(cellId(1, 1)).toBe('A1'); 416 + }); 417 + 418 + it('cellId(0, 0) returns "0" (invalid — no column letter)', () => { 419 + // Documents the bug: 0-based indices produce invalid cell IDs 420 + expect(cellId(0, 0)).toBe('0'); 421 + }); 422 + 423 + it('cellId(3, 5) returns "C5"', () => { 424 + expect(cellId(3, 5)).toBe('C5'); 425 + }); 426 + });