This page lists files in the current directory. You can view content, get download/execute commands for Wget, Curl, or PowerShell, or filter the list using wildcards (e.g., `*.sh`).
wget 'https://sme10.lists2.roe3.org/spreadsheet/LICENSE'
MIT License
Copyright (c) 2026 Supun Lakmal Abesekara
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
wget 'https://sme10.lists2.roe3.org/spreadsheet/README.md'
# Spreadsheet

A lightweight, client-only spreadsheet web application. All data persists in the URL hash for instant sharing—no backend required. Optional AES-GCM password protection keeps shared links locked without a server.



## Demo Links
- [Open example](https://spreadsheetlive.netlify.app/#N4IgTiBcCMCcA0IDGUAciAuUQBMCGYA1iIjlANrkAM8NIACgKZgDOA9gHZ4A2ABACp4AHrwDCPJAFdueDAEtOvABQB5egFEAcgEoQAXXjkATLUQBZThgAW3AJ68AkhyRsAto32GT0RL14AWKhogqgA6IN5PcgBmUxAATUYCO0dnNw8DGPgfED9-eFQg2iDwqkjMgFY4gDE2MEY5AHMOXgBlNkkwJEYWKIA2OM1GAHcBYV4AJVlGXgBaXmgKgFIogHY4oyojCoB6TaM+tezfBYKikNLyw3WTXN5YkOKwiKPYkEWVzPX8u-8K4JKLy+8CqIGiq3+VCi0GCiE0dVcPF4TFYnCRgiE0J8OT8PkKAOeZSx8Fufge50BRMy0B8byon0MNPgPz88z80O8Jx8jwuQMZJlJvBMPMpV3I0BMbz6DPFJhZvD6BMu0NiON4kKeyupsUFPg1vKpjNib2gqBl0Fi8tgSr54vyav1ouh+V1T01tug+TeRn85vy8olNsN4qqDophLFixJJ0dEehVTe0Xp8eZXP+7uD0AGgtjWsZA0T0uhAwDhQzkfWdH4bAw6OE0O+J0VQfKBhAvUglFhoBQkBAAGIAGaDkJQxAAL2wnpAAF9KqZQAAjbBDweMRhBEjIFejzdztvDChGaIIGE0M-ZIoX69XoJ6GdAA)
- [Encrypted example](https://spreadsheetlive.netlify.app/#ENC:JdpgBVSwRexyozWBU7kKmrSS8rmcirmh6S9ljc_F9GafTzv1JHdgxl0uDHQiy7Xi1_lMahO87faWio0gS_nkYrMb4Vea6YtXhDKvbaTN4lt3BRiE6_DcKb9PWqXKi9_5cz-apyepJKZ0rSqtBfT9RXmMqtDVhPaHeNS33tJXhhn-mM34UVr8Vb5IgLqFo3lv-2wppfhurJUnRyEDK49ybJiLI72lpo93_3-o2UxRikeZ98G_Wce1-CtHJYgPCl5eZ3ILX_Vu94zTVdigCFIR5GTvhsfbp7nMEe6BqurTsTSdpiDyQ_6FOMlUMpGjDGqZDlkMMYv_4KfD6GcqNvihxA0Fcgrxty9YNhSaW2KxC9V0wPEKYIutKLaIjHmnS8jO8Lyr5mCy4IKI5Yoji-gT6obHAq3Ln389Mg_9FPDYDQoU71D-O9NDthfPjR7LoujRKtcEpU7kDc_qSaSzCWYWG5o6YFC_Y_NcTLroeI3T6sFQXwU9Safjeb9WKWgxVnXWZr3mD5KBv-f0er7wlbWT_H8S1cw9WqIsbpQtfOZkahexy8ZDbLdB-SXXSqjJl18s08nlx7n4RNVitKo2VYbnV4jEE5lYKLApYMEYiS8EvDRFEu0L-9KmhtaeQ_hhXVrj_qiVznHHxpTwaxlPgG67WFfz-zz4-5RznFxjVRlMIaJkuxkcSbebBFqK_oNZ0-MwvNRvUnkC0z-opXjFH_B0mlTCu62hylMObNP6UQ7bzgxgC6hZ-loGx03i8hN0IuXIJ7oeTUa9hpJjxY5mDig2T83PkfEQyyRzIle4L7Ije92qxqvAmsoGUrbUIUzHe8S1iY6AOSKxHL2RZ6KRyBE2APci2Z1ytRpgyuiT_QQXwSws0TZO9DdeJ6ENkWxRPNSOi1tb8v89-ZG-GZqJRnRIomxF_Wiq5cZQJrOecYCJZB6GD8EYQNuJy99LmDsOtw)
- Password: `A2P7peq8aVixgB2`
## Screenshots
- Default grid (unencrypted):

- Password prompt before encrypting:

- Unlocking an encrypted link:

- Raw JSON (AI bridge) modal:

- Embed code generator (read-only iframe):

- QR code for mobile handoff:

- Live Collaboration:

## Features
### Core Functionality
- **Client-only** - All state saved in URL hash; no backend required
- **Compressed URL State** - LZ-String compression keeps share links short
- **Instant Sharing** - Copy URL to share your spreadsheet with anyone
- **Dynamic Grid** - Expandable grid; add as many rows/columns as you need
- **Arrow Key Navigation** - Move the active cell with arrow keys; Shift+Arrow expands selection
- **Persistent History** - Browser back/forward buttons restore previous states
### Text & Cell Styling
- **Bold** (Ctrl+B) - Apply bold formatting to selected text
- **Italic** (Ctrl+I) - Apply italic formatting to selected text
- **Underline** (Ctrl+U) - Apply underline formatting to selected text
- **Alignment** - Left/center/right alignment per cell or selection
- **Font Size** - Quick size buttons (Auto, 10-24px)
- **Cell Colors** - Background and text color pickers
- **Style Persistence** - Alignment, colors, and sizes saved in the URL
- Sanitized formatting preserved in cell content (B/I/U/STRONG/EM/SPAN with safe styles)
### Multi-Cell Selection (Google Sheets Style)
- **Click & Drag** - Select rectangular ranges by dragging
- **Shift+Click** - Extend selection from anchor point
- **Shift+Arrow** - Extend selection with the keyboard
- **Visual Feedback** - Selected cells highlighted with blue background
- **Border Outline** - Blue border around selection edges
- **Header Highlighting** - Row/column headers highlight for selected range
- **Hover Highlighting** - Row/column hover highlights for quick scanning
- **Escape to Clear** - Press Escape to deselect
- **Selection Stats** - Status bar shows count, sum, and average for numeric ranges
### Grid Management
- **Add Row** - Expand grid rows as needed
- **Add Column** - Expand grid columns as needed
- **Resize Rows/Columns** - Drag header handles to adjust sizes
- **Clear Spreadsheet** - Reset to empty 10x10 grid with confirmation
- **Live Grid Size** - Display shows current dimensions
### Data Import/Export
- **CSV Import** - Load .csv files from the toolbar
- **CSV Export** - Download the current grid as `spreadsheet.csv`
- **Formula-aware Import** - SUM/AVG formulas are preserved; unsupported formulas are imported as text
### Sharing & Access Control
- **Copy Link** - One click to copy the compressed URL hash
- **Read-only Toggle** - Share view-only links that disable editing
- **Embed Mode** - Generate a read-only iframe snippet with a dedicated `embed` flag in the URL
- **QR Handoff** - Show a QR code to continue editing on mobile; warns if the URL is too long
- **URL Length Indicator** - Live character count with warning/caution/critical thresholds
### Live Collaboration (Beta)
- **Peer-to-peer via PeerJS** - Browser-to-browser sync using WebRTC data channels
- **TURN Server Support** - Metered.ca TURN servers for NAT traversal; credentials fetched securely via Netlify Function
- **1-to-1 only** - One host and one joiner at a time (extra peers are rejected)
- **Real-time Sync** - Cell edits, formulas, and cursor positions broadcast instantly
- **Initial State Transfer** - Host sends complete spreadsheet state when joiner connects
- **Remote Cursor Presence** - See where the other user is editing in real-time
- **Automatic Fallback** - Falls back to STUN-only if TURN unavailable (~80% success rate)
- **Formula Commit Sync** - Computed values broadcast when formulas are confirmed
- **CSP/network requirement** - Allow `unpkg.com`, `*.peerjs.com`, and `*.metered.live`
### Password Protection
- **One-click lock** - Set a password from the toolbar lock button; password never leaves the browser
- **AES-GCM 256 + PBKDF2** - 100k iterations with random salt/IV, stored as URL-safe Base64
- **ENC-prefixed URLs** - Encrypted hashes use `ENC:`; recipients must unlock via modal
- **Optional** - Unencrypted links continue to work exactly as before
### AI Bridge (Raw JSON)
- **Raw Data Modal** - View and copy the minified spreadsheet state as JSON for AI edits
- **Clipboard Ready** - Copy button for quick transfer to ChatGPT/Claude
- **Safe Export** - Uses sanitized/minified state (only non-default values) for shorter prompts
### Formula Support
- **SUM / AVG Functions** - Calculate totals or averages with `=SUM(A1:B5)` / `=AVG(A1:B5)`
- **Formula Autocomplete** - Dropdown suggestions appear when typing `=`
- **Range Selection** - Click/drag cells while editing to insert range references
- **Live Evaluation** - Formulas evaluate on Enter or when leaving the cell
- **Recalculation** - Dependent formulas update when referenced cells change
- **Error Handling** - Shows `#REF!` for invalid ranges, `#ERROR!` for unknown formulas
- **Shareable Formulas** - Formulas preserved in URL for sharing
### Visual Formula Dependency Tracer
- **Trace Logic Overlay** - Click the diagram button to draw SVG curves from sources to formulas
- **Range Awareness** - Ranges render from the center of the referenced block
- **Auto Redraw** - Updates on edits, scroll, resize, and grid changes
### Theme Support
- **Dark/Light Mode** - Toggle with sun/moon button
- **System Detection** - Respects OS dark mode preference
- **Persistent Theme** - Saved to localStorage and URL
- **Smooth Transitions** - Elegant theme switching animation
### Mobile & Touch Support
- **Touch Selection** - Drag-to-select works on touch devices
- **Responsive Design** - Adapts to all screen sizes
- **iOS Optimized** - 16px font prevents auto-zoom
- **Touch-Friendly** - Larger tap targets on mobile
### Accessibility
- **ARIA Labels** - Screen reader support (e.g., "Cell A1")
- **Keyboard Navigation** - Arrow keys move selection; formatting shortcuts supported
- **Tooltips** - Descriptive hints on all controls
- **Focus Management** - Proper focus handling
### Security & Privacy
- **Password-protected links** - AES-GCM encryption with PBKDF2 key derivation; tampering detection via GCM tag
- **HTML Sanitization** - DOMParser-based whitelist for tags and safe styles
- **Formula Validation** - Only SUM/AVG range syntax is accepted in formulas
- **Safe URL Parsing** - Prototype pollution guards and hash length checks
- **Style Guardrails** - CSS color validation for user-provided styles
- **Content Security Policy** - CSP restricts scripts/styles/imgs/fonts to trusted sources
- **Analytics Hygiene** - URL hash excluded from Google Analytics page tracking
## Quick Start
Open `index.html` directly in your browser, or use a local server:
```bash
npx serve .
```
## How It Works
Your spreadsheet state is stored entirely in the URL hash. The hash is LZ-String compressed JSON to keep links short, and only non-default data is included. When password protection is enabled, that compressed string is encrypted with AES-GCM (256-bit) using a PBKDF2-derived key (100k iterations, random salt/IV) and stored as URL-safe Base64 with an `ENC:` prefix. The password never leaves the browser; recipients must enter it to decrypt locally.
Example state (decompressed, before encryption):
```
{
"rows": 2,
"cols": 2,
"data": [["A1", "B1"], ["A2", "B2"]],
"formulas": [["", "=SUM(A1:A2)"]],
"cellStyles": [[{"align": "center", "bg": "#f5f5f5", "color": "#111", "fontSize": "14"}]],
"colWidths": [120, 100],
"rowHeights": [32, 32],
"theme": "light"
}
```
When you edit cells, the URL updates automatically (debounced at 200ms). Formulas are stored separately from displayed values, so both the results and the original formulas are preserved. Column widths, row heights, and cell styles are saved too. Incoming URL state is sanitized and validated (DOMParser whitelist, formula regex, safe JSON parsing), oversized hashes are rejected, and legacy uncompressed hashes are still supported.
## Usage
| Action | How |
| --------------------- | ----------------------------------------------------------- |
| Edit cell | Double-click a cell or click and start typing |
| Format text | Select text, click B/I/U buttons or use Ctrl+B/I/U |
| Align text | Click left/center/right alignment buttons |
| Set font size | Use size buttons (Auto, 10-24) |
| Set cell colors | Use background/text color pickers |
| Resize column/row | Drag header resize handles |
| Navigate cells | Arrow keys (when not editing) |
| Select range | Click and drag across cells |
| Extend selection | Shift+Click or Shift+Arrow |
| Clear selection | Press Escape |
| Add row | Click "+ Row" button |
| Add column | Click "+ Column" button |
| Clear all | Click "Clear" button (with confirmation) |
| Import CSV | Click import button and choose a .csv file |
| Export CSV | Click download button |
| Toggle read-only | Click the pen/eye icon to switch between edit and view-only |
| Generate embed code | Click the `</>` button to copy an iframe snippet |
| Open raw JSON (AI) | Click the file-code button to view/copy JSON |
| Copy URL | Click the copy button in the toolbar |
| Open on mobile (QR) | Click the QR button to show a scannable code |
| Enter formula | Type `=` followed by function (e.g., `=SUM(A1:B5)`) |
| Select formula range | Click/drag cells while editing a formula |
| Trace dependencies | Click the diagram/trace button in the toolbar |
| Share | Click copy button to copy URL |
| Lock with password | Click the lock icon (open) and set a password in the modal |
| Unlock encrypted link | Open the link, enter password in the modal to decrypt |
| Remove password | Click the lock icon (closed) and confirm removal |
| Toggle theme | Click sun/moon icon |
| Start P2P hosting | Tools → Live Collaboration → Start Hosting |
| Join P2P session | Tools → Live Collaboration → Enter Host ID → Join |
## Keyboard Shortcuts
| Shortcut | Action |
| -------------------------------- | ----------------------------------------------------------------------------------- |
| Ctrl+B | Bold |
| Ctrl+I | Italic |
| Ctrl+U | Underline |
| Arrow keys | Move selection (when not editing) |
| Shift+Arrow | Extend selection |
| Enter | Evaluate formula and move down (or insert formula suggestion when dropdown is open) |
| Escape | Clear selection / Close formula dropdown |
| Arrow Up/Down (formula dropdown) | Navigate suggestions |
| Tab (formula dropdown) | Insert active suggestion |
## Tech Stack
- Vanilla HTML/CSS/JavaScript (no frameworks)
- CSS Grid for spreadsheet layout
- CSS Custom Properties for theming
- LZ-String (URL state compression via CDN)
- Web Crypto API (AES-GCM + PBKDF2) for optional password protection
- PeerJS 1.5.2 (WebRTC abstraction for P2P collaboration)
- Netlify Functions (serverless TURN credential proxy)
- Metered.ca (TURN server service for NAT traversal)
- Font Awesome 6.5.1 (icons via CDN)
- Google Analytics (gtag.js) for usage tracking
- No build tools required
## Browser Support
| Browser | Support |
| -------------- | -------------------- |
| Chrome | Full |
| Firefox | Full |
| Safari | Full |
| Edge | Full |
| Mobile Safari | Full |
| Chrome Android | Full |
| Older browsers | Graceful degradation |
## Limitations
- Default grid: 10 rows x 10 columns (expand as needed)
- Formulas limited to SUM and AVG range syntax
- Very large sheets may hit browser performance or URL length limits; encrypted links are longer
- Losing the password means the encrypted data cannot be recovered
- P2P collaboration limited to 1 host + 1 joiner (no multi-user rooms)
- P2P requires WebRTC support (modern browsers only)
- Without TURN server, P2P may fail on restrictive networks (~20% of cases)
## File Structure
```
spreadsheet/
|-- index.html # Single-page app structure
|-- styles.css # All styling including dark mode
|-- script.js # Application logic (IIFE module)
|-- netlify.toml # Netlify configuration for functions
|-- modules/
| |-- p2pManager.js # P2P connection & data sync
| |-- dependencyTracer.js # SVG dependency overlay
| |-- formulaManager.js # Formula evaluation & autocomplete
| |-- urlManager.js # URL state compression & validation
| |-- encryption.js # AES-GCM encryption utilities
| |-- passwordManager.js # Password modal UI flows
| |-- rowColManager.js # Grid rendering & selection
| |-- security.js # HTML sanitization & validation
| |-- toastManager.js # Toast notifications
| |-- csvManager.js # CSV import/export
| `-- constants.js # Config constants
|-- netlify/
| `-- functions/
| `-- get-turn-credentials.mjs # TURN credential proxy
|-- logo.png # App logo
|-- favicon.png # Browser favicon
|-- CLAUDE.md # Development documentation
`-- README.md # This file
```
## Architecture
- **State Management** - `data`, `formulas`, `cellStyles`, `rows`, `cols`, `colWidths`, `rowHeights`
- **URL Sync** - LZ-String compressed JSON with debounced updates and legacy fallback
- **Event Delegation** - All cell events handled on container
- **CSS Grid** - Dynamic column template and row heights set via JavaScript
- **Sticky Headers** - Row/column headers with z-index layering
## P2P Architecture
### Connection Flow
```
┌─────────────────┐ ┌─────────────────┐
│ HOST │ │ JOINER │
├─────────────────┤ ├─────────────────┤
│ 1. Click "Start │ │ │
│ Hosting" │ │ │
│ 2. Fetch ICE │ │ │
│ servers │ │ │
│ 3. Get Peer ID │──── Share ID via chat ──────▶│ 4. Enter Host ID│
│ │ │ 5. Fetch ICE │
│ │ │ servers │
│ │◀──── WebRTC Connection ─────│ 6. Connect │
│ 7. Send │ │ │
│ INITIAL_SYNC │─────────────────────────────▶│ 8. Load state │
│ │ │ │
│ 9. Edit cell │──── UPDATE_CELL ───────────▶│ 10. Update cell │
│ │◀─── UPDATE_CELL ────────────│ 11. Edit cell │
│ │ │ │
│ 12. Move cursor │──── UPDATE_CURSOR ─────────▶│ 13. Show cursor │
└─────────────────┘ └─────────────────┘
```
### ICE Server Configuration
The app uses a Netlify Function to securely fetch TURN credentials:
1. **Client** calls `/api/turn-credentials`
2. **Netlify Function** reads `METERED_API_KEY` from environment
3. **Function** fetches credentials from Metered.ca API
4. **Client** receives ICE servers with TURN credentials
5. **Credentials** cached for 1 hour to reduce API calls
**Fallback**: If TURN unavailable, falls back to Google STUN servers (limited NAT traversal)
### Message Types
| Type | Direction | Purpose |
| --------------- | ------------- | ---------------------------------------------- |
| `INITIAL_SYNC` | Host → Joiner | Complete spreadsheet state on connection |
| `FULL_SYNC` | Host → Joiner | Re-sync after structural changes (add row/col) |
| `UPDATE_CELL` | Bidirectional | Incremental cell update (value + formula) |
| `UPDATE_CURSOR` | Bidirectional | Remote cursor position for presence |
| `SYNC_REQUEST` | Joiner → Host | Request full state if out of sync |
### P2P Security
- **API Key Protection** - TURN API key stored server-side in Netlify env var
- **HTML Sanitization** - All incoming cell values sanitized via DOMParser whitelist
- **Formula Validation** - Only SUM/AVG formulas allowed (regex validation)
- **Bounds Checking** - Cell updates validated against grid dimensions
- **No E2E Encryption** - Data channel uses DTLS (transport encryption only)
## Development
No build process required. Edit files and refresh browser.
```bash
# Start local server
npx serve .
# Open in browser
http://localhost:3000
```
## TURN Server Setup (Self-Hosting)
To enable P2P collaboration with full NAT traversal support, you need to configure a TURN server. The app uses Metered.ca (free tier: 50GB/month).
### 1. Create Metered.ca Account
1. Sign up at [https://www.metered.ca](https://www.metered.ca)
2. Go to **Dashboard → Developers**
3. Copy your **API Key**
### 2. Configure Netlify Environment Variable
In your Netlify dashboard:
1. Go to **Site settings → Environment variables**
2. Add a new variable:
- **Key**: `METERED_API_KEY`
- **Value**: Your Metered.ca API key
- **Scope**: Functions
### 3. Deploy
Push your code to trigger a Netlify deploy. The function at `netlify/functions/get-turn-credentials.mjs` will automatically use the API key.
### 4. Local Testing with Netlify Dev
```bash
# Install Netlify CLI
npm install -g netlify-cli
# Create .env file for local testing
echo "METERED_API_KEY=your_api_key_here" > .env
# Run with functions support
netlify dev
```
### Without TURN Server
If `METERED_API_KEY` is not configured:
- P2P will use **STUN-only** mode (Google STUN servers)
- Works in ~80% of network configurations
- May fail behind symmetric NATs or strict firewalls
## Recent Updates
### Latest - Visual Dependency Tracer + P2P Formula Sync
- Added a Trace Logic overlay that renders formula dependencies with SVG curves
- Auto redraws on edits, scroll, resize, and grid updates
- Fixed P2P formula commits to broadcast computed values reliably
### AI Bridge, Embed, and Sharing Upgrades
- Added Raw Data (AI Bridge) modal to view/copy the current JSON state
- Read-only toggle and dedicated embed mode with iframe snippet generator
- QR handoff modal to open the sheet on mobile; warns on oversized URLs
- Live URL length indicator plus selection count/sum/avg stats in the status bar
### Optional Password Protection
- Added AES-GCM (256-bit) encryption with PBKDF2 (100k iterations) for URL hashes (`ENC:` prefix)
- Lock/unlock toolbar button with modal flows for setting, unlocking, and removing passwords
- URL-safe Base64 payloads; encryption failures fall back safely without breaking sharing
### v1.4 - Keyboard Navigation
- Arrow keys move the active selection without entering edit mode
- Shift+Arrow expands selections from the anchor cell
- Double-click or start typing to enter edit mode
### v1.3 - Formula Support
- Added formula evaluation with `=SUM(range)` function
- Formula autocomplete dropdown with suggestions
- Click-to-select range references while editing formulas
- Keyboard navigation in formula dropdown (Up/Down/Enter/Tab/Escape)
- Formulas preserved in shareable URLs
- Enter key evaluates formula and moves to next row
### v1.2 - Multi-Cell Selection & Clear
- Added Google Sheets-style multi-cell selection
- Click and drag to select cell ranges
- Shift+Click to extend selections
- Visual selection with border outline
- Added Clear button to reset spreadsheet
- Touch/mobile selection support
### v1.1 - Text Formatting
- Added Bold, Italic, Underline buttons
- Keyboard shortcuts (Ctrl+B/I/U)
- Active header highlighting
### v1.0 - Initial Release
- Core spreadsheet functionality
- URL-based state persistence
- Dark/light theme toggle
- Dynamic grid sizing
- Mobile responsive design
## License
MIT
## Contributing
1. Fork the repository
2. Create your feature branch
3. Commit your changes
4. Push to the branch
5. Open a Pull Request
wget 'https://sme10.lists2.roe3.org/spreadsheet/example.json.md'
## What `example.json` Represents
`example.json` is a minified spreadsheet state snapshot—exactly the shape we pack into the URL hash (via `minifyState` in `modules/urlManager.js`). It is **not** the expanded grid; it is the compact form with sparse arrays and short keys.
## Field Reference
- `r`: row count (int).
- `c`: column count (int).
- `t`: theme string (`"light"` or `"dark"`).
- `d`: data as sparse triplets `[row, col, value]`. Only non-empty cells appear.
- `f`: formulas as sparse triplets `[row, col, "=FORMULA()"]`.
- `s`: cell styles as sparse triplets `[row, col, styleObject]` where style keys are minified: `a` align, `b` background color, `c` text color, `z` font size (px).
- `w`: column widths array (length = `c`). Defaults are omitted, so presence here means custom widths.
## Supported Formulas
All formulas must start with `=`. The app supports:
### Range Functions
| Function | Syntax | Description |
|----------|--------|-------------|
| `SUM` | `=SUM(A1:B5)` | Adds all numbers in the range |
| `AVG` | `=AVG(A1:B5)` | Average of non-empty numeric cells in range |
### Arithmetic Expressions
Cell references and basic math with proper operator precedence:
- **Cell references**: `=A1`, `=B2`, `=AA99`
- **Operators**: `+` `-` `*` `/`
- **Parentheses**: `=(A1+B1)/2`
- **Unary**: `=-A1`, `=+B2`
Examples:
```
=A1+B1
=A1*B2-C3
=(A1+A2+A3)/3
=B5*1.15
```
### Error Codes
| Code | Meaning |
|------|---------|
| `#DIV/0!` | Division by zero |
| `#REF!` | Invalid or out-of-bounds cell reference |
| `#ERROR!` | Malformed expression or unknown formula |
## How to Read It
1) Treat `d`, `f`, and `s` as sparse arrays—build an empty `r x c` grid first, then set only the listed coordinates.
2) Expand style keys: `{ "b": "#ffee00", "c": "#000000" }` ⇒ `{ bg: "#ffee00", color: "#000000" }`.
3) If a field is missing, assume defaults: empty cells, no formulas, default styles, default row heights/col widths, light theme, read-only off.
## Typical Flow in Code
- Decode hash → `expandState`/`validateAndNormalizeState` → render.
- Encode for sharing → `minifyState` → compress/encrypt → hash.
This file is already decompressed; you can load it directly by parsing JSON, expanding sparse arrays to a dense grid, and applying styles/formulas.
## Example JSON
```json
{
"r": 19,
"c": 8,
"t": "dark",
"d": [
[0, 0, "Personal Tax Calculation (OPEN)"],
[2, 0, "Monthly Income"],
[2, 1, " 400,000.00 "],
[3, 0, "Yearly Income"],
[3, 1, " 4,800,000.00 "],
[3, 2, "404"],
[5, 0, "Foreign Sources"],
[6, 0, "New Tax Rate - 15%"],
[7, 0, "2025/2026"],
[7, 1, " 1,800,000.00 "],
[7, 2, " 3,000,000.00 "],
[7, 3, "15%"],
[7, 4, " 450,000.00 "],
[7, 5, "37500"],
[10, 0, "Normal Personal Tax"],
[11, 1, " 1,800,000.00 "],
[11, 2, " 3,000,000.00 "],
[11, 3, "0%"],
[11, 4, " - "],
[12, 1, " 1,000,000.00 "],
[12, 2, " 2,000,000.00 "],
[12, 3, "6%"],
[12, 4, " 60,000.00 "],
[13, 1, " 500,000.00 "],
[13, 2, " 1,500,000.00 "],
[13, 3, "18%"],
[13, 4, " 90,000.00 "],
[14, 1, " 500,000.00 "],
[14, 2, " 1,000,000.00 "],
[14, 3, "24%"],
[14, 4, " 120,000.00 "],
[15, 1, " 500,000.00 "],
[15, 2, " 500,000.00 "],
[15, 3, "30%"],
[15, 4, " 150,000.00 "],
[16, 2, " 500,000.00 "],
[16, 3, "36%"],
[16, 4, " 180,000.00 "],
[17, 0, "Total Tax"],
[17, 4, " 600,000.00 "]
],
"f": [[3, 2, "=SUM(B3:B4)"]],
"s": [
[0, 0, { "c": "#ff0000", "z": "14" }],
[5, 0, { "b": "#ffee00", "c": "#000000" }]
],
"w": [239, 100, 100, 100, 100, 100, 100, 100]
}
```
wget 'https://sme10.lists2.roe3.org/spreadsheet/favicon.png'
wget 'https://sme10.lists2.roe3.org/spreadsheet/gallery.css'
/* Gallery Page Styles */
/* CSS Variables for theming (same as main app) */
:root {
--bg-primary: #f5f5f5;
--bg-secondary: white;
--bg-header: #f8f9fa;
--text-primary: #333;
--text-secondary: #666;
--text-muted: #888;
--border-color: #e0e0e0;
--accent-color: #4a90d9;
--accent-hover: #3a7bc8;
--hover-bg: rgba(74, 144, 217, 0.06);
--glass-bg: rgba(255, 255, 255, 0.85);
--glass-border: rgba(255, 255, 255, 0.5);
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.12);
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 16px;
}
/* Dark mode colors */
.dark-mode {
--bg-primary: #121212;
--bg-secondary: #1e1e1e;
--bg-header: #252525;
--text-primary: #e0e0e0;
--text-secondary: #a0a0a0;
--text-muted: #666;
--border-color: #333;
--accent-color: #5a9fe9;
--accent-hover: #4a8fd9;
--hover-bg: rgba(90, 159, 233, 0.1);
--glass-bg: rgba(30, 30, 30, 0.85);
--glass-border: rgba(255, 255, 255, 0.08);
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.2);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.4);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
height: 100%;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: var(--bg-primary);
background-image: radial-gradient(circle at 1px 1px, var(--border-color) 1px, transparent 0);
background-size: 24px 24px;
background-attachment: fixed;
color: var(--text-primary);
transition: background-color 0.3s, color 0.3s;
letter-spacing: -0.01em;
}
/* Modern Scrollbars */
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.15);
border-radius: 5px;
border: 2px solid transparent;
background-clip: content-box;
}
.dark-mode ::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
}
/* ========== Layout ========== */
.gallery-container {
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* ========== Header ========== */
.gallery-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
background: var(--glass-bg);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-bottom: 1px solid var(--border-color);
position: sticky;
top: 0;
z-index: 100;
}
.gallery-header h1 {
font-size: 1.25rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 10px;
color: var(--text-primary);
}
.gallery-header h1 i {
color: var(--accent-color);
}
.gallery-header-right {
display: flex;
align-items: center;
gap: 10px;
}
.header-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-size: 0.875rem;
font-weight: 500;
text-decoration: none;
cursor: pointer;
transition: all 0.2s ease;
}
.header-btn:hover {
background: var(--hover-bg);
border-color: var(--accent-color);
color: var(--accent-color);
}
.header-btn.icon-only {
padding: 8px 10px;
}
.header-btn.icon-only span {
display: none;
}
/* Theme toggle icons */
.icon-dark {
display: none;
}
.dark-mode .icon-light {
display: none;
}
.dark-mode .icon-dark {
display: inline;
}
/* ========== Main Content ========== */
.gallery-main {
flex: 1;
padding: 40px 24px;
max-width: 1200px;
margin: 0 auto;
width: 100%;
}
.gallery-intro {
text-align: center;
margin-bottom: 40px;
}
.gallery-intro h2 {
font-size: 1.75rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 12px;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
}
.gallery-intro h2 i {
color: var(--accent-color);
}
.gallery-intro p {
font-size: 1rem;
color: var(--text-secondary);
max-width: 500px;
margin: 0 auto;
}
/* ========== Template Grid ========== */
.template-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 24px;
}
.template-card {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 28px 24px;
text-decoration: none;
color: inherit;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
transition: all 0.25s ease;
cursor: pointer;
}
.template-card:hover {
border-color: var(--accent-color);
transform: translateY(-4px);
box-shadow: var(--shadow-lg);
}
.template-card:active {
transform: translateY(-2px);
}
.template-icon {
width: 64px;
height: 64px;
display: flex;
align-items: center;
justify-content: center;
background: var(--hover-bg);
border-radius: var(--radius-md);
margin-bottom: 16px;
font-size: 1.5rem;
color: var(--accent-color);
transition: all 0.25s ease;
}
.template-card:hover .template-icon {
background: var(--accent-color);
color: white;
transform: scale(1.05);
}
.template-title {
font-size: 1.125rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 8px;
}
.template-description {
font-size: 0.875rem;
color: var(--text-secondary);
line-height: 1.5;
margin-bottom: 16px;
flex: 1;
}
.template-cta {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.875rem;
font-weight: 500;
color: var(--accent-color);
transition: gap 0.2s ease;
}
.template-card:hover .template-cta {
gap: 10px;
}
/* ========== Footer ========== */
.gallery-footer {
padding: 20px 24px;
text-align: center;
border-top: 1px solid var(--border-color);
background: var(--glass-bg);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
.gallery-footer p {
font-size: 0.8125rem;
color: var(--text-muted);
}
/* ========== Responsive ========== */
@media (max-width: 768px) {
.gallery-header {
padding: 12px 16px;
}
.gallery-header h1 {
font-size: 1.1rem;
}
.header-btn span {
display: none;
}
.header-btn {
padding: 8px 10px;
}
.gallery-main {
padding: 24px 16px;
}
.gallery-intro h2 {
font-size: 1.4rem;
}
.gallery-intro p {
font-size: 0.9rem;
}
.template-grid {
grid-template-columns: 1fr;
gap: 16px;
}
.template-card {
padding: 20px;
}
.template-icon {
width: 56px;
height: 56px;
font-size: 1.25rem;
}
}
@media (max-width: 480px) {
.gallery-header-left h1 span {
display: none;
}
}
wget 'https://sme10.lists2.roe3.org/spreadsheet/gallery.html'
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Template Gallery - Spreadsheet</title>
<link rel="icon" href="favicon.png" type="image/png">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
crossorigin="anonymous" referrerpolicy="no-referrer" />
<link rel="stylesheet" href="gallery.css">
</head>
<body>
<div class="gallery-container">
<!-- Header -->
<header class="gallery-header">
<div class="gallery-header-left">
<h1><i class="fa-solid fa-table-cells"></i> Spreadsheet</h1>
</div>
<div class="gallery-header-right">
<a href="index.html" class="header-btn" title="Create blank spreadsheet">
<i class="fa-solid fa-plus"></i>
<span>New Sheet</span>
</a>
<button id="theme-toggle" type="button" class="header-btn icon-only" title="Toggle dark/light mode">
<i class="fa-solid fa-sun icon-light"></i>
<i class="fa-solid fa-moon icon-dark"></i>
</button>
<a href="https://github.com/supunlakmal/spreadsheet" target="_blank" rel="noopener noreferrer"
class="header-btn icon-only" title="View on GitHub">
<i class="fa-brands fa-github"></i>
</a>
</div>
</header>
<!-- Main Content -->
<main class="gallery-main">
<div class="gallery-intro">
<h2><i class="fa-solid fa-shapes"></i> Template Gallery</h2>
<p>Choose a template to get started quickly. Click any template to open it in the spreadsheet.</p>
</div>
<div id="template-grid" class="template-grid">
<!-- Templates will be rendered here by JavaScript -->
</div>
</main>
<!-- Footer -->
<footer class="gallery-footer">
<p>Data is stored in the URL - no server required. Share your spreadsheet by sharing the link.</p>
</footer>
</div>
<script>
// Theme management
function initTheme() {
const savedTheme = localStorage.getItem("spreadsheet-theme");
if (savedTheme === "dark") {
document.body.classList.add("dark-mode");
}
}
function toggleTheme() {
document.body.classList.toggle("dark-mode");
const isDark = document.body.classList.contains("dark-mode");
localStorage.setItem("spreadsheet-theme", isDark ? "dark" : "light");
}
// Render template cards
function renderTemplates(templates) {
const grid = document.getElementById("template-grid");
if (!grid) return;
grid.innerHTML = templates.map(template => `
<a href="${template.link}" class="template-card">
<div class="template-icon">
<i class="fa-solid ${template.icon}"></i>
</div>
<h3 class="template-title">${template.name}</h3>
<p class="template-description">${template.description}</p>
<span class="template-cta">
Use Template <i class="fa-solid fa-arrow-right"></i>
</span>
</a>
`).join("");
}
// Load templates from JSON file
async function loadTemplates() {
const grid = document.getElementById("template-grid");
try {
const response = await fetch("templates.json");
if (!response.ok) throw new Error("Failed to load templates.json");
const templates = await response.json();
renderTemplates(templates);
} catch (error) {
console.error("Error loading templates:", error);
if (grid) {
grid.innerHTML = '<p style="color: var(--text-muted); text-align: center;">Failed to load templates. Please refresh the page.</p>';
}
}
}
// Initialize
function init() {
initTheme();
loadTemplates();
// Theme toggle
const themeToggle = document.getElementById("theme-toggle");
if (themeToggle) {
themeToggle.addEventListener("click", toggleTheme);
}
}
// Run when DOM is ready
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
</script>
</body>
</html>
wget 'https://sme10.lists2.roe3.org/spreadsheet/index.html'
<!DOCTYPE html>
<html lang="en">
<head><!-- Google tag (gtag.js) -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-77V3TP2B5T"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag() { dataLayer.push(arguments); }
gtag('js', new Date());
// Security: Exclude URL hash from analytics to prevent leaking spreadsheet data
gtag('config', 'G-77V3TP2B5T', {
'page_location': window.location.origin + window.location.pathname,
'page_path': window.location.pathname
});
</script>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<!--
Security Note: Content Security Policy
- 'unsafe-inline' in style-src is required for user-controlled dynamic cell styling (colors, alignment, font-size).
- Removing 'unsafe-inline' would require refactoring all style.setProperty() and inline style assignments.
- XSS mitigations in script.js:
1. sanitizeHTML() uses DOMParser-based whitelist filtering (ALLOWED_TAGS, ALLOWED_SPAN_STYLES)
2. isContentSafe() provides defense-in-depth pattern detection for dangerous content
3. isValidCSSColor() validates color inputs to prevent CSS injection
4. safeJSONParse() prevents prototype pollution from URL state
- Note: script-src does NOT include 'unsafe-inline' or 'unsafe-eval', blocking inline script injection
-->
<meta http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self' https://cdnjs.cloudflare.com https://www.googletagmanager.com https://unpkg.com 'sha256-vvt4KWwuNr51XfE5m+hzeNEGhiOfZzG97ccfqGsPwvE='; style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://fonts.googleapis.com; font-src 'self' https://cdnjs.cloudflare.com https://fonts.gstatic.com data:; img-src 'self' data:; connect-src 'self' https://www.google-analytics.com https://www.googletagmanager.com https://unpkg.com https://*.peerjs.com wss://*.peerjs.com https://*.metered.live;">
<title>Spreadsheet</title>
<link rel="icon" href="favicon.png" type="image/png">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
crossorigin="anonymous" referrerpolicy="no-referrer" />
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div class="container">
<div class="header">
<h1><i class="fa-solid fa-table-cells"></i> Spreadsheet</h1>
<button id="scroll-left" class="scroll-btn left hidden" aria-label="Scroll left"><i
class="fa-solid fa-chevron-left"></i></button>
<div class="toolbar">
<div class="format-buttons">
<button id="format-bold" type="button" class="format-btn" title="Bold (Ctrl+B)"><i
class="fa-solid fa-bold"></i></button>
<button id="format-italic" type="button" class="format-btn" title="Italic (Ctrl+I)"><i
class="fa-solid fa-italic"></i></button>
<button id="format-underline" type="button" class="format-btn" title="Underline (Ctrl+U)"><i
class="fa-solid fa-underline"></i></button>
</div>
<!-- Toolbar content continues... -->
<div class="toolbar-divider"></div>
<div class="format-buttons">
<button id="align-left" type="button" class="format-btn" title="Align left"><i
class="fa-solid fa-align-left"></i></button>
<button id="align-center" type="button" class="format-btn" title="Align center"><i
class="fa-solid fa-align-center"></i></button>
<button id="align-right" type="button" class="format-btn" title="Align right"><i
class="fa-solid fa-align-right"></i></button>
</div>
<div class="toolbar-divider"></div>
<div class="size-control" id="font-size-list" title="Font size" role="group" aria-label="Font size">
<span class="size-control-label" aria-hidden="true"><i class="fa-solid fa-text-height"></i></span>
<button type="button" class="size-option" data-size="" aria-label="Font size auto">Auto</button>
<button type="button" class="size-option" data-size="10" aria-label="Font size 10">10</button>
<button type="button" class="size-option" data-size="12" aria-label="Font size 12">12</button>
<button type="button" class="size-option" data-size="14" aria-label="Font size 14">14</button>
<button type="button" class="size-option" data-size="16" aria-label="Font size 16">16</button>
<button type="button" class="size-option" data-size="18" aria-label="Font size 18">18</button>
<button type="button" class="size-option" data-size="24" aria-label="Font size 24">24</button>
</div>
<div class="toolbar-divider"></div>
<div class="color-control" title="Cell background color">
<i class="fa-solid fa-fill-drip"></i>
<input id="cell-bg-color" type="color" value="#ffffff" aria-label="Cell background color">
</div>
<div class="color-control" title="Text color">
<i class="fa-solid fa-font"></i>
<input id="cell-text-color" type="color" value="#000000" aria-label="Cell text color">
</div>
<div class="toolbar-divider"></div>
<button id="add-row" type="button"><i class="fa-solid fa-plus"></i> Row</button>
<button id="add-col" type="button"><i class="fa-solid fa-plus"></i> Column</button>
<button id="clear-spreadsheet" type="button" title="Clear all data and reset spreadsheet">
<i class="fa-solid fa-eraser"></i> Clear
</button>
<button id="theme-toggle" type="button" class="theme-toggle" title="Toggle dark/light mode">
<i class="fa-solid fa-sun icon-light"></i>
<i class="fa-solid fa-moon icon-dark"></i>
</button>
<div class="toolbar-divider"></div>
<!-- Main Tools Button -->
<button id="tools-menu-btn" type="button" class="primary-action tools-btn">
<i class="fa-solid fa-layer-group"></i> Tools
</button>
<span id="grid-size" class="grid-size"><i class="fa-solid fa-grip"></i> 10 × 10</span>
<a href="https://github.com/supunlakmal/spreadsheet" target="_blank" rel="noopener noreferrer"
class="github-link" title="View on GitHub">
<i class="fa-brands fa-github"></i>
</a>
</div>
<button id="scroll-right" class="scroll-btn right hidden" aria-label="Scroll right"><i
class="fa-solid fa-chevron-right"></i></button>
</div>
<div id="readonly-banner" class="readonly-banner hidden">
<div class="readonly-banner-content">
<i class="fa-solid fa-eye"></i>
<span>Read-only mode - You can view and export, but not edit</span>
<button id="enable-editing" type="button" class="readonly-enable-btn">
Enable Editing
</button>
</div>
</div>
<div class="grid-wrapper">
<div id="spreadsheet" class="spreadsheet"></div>
</div>
<div class="status-bar">
<div class="status-hint">
<i class="fa-solid fa-link"></i> Data is saved in the URL - share the link to share your spreadsheet
</div>
<div class="status-bar-right">
<div id="selection-status" class="selection-stats" aria-live="polite">
<span class="stat-item">Count: <b id="stat-count">0</b></span>
<span class="stat-item stat-sum">Sum: <b id="stat-sum">0</b></span>
<span class="stat-item stat-avg">Avg: <b id="stat-avg">0</b></span>
</div>
<div class="url-length-indicator">
<span class="url-length-label">URL: <span id="url-length-value">0</span> chars</span>
<div class="url-progress-container" title="URL length indicator">
<div id="url-progress-bar" class="url-progress-bar"></div>
</div>
<span id="url-length-message" class="url-length-message"></span>
</div>
</div>
</div>
</div>
<!-- Password Modal -->
<div id="password-modal" class="modal hidden">
<div class="modal-backdrop"></div>
<div class="modal-content">
<h3 id="modal-title">Set Password</h3>
<p id="modal-description">Enter a password to encrypt this spreadsheet. Anyone with the link will need this
password to view it.</p>
<div class="modal-form">
<input type="password" id="password-input" placeholder="Enter password" autocomplete="new-password">
<input type="password" id="password-confirm" placeholder="Confirm password" autocomplete="new-password">
</div>
<p id="modal-error" class="modal-error hidden"></p>
<div class="modal-buttons">
<button id="modal-cancel" type="button" class="modal-btn modal-btn-secondary">Cancel</button>
<button id="modal-submit" type="button" class="modal-btn modal-btn-primary">Set Password</button>
</div>
</div>
</div>
<!-- Embed Code Modal -->
<div id="embed-modal" class="modal hidden">
<div class="modal-backdrop"></div>
<div class="modal-content">
<h3>Embed This Spreadsheet</h3>
<p>Copy this code to embed the spreadsheet on your website:</p>
<div class="embed-code-container">
<textarea id="embed-code-textarea" readonly></textarea>
</div>
<div class="modal-buttons">
<button id="embed-copy-btn" type="button" class="modal-btn modal-btn-primary">
<i class="fa-solid fa-copy"></i> Copy to Clipboard
</button>
<button id="embed-close-btn" type="button" class="modal-btn modal-btn-secondary">Close</button>
</div>
</div>
</div>
<!-- JSON Editor Modal -->
<div id="json-modal" class="modal hidden">
<div class="modal-backdrop"></div>
<div class="modal-content json-modal-content">
<div class="json-modal-header">
<h3>Raw Data (AI Bridge)</h3>
<button id="json-close-btn" type="button" class="modal-icon-btn" aria-label="Close JSON editor">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
<p class="json-modal-subtitle">Copy this JSON to an AI to modify data.</p>
<div class="json-editor-wrapper">
<textarea id="json-editor" spellcheck="false"></textarea>
</div>
<div class="json-modal-actions">
<button id="json-copy-btn" type="button" class="modal-btn modal-btn-secondary">
<i class="fa-solid fa-copy"></i> Copy JSON
</button>
</div>
<p id="json-error" class="modal-error hidden"></p>
</div>
</div>
<!-- Collaboration Modal -->
<div id="p2p-modal" class="modal hidden">
<div class="modal-backdrop"></div>
<div class="modal-content">
<h3>Live Collaboration (Beta)</h3>
<p>Share your session live without saving to a server.</p>
<div id="p2p-host-view" class="p2p-section">
<button id="p2p-start-host" class="modal-btn modal-btn-primary p2p-btn-full" type="button"><i
class="fa-solid fa-wifi"></i> Start Hosting</button>
<div id="p2p-id-display" class="p2p-id-display hidden">
<p class="p2p-label">Your Peer ID (share this):</p>
<div class="p2p-id-row">
<input type="text" id="p2p-my-id" class="p2p-input" readonly>
<button id="p2p-copy-id" class="modal-icon-btn" type="button" aria-label="Copy ID"><i
class="fa-solid fa-copy"></i></button>
</div>
</div>
</div>
<div class="p2p-divider">- OR -</div>
<div id="p2p-join-view" class="p2p-section">
<input type="text" id="p2p-remote-id" class="p2p-input" placeholder="Enter Host Peer ID">
<button id="p2p-join-btn" class="modal-btn modal-btn-secondary p2p-btn-full" type="button"><i
class="fa-solid fa-plug"></i> Join Session</button>
</div>
<p class="p2p-status" id="p2p-status">Waiting for connection...</p>
<div class="modal-buttons">
<button id="p2p-close-btn" type="button" class="modal-btn modal-btn-secondary">Close</button>
</div>
</div>
</div>
<!-- QR Code Modal -->
<div id="qr-modal" class="modal hidden">
<div class="modal-backdrop"></div>
<div class="modal-content qr-modal-content">
<h3>Open on Mobile</h3>
<p>Scan this code to continue on your phone.</p>
<div class="qr-card">
<div id="qrcode-container" class="qr-code"></div>
</div>
<p id="qr-warning" class="qr-warning hidden"></p>
<div class="modal-buttons">
<button id="qr-close-btn" type="button" class="modal-btn modal-btn-secondary">Close</button>
</div>
</div>
</div>
<!-- Tools Modal -->
<div id="tools-modal" class="modal hidden">
<div class="modal-backdrop"></div>
<div class="modal-content tools-modal-content">
<div class="tools-modal-header">
<h3><i class="fa-solid fa-layer-group"></i> All Tools</h3>
<button id="tools-close-btn" type="button" class="modal-icon-btn"><i
class="fa-solid fa-xmark"></i></button>
</div>
<div class="tools-grid">
<!-- Section: View -->
<div class="tools-section">
<h4>View</h4>
<div class="tools-list">
<button id="toggle-readonly" type="button" class="tool-item"><i
class="fa-solid fa-pen-to-square"></i> Toggle Read-only</button>
</div>
</div>
<!-- Section: Data & File -->
<div class="tools-section">
<h4>Data & File</h4>
<div class="tools-list">
<button id="import-csv" type="button" class="tool-item"><i class="fa-solid fa-file-import"></i>
Import CSV</button>
<button id="export-csv" type="button" class="tool-item"><i class="fa-solid fa-file-csv"></i>
Export CSV</button>
<button id="open-json-btn" type="button" class="tool-item"><i class="fa-solid fa-file-code"></i>
Edit JSON</button>
</div>
</div>
<!-- Section: Share & Embed -->
<div class="tools-section">
<h4>Share & Embed</h4>
<div class="tools-list">
<button id="copy-url" type="button" class="tool-item"><i class="fa-solid fa-copy"></i> Copy
Link</button>
<button id="qr-btn" type="button" class="tool-item"><i class="fa-solid fa-qrcode"></i> QR
Code</button>
<button id="generate-embed" type="button" class="tool-item"><i class="fa-solid fa-code"></i>
Embed Code</button>
<button id="present-btn" type="button" class="tool-item"><i class="fa-solid fa-tv"></i>
Presentation Mode</button>
</div>
</div>
<!-- Section: Advanced -->
<div class="tools-section">
<h4>Advanced</h4>
<div class="tools-list">
<button id="p2p-btn" type="button" class="tool-item"><i class="fa-solid fa-users"></i> Live
Collaboration</button>
<button id="trace-deps-btn" type="button" class="tool-item"><i
class="fa-solid fa-diagram-project"></i> Trace Dependencies</button>
<button id="lock-btn" type="button" class="tool-item"><i class="fa-solid fa-lock-open"></i>
Password Protect</button>
</div>
</div>
</div>
<!-- Hidden file input moved here -->
<input id="import-csv-file" type="file" accept=".csv,text/csv" hidden>
</div>
</div>
<!-- Toast Notifications Container -->
<div id="toast-container" class="toast-container" aria-live="polite" aria-atomic="true"></div>
<script src="https://unpkg.com/peerjs@1.5.2/dist/peerjs.min.js" crossorigin="anonymous"
referrerpolicy="no-referrer"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js" crossorigin="anonymous"
referrerpolicy="no-referrer"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/lz-string/1.5.0/lz-string.min.js"
integrity="sha512-qtX0GLM3qX8rxJN1gyDfcnMFFrKvixfoEOwbBib9VafR5vbChV5LeE5wSI/x+IlCkTY5ZFddFDCCfaVJJNnuKQ=="
crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script type="module" src="script.js"></script>
</body>
</html>
wget 'https://sme10.lists2.roe3.org/spreadsheet/logo.png'
wget 'https://sme10.lists2.roe3.org/spreadsheet/netlify.toml'
[build]
functions = "netlify/functions"
[functions]
node_bundler = "esbuild"
wget 'https://sme10.lists2.roe3.org/spreadsheet/script.js'
// Dynamic Spreadsheet Web App
// Data persists in URL hash for easy sharing
// Import constants from ES6 module
import { DEBOUNCE_DELAY, DEFAULT_COLS, DEFAULT_ROWS, MAX_COLS, MAX_ROWS } from "./modules/constants.js";
import { buildRangeRef, FormulaDropdownManager, FormulaEvaluator, isValidFormula } from "./modules/formulaManager.js";
import { DependencyTracer } from "./modules/dependencyTracer.js";
import { PasswordManager } from "./modules/passwordManager.js";
import { CSVManager } from "./modules/csvManager.js";
import { JSONManager } from "./modules/jsonManager.js";
import { P2PManager } from "./modules/p2pManager.js";
import { PresentationManager } from "./modules/presentationManager.js";
import {
addColumn,
addRow,
clearActiveHeaders,
clearSelectedCells,
clearSelection,
clearSpreadsheet,
focusCellAt,
getCellContentElement,
getCellElement,
getSelectionBounds,
getState,
handleMouseDown,
handleMouseLeave,
handleMouseMove,
handleMouseUp,
handleResizeStart,
handleTouchEnd,
handleTouchMove,
handleTouchStart,
hasMultiSelection,
renderGrid,
setActiveHeaders,
setCallbacks,
setState,
updateSelectionVisuals,
} from "./modules/rowColManager.js";
import { escapeHTML, isValidCSSColor, sanitizeHTML } from "./modules/security.js";
import { showToast } from "./modules/toastManager.js";
import {
createDefaultColumnWidths,
createDefaultRowHeights,
createEmptyCellStyle,
createEmptyCellStyles,
createEmptyData,
isCellStylesDefault,
isColWidthsDefault,
isDataEmpty,
isFormulasEmpty,
isRowHeightsDefault,
normalizeAlignment,
normalizeCellStyles,
normalizeColumnWidths,
normalizeFontSize,
normalizeRowHeights,
minifyStateForExport,
URLManager,
validateAndNormalizeState,
} from "./modules/urlManager.js";
(function () {
"use strict";
// Data model - dynamic 2D array (rows/cols managed by rowColManager)
let data = createEmptyData(DEFAULT_ROWS, DEFAULT_COLS);
// Formula storage - parallel array to data
let formulas = createEmptyData(DEFAULT_ROWS, DEFAULT_COLS);
// Cell styles - alignment, colors, and font size
let cellStyles = createEmptyCellStyles(DEFAULT_ROWS, DEFAULT_COLS);
// Read-only mode flag
let isReadOnly = false;
// Embed mode flag
let isEmbedMode = false;
// Debounce timer
let debounceTimer = null;
let dependencyDrawQueued = false;
// Safe limit before QR codes become unreadable on most phone cameras
const MAX_QR_URL_LENGTH = 2000;
// Formula range selection mode (for clicking to select ranges like Google Sheets)
let formulaEditMode = false; // true when typing a formula
let formulaEditCell = null; // { row, col, element } of cell being edited
let formulaRangeStart = null; // Start of range being selected
let formulaRangeEnd = null; // End of range being selected
let editingCell = null; // { row, col } when editing a cell's text
// P2P collaboration state
let isRemoteUpdate = false;
let hasInitialSync = false;
let fullSyncTimer = null;
const FULL_SYNC_DELAY = 300;
const REMOTE_CURSOR_CLASS = "remote-active";
const p2pUI = {
modal: null,
startHostBtn: null,
joinBtn: null,
copyIdBtn: null,
idDisplay: null,
statusEl: null,
myIdInput: null,
remoteIdInput: null,
startHostLabel: "",
joinLabel: "",
};
// Encryption state
// Encryption state handled by PasswordManager
// Toast functions moved to modules/toastManager.js
// Create empty data array with specified dimensions
// Factory functions moved to modules/urlManager.js
// ========== Security Functions ==========
// Security Functions moved to modules/security.js
// Insert text at current cursor position in contentEditable
function insertTextAtCursor(text) {
const selection = window.getSelection();
if (!selection.rangeCount) return;
const range = selection.getRangeAt(0);
range.deleteContents();
const textNode = document.createTextNode(text);
range.insertNode(textNode);
// Move cursor after inserted text
range.setStartAfter(textNode);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
}
// Move caret to end of contentEditable element
function setCaretToEnd(element) {
const range = document.createRange();
range.selectNodeContents(element);
range.collapse(false);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
}
function applyFormulaSuggestion(formulaName) {
const target = formulaEditCell ? formulaEditCell.element : document.activeElement;
if (!target || !target.classList.contains("cell-content")) return;
const row = parseInt(target.dataset.row, 10);
const col = parseInt(target.dataset.col, 10);
if (isNaN(row) || isNaN(col)) return;
const newValue = `=${formulaName}(`;
target.innerText = newValue;
setCaretToEnd(target);
formulaEditMode = true;
formulaEditCell = { row, col, element: target };
formulas[row][col] = newValue;
data[row][col] = newValue;
FormulaDropdownManager.hide();
debouncedUpdateURL();
scheduleDependencyDraw();
}
// ========== Formula Evaluation Functions ==========
// Get numeric value from cell (returns 0 for empty/non-numeric)
function getCellValue(row, col) {
const { rows, cols } = getState();
if (row < 0 || row >= rows || col < 0 || col >= cols) return 0;
const val = data[row][col];
if (!val || val === "") return 0;
// Strip HTML tags and parse
const stripped = String(val)
.replace(/<[^>]*>/g, "")
.trim();
const normalized = stripped.replace(/,/g, "");
const num = parseFloat(normalized);
return isNaN(num) ? 0 : num;
}
// Recalculate all formula cells
function recalculateFormulas() {
const { rows, cols } = getState();
const container = document.getElementById("spreadsheet");
const activeElement = document.activeElement;
const maxPasses = rows * cols;
let needsUpdate = false;
// Multiple passes to propagate formulas that depend on other formulas.
for (let pass = 0; pass < maxPasses; pass++) {
let changed = false;
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const formula = formulas[r][c];
if (formula && formula.startsWith("=")) {
const result = String(FormulaEvaluator.evaluate(formula, { getCellValue, data, rows, cols }));
if (data[r][c] !== result) {
data[r][c] = result;
changed = true;
needsUpdate = true;
}
}
}
}
if (!changed) break;
}
if (!needsUpdate || !container) return;
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const formula = formulas[r][c];
if (formula && formula.startsWith("=")) {
const cellContent = container.querySelector(`.cell-content[data-row="${r}"][data-col="${c}"]`);
if (!cellContent) continue;
const isEditingFormula = cellContent === activeElement && cellContent.innerText.trim().startsWith("=");
if (!isEditingFormula) {
cellContent.innerText = data[r][c];
}
}
}
}
}
// Get current theme
function isDarkMode() {
return document.body.classList.contains("dark-mode");
}
// Normalization functions moved to modules/urlManager.js
function extractPlainText(value) {
if (value === null || value === undefined) return "";
// Use DOMParser for safe HTML parsing (doesn't execute scripts)
const parser = new DOMParser();
const doc = parser.parseFromString("<body>" + String(value) + "</body>", "text/html");
const text = doc.body.textContent || "";
return text.replace(/\u00a0/g, " ");
}
function getDataArray() {
return data;
}
function setDataArray(newData) {
data = newData;
}
function getFormulasArray() {
return formulas;
}
function setFormulasArray(newFormulas) {
formulas = newFormulas;
}
function getCellStylesArray() {
return cellStyles;
}
function setCellStylesArray(newCellStyles) {
cellStyles = newCellStyles;
}
function formatStatNumber(value) {
return Number.isInteger(value) ? value : value.toFixed(2);
}
// Calculate and render selection summary stats
function updateSelectionStatusBar(boundsOverride = null) {
const bar = document.getElementById("selection-status");
const countEl = document.getElementById("stat-count");
const sumEl = document.getElementById("stat-sum");
const avgEl = document.getElementById("stat-avg");
const sumWrapper = sumEl ? sumEl.parentElement : null;
const avgWrapper = avgEl ? avgEl.parentElement : null;
if (!bar || !countEl || !sumEl || !avgEl) return;
if (isEmbedMode) {
bar.classList.remove("active");
return;
}
const bounds = boundsOverride || getSelectionBounds();
if (!bounds) {
bar.classList.remove("active");
return;
}
const selectedCellCount = (bounds.maxRow - bounds.minRow + 1) * (bounds.maxCol - bounds.minCol + 1);
if (selectedCellCount < 2) {
bar.classList.remove("active");
return;
}
let sum = 0;
let countNumeric = 0;
let countTotal = 0;
for (let r = bounds.minRow; r <= bounds.maxRow; r++) {
for (let c = bounds.minCol; c <= bounds.maxCol; c++) {
const raw = data[r] && data[r][c] !== undefined ? data[r][c] : "";
const text = extractPlainText(raw).trim();
if (!text) continue;
countTotal++;
const cleaned = text.replace(/[^0-9.-]+/g, "");
const numeric = cleaned && cleaned !== "-" && cleaned !== "." && cleaned !== "-." ? parseFloat(cleaned) : NaN;
if (!isNaN(numeric)) {
sum += numeric;
countNumeric++;
}
}
}
if (countTotal === 0) {
bar.classList.remove("active");
return;
}
countEl.innerText = countTotal;
if (countNumeric > 0) {
sumEl.innerText = formatStatNumber(sum);
avgEl.innerText = (sum / countNumeric).toFixed(2);
if (sumWrapper) sumWrapper.style.display = "inline";
if (avgWrapper) avgWrapper.style.display = "inline";
} else {
if (sumWrapper) sumWrapper.style.display = "none";
if (avgWrapper) avgWrapper.style.display = "none";
}
bar.classList.add("active");
}
// Helper functions moved to modules/urlManager.js
// Minify state object keys for smaller URL payload
// State helpers moved to modules/urlManager.js
// ========== Serialization Codec (Wrapper for minify/expand + compression) ==========
// Handles state serialization without encryption concerns
// Codec, validation, and decode functions moved to modules/urlManager.js
// Apply theme to body
function applyTheme(theme) {
if (theme === "dark") {
document.body.classList.add("dark-mode");
} else if (theme === "light") {
document.body.classList.remove("dark-mode");
}
// Save to localStorage as well
try {
localStorage.setItem("spreadsheet-theme", theme);
} catch (e) {}
}
// Build current state object (only includes non-empty/non-default values)
function buildCurrentState() {
const { rows, cols, colWidths, rowHeights } = getState();
const stateObj = {
rows,
cols,
theme: isDarkMode() ? "dark" : "light",
};
// Only include readOnly if true (saves URL bytes)
if (isReadOnly || isEmbedMode) {
stateObj.readOnly = 1;
}
// Only include embed if true (saves URL bytes)
if (isEmbedMode) {
stateObj.embed = 1;
}
// Only include data if not all empty
if (!isDataEmpty(data)) {
stateObj.data = data;
}
// Only include formulas if any exist
if (!isFormulasEmpty(formulas)) {
stateObj.formulas = formulas;
}
// Only include cell styles if any are non-default
if (!isCellStylesDefault(cellStyles)) {
stateObj.cellStyles = cellStyles;
}
// Only include colWidths if not all default
if (!isColWidthsDefault(colWidths, cols)) {
stateObj.colWidths = colWidths;
}
// Only include rowHeights if not all default
if (!isRowHeightsDefault(rowHeights, rows)) {
stateObj.rowHeights = rowHeights;
}
return stateObj;
}
// Update URL hash without page jump
async function updateURL() {
const state = buildCurrentState();
await URLManager.updateURL(state, PasswordManager.getPassword());
}
// Debounced URL update
function debouncedUpdateURL() {
if (debounceTimer) {
clearTimeout(debounceTimer);
}
debounceTimer = setTimeout(updateURL, DEBOUNCE_DELAY);
}
function scheduleDependencyDraw() {
if (!DependencyTracer.isActive) return;
if (dependencyDrawQueued) return;
dependencyDrawQueued = true;
requestAnimationFrame(() => {
dependencyDrawQueued = false;
DependencyTracer.draw(getFormulasArray());
});
}
function refreshDependencyLayer() {
DependencyTracer.init();
scheduleDependencyDraw();
}
// ========== Read-Only Mode Helper Functions ==========
// Apply read-only mode UI state
function applyReadOnlyMode() {
document.body.classList.add("readonly-mode");
// Set contentEditable on all cells
const container = document.getElementById("spreadsheet");
if (container) {
container.querySelectorAll(".cell-content").forEach((cell) => {
cell.contentEditable = "false";
});
}
// Update toggle button
const toggleBtn = document.getElementById("toggle-readonly");
if (toggleBtn) {
toggleBtn.classList.add("active");
const icon = toggleBtn.querySelector("i");
if (icon) icon.className = "fa-solid fa-eye";
}
// Show banner
const banner = document.getElementById("readonly-banner");
if (banner) banner.classList.remove("hidden");
}
// Clear read-only mode UI state
function clearReadOnlyMode() {
document.body.classList.remove("readonly-mode");
// Reset contentEditable
const container = document.getElementById("spreadsheet");
if (container) {
container.querySelectorAll(".cell-content").forEach((cell) => {
cell.contentEditable = "true";
});
}
// Update toggle button
const toggleBtn = document.getElementById("toggle-readonly");
if (toggleBtn) {
toggleBtn.classList.remove("active");
const icon = toggleBtn.querySelector("i");
if (icon) icon.className = "fa-solid fa-pen-to-square";
}
// Hide banner
const banner = document.getElementById("readonly-banner");
if (banner) banner.classList.add("hidden");
}
// Apply embed mode UI state
function applyEmbedMode() {
document.body.classList.add("embed-mode");
isReadOnly = true;
applyReadOnlyMode();
}
// Clear embed mode UI state
function clearEmbedMode() {
document.body.classList.remove("embed-mode");
}
// Toggle read-only mode
function toggleReadOnlyMode() {
isReadOnly = !isReadOnly;
applyReadOnlyState(isReadOnly);
showToast(isReadOnly ? "Read-only mode enabled - Share this link for view-only access" : "Edit mode enabled", "success");
// Update URL immediately (no debounce)
updateURL();
scheduleFullSync();
}
// Generate embed code
async function generateEmbedCode() {
if (isEmbedMode) {
showToast("Already in embed mode. Share current URL instead.", "warning");
return null;
}
const currentState = buildCurrentState();
currentState.embed = 1;
currentState.readOnly = 1;
const encoded = await URLManager.encodeState(currentState, PasswordManager.getPassword());
const embedURL = window.location.origin + window.location.pathname + "#" + encoded;
if (embedURL.length > 2000) {
showToast("Warning: Embed URL is very long", "warning");
}
return `<iframe
src="${embedURL}"
width="800"
height="600"
frameborder="0"
style="border: 1px solid #e0e0e0; border-radius: 8px;"
title="Embedded Spreadsheet">
</iframe>`;
}
// Show embed modal with generated code
async function showEmbedModal() {
const embedCode = await generateEmbedCode();
if (!embedCode) return;
const modal = document.getElementById("embed-modal");
const textarea = document.getElementById("embed-code-textarea");
textarea.value = embedCode;
modal.classList.remove("hidden");
textarea.select();
}
// Hide embed modal
function hideEmbedModal() {
const modal = document.getElementById("embed-modal");
modal.classList.add("hidden");
}
// Handle input changes
function handleInput(event) {
const target = event.target;
if (!target.classList.contains("cell-content")) return;
// GUARD: Block in read-only mode
if (isReadOnly) {
event.preventDefault();
target.blur();
return;
}
const row = parseInt(target.dataset.row, 10);
const col = parseInt(target.dataset.col, 10);
// DON'T clear selection if in formula mode (user may be selecting range)
if (hasMultiSelection() && !formulaEditMode) {
clearSelection();
setActiveHeaders(row, col);
}
const { rows, cols } = getState();
if (!isNaN(row) && !isNaN(col) && row < rows && col < cols) {
setEditingCell(row, col);
const previousFormula = formulas[row] ? formulas[row][col] : "";
const rawValue = target.innerText.trim();
if (rawValue.startsWith("=")) {
// Enter formula edit mode
formulaEditMode = true;
formulaEditCell = { row, col, element: target };
// Store formula but DON'T evaluate during typing
formulas[row][col] = rawValue;
data[row][col] = rawValue;
FormulaDropdownManager.update(target, rawValue);
} else {
// Exit formula edit mode
formulaEditMode = false;
formulaEditCell = null;
// Regular value - clear any existing formula
formulas[row][col] = "";
data[row][col] = sanitizeHTML(target.innerHTML);
FormulaDropdownManager.hide();
// Recalculate dependent formulas when regular values change
recalculateFormulas();
}
debouncedUpdateURL();
if (canBroadcastP2P()) {
P2PManager.broadcastCellUpdate(row, col, data[row][col], formulas[row][col]);
}
updateSelectionStatusBar();
if (previousFormula !== formulas[row][col]) {
scheduleDependencyDraw();
}
}
}
// Selection and hover functions moved to modules/rowColManager.js
// Cell positioning functions moved to modules/rowColManager.js
function setEditingCell(row, col) {
editingCell = { row, col };
}
function clearEditingCell() {
editingCell = null;
}
function isEditingCell(row, col) {
return !!(editingCell && editingCell.row === row && editingCell.col === col);
}
function handleFocusIn(event) {
const target = event.target;
if (!target.classList.contains("cell-content")) return;
const row = parseInt(target.dataset.row, 10);
const col = parseInt(target.dataset.col, 10);
if (isNaN(row) || isNaN(col)) return;
// If we have a multi-selection and focus moves to a cell outside it, clear selection
if (hasMultiSelection()) {
const bounds = getSelectionBounds();
if (bounds && (row < bounds.minRow || row > bounds.maxRow || col < bounds.minCol || col > bounds.maxCol)) {
clearSelection();
}
}
// Update header highlighting for single cell focus (if no multi-selection)
if (!hasMultiSelection()) {
setActiveHeaders(row, col);
}
// Show formula text when focused (for editing)
if (formulas[row][col] && formulas[row][col].startsWith("=")) {
target.innerText = formulas[row][col];
}
if (canBroadcastP2P()) {
P2PManager.broadcastCursor(row, col, "#ff0055");
}
}
function handleFocusOut(event) {
const target = event.target;
if (!target.classList.contains("cell-content")) return;
FormulaDropdownManager.hide();
const row = parseInt(target.dataset.row, 10);
const col = parseInt(target.dataset.col, 10);
// If we're in formula edit mode and currently selecting a range, don't process blur
const { isSelecting, rows, cols } = getState();
if (formulaEditMode && isSelecting) {
return;
}
// Evaluate formula when blurred
if (!isNaN(row) && !isNaN(col)) {
const rawValue = target.innerText.trim();
const previousFormula = formulas[row] ? formulas[row][col] : "";
if (rawValue.startsWith("=")) {
// NOW evaluate the formula
formulas[row][col] = rawValue;
const result = FormulaEvaluator.evaluate(rawValue, { getCellValue, data, rows, cols });
data[row][col] = String(result);
target.innerText = String(result);
// Recalculate all dependent formulas
recalculateFormulas();
debouncedUpdateURL();
updateSelectionStatusBar();
if (canBroadcastP2P()) {
P2PManager.broadcastCellUpdate(row, col, data[row][col], formulas[row][col]);
}
if (previousFormula !== formulas[row][col]) {
scheduleDependencyDraw();
}
}
}
// Exit formula edit mode when focus truly leaves
clearEditingCell();
formulaEditMode = false;
formulaEditCell = null;
formulaRangeStart = null;
formulaRangeEnd = null;
const container = document.getElementById("spreadsheet");
if (!container) return;
const next = event.relatedTarget;
if (next && container.contains(next)) {
return;
}
clearActiveHeaders();
}
// ========== P2P Collaboration ==========
function setP2PStatus(message) {
if (p2pUI.statusEl) {
p2pUI.statusEl.textContent = message;
}
}
function resetP2PControls() {
if (p2pUI.startHostBtn) {
p2pUI.startHostBtn.disabled = false;
if (p2pUI.startHostLabel) {
p2pUI.startHostBtn.innerHTML = p2pUI.startHostLabel;
}
}
if (p2pUI.joinBtn) {
p2pUI.joinBtn.disabled = false;
if (p2pUI.joinLabel) {
p2pUI.joinBtn.innerHTML = p2pUI.joinLabel;
}
}
if (p2pUI.idDisplay) {
p2pUI.idDisplay.classList.add("hidden");
}
}
function canBroadcastP2P() {
if (isRemoteUpdate) return false;
if (!P2PManager.canSend()) return false;
if (!P2PManager.isHost && !hasInitialSync) return false;
return true;
}
function sendFullSyncNow() {
if (!canBroadcastP2P()) return;
recalculateFormulas();
const fullState = buildCurrentState();
P2PManager.sendFullSync(fullState);
}
function scheduleFullSync() {
if (!canBroadcastP2P()) return;
if (fullSyncTimer) {
clearTimeout(fullSyncTimer);
}
fullSyncTimer = setTimeout(() => {
sendFullSyncNow();
}, FULL_SYNC_DELAY);
}
function handleGridResize() {
scheduleFullSync();
scheduleDependencyDraw();
}
function clearRemoteCursor() {
document.querySelectorAll(`.${REMOTE_CURSOR_CLASS}`).forEach((el) => el.classList.remove(REMOTE_CURSOR_CLASS));
}
function handleP2PConnection(amIHost) {
hasInitialSync = amIHost;
if (amIHost) {
recalculateFormulas();
const fullState = buildCurrentState();
P2PManager.sendInitialSync(fullState);
setP2PStatus("Connected. You can now edit together.");
if (p2pUI.modal) {
p2pUI.modal.classList.add("hidden");
}
} else {
setP2PStatus("Connected. Waiting for host data...");
showToast("Waiting for host data...", "info");
}
}
function handleInitialSync(remoteState) {
if (!remoteState) return;
isRemoteUpdate = true;
applyLoadedState(remoteState);
renderGrid();
DependencyTracer.init();
recalculateFormulas();
debouncedUpdateURL();
updateSelectionStatusBar();
scheduleDependencyDraw();
clearRemoteCursor();
isRemoteUpdate = false;
hasInitialSync = true;
setP2PStatus("Synced with host.");
if (p2pUI.modal) {
p2pUI.modal.classList.add("hidden");
}
showToast("Synced with host!", "success");
}
function handleRemoteCellUpdate({ row, col, value, formula }) {
const rowIndex = parseInt(row, 10);
const colIndex = parseInt(col, 10);
if (isNaN(rowIndex) || isNaN(colIndex)) return;
const { rows, cols } = getState();
if (rowIndex < 0 || colIndex < 0 || rowIndex >= rows || colIndex >= cols) {
P2PManager.requestFullSync();
return;
}
const rawValue = value === undefined || value === null ? "" : String(value);
const rawFormula = formula === undefined || formula === null ? "" : String(formula);
const previousFormula = formulas[rowIndex] ? formulas[rowIndex][colIndex] : "";
const safeValue = sanitizeHTML(rawValue);
const isFormulaCell = rawFormula.startsWith("=");
const isFormulaEdit = isFormulaCell && rawValue.trim().startsWith("=");
isRemoteUpdate = true;
data[rowIndex][colIndex] = isFormulaEdit ? rawFormula : safeValue;
formulas[rowIndex][colIndex] = rawFormula;
const cellContent = getCellContentElement(rowIndex, colIndex);
if (cellContent && document.activeElement !== cellContent) {
if (isFormulaEdit) {
cellContent.innerText = rawFormula;
} else {
cellContent.innerHTML = safeValue;
}
}
if (!isFormulaEdit) {
recalculateFormulas();
}
debouncedUpdateURL();
if (previousFormula !== formulas[rowIndex][colIndex]) {
scheduleDependencyDraw();
}
isRemoteUpdate = false;
}
function handleRemoteCursor({ row, col }) {
const rowIndex = parseInt(row, 10);
const colIndex = parseInt(col, 10);
if (isNaN(rowIndex) || isNaN(colIndex)) return;
const { rows, cols } = getState();
if (rowIndex < 0 || colIndex < 0 || rowIndex >= rows || colIndex >= cols) return;
clearRemoteCursor();
const cell = getCellElement(rowIndex, colIndex);
if (cell) {
cell.classList.add(REMOTE_CURSOR_CLASS);
}
}
function handleSyncRequest() {
if (!P2PManager.canSend()) return;
if (!hasInitialSync && !P2PManager.isHost) return;
sendFullSyncNow();
}
function handleP2PDisconnect() {
hasInitialSync = false;
clearRemoteCursor();
resetP2PControls();
setP2PStatus("Disconnected.");
showToast("Peer disconnected", "warning");
}
// ========== Mouse Selection Handlers ==========
function handleCellDoubleClick(event) {
if (!(event.target instanceof Element)) return;
let cellContent = null;
if (event.target.classList.contains("cell-content")) {
cellContent = event.target;
} else if (event.target.classList.contains("cell")) {
cellContent = event.target.querySelector(".cell-content");
} else {
cellContent = event.target.closest(".cell-content");
}
if (!cellContent || !cellContent.classList.contains("cell-content")) return;
const row = parseInt(cellContent.dataset.row, 10);
const col = parseInt(cellContent.dataset.col, 10);
if (isNaN(row) || isNaN(col)) return;
setEditingCell(row, col);
}
// Mouse handlers moved to modules/rowColManager.js
function handleSelectionKeyDown(event) {
if (FormulaDropdownManager.isOpen()) {
if (event.key === "ArrowDown") {
FormulaDropdownManager.moveSelection(1);
event.preventDefault();
return;
}
if (event.key === "ArrowUp") {
FormulaDropdownManager.moveSelection(-1);
event.preventDefault();
return;
}
if (event.key === "Enter" || event.key === "Tab") {
const formulaName = FormulaDropdownManager.getActiveFormulaName();
if (formulaName) {
applyFormulaSuggestion(formulaName);
}
event.preventDefault();
return;
}
if (event.key === "Escape") {
FormulaDropdownManager.hide();
event.preventDefault();
return;
}
}
const state = getState();
const { rows, cols, selectionStart } = state;
if (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "ArrowLeft" || event.key === "ArrowRight") {
const target = event.target;
if (target.classList.contains("cell-content") && !event.altKey && !event.ctrlKey && !event.metaKey) {
const row = parseInt(target.dataset.row, 10);
const col = parseInt(target.dataset.col, 10);
if (!isNaN(row) && !isNaN(col) && !formulaEditMode && !isEditingCell(row, col)) {
let nextRow = row;
let nextCol = col;
if (event.key === "ArrowUp") nextRow -= 1;
if (event.key === "ArrowDown") nextRow += 1;
if (event.key === "ArrowLeft") nextCol -= 1;
if (event.key === "ArrowRight") nextCol += 1;
nextRow = Math.max(0, Math.min(rows - 1, nextRow));
nextCol = Math.max(0, Math.min(cols - 1, nextCol));
event.preventDefault();
if (event.shiftKey) {
if (!selectionStart) {
setState("selectionStart", { row, col });
}
setState("selectionEnd", { row: nextRow, col: nextCol });
} else {
setState("selectionStart", { row: nextRow, col: nextCol });
setState("selectionEnd", { row: nextRow, col: nextCol });
}
updateSelectionVisuals();
focusCellAt(nextRow, nextCol);
return;
}
}
}
// Escape key clears selection
if (event.key === "Escape" && hasMultiSelection()) {
clearSelection();
event.preventDefault();
return;
}
// Delete/Backspace key clears selected cells
if (event.key === "Delete" || event.key === "Backspace") {
const activeElement = document.activeElement;
const isEditingContent = activeElement && activeElement.classList.contains("cell-content") && activeElement.innerText.length > 0;
// Clear all cells if multi-selection, or clear single cell if not actively editing
if (hasMultiSelection() || !isEditingContent) {
event.preventDefault();
clearSelectedCells();
return;
}
}
// Enter key: evaluate formula / move to cell below
if (event.key === "Enter") {
const target = event.target;
if (!target.classList.contains("cell-content")) return;
const row = parseInt(target.dataset.row, 10);
const col = parseInt(target.dataset.col, 10);
if (isNaN(row) || isNaN(col)) return;
// Prevent default newline behavior
event.preventDefault();
clearEditingCell();
// Check if this is a formula cell - evaluate it
const rawValue = target.innerText.trim();
if (rawValue.startsWith("=")) {
const previousFormula = formulas[row] ? formulas[row][col] : "";
formulas[row][col] = rawValue;
const result = FormulaEvaluator.evaluate(rawValue, { getCellValue, data, rows, cols });
data[row][col] = String(result);
target.innerText = String(result);
recalculateFormulas();
debouncedUpdateURL();
if (canBroadcastP2P()) {
P2PManager.broadcastCellUpdate(row, col, data[row][col], formulas[row][col]);
}
if (previousFormula !== formulas[row][col]) {
scheduleDependencyDraw();
}
// Exit formula edit mode
formulaEditMode = false;
formulaEditCell = null;
}
// Try to move to cell below
const nextRow = row + 1;
if (nextRow < rows) {
// Focus cell below
const nextCell = document.querySelector(`.cell-content[data-row="${nextRow}"][data-col="${col}"]`);
if (nextCell) {
nextCell.focus();
}
} else {
// No row below - just blur current cell
target.blur();
}
}
}
// Resize and Touch handlers moved to modules/rowColManager.js
function forEachTargetCell(callback) {
const bounds = getSelectionBounds();
if (bounds) {
for (let r = bounds.minRow; r <= bounds.maxRow; r++) {
for (let c = bounds.minCol; c <= bounds.maxCol; c++) {
callback(r, c);
}
}
return true;
}
const activeElement = document.activeElement;
if (activeElement && activeElement.classList.contains("cell-content")) {
const row = parseInt(activeElement.dataset.row, 10);
const col = parseInt(activeElement.dataset.col, 10);
if (!isNaN(row) && !isNaN(col)) {
callback(row, col);
return true;
}
}
const { activeRow, activeCol } = getState();
if (activeRow !== null && activeCol !== null) {
callback(activeRow, activeCol);
return true;
}
return false;
}
function applyAlignment(align) {
const normalized = normalizeAlignment(align);
if (!normalized) return;
const updated = forEachTargetCell(function (row, col) {
if (!cellStyles[row]) cellStyles[row] = [];
if (!cellStyles[row][col]) cellStyles[row][col] = createEmptyCellStyle();
cellStyles[row][col].align = normalized;
const cellContent = getCellContentElement(row, col);
if (cellContent) {
cellContent.style.textAlign = normalized;
}
});
if (updated) {
debouncedUpdateURL();
scheduleFullSync();
}
}
function applyCellBackground(color) {
if (!isValidCSSColor(color)) return;
const updated = forEachTargetCell(function (row, col) {
if (!cellStyles[row]) cellStyles[row] = [];
if (!cellStyles[row][col]) cellStyles[row][col] = createEmptyCellStyle();
cellStyles[row][col].bg = color;
const cell = getCellElement(row, col);
if (cell) {
if (color) {
cell.style.setProperty("--cell-bg", color);
} else {
cell.style.removeProperty("--cell-bg");
}
}
});
if (updated) {
debouncedUpdateURL();
scheduleFullSync();
}
}
function applyCellTextColor(color) {
if (!isValidCSSColor(color)) return;
const updated = forEachTargetCell(function (row, col) {
if (!cellStyles[row]) cellStyles[row] = [];
if (!cellStyles[row][col]) cellStyles[row][col] = createEmptyCellStyle();
cellStyles[row][col].color = color;
const cellContent = getCellContentElement(row, col);
if (cellContent) {
cellContent.style.color = color;
}
});
if (updated) {
debouncedUpdateURL();
scheduleFullSync();
}
}
function applyFontSize(size) {
const normalized = normalizeFontSize(size);
const updated = forEachTargetCell(function (row, col) {
if (!cellStyles[row]) cellStyles[row] = [];
if (!cellStyles[row][col]) cellStyles[row][col] = createEmptyCellStyle();
cellStyles[row][col].fontSize = normalized;
const cellContent = getCellContentElement(row, col);
if (cellContent) {
cellContent.style.fontSize = normalized ? `${normalized}px` : "";
}
});
if (updated) {
debouncedUpdateURL();
scheduleFullSync();
}
}
// Apply text formatting using modern Selection/Range API (replaces deprecated execCommand)
function applyFormat(command) {
const selection = window.getSelection();
if (!selection || !selection.rangeCount) return;
const range = selection.getRangeAt(0);
const selectedText = range.toString();
if (!selectedText) return;
// Create wrapper element based on command
let wrapper;
switch (command) {
case "bold":
wrapper = document.createElement("b");
break;
case "italic":
wrapper = document.createElement("i");
break;
case "underline":
wrapper = document.createElement("u");
break;
default:
return;
}
// Only proceed if selection is within a cell-content element
const activeElement = document.activeElement;
if (!activeElement || !activeElement.classList.contains("cell-content")) return;
try {
// Wrap the selected content
range.surroundContents(wrapper);
} catch (e) {
// surroundContents fails if selection crosses element boundaries
// Fall back to extracting and re-inserting
const fragment = range.extractContents();
wrapper.appendChild(fragment);
range.insertNode(wrapper);
}
// Update data after formatting
const row = parseInt(activeElement.dataset.row, 10);
const col = parseInt(activeElement.dataset.col, 10);
const { rows, cols } = getState();
if (!isNaN(row) && !isNaN(col) && row < rows && col < cols) {
data[row][col] = sanitizeHTML(activeElement.innerHTML);
debouncedUpdateURL();
if (canBroadcastP2P()) {
P2PManager.broadcastCellUpdate(row, col, data[row][col], formulas[row][col]);
}
}
}
// Handle paste to strip unwanted HTML
function handlePaste(event) {
const target = event.target;
if (!target.classList.contains("cell-content")) return;
// GUARD: Block in read-only mode (silent)
if (isReadOnly) {
event.preventDefault();
return;
}
event.preventDefault();
const text = event.clipboardData.getData("text/plain");
document.execCommand("insertText", false, text);
}
// clearSpreadsheet, addRow, addColumn moved to modules/rowColManager.js
// Load state from URL on page load
// Returns true if data loaded successfully, false if waiting for password
function loadStateFromURL() {
const hash = window.location.hash.slice(1); // Remove #
if (hash) {
const loadedState = URLManager.decodeState(hash);
if (loadedState) {
// Check if data is encrypted
if (loadedState.encrypted) {
// delegate to PasswordManager
PasswordManager.handleEncryptedData(loadedState.data);
// Initialize with default state while waiting for password
setState("rows", DEFAULT_ROWS);
setState("cols", DEFAULT_COLS);
setState("colWidths", createDefaultColumnWidths(DEFAULT_COLS));
setState("rowHeights", createDefaultRowHeights(DEFAULT_ROWS));
data = createEmptyData(DEFAULT_ROWS, DEFAULT_COLS);
cellStyles = createEmptyCellStyles(DEFAULT_ROWS, DEFAULT_COLS);
formulas = createEmptyData(DEFAULT_ROWS, DEFAULT_COLS);
isReadOnly = false; // Default while waiting for decrypt
return false;
}
const loadedRows = loadedState.rows;
const loadedCols = loadedState.cols;
setState("rows", loadedRows);
setState("cols", loadedCols);
setState("colWidths", loadedState.colWidths || createDefaultColumnWidths(loadedCols));
setState("rowHeights", loadedState.rowHeights || createDefaultRowHeights(loadedRows));
data = loadedState.data;
formulas = loadedState.formulas || createEmptyData(loadedRows, loadedCols);
cellStyles = loadedState.cellStyles || createEmptyCellStyles(loadedRows, loadedCols);
// Apply theme from URL if present
if (loadedState.theme) {
applyTheme(loadedState.theme);
}
// Load read-only mode
applyReadOnlyState(loadedState.readOnly);
// Load embed mode
isEmbedMode = loadedState.embed || false;
if (isEmbedMode) {
applyEmbedMode();
}
return true;
}
}
// Default state
setState("rows", DEFAULT_ROWS);
setState("cols", DEFAULT_COLS);
setState("colWidths", createDefaultColumnWidths(DEFAULT_COLS));
setState("rowHeights", createDefaultRowHeights(DEFAULT_ROWS));
data = createEmptyData(DEFAULT_ROWS, DEFAULT_COLS);
cellStyles = createEmptyCellStyles(DEFAULT_ROWS, DEFAULT_COLS);
formulas = createEmptyData(DEFAULT_ROWS, DEFAULT_COLS);
isReadOnly = false;
isEmbedMode = false;
clearReadOnlyMode();
clearEmbedMode();
return true;
}
// Toggle dark/light mode
function toggleTheme() {
const body = document.body;
const isDark = body.classList.toggle("dark-mode");
const theme = isDark ? "dark" : "light";
// Save preference to localStorage
try {
localStorage.setItem("spreadsheet-theme", theme);
} catch (e) {
// localStorage not available
}
// Update URL with new theme
debouncedUpdateURL();
scheduleFullSync();
}
// Load saved theme preference
function loadTheme() {
try {
const savedTheme = localStorage.getItem("spreadsheet-theme");
if (savedTheme === "dark") {
document.body.classList.add("dark-mode");
} else if (savedTheme === "light") {
document.body.classList.remove("dark-mode");
} else {
// Check system preference if no saved preference
if (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches) {
document.body.classList.add("dark-mode");
}
}
} catch (e) {
// localStorage not available
}
}
// Copy URL to clipboard
function copyURL() {
const url = window.location.href;
const copyBtn = document.getElementById("copy-url");
navigator.clipboard
.writeText(url)
.then(function () {
// Show success feedback
showToast("Link copied to clipboard!", "success");
if (copyBtn) {
copyBtn.classList.add("copied");
const icon = copyBtn.querySelector("i");
if (icon) {
icon.className = "fa-solid fa-check";
}
// Reset after 2 seconds
setTimeout(function () {
copyBtn.classList.remove("copied");
if (icon) {
icon.className = "fa-solid fa-copy";
}
}, 2000);
}
})
.catch(function (err) {
console.error("Failed to copy URL:", err);
// Fallback for older browsers
const textArea = document.createElement("textarea");
textArea.value = url;
textArea.style.position = "fixed";
textArea.style.opacity = "0";
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand("copy");
if (copyBtn) {
copyBtn.classList.add("copied");
setTimeout(function () {
copyBtn.classList.remove("copied");
}, 2000);
}
} catch (e) {
console.error("Fallback copy failed:", e);
}
document.body.removeChild(textArea);
});
}
// Generate and display QR code for the current URL
async function showQRModalWithCode() {
const modal = document.getElementById("qr-modal");
const container = document.getElementById("qrcode-container");
const warningText = document.getElementById("qr-warning");
if (!modal || !container) return;
if (typeof QRCode !== "function") {
alert("QR Generator module not loaded.");
return;
}
// Ensure formulas are fresh and the latest state is encoded into the URL
recalculateFormulas();
try {
await updateURL();
} catch (err) {
console.error("Failed to sync data before generating QR", err);
alert("Could not prepare data for QR code.");
return;
}
const currentUrl = window.location.href;
container.innerHTML = "";
if (warningText) {
warningText.textContent = "";
warningText.classList.add("hidden");
}
if (currentUrl.length > MAX_QR_URL_LENGTH) {
if (warningText) {
warningText.textContent = `Spreadsheet too large for QR transfer (${currentUrl.length} characters). Remove some data and try again.`;
warningText.classList.remove("hidden");
}
modal.classList.remove("hidden");
return;
}
try {
new QRCode(container, {
text: currentUrl,
width: 240,
height: 240,
colorDark: "#000000",
colorLight: "#ffffff",
correctLevel: QRCode.CorrectLevel.M,
});
modal.classList.remove("hidden");
} catch (e) {
console.error("QR Library missing or error", e);
alert("Could not generate QR code.");
}
}
function hideQRModal() {
const modal = document.getElementById("qr-modal");
const container = document.getElementById("qrcode-container");
const warningText = document.getElementById("qr-warning");
if (modal) {
modal.classList.add("hidden");
}
if (container) {
container.innerHTML = "";
}
if (warningText) {
warningText.textContent = "";
warningText.classList.add("hidden");
}
}
// ========== Password/Encryption Modal Functions ==========
// Handled by PasswordManager module
/**
* Apply read-only state to the spreadsheet
* @param {boolean} readOnlyFlag - Whether to enable read-only mode
*/
function applyReadOnlyState(readOnlyFlag) {
// If in embed mode, always stay read-only
if (isEmbedMode) {
isReadOnly = true;
applyReadOnlyMode();
return;
}
isReadOnly = readOnlyFlag || false;
if (isReadOnly) {
applyReadOnlyMode();
} else {
clearReadOnlyMode();
}
}
// Apply loaded state to variables
function applyLoadedState(loadedState) {
if (!loadedState) return;
const r = Math.min(Math.max(1, loadedState.rows || DEFAULT_ROWS), MAX_ROWS);
const c = Math.min(Math.max(1, loadedState.cols || DEFAULT_COLS), MAX_COLS);
setState("rows", r);
setState("cols", c);
// Process data array
let d = loadedState.data;
if (Array.isArray(d)) {
d = d.slice(0, r).map((row) => {
if (Array.isArray(row)) {
return row.slice(0, c).map((cell) => sanitizeHTML(String(cell || "")));
}
return Array(c).fill("");
});
while (d.length < r) d.push(Array(c).fill(""));
d = d.map((row) => {
while (row.length < c) row.push("");
return row;
});
} else {
d = createEmptyData(r, c);
}
data = d;
// Process formulas
let f = loadedState.formulas;
if (Array.isArray(f)) {
f = f.slice(0, r).map((row, rowIdx) => {
if (Array.isArray(row)) {
return row.slice(0, c).map((cell, colIdx) => {
const formula = String(cell || "");
if (formula.startsWith("=")) {
if (isValidFormula(formula)) {
return formula;
} else {
data[rowIdx][colIdx] = escapeHTML(formula);
return "";
}
}
return formula;
});
}
return Array(c).fill("");
});
while (f.length < r) f.push(Array(c).fill(""));
f = f.map((row) => {
while (row.length < c) row.push("");
return row;
});
} else {
f = createEmptyData(r, c);
}
formulas = f;
cellStyles = normalizeCellStyles(loadedState.cellStyles, r, c);
setState("colWidths", normalizeColumnWidths(loadedState.colWidths, c));
setState("rowHeights", normalizeRowHeights(loadedState.rowHeights, r));
if (loadedState.theme) {
applyTheme(loadedState.theme);
}
isEmbedMode = Boolean(loadedState.embed);
if (isEmbedMode) {
applyEmbedMode();
} else {
clearEmbedMode();
}
// Apply read-only mode if present (embed mode forces read-only)
applyReadOnlyState(loadedState.readOnly);
}
// Initialize the app
function init() {
// Set up callbacks for rowColManager module
setCallbacks({
debouncedUpdateURL,
recalculateFormulas,
getDataArray,
setDataArray,
getFormulasArray,
setFormulasArray,
getCellStylesArray,
setCellStylesArray,
PasswordManager,
getReadOnlyFlag: () => isReadOnly,
// Formula mode callbacks
getFormulaEditMode: () => formulaEditMode,
getFormulaEditCell: () => formulaEditCell,
setFormulaRangeStart: (val) => {
formulaRangeStart = val;
},
setFormulaRangeEnd: (val) => {
formulaRangeEnd = val;
},
getFormulaRangeStart: () => formulaRangeStart,
getFormulaRangeEnd: () => formulaRangeEnd,
buildRangeRef,
insertTextAtCursor,
FormulaDropdownManager,
onSelectionChange: updateSelectionStatusBar,
onGridResize: handleGridResize,
onFormulaChange: scheduleDependencyDraw,
});
CSVManager.init({
getState,
getDataArray,
setDataArray,
setFormulasArray,
setCellStylesArray,
setState,
renderGrid,
recalculateFormulas,
debouncedUpdateURL,
showToast,
extractPlainText,
onImport: () => {
scheduleFullSync();
refreshDependencyLayer();
},
});
JSONManager.init({
buildCurrentState: () => minifyStateForExport(buildCurrentState()),
recalculateFormulas,
showToast,
});
P2PManager.init({
onHostReady: (id) => {
if (p2pUI.myIdInput) {
p2pUI.myIdInput.value = id;
}
if (p2pUI.idDisplay) {
p2pUI.idDisplay.classList.remove("hidden");
}
setP2PStatus("Waiting for connection...");
},
onConnectionOpened: handleP2PConnection,
onInitialSync: handleInitialSync,
onRemoteCellUpdate: handleRemoteCellUpdate,
onRemoteCursorMove: handleRemoteCursor,
onSyncRequest: handleSyncRequest,
onConnectionClosed: handleP2PDisconnect,
onConnectionError: () => {
resetP2PControls();
setP2PStatus("Connection error.");
},
});
// Load theme preference first (before any rendering)
loadTheme();
// Load any existing state from URL
loadStateFromURL();
// Render the grid
renderGrid();
// Initialize Dependency Tracer layer
DependencyTracer.init();
// Initialize Formula Dropdown
FormulaDropdownManager.init(applyFormulaSuggestion);
// Initialize Password Manager
PasswordManager.init({
decryptAndDecode: URLManager.decryptAndDecode,
onDecryptSuccess: (state) => {
applyLoadedState(state);
renderGrid();
DependencyTracer.init();
scheduleDependencyDraw();
},
updateURL: updateURL,
showToast: showToast,
validateState: validateAndNormalizeState,
});
PresentationManager.init();
// Initialize URL length indicator with current hash length
const currentHash = window.location.hash.slice(1);
URLManager.updateURLLengthIndicator(currentHash.length);
// Set up event delegation for input handling
const container = document.getElementById("spreadsheet");
if (container) {
container.addEventListener("input", handleInput);
container.addEventListener("focusin", handleFocusIn);
container.addEventListener("focusout", handleFocusOut);
container.addEventListener("paste", handlePaste);
// Selection mouse events
container.addEventListener("mousedown", handleResizeStart);
container.addEventListener("mousedown", handleMouseDown);
container.addEventListener("dblclick", handleCellDoubleClick);
container.addEventListener("mousemove", handleMouseMove);
container.addEventListener("mouseleave", handleMouseLeave);
container.addEventListener("mouseup", handleMouseUp);
container.addEventListener("keydown", handleSelectionKeyDown);
container.addEventListener("keyup", () => updateSelectionStatusBar());
// Touch events for mobile selection
container.addEventListener("touchstart", handleTouchStart, { passive: false });
container.addEventListener("touchmove", handleTouchMove, { passive: false });
container.addEventListener("touchend", handleTouchEnd);
}
const gridWrapper = document.querySelector(".grid-wrapper");
if (gridWrapper) {
gridWrapper.addEventListener("scroll", function () {
if (FormulaDropdownManager.isOpen() && FormulaDropdownManager.anchor) {
FormulaDropdownManager.position(FormulaDropdownManager.anchor);
}
scheduleDependencyDraw();
});
}
window.addEventListener("resize", function () {
if (FormulaDropdownManager.isOpen() && FormulaDropdownManager.anchor) {
FormulaDropdownManager.position(FormulaDropdownManager.anchor);
}
scheduleDependencyDraw();
});
// Global mouseup to catch drag ending outside container
document.addEventListener("mouseup", handleMouseUp);
// Format button event listeners
const boldBtn = document.getElementById("format-bold");
const italicBtn = document.getElementById("format-italic");
const underlineBtn = document.getElementById("format-underline");
const alignLeftBtn = document.getElementById("align-left");
const alignCenterBtn = document.getElementById("align-center");
const alignRightBtn = document.getElementById("align-right");
const cellBgPicker = document.getElementById("cell-bg-color");
const cellTextColorPicker = document.getElementById("cell-text-color");
const fontSizeList = document.getElementById("font-size-list");
if (boldBtn) {
boldBtn.addEventListener("mousedown", function (e) {
e.preventDefault(); // Prevent focus loss
applyFormat("bold");
});
}
if (italicBtn) {
italicBtn.addEventListener("mousedown", function (e) {
e.preventDefault();
applyFormat("italic");
});
}
if (underlineBtn) {
underlineBtn.addEventListener("mousedown", function (e) {
e.preventDefault();
applyFormat("underline");
});
}
if (alignLeftBtn) {
alignLeftBtn.addEventListener("mousedown", function (e) {
e.preventDefault();
applyAlignment("left");
});
}
if (alignCenterBtn) {
alignCenterBtn.addEventListener("mousedown", function (e) {
e.preventDefault();
applyAlignment("center");
});
}
if (alignRightBtn) {
alignRightBtn.addEventListener("mousedown", function (e) {
e.preventDefault();
applyAlignment("right");
});
}
if (cellBgPicker) {
cellBgPicker.addEventListener("input", function (e) {
applyCellBackground(e.target.value);
});
}
if (cellTextColorPicker) {
cellTextColorPicker.addEventListener("input", function (e) {
applyCellTextColor(e.target.value);
});
}
if (fontSizeList) {
fontSizeList.addEventListener("mousedown", function (e) {
if (e.target.closest("button")) {
e.preventDefault();
}
});
fontSizeList.addEventListener("click", function (e) {
const button = e.target.closest("button[data-size]");
if (!button) return;
applyFontSize(button.dataset.size);
});
}
// Button event listeners
const addRowBtn = document.getElementById("add-row");
const addColBtn = document.getElementById("add-col");
const clearBtn = document.getElementById("clear-spreadsheet");
const themeToggleBtn = document.getElementById("theme-toggle");
const copyUrlBtn = document.getElementById("copy-url");
const importCsvBtn = document.getElementById("import-csv");
const importCsvInput = document.getElementById("import-csv-file");
const exportCsvBtn = document.getElementById("export-csv");
const presentBtn = document.getElementById("present-btn");
const qrBtn = document.getElementById("qr-btn");
const qrCloseBtn = document.getElementById("qr-close-btn");
const qrBackdrop = document.querySelector("#qr-modal .modal-backdrop");
const traceDepsBtn = document.getElementById("trace-deps-btn");
if (addRowBtn) {
addRowBtn.addEventListener("click", () => {
addRow();
scheduleFullSync();
refreshDependencyLayer();
});
}
if (addColBtn) {
addColBtn.addEventListener("click", () => {
addColumn();
scheduleFullSync();
refreshDependencyLayer();
});
}
if (clearBtn) {
clearBtn.addEventListener("click", () => {
clearSpreadsheet();
scheduleFullSync();
refreshDependencyLayer();
});
}
if (themeToggleBtn) {
themeToggleBtn.addEventListener("click", toggleTheme);
}
if (copyUrlBtn) {
copyUrlBtn.addEventListener("click", copyURL);
}
if (importCsvBtn && importCsvInput) {
importCsvBtn.addEventListener("click", function () {
importCsvInput.click();
});
}
if (importCsvInput) {
importCsvInput.addEventListener("change", function (e) {
const file = e.target.files && e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = function () {
CSVManager.importCSVText(String(reader.result || ""));
};
reader.onerror = function () {
alert("Failed to read the CSV file.");
};
reader.readAsText(file);
e.target.value = "";
});
}
if (exportCsvBtn) {
exportCsvBtn.addEventListener("click", () => CSVManager.downloadCSV());
}
if (presentBtn) {
presentBtn.addEventListener("click", () => {
recalculateFormulas();
const { rows, cols } = getState();
PresentationManager.start(data, formulas, {
getCellValue,
data,
rows,
cols,
});
});
}
if (qrBtn) {
qrBtn.addEventListener("click", showQRModalWithCode);
}
if (qrCloseBtn) {
qrCloseBtn.addEventListener("click", hideQRModal);
}
if (qrBackdrop) {
qrBackdrop.addEventListener("click", hideQRModal);
}
if (traceDepsBtn) {
traceDepsBtn.addEventListener("click", () => {
DependencyTracer.init();
const isActive = DependencyTracer.toggle();
traceDepsBtn.classList.toggle("active", isActive);
if (isActive) {
DependencyTracer.draw(getFormulasArray());
showToast("Visualizing formula dependencies", "info");
} else {
showToast("Logic visualization hidden", "info");
}
});
}
// Embed mode event listeners
const generateEmbedBtn = document.getElementById("generate-embed");
if (generateEmbedBtn) {
generateEmbedBtn.addEventListener("click", showEmbedModal);
}
const embedCopyBtn = document.getElementById("embed-copy-btn");
if (embedCopyBtn) {
embedCopyBtn.addEventListener("click", async () => {
const textarea = document.getElementById("embed-code-textarea");
try {
await navigator.clipboard.writeText(textarea.value);
showToast("Embed code copied to clipboard!", "success");
embedCopyBtn.innerHTML = '<i class="fa-solid fa-check"></i> Copied!';
setTimeout(() => {
embedCopyBtn.innerHTML = '<i class="fa-solid fa-copy"></i> Copy to Clipboard';
}, 2000);
} catch (err) {
textarea.select();
document.execCommand("copy");
showToast("Embed code copied!", "success");
}
});
}
const embedCloseBtn = document.getElementById("embed-close-btn");
if (embedCloseBtn) {
embedCloseBtn.addEventListener("click", hideEmbedModal);
}
const embedBackdrop = document.querySelector("#embed-modal .modal-backdrop");
if (embedBackdrop) {
embedBackdrop.addEventListener("click", hideEmbedModal);
}
// JSON editor modal event listeners
const openJSONBtn = document.getElementById("open-json-btn");
const jsonCloseBtn = document.getElementById("json-close-btn");
const jsonCopyBtn = document.getElementById("json-copy-btn");
const jsonBackdrop = document.querySelector("#json-modal .modal-backdrop");
if (openJSONBtn) {
openJSONBtn.addEventListener("click", () => JSONManager.openModal());
}
if (jsonCloseBtn) {
jsonCloseBtn.addEventListener("click", () => JSONManager.closeModal());
}
if (jsonBackdrop) {
jsonBackdrop.addEventListener("click", () => JSONManager.closeModal());
}
if (jsonCopyBtn) {
jsonCopyBtn.addEventListener("click", () => JSONManager.copyJSONToClipboard());
}
// P2P collaboration event listeners
const p2pBtn = document.getElementById("p2p-btn");
p2pUI.modal = document.getElementById("p2p-modal");
p2pUI.startHostBtn = document.getElementById("p2p-start-host");
p2pUI.joinBtn = document.getElementById("p2p-join-btn");
p2pUI.copyIdBtn = document.getElementById("p2p-copy-id");
p2pUI.idDisplay = document.getElementById("p2p-id-display");
p2pUI.statusEl = document.getElementById("p2p-status");
p2pUI.myIdInput = document.getElementById("p2p-my-id");
p2pUI.remoteIdInput = document.getElementById("p2p-remote-id");
const p2pCloseBtn = document.getElementById("p2p-close-btn");
const p2pBackdrop = document.querySelector("#p2p-modal .modal-backdrop");
if (p2pUI.startHostBtn) {
p2pUI.startHostLabel = p2pUI.startHostBtn.innerHTML;
}
if (p2pUI.joinBtn) {
p2pUI.joinLabel = p2pUI.joinBtn.innerHTML;
}
if (p2pBtn && p2pUI.modal) {
p2pBtn.addEventListener("click", () => p2pUI.modal.classList.remove("hidden"));
}
if (p2pCloseBtn && p2pUI.modal) {
p2pCloseBtn.addEventListener("click", () => p2pUI.modal.classList.add("hidden"));
}
if (p2pBackdrop && p2pUI.modal) {
p2pBackdrop.addEventListener("click", () => p2pUI.modal.classList.add("hidden"));
}
if (p2pUI.startHostBtn) {
p2pUI.startHostBtn.addEventListener("click", async () => {
p2pUI.startHostBtn.disabled = true;
p2pUI.startHostBtn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Connecting...';
const started = await P2PManager.startHosting();
if (!started) {
p2pUI.startHostBtn.disabled = false;
p2pUI.startHostBtn.innerHTML = p2pUI.startHostLabel;
return;
}
p2pUI.startHostBtn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Hosting...';
});
}
if (p2pUI.joinBtn) {
p2pUI.joinBtn.addEventListener("click", async () => {
const id = p2pUI.remoteIdInput ? p2pUI.remoteIdInput.value.trim() : "";
if (!id) {
showToast("Enter host ID to join", "warning");
return;
}
p2pUI.joinBtn.disabled = true;
p2pUI.joinBtn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Connecting...';
const started = await P2PManager.joinSession(id);
if (!started) {
p2pUI.joinBtn.disabled = false;
p2pUI.joinBtn.innerHTML = p2pUI.joinLabel;
}
});
}
if (p2pUI.copyIdBtn) {
p2pUI.copyIdBtn.addEventListener("click", async () => {
if (!p2pUI.myIdInput) return;
try {
await navigator.clipboard.writeText(p2pUI.myIdInput.value);
showToast("ID copied!", "success");
} catch (err) {
p2pUI.myIdInput.select();
document.execCommand("copy");
showToast("ID copied!", "success");
}
});
}
// Read-only mode event listeners
const toggleReadOnlyBtn = document.getElementById("toggle-readonly");
if (toggleReadOnlyBtn) {
toggleReadOnlyBtn.addEventListener("click", toggleReadOnlyMode);
}
const enableEditingBtn = document.getElementById("enable-editing");
if (enableEditingBtn) {
enableEditingBtn.addEventListener("click", function () {
if (isReadOnly) {
toggleReadOnlyMode();
}
});
}
// Password/Encryption event listeners
// Password/Encryption event listeners handled by PasswordManager
/*
const lockBtn = document.getElementById("lock-btn");
const modalCancel = document.getElementById("modal-cancel");
const modalSubmit = document.getElementById("modal-submit");
const modalBackdrop = document.querySelector(".modal-backdrop");
const passwordInput = document.getElementById("password-input");
if (lockBtn) {
lockBtn.addEventListener("click", handleLockButtonClick);
}
if (modalCancel) {
modalCancel.addEventListener("click", hidePasswordModal);
}
if (modalSubmit) {
modalSubmit.addEventListener("click", handleModalSubmit);
}
if (modalBackdrop) {
modalBackdrop.addEventListener("click", function () {
// Only allow closing if not in decrypt mode (user must enter password)
if (modalMode !== "decrypt") {
hidePasswordModal();
}
});
}
if (passwordInput) {
passwordInput.addEventListener("keydown", function (e) {
if (e.key === "Enter") {
e.preventDefault();
const confirmInput = document.getElementById("password-confirm");
// If confirm is visible and empty, focus it; otherwise submit
if (confirmInput && confirmInput.style.display !== "none" && !confirmInput.value) {
confirmInput.focus();
} else {
handleModalSubmit();
}
}
});
}
const passwordConfirm = document.getElementById("password-confirm");
if (passwordConfirm) {
passwordConfirm.addEventListener("keydown", function (e) {
if (e.key === "Enter") {
e.preventDefault();
handleModalSubmit();
}
});
}
*/
// Handle browser back/forward
window.addEventListener("hashchange", function () {
loadStateFromURL();
renderGrid();
DependencyTracer.init();
scheduleDependencyDraw();
});
}
// Start when DOM is ready
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
// Expose audit function globally for testing URL sizes
window.auditURLSize = async function () {
const scenarios = [
{ name: "Empty 30x15", rows: 30, cols: 15, fill: null },
{ name: "Short text (A1, B1...)", rows: 30, cols: 15, fill: "coords" },
{ name: "Medium text (10 chars)", rows: 30, cols: 15, fill: "medium" },
{ name: "Numbers only", rows: 30, cols: 15, fill: "numbers" },
{ name: "With formulas", rows: 30, cols: 15, fill: "formulas" },
];
console.log("=== URL Size Audit ===");
console.log("Max grid: 30 rows x 15 cols = 450 cells\n");
for (const scenario of scenarios) {
const testData = [];
for (let r = 0; r < scenario.rows; r++) {
const row = [];
for (let c = 0; c < scenario.cols; c++) {
const colLetter = String.fromCharCode(65 + c);
const cellRef = colLetter + (r + 1);
switch (scenario.fill) {
case "coords":
row.push(cellRef);
break;
case "medium":
row.push("Text_" + cellRef.padEnd(5, "X"));
break;
case "numbers":
row.push(String(Math.floor(Math.random() * 10000)));
break;
case "formulas":
// Only put formulas in some cells to simulate realistic usage
if (r > 0 && c === 0) {
row.push("=SUM(B" + (r + 1) + ":O" + (r + 1) + ")");
} else {
row.push(String(Math.floor(Math.random() * 100)));
}
break;
default:
row.push("");
}
}
testData.push(row);
}
const state = {
rows: scenario.rows,
cols: scenario.cols,
theme: "light",
data: testData,
};
const json = JSON.stringify(state);
const compressed = LZString.compressToEncodedURIComponent(json);
console.log(`${scenario.name}:`);
console.log(` JSON size: ${json.length.toLocaleString()} chars`);
console.log(` Compressed: ${compressed.length.toLocaleString()} chars`);
console.log(` Compression ratio: ${((1 - compressed.length / json.length) * 100).toFixed(1)}%`);
console.log("");
}
console.log("=== Thresholds ===");
console.log("< 2,000 chars: OK (safe for all browsers)");
console.log("2,000-4,000: Warning (some older browsers may truncate)");
console.log("4,000-8,000: Caution (URL shorteners may fail)");
console.log("> 8,000: Critical (some browsers may fail)");
};
// ========== Toolbar Scroll Logic ==========
function initToolbarScroll() {
const toolbar = document.querySelector(".toolbar");
const scrollLeftBtn = document.getElementById("scroll-left");
const scrollRightBtn = document.getElementById("scroll-right");
if (!toolbar || !scrollLeftBtn || !scrollRightBtn) return;
function updateScrollButtons() {
// Check if content overflows
const isOverflowing = toolbar.scrollWidth > toolbar.clientWidth;
const scrollLeft = toolbar.scrollLeft;
const maxScroll = toolbar.scrollWidth - toolbar.clientWidth;
// Tolerance (fixes weird browser sub-pixel issues)
const tolerance = 2;
if (!isOverflowing) {
scrollLeftBtn.classList.add("hidden");
scrollRightBtn.classList.add("hidden");
return;
}
// Show/Hide Left Button
if (scrollLeft > tolerance) {
scrollLeftBtn.classList.remove("hidden");
} else {
scrollLeftBtn.classList.add("hidden");
}
// Show/Hide Right Button
if (scrollLeft < maxScroll - tolerance) {
scrollRightBtn.classList.remove("hidden");
} else {
scrollRightBtn.classList.add("hidden");
}
}
// Scroll amount for button clicks
const scrollAmount = 200;
scrollLeftBtn.addEventListener("click", () => {
toolbar.scrollBy({ left: -scrollAmount, behavior: "smooth" });
});
scrollRightBtn.addEventListener("click", () => {
toolbar.scrollBy({ left: scrollAmount, behavior: "smooth" });
});
// Listen for scroll events
toolbar.addEventListener("scroll", () => {
// Debounce the UI update slightly for performance
requestAnimationFrame(updateScrollButtons);
});
// Update on resize
window.addEventListener("resize", updateScrollButtons);
// Initial check
updateScrollButtons();
}
// Tools Modal Logic
const toolsMenuBtn = document.getElementById("tools-menu-btn");
const toolsModal = document.getElementById("tools-modal");
const toolsCloseBtn = document.getElementById("tools-close-btn");
if (toolsMenuBtn && toolsModal && toolsCloseBtn) {
toolsMenuBtn.addEventListener("click", () => {
toolsModal.classList.remove("hidden");
});
toolsCloseBtn.addEventListener("click", () => {
toolsModal.classList.add("hidden");
});
toolsModal.addEventListener("click", (e) => {
if (e.target === toolsModal || e.target.classList.contains("modal-backdrop")) {
toolsModal.classList.add("hidden");
}
});
// Close modal when a tool button is clicked (improved UX)
toolsModal.querySelectorAll('.tool-item').forEach(btn => {
btn.addEventListener('click', () => {
// Optional: Don't close for toggles if we want to toggle multiple times
// But for now, let's close it to emulate a menu
// Exception: maybe theme toggle?
if (!btn.id.includes('toggle')) {
toolsModal.classList.add("hidden");
}
});
});
}
// Initialize all modules
initToolbarScroll();
})();
wget 'https://sme10.lists2.roe3.org/spreadsheet/styles.css'
/* CSS Variables for theming */
:root {
--bg-primary: #f5f5f5;
--bg-secondary: white;
--bg-header: #f8f9fa;
--bg-hint: #fafafa;
--bg-cell-focus: #e8f4fc;
--text-primary: #333;
--text-secondary: #666;
--text-muted: #888;
--border-color: #e0e0e0;
--border-hover: #999;
--accent-color: #4a90d9;
--accent-hover: #3a7bc8;
--cell-focus-shadow: rgba(74, 144, 217, 0.4);
--selection-bg: rgba(74, 144, 217, 0.12);
--selection-border: #4a90d9;
--selection-grid: rgba(0, 0, 0, 0.08);
--hover-bg: rgba(74, 144, 217, 0.06);
--hover-header-bg: rgba(74, 144, 217, 0.1);
--glass-bg: rgba(255, 255, 255, 0.85);
--glass-border: rgba(255, 255, 255, 0.5);
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
--radius-sm: 6px;
--radius-md: 10px;
}
/* Dark mode colors */
.dark-mode {
--bg-primary: #121212;
--bg-secondary: #1e1e1e;
--bg-header: #252525;
--bg-hint: #1a1a1a;
--bg-cell-focus: #142842;
--text-primary: #e0e0e0;
--text-secondary: #a0a0a0;
--text-muted: #666;
--border-color: #333;
--border-hover: #505050;
--accent-color: #5a9fe9;
--accent-hover: #4a8fd9;
--cell-focus-shadow: rgba(90, 159, 233, 0.35);
--selection-bg: rgba(90, 159, 233, 0.15);
--selection-border: #5a9fe9;
--selection-grid: rgba(255, 255, 255, 0.08);
--hover-bg: rgba(90, 159, 233, 0.1);
--hover-header-bg: rgba(90, 159, 233, 0.18);
--glass-bg: rgba(30, 30, 30, 0.85);
--glass-border: rgba(255, 255, 255, 0.08);
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.2);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.3);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
height: 100%;
overflow: hidden;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: var(--bg-primary);
/* Premium Dot Grid Pattern */
background-image:
radial-gradient(circle at 1px 1px, var(--border-color) 1px, transparent 0);
background-size: 24px 24px;
background-attachment: fixed;
color: var(--text-primary);
transition: background-color 0.3s, color 0.3s;
letter-spacing: -0.01em;
}
/* Modern Scrollbars */
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.15);
border-radius: 5px;
border: 2px solid transparent;
background-clip: content-box;
transition: background 0.2s;
}
::-webkit-scrollbar-thumb:hover {
background-color: rgba(0, 0, 0, 0.3);
}
.dark-mode ::-webkit-scrollbar-thumb {
background-color: rgba(255, 255, 255, 0.2);
}
.dark-mode ::-webkit-scrollbar-thumb:hover {
background-color: rgba(255, 255, 255, 0.35);
}
.container {
display: flex;
flex-direction: column;
height: 100vh;
width: 100vw;
}
/* Header bar */
.header {
display: flex;
align-items: center;
padding: 12px 16px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
gap: 16px;
flex-shrink: 0;
transition: background-color 0.3s, border-color 0.3s;
}
.header h1 {
color: var(--text-primary);
font-weight: 500;
font-size: 1.25rem;
margin: 0;
display: flex;
align-items: center;
gap: 8px;
}
.header h1 i {
color: var(--accent-color);
}
.toolbar {
display: flex;
gap: 8px;
align-items: center;
margin: 0;
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0; /* Prevent title from shrinking */
}
.header h1 i {
color: var(--accent-color);
}
.toolbar {
display: flex;
gap: 8px;
align-items: center;
flex: 1;
overflow-x: auto; /* Enable scroll on all devices */
overflow-y: hidden;
scrollbar-width: none; /* Hide scrollbar Firefox */
-ms-overflow-style: none; /* Hide scrollbar IE/Edge */
padding: 4px 0; /* Space for focus rings */
scroll-behavior: smooth;
mask-image: linear-gradient(to right, transparent, black 12px, black calc(100% - 12px), transparent);
-webkit-mask-image: linear-gradient(to right, transparent, black 12px, black calc(100% - 12px), transparent);
/* Add padding to ensure content isn't cut off by mask */
padding-left: 12px;
padding-right: 12px;
}
.toolbar::-webkit-scrollbar {
display: none; /* Hide scrollbar Chrome/Safari */
}
/* Scroll Indicators */
.scroll-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--bg-primary);
border: 1px solid var(--border-color);
color: var(--text-secondary);
cursor: pointer;
flex-shrink: 0;
z-index: 5;
transition: all 0.2s;
box-shadow: var(--shadow-sm);
}
.scroll-btn:hover {
background: var(--bg-secondary);
color: var(--accent-color);
box-shadow: var(--shadow-md);
transform: scale(1.1);
}
.scroll-btn.hidden {
display: none;
pointer-events: none;
opacity: 0;
}
.format-buttons {
display: flex;
gap: 4px;
background: transparent;
border-radius: var(--radius-sm);
padding: 3px;
/* Removed border for cleaner look */
}
.format-btn {
width: 32px;
height: 32px;
padding: 0 !important;
background: transparent !important;
border: none !important;
border-radius: 6px;
color: var(--text-secondary) !important;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s cubic-bezier(0.2, 0, 0, 1);
}
.format-btn:hover {
background: var(--hover-bg) !important;
color: var(--accent-color) !important;
transform: translateY(-1px);
}
.format-btn:active {
transform: translateY(0);
background: var(--hover-header-bg) !important;
}
.format-btn.active {
background: var(--accent-color) !important;
color: white !important;
box-shadow: 0 2px 4px rgba(74, 144, 217, 0.25);
}
.format-btn i {
font-size: 0.9375rem !important; /* Slightly larger icon */
}
/* Toolbar divider */
.toolbar-divider {
width: 1px;
height: 20px;
background: var(--border-color);
margin: 0 8px;
opacity: 0.6;
}
.color-control {
display: flex;
align-items: center;
gap: 6px;
padding: 2px 6px;
border-radius: 4px;
border: 1px solid var(--border-color);
background: var(--bg-header);
}
.color-control i {
font-size: 0.8125rem;
color: var(--text-secondary);
}
.color-control input[type="color"] {
width: 28px;
height: 28px;
padding: 0;
border: none;
background: transparent;
cursor: pointer;
}
.color-control input[type="color"]::-webkit-color-swatch-wrapper {
padding: 0;
}
.color-control input[type="color"]::-webkit-color-swatch {
border: 1px solid var(--border-color);
border-radius: 4px;
}
.color-control input[type="color"]::-moz-color-swatch {
border: 1px solid var(--border-color);
border-radius: 4px;
}
/* ========== Read-only Mode Styles ========== */
/* Read-only mode banner */
.readonly-banner {
background: rgba(74, 144, 217, 0.1);
border-bottom: 1px solid rgba(74, 144, 217, 0.3);
padding: 8px 16px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all 0.3s;
}
.readonly-banner.hidden {
display: none;
}
.readonly-banner-content {
display: flex;
align-items: center;
gap: 12px;
color: var(--accent-color);
font-size: 0.9rem;
}
.readonly-enable-btn {
padding: 4px 12px;
background: var(--accent-color);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
transition: all 0.2s;
}
.readonly-enable-btn:hover {
background: var(--accent-hover);
}
/* Disable editing controls in read-only mode */
.readonly-mode .format-btn:not(#toggle-readonly):not(#theme-toggle):not(#export-csv):not(#copy-url),
.readonly-mode #add-row,
.readonly-mode #add-col,
.readonly-mode #clear-spreadsheet,
.readonly-mode #import-csv,
.readonly-mode #lock-btn,
.readonly-mode .color-control,
.readonly-mode #font-size-list {
opacity: 0.4;
cursor: not-allowed;
pointer-events: none;
}
/* Cell styling in read-only */
.readonly-mode .cell-content {
cursor: default !important;
}
.readonly-mode .cell-content:focus {
outline: 2px solid rgba(74, 144, 217, 0.3);
outline-offset: -2px;
}
/* Toggle button active state */
#toggle-readonly.active {
background: var(--accent-color) !important;
color: white !important;
}
/* ========== End Read-only Mode Styles ========== */
/* ========== Embed Mode Styles ========== */
.embed-mode .header {
display: none;
}
.embed-mode .status-bar {
display: none;
}
.embed-mode .scroll-btn {
display: none;
}
.embed-mode .readonly-banner {
display: none;
}
.embed-mode .grid-wrapper {
height: 100vh;
}
/* ========== End Embed Mode Styles ========== */
.size-control {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 6px;
border-radius: 4px;
border: 1px solid var(--border-color);
background: var(--bg-header);
}
.size-control-label {
display: inline-flex;
align-items: center;
color: var(--text-secondary);
font-size: 0.8125rem;
margin-right: 2px;
}
.size-control-label i {
font-size: 0.8125rem;
}
.size-option {
border: none;
background: transparent;
color: var(--text-secondary);
font-size: 0.75rem;
font-family: inherit;
padding: 4px 6px;
border-radius: 3px;
cursor: pointer;
transition: background-color 0.2s, color 0.2s;
}
.size-option:hover,
.size-option:focus-visible {
background: var(--bg-secondary);
color: var(--text-primary);
outline: none;
}
.toolbar button {
padding: 8px 14px;
font-size: 0.8125rem;
font-weight: 500;
font-family: inherit;
background-color: transparent; /* Ghost button by default */
color: var(--text-secondary);
border: 1px solid transparent; /* Reserve space for border */
border-radius: var(--radius-sm);
cursor: pointer;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
gap: 8px;
box-shadow: none;
}
.toolbar button i {
font-size: 0.8125rem;
}
.toolbar button:hover:not(:disabled) {
background-color: var(--hover-bg);
color: var(--accent-color);
transform: translateY(-1px);
box-shadow: none;
}
.toolbar button:active:not(:disabled) {
transform: translateY(0);
background-color: var(--hover-header-bg);
}
/* Primary actions can keep the accent color */
.toolbar button.primary-action {
background-color: var(--accent-color);
color: white;
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
}
.toolbar button.primary-action:hover:not(:disabled) {
background-color: var(--accent-hover);
color: white;
box-shadow: 0 4px 8px rgba(74, 144, 217, 0.25);
}
.toolbar button:disabled {
background-color: transparent;
color: var(--text-muted);
cursor: not-allowed;
opacity: 0.5;
}
.dark-mode .toolbar button:disabled {
color: #666;
}
/* Theme toggle button */
.theme-toggle {
background: transparent !important;
border: 1px solid var(--border-color) !important;
padding: 8px 10px !important;
font-size: 0.875rem !important;
color: var(--text-secondary) !important;
}
.theme-toggle:hover:not(:disabled) {
background: var(--bg-header) !important;
color: var(--text-primary) !important;
}
.theme-toggle i {
font-size: 0.875rem !important;
}
.icon-dark {
display: none;
}
.dark-mode .icon-light {
display: none;
}
.dark-mode .icon-dark {
display: inline;
}
/* Copy URL button */
.copy-btn {
background: transparent !important;
border: 1px solid var(--border-color) !important;
padding: 8px 10px !important;
font-size: 0.875rem !important;
color: var(--text-secondary) !important;
}
.copy-btn:hover:not(:disabled) {
background: var(--bg-header) !important;
color: var(--text-primary) !important;
}
.copy-btn.copied {
color: #22c55e !important;
border-color: #22c55e !important;
}
.copy-btn.copied i::before {
content: "\f00c"; /* fa-check */
}
/* GitHub link */
.github-link {
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
padding: 8px 10px;
border-radius: 4px;
border: 1px solid var(--border-color);
transition: color 0.2s, background-color 0.2s;
text-decoration: none;
font-size: 1rem;
}
.github-link:hover {
color: var(--text-primary);
background-color: var(--bg-header);
}
.grid-size {
color: var(--text-muted);
font-size: 0.8125rem;
margin-left: auto;
display: flex;
align-items: center;
gap: 6px;
}
.grid-size i {
opacity: 0.7;
}
/* Main grid area - fills remaining space */
.grid-wrapper {
flex: 1;
overflow: auto;
background: transparent;
transition: background-color 0.3s;
}
.spreadsheet {
display: grid;
/* grid-template-columns set dynamically via JS */
gap: 0;
min-width: fit-content;
position: relative;
}
/* Dependency Tracer Styles */
.dependency-layer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none; /* Let clicks pass through to cells */
z-index: 1; /* Above cells, below headers/tooltips */
overflow: visible;
}
.dependency-layer.hidden {
display: none;
}
/* Dependency Tracing Highlights */
.dependency-source {
border: 2px solid #2196F3 !important; /* Solid Blue for Source */
background-color: rgba(33, 150, 243, 0.05);
z-index: 5;
border-radius: 2px;
box-shadow: 0 0 0 1px white inset; /* Inner white border for contrast */
}
.dependency-target {
border: 2px solid #2196F3 !important; /* Solid Blue for Target */
background-color: rgba(33, 150, 243, 0.1);
z-index: 6; /* Slightly higher than source */
border-radius: 2px;
box-shadow: 0 0 0 1px white inset, 0 2px 8px rgba(33, 150, 243, 0.25); /* Glow for target */
}
.dependency-line {
filter: drop-shadow(0 1px 2px rgba(0,0,0,0.1)); /* Subtle depth */
transition: opacity 0.3s;
}
/* Removed animation keyframes */
/* Dependency tracer button active state */
#trace-deps-btn.active {
background-color: #2196F3;
color: white;
border-color: #2196F3;
}
/* Header cells (A-Z and row numbers) */
.header-cell {
background-color: var(--bg-header);
border: 1px solid var(--border-color); /* Kept standard border for grid clarity */
border-right-color: #d0d0d0; /* Slightly darker vertical separator */
border-bottom-color: #d0d0d0;
padding: 8px;
text-align: center;
font-weight: 600; /* Bolder headers */
color: var(--text-secondary);
font-size: 0.75rem; /* Slightly smaller, cleaner */
user-select: none;
position: sticky;
z-index: 2;
transition: background-color 0.2s, color 0.2s;
}
.resize-handle {
position: absolute;
z-index: 5;
background: transparent;
touch-action: none;
}
.resize-handle.col-resize {
top: 0;
right: -3px;
width: 6px;
height: 100%;
cursor: col-resize;
}
.resize-handle.row-resize {
left: 0;
bottom: -3px;
width: 100%;
height: 6px;
cursor: row-resize;
}
body.resizing {
user-select: none;
}
.header-cell.header-active {
background-color: var(--bg-cell-focus);
border-color: var(--accent-color);
color: var(--text-primary);
}
.header-cell.header-hover:not(.header-active) {
background-color: var(--hover-header-bg);
color: var(--text-primary);
}
/* Column headers stick to top */
.header-cell.col-header {
top: 0;
z-index: 3;
}
/* Row headers stick to left */
.header-cell.row-header {
left: 0;
z-index: 2;
}
/* Corner cell (top-left) */
.corner-cell {
background-color: var(--bg-header);
border: 1px solid var(--border-color);
border-right-color: #d0d0d0;
border-bottom-color: #d0d0d0;
position: sticky;
top: 0;
z-index: 4; /* Fixed Z-Index issue */
left: 0;
transition: background-color 0.3s;
}
/* Data cells */
.cell {
border: 1px solid var(--border-color);
margin: -1px 0 0 -1px;
transition: border-color 0.3s, background-color 0.3s;
background-color: var(--cell-bg, var(--bg-secondary));
}
.cell:hover {
border-color: var(--border-hover);
z-index: 1;
position: relative;
}
.cell-content {
width: 100%;
height: 100%;
min-height: 32px;
padding: 6px 8px;
border: none;
outline: none;
font-size: 0.8125rem;
font-family: inherit;
background: transparent;
color: var(--text-primary);
transition: background-color 0.3s, color 0.3s;
overflow: hidden;
word-wrap: break-word;
}
.cell-content:focus {
background-color: var(--bg-cell-focus);
}
.cell:has(.cell-content:focus) {
border-color: var(--accent-color);
box-shadow: 0 0 0 2px var(--cell-focus-shadow);
z-index: 10;
position: relative;
border-radius: 2px;
}
/* Remote user cursor */
.cell.remote-active {
position: relative;
border: 2px solid #ff0055 !important;
z-index: 20;
}
.cell.remote-active::after {
content: "Peer";
position: absolute;
top: -18px;
right: -2px;
background: #ff0055;
color: #ffffff;
font-size: 10px;
padding: 1px 4px;
border-radius: 3px 3px 0 0;
pointer-events: none;
}
/* Formula suggestions dropdown */
.formula-dropdown {
position: fixed;
min-width: 220px;
max-width: 320px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15);
padding: 4px;
z-index: 10;
display: none;
}
.formula-dropdown.open {
display: block;
}
.formula-item {
padding: 6px 8px;
border-radius: 4px;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 2px;
}
.formula-item:hover,
.formula-item.active {
background: var(--bg-cell-focus);
}
.formula-name {
color: var(--text-primary);
font-weight: 600;
font-size: 0.8125rem;
}
.formula-hint {
color: var(--text-muted);
font-size: 0.75rem;
}
/* Multi-cell selection styles */
.cell.cell-selected {
background-color: var(--cell-bg, var(--bg-secondary));
box-shadow: inset 0 0 0 9999px var(--selection-bg);
border-color: var(--selection-grid);
position: relative;
z-index: 1;
}
.cell.cell-selected .cell-content {
background-color: transparent;
}
.cell.hover-row:not(.cell-selected),
.cell.hover-col:not(.cell-selected) {
box-shadow: inset 0 0 0 9999px var(--hover-bg);
}
/* Selection border outline (Google Sheets style) */
.cell.cell-selected.selection-top {
border-top: 2px solid var(--selection-border);
}
.cell.cell-selected.selection-bottom {
border-bottom: 2px solid var(--selection-border);
}
.cell.cell-selected.selection-left {
border-left: 2px solid var(--selection-border);
}
.cell.cell-selected.selection-right {
border-right: 2px solid var(--selection-border);
}
/* Prevent text selection during drag */
.spreadsheet.selecting {
user-select: none;
cursor: cell;
}
.spreadsheet.selecting .cell-content {
user-select: none;
pointer-events: none;
}
/* Status bar (bottom) */
.status-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 16px;
background: var(--bg-hint);
border-top: 1px solid var(--border-color);
font-size: 0.75rem;
color: var(--text-muted);
flex-shrink: 0;
transition: background-color 0.3s, border-color 0.3s, color 0.3s;
}
.status-hint {
display: flex;
align-items: center;
gap: 8px;
}
.status-hint i {
opacity: 0.7;
}
.url-length-indicator {
display: flex;
align-items: center;
gap: 8px;
}
.url-length-label {
white-space: nowrap;
}
.url-progress-container {
width: 100px;
height: 6px;
background: var(--border-color);
border-radius: 10px;
overflow: hidden;
}
.url-progress-bar {
height: 100%;
width: 0%;
background: var(--accent-color);
border-radius: 3px;
transition: width 0.3s, background-color 0.3s;
}
/* Progress bar color states */
.url-progress-bar.warning {
background: #f0ad4e;
}
.url-progress-bar.caution {
background: #fd7e14;
}
.url-progress-bar.critical {
background: #dc3545;
}
/* URL length warning message */
.url-length-message {
font-size: 0.7rem;
margin-left: 4px;
white-space: nowrap;
}
.url-length-message.warning {
color: #f0ad4e;
}
.url-length-message.caution {
color: #fd7e14;
}
.url-length-message.critical {
color: #dc3545;
}
.status-bar-right {
display: flex;
align-items: center;
gap: 16px;
margin-left: auto;
justify-content: flex-end;
}
.selection-stats {
display: none;
align-items: center;
gap: 12px;
font-size: inherit;
}
.selection-stats.active {
display: inline-flex;
}
.selection-stats .stat-item {
color: var(--text-muted);
white-space: nowrap;
}
.selection-stats b {
font-weight: 600;
color: var(--text-primary);
}
.url-length-indicator {
margin-left: auto;
}
/* Mobile/Tablet: Bottom toolbar with horizontal scroll */
@media (max-width: 1024px) {
/* Hide title completely */
.header h1 {
display: none;
}
/* Fixed bottom toolbar */
.header {
position: fixed;
bottom: 16px;
left: 16px;
right: 16px;
z-index: 100;
padding: 8px 12px;
border: 1px solid var(--glass-border);
border-radius: 16px;
background: var(--glass-bg);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
box-shadow: var(--shadow-md);
gap: 8px; /* Reduce gap for mobile */
}
/* Horizontal scrolling toolbar */
.toolbar {
/* Already handled by global styles, but ensure width */
width: 100%;
mask-image: linear-gradient(to right, transparent, black 12px, black 95%, transparent);
-webkit-mask-image: linear-gradient(to right, transparent, black 12px, black 95%, transparent);
}
/* Hide scrollbar */
.toolbar::-webkit-scrollbar {
display: none;
}
/* Prevent buttons from shrinking */
.toolbar button,
.toolbar select,
.toolbar .format-buttons,
.toolbar .toolbar-divider,
.toolbar input[type="color"] {
flex-shrink: 0;
}
/* Touch-friendly button sizes */
.toolbar button {
min-height: 44px;
padding: 8px 12px;
}
.format-buttons button {
min-width: 40px;
min-height: 40px;
}
/* Add bottom margin to grid for fixed toolbar */
.grid-wrapper {
margin-bottom: 70px;
}
/* Hide status bar on mobile/tablet */
.status-bar {
display: none;
}
}
/* Responsive adjustments for smaller mobile */
@media (max-width: 768px) {
.toolbar button {
padding: 6px 10px;
font-size: 0.75rem;
}
.header-cell {
padding: 6px;
font-size: 0.75rem;
}
.cell-content {
min-height: 44px;
padding: 6px;
font-size: 16px; /* Prevents zoom on iOS */
}
}
/* ========== Password Modal Styles ========== */
.modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
}
.modal.hidden {
display: none;
}
.modal-backdrop {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(2px);
}
.modal-content {
position: relative;
background: var(--bg-secondary);
border-radius: 16px;
padding: 32px;
max-width: 400px;
width: 90%;
box-shadow: var(--shadow-md);
border: 1px solid var(--border-color);
animation: modal-pop 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
@keyframes modal-pop {
0% { transform: scale(0.95); opacity: 0; }
100% { transform: scale(1); opacity: 1; }
}
.modal-content h3 {
margin: 0 0 8px 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary);
}
.modal-content p {
margin: 0 0 20px 0;
font-size: 0.875rem;
color: var(--text-secondary);
line-height: 1.5;
}
.modal-form {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 16px;
}
.modal-form input {
width: 100%;
padding: 12px 14px;
font-size: 0.9375rem;
font-family: inherit;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-primary);
color: var(--text-primary);
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
}
.modal-form input:focus {
border-color: var(--accent-color);
box-shadow: 0 0 0 3px var(--cell-focus-shadow);
}
.modal-form input::placeholder {
color: var(--text-muted);
}
.modal-error {
margin: 0 0 16px 0;
padding: 10px 12px;
font-size: 0.8125rem;
color: #dc2626;
background: rgba(220, 38, 38, 0.1);
border-radius: 6px;
border: 1px solid rgba(220, 38, 38, 0.2);
}
.modal-error.hidden {
display: none;
}
.modal-buttons {
display: flex;
gap: 12px;
justify-content: flex-end;
}
.modal-btn {
padding: 10px 20px;
font-size: 0.875rem;
font-family: inherit;
font-weight: 500;
border-radius: 8px;
cursor: pointer;
transition: background-color 0.2s, transform 0.1s;
}
.modal-btn:active {
transform: scale(0.98);
}
.modal-btn-secondary {
background: transparent;
border: 1px solid var(--border-color);
color: var(--text-secondary);
}
.modal-btn-secondary:hover {
background: var(--bg-header);
color: var(--text-primary);
}
.modal-btn-primary {
background: var(--accent-color);
border: none;
color: white;
}
.modal-btn-primary:hover {
background: var(--accent-hover);
}
.modal-btn-primary:disabled {
background: var(--text-muted);
cursor: not-allowed;
}
/* Collaboration modal */
.p2p-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.p2p-btn-full {
width: 100%;
}
.p2p-id-display {
margin-top: 8px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 10px;
}
.p2p-id-display.hidden {
display: none;
}
.p2p-id-row {
display: flex;
gap: 8px;
align-items: center;
}
.p2p-label {
margin: 0;
font-size: 0.8rem;
color: var(--text-muted);
}
.p2p-input {
width: 100%;
padding: 8px 10px;
border-radius: 6px;
border: 1px solid var(--border-color);
background: var(--bg-secondary);
color: var(--text-primary);
}
.p2p-input:focus {
outline: none;
border-color: var(--accent-color);
box-shadow: 0 0 0 3px var(--cell-focus-shadow);
}
.p2p-status {
font-size: 0.8rem;
color: var(--text-secondary);
margin: 6px 0 0;
}
.p2p-divider {
text-align: center;
margin: 14px 0;
font-weight: 600;
color: var(--text-muted);
}
/* Embed modal specific styles */
.embed-code-container {
margin: 16px 0;
}
#embed-code-textarea {
width: 100%;
min-height: 120px;
padding: 12px;
font-family: 'Courier New', monospace;
font-size: 0.875rem;
background: var(--bg-primary);
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: 8px;
resize: vertical;
line-height: 1.5;
}
.json-modal-content {
max-width: 960px;
width: 94%;
display: flex;
flex-direction: column;
gap: 12px;
}
.json-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.modal-icon-btn {
background: transparent;
border: 1px solid var(--border-color);
color: var(--text-secondary);
width: 36px;
height: 36px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 8px;
cursor: pointer;
transition: background-color 0.2s, color 0.2s, border-color 0.2s;
}
.modal-icon-btn:hover {
background: var(--bg-header);
color: var(--text-primary);
border-color: var(--accent-color);
}
.json-modal-subtitle {
margin: 0;
color: var(--text-secondary);
line-height: 1.5;
}
.json-editor-wrapper {
flex: 1;
}
#json-editor {
width: 100%;
min-height: 320px;
padding: 12px;
font-family: 'Courier New', monospace;
font-size: 0.875rem;
background: var(--bg-primary);
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: 12px;
resize: vertical;
line-height: 1.5;
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.04);
}
.json-modal-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
align-items: center;
}
.json-tip {
background: var(--bg-hint);
border: 1px dashed var(--border-color);
border-radius: 12px;
padding: 12px;
color: var(--text-secondary);
line-height: 1.5;
}
.json-tip-label {
display: inline-block;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 6px;
}
.qr-modal-content {
max-width: 420px;
width: 92%;
text-align: center;
}
.qr-card {
background: #fff;
padding: 20px;
border-radius: 12px;
border: 1px solid #e5e7eb;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.08);
display: flex;
align-items: center;
justify-content: center;
margin: 12px 0 4px 0;
}
.qr-code img,
.qr-code canvas {
display: block;
height: auto;
width: 240px;
max-width: 100%;
}
.qr-warning {
margin: 8px 0 0 0;
font-size: 0.85rem;
color: #dc2626;
}
.qr-warning.hidden {
display: none;
}
/* Lock button states */
.lock-btn.locked {
color: var(--accent-color) !important;
border-color: var(--accent-color) !important;
}
.lock-btn.locked i::before {
content: "\f023"; /* fa-lock */
}
/* ========== Toast Notification System ========== */
.toast-container {
position: fixed;
top: 20px;
right: 20px;
z-index: 2000;
display: flex;
flex-direction: column;
gap: 10px;
pointer-events: none;
max-width: 360px;
}
.toast {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 18px;
background: var(--glass-bg);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--glass-border);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
color: var(--text-primary);
font-size: 0.875rem;
font-weight: 500;
pointer-events: auto;
transform: translateX(120%);
opacity: 0;
animation: toast-slide-in 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.toast.toast-exit {
animation: toast-slide-out 0.3s cubic-bezier(0.4, 0, 1, 1) forwards;
}
@keyframes toast-slide-in {
0% {
transform: translateX(120%);
opacity: 0;
}
100% {
transform: translateX(0);
opacity: 1;
}
}
@keyframes toast-slide-out {
0% {
transform: translateX(0);
opacity: 1;
}
100% {
transform: translateX(120%);
opacity: 0;
}
}
.toast-icon {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
flex-shrink: 0;
font-size: 0.875rem;
}
.toast-message {
flex: 1;
line-height: 1.4;
}
.toast-close {
background: transparent;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 4px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.2s, background 0.2s;
}
.toast-close:hover {
color: var(--text-primary);
background: var(--hover-bg);
}
/* Toast Types */
.toast.toast-success .toast-icon {
background: rgba(34, 197, 94, 0.15);
color: #22c55e;
}
.toast.toast-error .toast-icon {
background: rgba(239, 68, 68, 0.15);
color: #ef4444;
}
.toast.toast-warning .toast-icon {
background: rgba(245, 158, 11, 0.15);
color: #f59e0b;
}
.toast.toast-info .toast-icon {
background: rgba(59, 130, 246, 0.15);
color: #3b82f6;
}
/* Progress bar for auto-dismiss */
.toast-progress {
position: absolute;
bottom: 0;
left: 0;
height: 3px;
background: var(--accent-color);
border-radius: 0 0 12px 12px;
animation: toast-progress linear forwards;
}
@keyframes toast-progress {
0% { width: 100%; }
100% { width: 0%; }
}
/* Mobile positioning */
@media (max-width: 768px) {
.toast-container {
top: auto;
bottom: 90px;
left: 16px;
right: 16px;
max-width: none;
}
.toast {
transform: translateY(120%);
}
@keyframes toast-slide-in {
0% {
transform: translateY(120%);
opacity: 0;
}
100% {
transform: translateY(0);
opacity: 1;
}
}
@keyframes toast-slide-out {
0% {
transform: translateY(0);
opacity: 1;
}
100% {
transform: translateY(120%);
opacity: 0;
}
}
}
/* --- Presentation Mode (Auto-Deck) --- */
body.presentation-mode-active {
overflow: hidden;
}
.presentation-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: #111;
color: #fff;
z-index: 10000;
overflow-y: auto;
scroll-snap-type: y mandatory;
scroll-behavior: smooth;
display: flex;
flex-direction: column;
scrollbar-width: none;
}
.presentation-overlay:focus {
outline: none;
}
.presentation-overlay::-webkit-scrollbar {
display: none;
}
.slide {
height: 100vh;
width: 100vw;
flex-shrink: 0;
scroll-snap-align: start;
scroll-snap-stop: always;
display: flex;
align-items: center;
justify-content: center;
padding: 40px;
box-sizing: border-box;
position: relative;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.slide-content {
max-width: 1000px;
width: 100%;
text-align: center;
display: flex;
flex-direction: column;
gap: 32px;
animation: slideUp 0.6s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(40px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.slide-title {
font-size: 3.5rem;
font-weight: 700;
line-height: 1.1;
background: linear-gradient(to right, #4a90d9, #5a9fe9);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
margin-bottom: 12px;
}
.slide-text {
font-size: 1.6rem;
color: #cfcfcf;
font-weight: 300;
line-height: 1.5;
}
.slide-visual {
font-size: 1.8rem;
display: flex;
justify-content: center;
align-items: center;
margin: 10px 0;
}
.slide-visual.slide-title {
font-size: inherit;
background: none;
-webkit-text-fill-color: inherit;
}
.presentation-controls {
position: fixed;
bottom: 24px;
right: 24px;
display: flex;
align-items: center;
gap: 16px;
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
padding: 8px 16px;
border-radius: 30px;
border: 1px solid rgba(255, 255, 255, 0.2);
}
.slide-counter {
font-size: 0.9rem;
font-weight: 600;
color: rgba(255, 255, 255, 0.85);
}
#exit-pres-btn {
background: transparent;
border: none;
color: #fff;
cursor: pointer;
font-weight: 600;
display: flex;
align-items: center;
gap: 6px;
}
#exit-pres-btn:hover {
color: #ff4d4f;
}
.presentation-overlay .visual-progress {
display: flex;
flex-direction: column;
gap: 8px;
width: min(720px, 85vw);
}
.presentation-overlay .visual-progress-track {
height: 40px;
border-radius: 20px;
overflow: hidden;
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.2);
}
.presentation-overlay .visual-progress-fill {
height: 100%;
line-height: 40px;
font-size: 1rem;
font-weight: 600;
text-align: center;
background: linear-gradient(to right, #4a90d9, #5a9fe9);
color: #fff;
transition: width 0.3s ease;
}
.presentation-overlay .visual-progress-label {
font-size: 0.95rem;
color: #d4d4d4;
}
.presentation-overlay .visual-tag {
padding: 8px 24px;
font-size: 1.2rem;
font-weight: 600;
border-radius: 999px;
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.3);
display: inline-flex;
align-items: center;
justify-content: center;
}
.presentation-overlay .visual-rating {
display: inline-flex;
gap: 8px;
font-size: 1.8rem;
color: #f5c451;
}
.presentation-overlay .visual-rating .fa-regular {
color: rgba(255, 255, 255, 0.35);
}
@media (max-width: 768px) {
.slide {
padding: 24px;
}
.slide-title {
font-size: 2.4rem;
}
.slide-text {
font-size: 1.2rem;
}
.presentation-controls {
bottom: 16px;
right: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.presentation-overlay {
scroll-behavior: auto;
}
.slide-content {
animation: none;
}
}
/* ========== Tools Modal Styles ========== */
.tools-btn {
font-weight: 600 !important;
background-color: var(--accent-color) !important;
color: white !important;
border: 1px solid var(--accent-color) !important;
padding: 6px 12px !important;
border-radius: 6px !important;
}
.tools-btn:hover {
background-color: var(--accent-hover) !important;
box-shadow: 0 2px 4px rgba(74, 144, 217, 0.25);
}
.tools-modal-content {
max-width: 600px;
width: 90%;
padding: 0 !important; /* Reset padding for header/body split */
overflow: hidden;
border-radius: 12px;
}
.tools-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
background: var(--bg-header);
border-bottom: 1px solid var(--border-color);
}
.tools-modal-header h3 {
margin: 0;
font-size: 1.1rem;
color: var(--text-primary);
display: flex;
align-items: center;
gap: 8px;
}
.tools-modal-header h3 i {
color: var(--accent-color);
}
.tools-grid {
padding: 20px;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
max-height: 70vh;
overflow-y: auto;
}
.tools-section h4 {
margin: 0 0 10px 0;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
font-weight: 600;
}
.tools-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.tool-item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 10px 12px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-secondary);
color: var(--text-primary);
font-size: 0.95rem;
cursor: pointer;
transition: all 0.2s;
text-align: left;
}
.tool-item:hover {
background: var(--bg-cell-focus);
border-color: var(--accent-color);
transform: translateY(-1px);
box-shadow: 0 2px 5px rgba(0,0,0,0.05);
}
.tool-item i {
font-size: 1rem;
width: 20px;
text-align: center;
color: var(--text-secondary);
transition: color 0.2s;
}
.tool-item:hover i {
color: var(--accent-color);
}
.tool-item.tool-danger:hover {
background: #fff5f5;
border-color: #fc8181;
}
.tool-item.tool-danger:hover i {
color: #e53e3e;
}
/* Modal Close Button */
.modal-icon-btn {
background: transparent;
border: none;
color: var(--text-secondary);
font-size: 1.1rem;
cursor: pointer;
padding: 4px;
border-radius: 4px;
transition: all 0.2s;
}
.modal-icon-btn:hover {
background: var(--bg-primary);
color: var(--text-primary);
}
/* Responsive adjustments */
@media (max-width: 600px) {
.tools-grid {
grid-template-columns: 1fr;
}
}
wget 'https://sme10.lists2.roe3.org/spreadsheet/task.md'
This feature adds a **Visual Formula Dependency Tracer**.
**Why this is innovative:**
Standard spreadsheets hide the logic flow. You have to click individual cells to see what they reference. This feature creates a **"Logic Layer"** overlay using dynamic SVG Bezier curves. It visualizes the "mind" of your spreadsheet, instantly drawing lines from data sources to the formulas that use them. It turns abstract references (like `A1:B5`) into a tangible visual graph, making debugging complex sheets effortless.
### 1. New Module: `dependencyTracer.js`
Create this file to handle the geometry calculations and SVG rendering.
**File:** `modules/dependencyTracer.js`
```javascript
/**
* Visual Formula Dependency Tracer
* Draws Bezier curves between formula cells and their data sources.
*/
import { parseRange, parseCellRef } from "./formulaManager.js";
import { getState, getCellElement } from "./rowColManager.js";
export const DependencyTracer = {
isActive: false,
container: null,
svg: null,
resizeObserver: null,
init() {
// Create the SVG overlay layer inside the spreadsheet
const spreadsheet = document.getElementById("spreadsheet");
if (!spreadsheet) return;
this.container = spreadsheet;
// Create SVG element
const ns = "http://www.w3.org/2000/svg";
this.svg = document.createElementNS(ns, "svg");
this.svg.classList.add("dependency-layer", "hidden");
// Define arrow marker
const defs = document.createElementNS(ns, "defs");
const marker = document.createElementNS(ns, "marker");
marker.setAttribute("id", "arrowhead");
marker.setAttribute("markerWidth", "10");
marker.setAttribute("markerHeight", "7");
marker.setAttribute("refX", "9");
marker.setAttribute("refY", "3.5");
marker.setAttribute("orient", "auto");
const polygon = document.createElementNS(ns, "polygon");
polygon.setAttribute("points", "0 0, 10 3.5, 0 7");
polygon.setAttribute("fill", "#ff0055"); // Accent color
marker.appendChild(polygon);
defs.appendChild(marker);
this.svg.appendChild(defs);
this.container.appendChild(this.svg);
// Observe grid resizing to redraw
this.resizeObserver = new ResizeObserver(() => {
if (this.isActive) this.render();
});
this.resizeObserver.observe(this.container);
},
toggle() {
this.isActive = !this.isActive;
if (this.isActive) {
this.svg.classList.remove("hidden");
this.render();
} else {
this.svg.classList.add("hidden");
this.clear();
}
return this.isActive;
},
clear() {
// Keep defs, remove paths
const paths = this.svg.querySelectorAll("path");
paths.forEach((p) => p.remove());
},
render() {
if (!this.isActive || !this.svg) return;
this.clear();
const { rows, cols } = getState();
// We need to access the formulas array.
// Since we don't have direct access here, we rely on the DOM or a passed accessor.
// For this implementation, we will scan the DOM for formula cells if data isn't passed,
// but better to rely on the module system's formula state if accessible.
// However, relying on DOM is safer for the visualizer to ensure alignment.
// Better approach: We iterate the grid state via callbacks if provided,
// or just assume we can see the module state via `script.js` passing it.
// To keep this module standalone, we will scan the module state via an exposed getter
// or just rely on the DOM attributes if we added them.
// Let's use the `formulas` array which script.js can pass or we can get via callback.
},
// Script.js will call this with the formulas array
draw(formulas) {
if (!this.isActive) return;
this.clear();
const ns = "http://www.w3.org/2000/svg";
const { rows, cols } = getState();
// Helper to get center coordinates of a cell
const getCellCenter = (r, c) => {
const cell = getCellElement(r, c);
if (!cell) return null;
// We need coordinates relative to the spreadsheet container
const rect = cell.getBoundingClientRect();
const containerRect = this.container.getBoundingClientRect();
return {
x: rect.left - containerRect.left + rect.width / 2 + this.container.scrollLeft,
y: rect.top - containerRect.top + rect.height / 2 + this.container.scrollTop,
};
};
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const formula = formulas[r][c];
if (!formula || !formula.startsWith("=")) continue;
const targetCenter = getCellCenter(r, c);
if (!targetCenter) continue;
// Extract references using regex (simple extraction for A1 and A1:B2)
// Matches A1, AA1, A1:B2
const refs = formula.match(/([A-Z]+[0-9]+)(?::([A-Z]+[0-9]+))?/g);
if (!refs) continue;
refs.forEach((refStr) => {
let startRow, startCol, endRow, endCol;
if (refStr.includes(":")) {
// It's a range
const range = parseRange(refStr);
if (!range) return;
startRow = range.startRow;
startCol = range.startCol;
endRow = range.endRow;
endCol = range.endCol;
} else {
// It's a single cell
const cellRef = parseCellRef(refStr);
if (!cellRef) return;
startRow = cellRef.row;
startCol = cellRef.col;
endRow = cellRef.row;
endCol = cellRef.col;
}
// Draw line from EVERY cell in the source range to the formula cell
// Optimization: For large ranges, maybe just corners?
// Let's do all cells for "Cool Factor" but limit to visible viewport?
// Let's do corners + center of range to keep it clean.
// Actually, visually referencing the *block* is better.
// Let's draw from the *center* of the referenced range to the formula.
// Calculate center of the source range
const topLeft = getCellCenter(startRow, startCol);
const bottomRight = getCellCenter(endRow, endCol);
if (!topLeft || !bottomRight) return;
const sourceX = (topLeft.x + bottomRight.x) / 2;
const sourceY = (topLeft.y + bottomRight.y) / 2;
// Draw Bezier Curve
const path = document.createElementNS(ns, "path");
// Logic for curve control points to make it look organic
const deltaX = targetCenter.x - sourceX;
const deltaY = targetCenter.y - sourceY;
// Control points curvature
const c1x = sourceX + deltaX * 0.5;
const c1y = sourceY;
const c2x = targetCenter.x - deltaX * 0.5;
const c2y = targetCenter.y;
const d = `M ${sourceX} ${sourceY} C ${c1x} ${c1y}, ${c2x} ${c2y}, ${targetCenter.x} ${targetCenter.y}`;
path.setAttribute("d", d);
path.setAttribute("stroke", "#ff0055");
path.setAttribute("stroke-width", "2");
path.setAttribute("fill", "none");
path.setAttribute("opacity", "0.6");
path.setAttribute("marker-end", "url(#arrowhead)");
path.classList.add("dependency-line");
// Add animation
path.style.strokeDasharray = "1000";
path.style.strokeDashoffset = "1000";
path.style.animation = "dash 1.5s ease-out forwards";
this.svg.appendChild(path);
});
}
}
},
};
```
### 2. Update `styles.css`
Add the styles for the SVG overlay and the drawing animation.
**File:** `styles.css`
```css
/* Dependency Tracer Styles */
.dependency-layer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none; /* Let clicks pass through to cells */
z-index: 50; /* Above cells, below tooltips */
overflow: visible;
}
.dependency-layer.hidden {
display: none;
}
.dependency-line {
transition: opacity 0.3s;
}
@keyframes dash {
to {
stroke-dashoffset: 0;
}
}
/* Add active state for the tracer button */
#trace-deps-btn.active {
background-color: var(--accent-color);
color: white;
border-color: var(--accent-color);
}
```
### 3. Update `index.html`
Add the "Trace Logic" button to the toolbar.
**File:** `index.html` (Inside `<div class="toolbar">`)
```html
<!-- Add this after the P2P button -->
<button id="trace-deps-btn" type="button" class="copy-btn" title="Trace Logic (Visualize Dependencies)">
<i class="fa-solid fa-diagram-project"></i>
</button>
```
### 4. Update `script.js`
Wire it all together.
**File:** `script.js`
1. **Import the module:**
```javascript
import { DependencyTracer } from "./modules/dependencyTracer.js";
```
2. **Initialize in `init()`:**
```javascript
// Inside init(), usually after renderGrid()
DependencyTracer.init();
```
3. **Add Event Listener for the Button:**
```javascript
const traceBtn = document.getElementById("trace-deps-btn");
if (traceBtn) {
traceBtn.addEventListener("click", () => {
const isActive = DependencyTracer.toggle();
traceBtn.classList.toggle("active", isActive);
if (isActive) {
// Pass current formulas array to draw
DependencyTracer.draw(getFormulasArray());
showToast("Visualizing formula dependencies", "info");
} else {
showToast("Logic visualization hidden", "info");
}
});
}
```
4. **Hook into Updates:**
We need the lines to redraw if the grid moves (scroll), resizes (add row), or data changes (new formula).
- **On Scroll:**
Already handled? The SVG is inside `#spreadsheet` (the grid container). If we use CSS Grid, the container grows. The SVG is `width:100%`.
However, `DependencyTracer` uses `getBoundingClientRect`. If the user scrolls the _window_ or the _wrapper_, coords might shift relative to viewport but the internal SVG logic handles relative positioning to container.
To be safe, add a re-draw trigger on the wrapper scroll:
```javascript
const gridWrapper = document.querySelector(".grid-wrapper");
if (gridWrapper) {
gridWrapper.addEventListener("scroll", () => {
// Debounce this slightly in a real app, but for now:
if (DependencyTracer.isActive) DependencyTracer.draw(getFormulasArray());
});
}
```
- **On Data Change (Input/P2P):**
Find `handleInput`, `handleRemoteCellUpdate`, `recalculateFormulas`, `addRow`, `addColumn`.
Add this logic:
```javascript
if (DependencyTracer.isActive) DependencyTracer.draw(getFormulasArray());
```
_Optimization:_ You can add this call inside `debouncedUpdateURL` or `recalculateFormulas` since those happen whenever structure changes.
### Summary of Value
This feature is:
1. **Innovative:** Transforms the spreadsheet from a static grid into a visual node graph.
2. **Cutting Edge:** Uses dynamic SVG generation mapped to DOM geometry with Bezier math.
3. **Helpful:** Instantly debugs broken sheets by visually tracing where data comes from.
4. **Not Cheap:** It's a sophisticated visualization layer, not just a simple HTML/CSS tweak.
wget 'https://sme10.lists2.roe3.org/spreadsheet/templates.json'
[
{
"id": "todo",
"name": "Todo List",
"icon": "fa-list-check",
"description": "Track tasks with status, priority, and due dates",
"link": "/#N4IgTiBcCMBMA0IDGUCsiAuUQBsCWA5gBZaIAmUA2pQAzx0gDKGAhlgLry3zSIAqLAM4BrAAQARAKaCkYPAAcMeAPYA7EJ24IQABTnK5GAJ4audAMyJxAV0kS2k09wAsiAKKCMAOlEAJZdZggk68DOIA8gBybiE8iIySGNbyogBKkvLKgngYBiaavNq+hESxliCwNLDmALTQNDU00LGuFU4IDADqAJI67XEg4mAsAGYYogCCOt2ijEhEkgC2LP1FJf3lldV1DTSo-a2oXvualgx84RFOlryD0oSqogAyygR4jzosBI6n8NoAspIKL9NlVavUdtd4K1nE5XOdLuE4QNOoY7ABVVQ5UR8aQYYKaVzaF4Ad2RoO2ELgyNa5ic6ARV006FuuJYi1mRlUKGZf0QpPp8Ap4Ia0BOXHQrWamgA7EL+MpWDhnsoWGRIE4AJwDSKK6Qagp0W56FSGIzPSTfVTqvwlUQACnSZAAlPBRICyA6AJqSHA4ZQk10qkkOgBCOFszo0nBAIyolDlrQAvIx0f97W5YJA3AA2KPsGPBSDUOh0UAAI2wAGJzLW6yBECgNVWRq22w2QCsNUhJKoMJIIABfTRG+AV6t1+uN6tt9vDsx88fNye1jtNkAt2dxxBd5C9-tDkfypcbld06fNrfbzvYHt9gcgecuMcgSvLldrmdbju7uTELBPqEL7rlWNA0CwLBgT+t77g+gFxKAIEABwoahj4FIuyBfmBUE7jB96HlwHTAV+LCoLhN7drBhGUAgCCIdhOHQVRBHoVwZwkc2EHcSseEsQebGUJY9FYc2ZDOJB0Aysxe6sU+8KcRuPEQTJd4CfJmEgThNCtqp1GCQyilVspvGUbJ6m8iJWk4bpfHmXBsrHmZf4kB2b4bpInleYJiYvu5VZed5iAAF7YNAsJPtqvAnpuIwsEgFAXhuqApalgn1AhIChRq9Sfs2OYFYV6ExmSxY5nQsDkfASFGmB1U0Owg5AA"
}
]