diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 17fa919..eeafd61 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -12,7 +12,24 @@ on: workflow_dispatch: jobs: + preflight: + runs-on: ubuntu-latest + outputs: + has-secrets: ${{ steps.check.outputs.has-secrets }} + steps: + - name: Check Vercel secrets + id: check + run: | + if [ -n "${{ secrets.VERCEL_TOKEN }}" ] && [ -n "${{ secrets.VERCEL_ORG_ID }}" ] && [ -n "${{ secrets.VERCEL_PROJECT_ID }}" ]; then + echo "has-secrets=true" >> "$GITHUB_OUTPUT" + else + echo "has-secrets=false" >> "$GITHUB_OUTPUT" + echo "Vercel secrets not configured; skipping deploy." + fi + deploy: + needs: preflight + if: needs.preflight.outputs.has-secrets == 'true' runs-on: ubuntu-latest permissions: contents: read diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 792cc3f..39566f4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -54,6 +54,13 @@ Before submitting a PR: - [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) — Vercel deployment - [docs/TESTING.md](docs/TESTING.md) — Testing checklist +When you change behavior (storage, sharing, limits, encryption), update the relevant docs and README sections so the system remains self-explanatory. + +## Deployment Notes + +- This is a **static-only** app. Do not add Edge Functions or serverless code. +- GitHub Actions deploys to Vercel only when repository secrets are configured. + ## Questions? Open an issue for bugs, feature requests, or questions. diff --git a/README.md b/README.md index 4325f4a..f02b7d0 100644 --- a/README.md +++ b/README.md @@ -31,15 +31,15 @@ python -m http.server 8000 - **Block editor** — Notion-style blocks: `/` for slash commands, Tab/Shift+Tab to indent, drag handles to reorder. Supports headings, lists, todos, code, quotes, callouts, dividers, toggles. - **URL as storage** — Content is compressed (binary + deflate/brotli + base85) and stored in the URL hash. ~25–40% more capacity than before. No backend, no accounts. -- **Share by link** — Copy the URL to share; recipients see the same content when they open it. +- **Share by link** — Copy the URL to share; recipients see the same content when they open it. Share/QR links are computed from the current saved hash. - **QR code** — Use `/qr` for a QR code of the current page; a small QR is also shown in the bottom-right panel. - **Local version history** — Git-like branching stored in `localStorage`: each version has a hash, timestamp, and parent. You can undo/redo across versions and create branches by editing an older version and saving. - **History graph** — In the **History** menu: SVG graph (time left→right, branches stacked) and a vertical list. Click a version to **preview** it (read-only with a banner); from there you can **Restore this version** or **Back to current**. - **Version undo/redo** — Buttons in the History menu step to parent (undo) or forward along the redo stack (redo). Editor undo/redo remains `Ctrl+Z` / `Ctrl+Y`. - **Reset** — Clears version history and keeps only the current document as a single version. - **Export** — Share menu: **Copy link**, **Export TXT**, **Export HTML**, **Export MD**. -- **Lock** — Share menu: **Lock with password** encrypts content (AES-GCM). Share the password separately with recipients. -- **URL size limit** — Status bar shows URL size; at 8 KB input is blocked (typing disabled, paste truncated). Delete content to continue. +- **Lock** — Share menu: **Lock with password** encrypts content (AES-GCM). Requires HTTPS or localhost. Share the password separately with recipients. +- **URL size limit** — Status bar shows the computed share URL size with thresholds (safe/warning/danger/critical). At 8 KB input is blocked (typing disabled, paste truncated). Delete content to continue. - **Theme** — Light/dark via `?theme=light` or `?theme=dark`, or system preference. - **PWA** — Installable; manifest and service worker for offline support. @@ -72,17 +72,49 @@ kheMessage/ --- +## Deployment (static-only) + +This project is a **static PWA**: there are no serverless or Edge Functions. Deploy the files as-is on any static host (Vercel, Netlify, GitHub Pages). Vercel configuration lives in `vercel.json` and only defines rewrites/headers/caching. + ## How it works (overview) 1. You type in a **block editor** (Notion-style): each line is a block. Use `/` for slash commands, Tab/Shift+Tab to indent, drag handles to reorder. Content is Markdown-friendly with inline formatting. -2. Changes are **debounced** (e.g. 1200 ms); then **save** runs: content + optional inline styles are compressed, turned into a hash, and written to the URL and `localStorage`. A **version entry** `{ hash, t, parents }` is appended to local history. -3. **Version undo** moves to the parent of the current head and pushes the current head onto a redo stack; **version redo** pops from that stack. Any new save clears the redo stack and can create a new branch. -4. **Preview** loads a chosen version into the editor (read-only), with a banner to restore that version or return to the current head. +2. Changes are **debounced** (e.g. 1200 ms); then **save** runs: blocks → serialize → compress → hash. The hash is written to the URL and `localStorage`. A **version entry** `{ hash, t, parents }` is appended to local history. +3. **Share/QR** uses the computed share URL (origin + path + optional theme + hash) so the status bar and QR match what you can copy. +4. **Version undo** moves to the parent of the current head and pushes the current head onto a redo stack; **version redo** pops from that stack. Any new save clears the redo stack and can create a new branch. +5. **Preview** loads a chosen version into the editor (read-only), with a banner to restore that version or return to the current head. For a deeper dive, see [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md). --- +## Storage, limits, and sharing (details) + +### URL storage pipeline + +Blocks are serialized into a compact binary format, compressed (brotli when available, otherwise deflate-raw), then encoded into a URL-safe base85 or base64url hash. The hash is stored in the URL fragment (`#...`) so there is **no backend** and nothing is sent to a server when you type. + +### URL size thresholds + +The status bar shows the byte size of the computed share URL. Thresholds are conservative: + +- **safe**: 2 KB +- **warning**: 4 KB +- **danger**: 8 KB (input blocked) +- **critical**: 16 KB (most platforms reject these URLs) + +These limits are heuristics; browsers and platforms vary in their actual max URL length. The app errs on the safe side. + +### Share link and QR + +The **Share → Copy link** action saves first (when possible) and copies the computed share URL. QR codes use the same computed URL so QR density matches the actual link length. + +### Encryption (password lock) + +When you lock a note, the serialized blocks are encrypted with **AES‑GCM** using a key derived from the password (PBKDF2, SHA‑256). Encryption requires **HTTPS or localhost** because WebCrypto is only available in secure contexts. + +--- + ## Architecture ```mermaid diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ab0c51b..8439080 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -119,6 +119,27 @@ flowchart TB - `hash` — Current head hash (compressed content) - `kheMessage_localVersions` — Array of `{ hash, t, parents }` entries (max 50) +## Share URL + Size Metrics + +The UI (status bar, QR, and copy link) is driven by a computed **share URL** rather than raw `location.href`: + +1. **Compute hash** from current blocks (`getShortestHash`). +2. **Build share URL**: `origin + path + ?theme + #hash`. +3. **Cache size** in bytes to avoid expensive recomputation on every keystroke. + +This ensures the size indicator and QR code reflect the **actual shareable link** (not a stale hash). + +### Size Thresholds + +The status bar uses conservative thresholds to warn early: + +- **safe**: 2 KB +- **warning**: 4 KB +- **danger**: 8 KB (input blocked) +- **critical**: 16 KB (link likely fails in many contexts) + +These are **heuristics**. Real platform limits vary, so the app prevents writes past the danger threshold to avoid broken links. + ## Version History kheMessage implements a Git-like version history with branching and merge support. @@ -198,3 +219,7 @@ flowchart TB ``` Cache version is defined in `sw.js` as `CACHE_VERSION`; updating it invalidates old caches. + +## Encryption Context + +Password lock uses WebCrypto (AES‑GCM + PBKDF2). WebCrypto only works in secure contexts, so encryption is available on **HTTPS or localhost**. diff --git a/docs/CODE_STRUCTURE.md b/docs/CODE_STRUCTURE.md index 38dcc4f..e4bb5a0 100644 --- a/docs/CODE_STRUCTURE.md +++ b/docs/CODE_STRUCTURE.md @@ -20,18 +20,18 @@ A line-by-line map of the main application file and key functions. | ~2310–2459 | Binary serialization | `serializeBlocks`, `deserializeBlocks` | | ~2459–2525 | Base85 encoding | `encodeBase85`, `decodeBase85`, `looksLikeBase85` | | ~2525–2585 | Compression | `compressBlocks`, `decompressToBlocks` | -| ~2585–2781 | Encryption | `encryptBlocks`, `decryptBlocks`, AES-GCM, PBKDF2 | +| ~2585–2781 | Encryption | `encryptBlocks`, `decryptToBlocks`, AES-GCM, PBKDF2 | | ~2781–3030 | Block rendering | `renderBlocks`, `blocksToDOM`, `domToBlocks` | | ~3030–3382 | Block operations | `insertBlock`, `deleteBlock`, `moveBlock`, `setBlockType` | | ~3382–3471 | Slash menu | Slash command handling, type selection | | ~3471–3501 | Multi-select | Shift+click range selection | | ~3501–3597 | Drag and drop | Block reordering, indent level on drop | -| ~3597–3624 | URL limit | 8KB enforcement, typing disabled at limit | -| ~3624–3731 | Status bar | URL size display, size warnings | -| ~3731–3908 | Save/load | `save`, `set`, `setFromHash`, `get`, `blocksToHash`, `hashToBlocks` | -| ~3908–3962 | Theme | Light/dark toggle, URL param, system preference | -| ~3962–4520 | Version history | `pushLocalVersion`, `versionUndo`, `versionRedo`, `enterPreview`, `exitPreview` | -| ~4520–4588 | UI helpers | `setSaveStatus`, `notify`, `debounce`, `updateQR` | +| ~3597–3720 | URL metrics + limit | `buildShareUrl`, URL size caching, 8KB enforcement | +| ~3720–3840 | Status bar | URL size display, size warnings | +| ~3840–4030 | Save/load | `save`, `load`, `setFromHash`, URL updates | +| ~4030–4125 | Theme | Light/dark toggle, URL param, system preference | +| ~4125–4685 | Version history | `pushLocalVersion`, `versionUndo`, `versionRedo`, `enterPreview`, `exitPreview` | +| ~4685–4760 | UI helpers | `setSaveStatus`, `notify`, `updateQR` | | ~4588–4727 | Export | `downloadTXT`, `downloadHTML`, `downloadMD` | | ~4727–5100 | Initialization | DOM ready, hash load, event listeners, Editor setup | @@ -42,11 +42,10 @@ A line-by-line map of the main application file and key functions. | Function | Description | |----------|-------------| | `save()` | Gets blocks → compresses → updates URL hash + localStorage, pushes version | -| `set(hash)` | Decompresses hash → sets blocks → renders DOM | -| `setFromHash(hash)` | Async version of `set`; handles encrypted content | -| `get()` | Returns string representation of content + style | -| `blocksToHash(blocks)` | Serializes and compresses blocks to hash string | -| `hashToBlocks(hashStr)` | Decompresses and deserializes hash to blocks | +| `load()` | Resolves hash from URL/localStorage → renders blocks | +| `setFromHash(hash)` | Async load; handles encrypted content | +| `getShortestHash(blocks)` | Computes best hash encoding (binary vs compact) | +| `buildShareUrl(hash)` | Builds share URL for size/QR/copy | ### Version History @@ -59,6 +58,14 @@ A line-by-line map of the main application file and key functions. | `exitPreview()` | Returns to current head, editable | | `clearVersionHistory()` | Clears all history (Reset) | +### URL Size + Share Metrics + +| Function | Description | +|----------|-------------| +| `refreshUrlMetrics()` | Computes cached share URL + byte size | +| `syncUrlMetricsToLocation()` | Syncs metrics to `location.href` (preview/lock) | +| `isAtInputLimit()` | Enforces danger threshold (8 KB) based on share URL size | + ### Block Types (BLOCK_TYPES) | Value | Type | Description | diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 3c79ee9..0fcb3e2 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -44,6 +44,7 @@ The repository includes `.github/workflows/deploy.yaml` that: - **Production**: Automatic deploy on push to main branches - **Preview**: Automatic deploy on pull requests (URL commented on PR) - **Action**: Uses `amondnet/vercel-action@v25` +- **Secrets guard**: Deploy steps are skipped when required secrets are missing (common for forked PRs). ### Required GitHub Secrets @@ -75,6 +76,10 @@ The project includes `vercel.json` with: - `qrcode.js`: `max-age=31536000, immutable` (1 year) - Cursor files: `max-age=31536000, immutable` (1 year) +## No Edge Functions + +This project is **static-only** and does not use Vercel Edge Functions or serverless functions. All functionality is client-side in `index.html`. + ## Custom Domain 1. Add your domain in Vercel project settings diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index d12440f..bd4d858 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -23,6 +23,8 @@ php -S localhost:8000 Then visit `http://localhost:8000`. +> **Tip:** Encryption (password lock) requires HTTPS or localhost because WebCrypto is only available in secure contexts. + ### Option 3: VS Code Live Server Use the Live Server extension to serve the project. @@ -69,6 +71,12 @@ See [docs/TESTING.md](TESTING.md) for the full testing checklist. Before submitt 4. Test keyboard navigation 5. Check console for errors +## URL Storage + Limits (Developer Notes) + +- **Share URL computation**: The app computes the share URL from the current saved hash (origin + path + optional theme + hash). Status bar and QR use this computed URL so the displayed size matches the actual share link. +- **Limits are conservative**: The 8 KB “danger” threshold is a safety limit; real platform maximums vary. The app blocks input at the danger threshold to prevent broken links. +- **Local files**: When running from `file://`, share URLs reuse the current file path + hash because `location.origin` is `null`. + ## Cursor IDE The project includes `.cursor/` with: diff --git a/docs/TESTING.md b/docs/TESTING.md index 1e8cd07..4183561 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -61,8 +61,9 @@ Before deployment, ensure these secrets are configured in GitHub repository sett - [ ] URL updates after typing (debounced) - [ ] Refreshing page preserves content - [ ] Sharing URL works - recipient sees same content -- [ ] URL size indicator shows in status bar -- [ ] 8KB limit enforced (typing disabled at limit) +- [ ] URL size indicator shows in status bar (matches share URL) +- [ ] 8KB danger limit enforced (typing disabled at limit; paste truncated) +- [ ] Size warnings appear at 4KB/8KB/16KB thresholds #### Version History @@ -82,6 +83,7 @@ Before deployment, ensure these secrets are configured in GitHub repository sett - [ ] Export TXT downloads correctly - [ ] Export HTML downloads correctly - [ ] Export MD downloads correctly +- [ ] Lock/unlock works (AES-GCM) on HTTPS/localhost #### QR Code @@ -188,6 +190,7 @@ Verify cache headers: - [ ] URL with invalid base64 - [ ] localStorage full/disabled - [ ] Service worker registration fails +- [ ] Password lock unavailable on insecure origins (shows message) ## Performance Metrics diff --git a/index.html b/index.html index 812ccd9..d3b3218 100644 --- a/index.html +++ b/index.html @@ -2170,7 +2170,7 @@

Document is getting large

Large document - msg.khe.money v.2026.02.01 + msg.khe.money v.2026.02.03
@@ -2249,6 +2249,13 @@

Document is getting large

let versionRedoStack = [] let theme = null let encryptedContent = null + let contentVersion = 0 + let urlMetrics = { + url: location.href, + size: new Blob([location.href]).size, + hash: '', + version: 0 + } const MAX_INDENT = 5 @@ -2566,39 +2573,50 @@

Document is getting large

if (!hashStr || typeof hashStr !== 'string' || hashStr.includes('undefined')) { throw new Error('Invalid or corrupt hash') } - let bytes - let fmt = FMT_DEFLATE_B64 - - if (looksLikeBase85(hashStr)) { - const decoded = decodeBase85(hashStr) - fmt = decoded[0] - bytes = decoded.slice(1) - } else { - bytes = Uint8Array.fromBase64(hashStr, {alphabet: 'base64url'}) - } - - if (fmt === FMT_ENCRYPTED) { - const err = new Error('Encrypted content') - err.type = 'ENCRYPTED' - err.encryptedData = bytes - throw err - } - - const alg = (fmt === FMT_BROTLI_B85) ? 'brotli' : 'deflate-raw' - const stream = new DecompressionStream(alg) - const writer = stream.writable.getWriter() - writer.write(bytes) - writer.close() - const buffer = await new Response(stream.readable).arrayBuffer() - const decoded = new Uint8Array(buffer) - - if (decoded[0] === BINARY_VERSION) { - return deserializeBlocks(decoded) + const attemptOrder = looksLikeBase85(hashStr) + ? ['base85', 'base64'] + : ['base64', 'base85'] + let lastError + for (const attempt of attemptOrder) { + try { + let bytes + let fmt = FMT_DEFLATE_B64 + if (attempt === 'base85') { + const decoded = decodeBase85(hashStr) + fmt = decoded[0] + bytes = decoded.slice(1) + } else { + bytes = Uint8Array.fromBase64(hashStr, {alphabet: 'base64url'}) + } + + if (fmt === FMT_ENCRYPTED) { + const err = new Error('Encrypted content') + err.type = 'ENCRYPTED' + err.encryptedData = bytes + throw err + } + + const alg = (fmt === FMT_BROTLI_B85) ? 'brotli' : 'deflate-raw' + const stream = new DecompressionStream(alg) + const writer = stream.writable.getWriter() + writer.write(bytes) + writer.close() + const buffer = await new Response(stream.readable).arrayBuffer() + const decoded = new Uint8Array(buffer) + + if (decoded[0] === BINARY_VERSION) { + return deserializeBlocks(decoded) + } + + const text = new TextDecoder().decode(decoded) + const [content, style] = text.split('\x00') + return parseTextToBlocks(content) + } catch (err) { + if (err?.type === 'ENCRYPTED') throw err + lastError = err + } } - - const text = new TextDecoder().decode(decoded) - const [content, style] = text.split('\x00') - return parseTextToBlocks(content) + throw lastError || new Error('Invalid or corrupt hash') } // ============================================ @@ -3617,6 +3635,67 @@

Document is getting large

// URL LIMIT ENFORCEMENT // ============================================ + function debounce(ms, fn) { + let timer + return (...args) => { + clearTimeout(timer) + timer = setTimeout(() => fn(...args), ms) + } + } + + function normalizeHash(hash) { + if (!hash) return '' + return hash.startsWith('#') ? hash.slice(1) : hash + } + + function buildShareUrl(hash) { + const normalized = normalizeHash(hash) + const hashSuffix = normalized ? ('#' + encodeURIComponent(normalized)) : '' + if (location.origin === 'null') { + const base = location.href.split('#')[0] + return base + hashSuffix + } + return location.origin + buildUrl(normalized) + } + + async function refreshUrlMetrics(versionAtSchedule = contentVersion) { + if (previewHash || encryptedContent) { + const url = location.href + urlMetrics = { + url, + size: new Blob([url]).size, + hash: getHashFromUrl(), + version: contentVersion + } + return urlMetrics + } + const hash = await getShortestHash(blocks) + if (versionAtSchedule !== contentVersion) return null + const url = buildShareUrl(hash) + urlMetrics = { url, size: new Blob([url]).size, hash, version: versionAtSchedule } + return urlMetrics + } + + function syncUrlMetricsToLocation() { + const url = location.href + urlMetrics = { + url, + size: new Blob([url]).size, + hash: getHashFromUrl(), + version: contentVersion + } + } + + const scheduleUrlMetricsUpdate = debounce(350, () => { + const versionAtSchedule = contentVersion + refreshUrlMetrics(versionAtSchedule).then(metrics => { + if (metrics && metrics.version === contentVersion) { + updateStatusBar() + updateQR() + } + }) + }) + function getBaseUrlLength() { return (location.origin + location.pathname + location.search + '#').length } @@ -3629,7 +3708,10 @@

Document is getting large

} function isAtInputLimit() { - return estimateUrlSizeFromBlocks(blocks) >= URL_THRESHOLDS.danger + const size = (urlMetrics.version === contentVersion) + ? urlMetrics.size + : new Blob([location.href]).size + return size >= URL_THRESHOLDS.danger } function applyLimitState() { @@ -3687,8 +3769,16 @@

Document is getting large

} function updateStatusBar() { - const url = location.href - const size = new Blob([url]).size + if (!previewHash && !encryptedContent && urlMetrics.version !== contentVersion) { + scheduleUrlMetricsUpdate() + } + const useLocation = previewHash || encryptedContent + const url = useLocation + ? location.href + : ((urlMetrics.version === contentVersion) ? urlMetrics.url : location.href) + const size = useLocation + ? new Blob([url]).size + : ((urlMetrics.version === contentVersion) ? urlMetrics.size : new Blob([url]).size) const percent = Math.min(100, (size / URL_THRESHOLDS.critical) * 100) // Update progress bar @@ -3719,8 +3809,13 @@

Document is getting large

// Tooltip interaction document.getElementById('size-progress')?.addEventListener('click', () => { - const url = location.href - const size = new Blob([url]).size + const useLocation = previewHash || encryptedContent + const url = useLocation + ? location.href + : ((urlMetrics.version === contentVersion) ? urlMetrics.url : location.href) + const size = useLocation + ? new Blob([url]).size + : ((urlMetrics.version === contentVersion) ? urlMetrics.size : new Blob([url]).size) let status = 'warning' if (size > URL_THRESHOLDS.critical) status = 'critical' @@ -3755,6 +3850,8 @@

Document is getting large

function scheduleSave() { if (saveTimeout) clearTimeout(saveTimeout) + contentVersion += 1 + scheduleUrlMetricsUpdate() saveTimeout = setTimeout(() => { if (typeof requestIdleCallback !== 'undefined') { requestIdleCallback(() => save(), { timeout: 2000 }) @@ -3772,6 +3869,13 @@

Document is getting large

const head = getHeadHash() const prevHash = head || (getLocalVersions()[0]?.hash) || null const hash = '#' + await getShortestHash(blocks) + const shareUrl = buildShareUrl(hash) + urlMetrics = { + url: shareUrl, + size: new Blob([shareUrl]).size, + hash: hash.slice(1), + version: contentVersion + } if (getHashFromUrl() !== hash.slice(1)) { history.replaceState({}, '', buildUrl(hash)) @@ -3859,6 +3963,8 @@

Document is getting large

} } } + contentVersion = 0 + syncUrlMetricsToLocation() if (!blocks.length) blocks = [createBlock('p', '')] @@ -3936,6 +4042,8 @@

Document is getting large

} catch (e) {} history.replaceState({}, '', buildUrl('')) + contentVersion = 0 + syncUrlMetricsToLocation() updateTitle() updateQR() updateStatusBar() @@ -4002,6 +4110,9 @@

Document is getting large

theme = t applyTheme(t) history.replaceState({}, '', buildUrl()) + syncUrlMetricsToLocation() + updateStatusBar() + updateQR() } function initTheme() { @@ -4011,6 +4122,7 @@

Document is getting large

history.replaceState({}, '', buildUrl()) } applyTheme(theme) + syncUrlMetricsToLocation() document.getElementById('theme-toggle')?.addEventListener('click', () => { setTheme(theme === 'dark' ? 'light' : 'dark') }) @@ -4425,6 +4537,8 @@

Document is getting large

pushLocalVersion(mergedHash, [head, source].filter(Boolean)) try { localStorage.setItem('hash', mergedHash) } catch (e) {} history.replaceState({}, '', buildUrl(mergedHash)) + contentVersion = 0 + syncUrlMetricsToLocation() versionRedoStack = [] blocks = mergedBlocks renderAllBlocks() @@ -4490,6 +4604,8 @@

Document is getting large

} history.replaceState({}, '', buildUrl(head || '')) + contentVersion = 0 + syncUrlMetricsToLocation() previewHash = null const wrap = document.getElementById('editor-wrap') @@ -4532,6 +4648,8 @@

Document is getting large

await setFromHash(parent) try { localStorage.setItem('hash', parent) } catch (e) {} history.replaceState({}, '', buildUrl(parent)) + contentVersion = 0 + syncUrlMetricsToLocation() updateTitle() updateQR() @@ -4550,6 +4668,8 @@

Document is getting large

await setFromHash(next) try { localStorage.setItem('hash', next) } catch (e) {} history.replaceState({}, '', buildUrl(next)) + contentVersion = 0 + syncUrlMetricsToLocation() updateTitle() updateQR() @@ -4567,6 +4687,8 @@

Document is getting large

if (head) { await setFromHash(head) history.replaceState({}, '', buildUrl(head)) + contentVersion = 0 + syncUrlMetricsToLocation() } clearVersionHistory() updateTitle() @@ -4604,14 +4726,6 @@

Document is getting large

setTimeout(() => notification.classList.remove('visible'), 2000) } - function debounce(ms, fn) { - let timer - return (...args) => { - clearTimeout(timer) - timer = setTimeout(() => fn(...args), ms) - } - } - function updateLockButtonVisibility() { const lockBtn = document.getElementById('lock-btn') if (!lockBtn) return @@ -4631,7 +4745,9 @@

Document is getting large

function updateQR() { const el = document.getElementById('qrcode') if (!el || typeof qrcode !== 'function') return - const url = location.href + const url = (previewHash || encryptedContent) + ? location.href + : ((urlMetrics.version === contentVersion) ? urlMetrics.url : location.href) try { const qr = qrcode(0, 'L') qr.addData(url) @@ -4870,12 +4986,24 @@

Document is getting large

notify('New note') }) - document.getElementById('share-copy-link')?.addEventListener('click', e => { + document.getElementById('share-copy-link')?.addEventListener('click', async e => { e.preventDefault() if (!navigator.clipboard) { alert('Clipboard not supported'); return } - navigator.clipboard.writeText(location.href) - notify('Link copied') - closeAllDropdowns() + try { + if (!previewHash && !encryptedContent) { + await save() + await refreshUrlMetrics(contentVersion) + } + const url = (previewHash || encryptedContent) + ? location.href + : ((urlMetrics.version === contentVersion) ? urlMetrics.url : location.href) + await navigator.clipboard.writeText(url) + notify('Link copied') + } catch (err) { + notify('Could not copy link') + } finally { + closeAllDropdowns() + } }) document.getElementById('share-export-txt')?.addEventListener('click', e => { @@ -4937,6 +5065,8 @@

Document is getting large

const hashStr = await encryptBlocks(blocks, password) const hash = '#' + hashStr history.replaceState({}, '', buildUrl(hash)) + contentVersion = 0 + syncUrlMetricsToLocation() try { pushLocalVersion(hash, prevHead ? [prevHead] : (getLocalVersions()[0]?.hash ? [getLocalVersions()[0].hash] : [])) localStorage.setItem('hash', hash) @@ -4983,6 +5113,8 @@

Document is getting large

const prevHead = getHeadHash() const hash = '#' + await getShortestHash(blocks) history.replaceState({}, '', buildUrl(hash)) + contentVersion = 0 + syncUrlMetricsToLocation() try { pushLocalVersion(hash, prevHead ? [prevHead] : (getLocalVersions()[0]?.hash ? [getLocalVersions()[0].hash] : [])) localStorage.setItem('hash', hash)