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

Configure Feed

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

Merge pull request 'feat(sheets): touch events, share dialog state, dark mode & mobile fixes' (#90) from feat/ux-polish-batch into main

scott 8bc459bd aa29762e

+199 -5
+11
CHANGELOG.md
··· 7 7 8 8 ## [Unreleased] 9 9 10 + ## [0.9.2] — 2026-03-22 11 + 12 + ### Added 13 + - **Touch support for sheets grid** (#148): tap to select cells, drag to select ranges, double-tap to edit, long-press for context menu, touch-based column/row resize 14 + - **Share dialog shows current state** (#151): share mode and link expiry dropdowns now reflect saved server values when opened 15 + 16 + ### Fixed 17 + - **Dark mode dropdown menus**: toolbar dropdown and overflow menus use `--color-surface` instead of `--color-bg` for proper dark mode contrast (#199) 18 + - **Mobile toolbar button spacing**: increased gap from 2px to 4px for easier touch targeting (#199) 19 + - **Formula bar mobile overflow**: formula input now scrolls horizontally on mobile instead of wrapping (#199) 20 + 10 21 ## [0.9.1] — 2026-03-22 11 22 12 23 ### Changed
+1 -1
package.json
··· 1 1 { 2 2 "name": "tools", 3 - "version": "0.9.1", 3 + "version": "0.9.2", 4 4 "private": true, 5 5 "type": "module", 6 6 "scripts": {
+7 -4
src/css/app.css
··· 2338 2338 min-width: 10rem; 2339 2339 padding: var(--space-xs) 0; 2340 2340 margin-top: 2px; 2341 - background: var(--color-bg); 2341 + background: var(--color-surface); 2342 2342 border: 1px solid var(--color-border); 2343 2343 border-radius: var(--radius-md); 2344 2344 box-shadow: 0 4px 16px oklch(0.22 0.02 55 / 0.12), 0 1px 3px oklch(0.22 0.02 55 / 0.06); ··· 2428 2428 min-width: 13rem; 2429 2429 padding: var(--space-xs) 0; 2430 2430 margin-top: 2px; 2431 - background: var(--color-bg); 2431 + background: var(--color-surface); 2432 2432 border: 1px solid var(--color-border); 2433 2433 border-radius: var(--radius-md); 2434 2434 box-shadow: var(--shadow-md); ··· 4274 4274 min-height: 44px; 4275 4275 } 4276 4276 4277 - /* Toolbar allow wrapping */ 4277 + /* Toolbar allow wrapping with wider gap for touch */ 4278 4278 .toolbar.gdocs-toolbar { 4279 4279 height: auto; 4280 4280 flex-wrap: wrap; 4281 4281 padding: var(--space-xs); 4282 + gap: 4px; 4282 4283 } 4283 4284 4284 4285 /* Editor full-width on mobile */ ··· 4301 4302 .formula-input, 4302 4303 .formula-input-wrap { 4303 4304 min-width: 0; 4304 - flex: 1; 4305 + width: 100%; 4306 + flex: 1 1 100%; 4305 4307 font-size: 0.9rem; 4308 + overflow-x: auto; 4306 4309 } 4307 4310 4308 4311 /* Sheet container: horizontal scroll with sticky column A */
+16
src/lib/share-dialog.ts
··· 108 108 shareModeSelect.value = doc.share_mode; 109 109 updateShareLink(); 110 110 } 111 + if (shareExpiry && doc.expires_at) { 112 + // Find the closest matching expiry option based on remaining time 113 + const remaining = new Date(doc.expires_at as string).getTime() - Date.now(); 114 + if (remaining > 0) { 115 + const sorted = Object.entries(expiryDurations).sort((a, b) => a[1] - b[1]); 116 + let best = 'none'; 117 + for (const [key, dur] of sorted) { 118 + if (remaining <= dur * 1.1) { best = key; break; } 119 + } 120 + shareExpiry.value = best; 121 + } else { 122 + shareExpiry.value = 'none'; 123 + } 124 + } else if (shareExpiry) { 125 + shareExpiry.value = 'none'; 126 + } 111 127 }) 112 128 .catch(() => { /* ignore */ }); 113 129 });
+164
src/sheets/main.ts
··· 813 813 function attachGridEvents() { 814 814 grid.addEventListener('mousedown', onGridMouseDown); 815 815 grid.addEventListener('dblclick', onGridDblClick); 816 + // Touch support for mobile/tablet (#148) 817 + grid.addEventListener('touchstart', onGridTouchStart, { passive: false }); 816 818 } 819 + 820 + // --- Touch event support (#148) --- 821 + let _touchTimer: ReturnType<typeof setTimeout> | null = null; 822 + let _touchMoved = false; 823 + let _touchStartCell: { col: number; row: number } | null = null; 824 + 825 + function onGridTouchStart(e: TouchEvent) { 826 + if (e.touches.length !== 1) return; 827 + const touch = e.touches[0]; 828 + const target = document.elementFromPoint(touch.clientX, touch.clientY) as HTMLElement; 829 + if (!target) return; 830 + 831 + // Handle col/row resize handles 832 + const colHandle = target.closest('.col-resize-handle') as HTMLElement; 833 + if (colHandle) { 834 + e.preventDefault(); 835 + startColumnResizeTouch(colHandle, touch); 836 + return; 837 + } 838 + const rowHandle = target.closest('.row-resize-handle') as HTMLElement; 839 + if (rowHandle) { 840 + e.preventDefault(); 841 + startRowResizeTouch(rowHandle, touch); 842 + return; 843 + } 844 + 845 + // Cell selection 846 + const td = target.closest('td[data-id]') as HTMLElement; 847 + if (!td) return; 848 + 849 + _touchMoved = false; 850 + _touchStartCell = { col: parseInt(td.dataset.col!), row: parseInt(td.dataset.row!) }; 851 + 852 + // Long-press opens context menu (500ms) 853 + _touchTimer = setTimeout(() => { 854 + _touchTimer = null; 855 + if (!_touchMoved && _touchStartCell) { 856 + const contextEvent = new MouseEvent('contextmenu', { 857 + clientX: touch.clientX, 858 + clientY: touch.clientY, 859 + bubbles: true, 860 + }); 861 + td.dispatchEvent(contextEvent); 862 + } 863 + }, 500); 864 + 865 + if (editingCell) commitEdit(); 866 + selectedCell = { col: _touchStartCell.col, row: _touchStartCell.row }; 867 + selectionRange = { startCol: _touchStartCell.col, startRow: _touchStartCell.row, endCol: _touchStartCell.col, endRow: _touchStartCell.row }; 868 + isSelecting = true; 869 + updateSelectionVisuals(); 870 + updateFormulaBar(); 871 + 872 + const onTouchMove = (ev: TouchEvent) => { 873 + _touchMoved = true; 874 + if (_touchTimer) { clearTimeout(_touchTimer); _touchTimer = null; } 875 + if (ev.touches.length !== 1) return; 876 + ev.preventDefault(); 877 + const t = ev.touches[0]; 878 + const el = document.elementFromPoint(t.clientX, t.clientY) as HTMLElement; 879 + if (!el) return; 880 + const moveTd = el.closest('td[data-id]') as HTMLElement; 881 + if (moveTd) { 882 + selectionRange.endCol = parseInt(moveTd.dataset.col!); 883 + selectionRange.endRow = parseInt(moveTd.dataset.row!); 884 + updateSelectionVisuals(); 885 + } 886 + }; 887 + 888 + const onTouchEnd = () => { 889 + if (_touchTimer) { clearTimeout(_touchTimer); _touchTimer = null; } 890 + isSelecting = false; 891 + updateMergeButtonState(); 892 + document.removeEventListener('touchmove', onTouchMove); 893 + document.removeEventListener('touchend', onTouchEnd); 894 + document.removeEventListener('touchcancel', onTouchEnd); 895 + }; 896 + 897 + document.addEventListener('touchmove', onTouchMove, { passive: false }); 898 + document.addEventListener('touchend', onTouchEnd); 899 + document.addEventListener('touchcancel', onTouchEnd); 900 + } 901 + 902 + // Touch-based column resize 903 + function startColumnResizeTouch(handle: HTMLElement, touch: Touch) { 904 + const col = parseInt(handle.dataset.resizeCol!); 905 + const startX = touch.clientX; 906 + const startWidth = getColWidth(col); 907 + handle.classList.add('active'); 908 + 909 + const onTouchMove = (ev: TouchEvent) => { 910 + if (ev.touches.length !== 1) return; 911 + ev.preventDefault(); 912 + }; 913 + 914 + const onTouchEnd = (ev: TouchEvent) => { 915 + const endTouch = ev.changedTouches[0]; 916 + const delta = endTouch.clientX - startX; 917 + const newWidth = Math.max(MIN_COL_WIDTH, startWidth + delta); 918 + setColWidth(col, newWidth); 919 + handle.classList.remove('active'); 920 + renderGrid(); 921 + document.removeEventListener('touchmove', onTouchMove); 922 + document.removeEventListener('touchend', onTouchEnd); 923 + document.removeEventListener('touchcancel', onTouchEnd); 924 + }; 925 + 926 + document.addEventListener('touchmove', onTouchMove, { passive: false }); 927 + document.addEventListener('touchend', onTouchEnd); 928 + document.addEventListener('touchcancel', onTouchEnd); 929 + } 930 + 931 + // Touch-based row resize 932 + function startRowResizeTouch(handle: HTMLElement, touch: Touch) { 933 + const row = parseInt(handle.dataset.resizeRow!); 934 + const startY = touch.clientY; 935 + const startHeight = getRowHeight(row); 936 + handle.classList.add('active'); 937 + 938 + const onTouchMove = (ev: TouchEvent) => { 939 + if (ev.touches.length !== 1) return; 940 + ev.preventDefault(); 941 + }; 942 + 943 + const onTouchEnd = (ev: TouchEvent) => { 944 + const endTouch = ev.changedTouches[0]; 945 + const delta = endTouch.clientY - startY; 946 + const newHeight = Math.max(14, startHeight + delta); 947 + setRowHeight(row, newHeight); 948 + handle.classList.remove('active'); 949 + renderGrid(); 950 + document.removeEventListener('touchmove', onTouchMove); 951 + document.removeEventListener('touchend', onTouchEnd); 952 + document.removeEventListener('touchcancel', onTouchEnd); 953 + }; 954 + 955 + document.addEventListener('touchmove', onTouchMove, { passive: false }); 956 + document.addEventListener('touchend', onTouchEnd); 957 + document.addEventListener('touchcancel', onTouchEnd); 958 + } 959 + 960 + // Double-tap to edit cell 961 + let _lastTapTime = 0; 962 + let _lastTapCell = ''; 963 + grid.addEventListener('touchend', (e: TouchEvent) => { 964 + if (_touchMoved) return; 965 + const touch = e.changedTouches[0]; 966 + const el = document.elementFromPoint(touch.clientX, touch.clientY) as HTMLElement; 967 + if (!el) return; 968 + const td = el.closest('td[data-id]') as HTMLElement; 969 + if (!td) return; 970 + const now = Date.now(); 971 + const cellKey = td.dataset.id!; 972 + if (now - _lastTapTime < 400 && cellKey === _lastTapCell) { 973 + startEditing(parseInt(td.dataset.col!), parseInt(td.dataset.row!)); 974 + _lastTapTime = 0; 975 + _lastTapCell = ''; 976 + } else { 977 + _lastTapTime = now; 978 + _lastTapCell = cellKey; 979 + } 980 + }); 817 981 818 982 function onGridMouseDown(e) { 819 983 // Click hidden-row indicator to unhide