Compare commits

..
42 Commits
Author SHA1 Message Date
github-actions[bot] cfc03b54be chore: bump version to 0.29.0 2026-07-12 17:14:42 +00:00
Frederic CabassutandClaude Fable 5 4b7e346206 fix(editor): keep toolbar and dropdowns above the Android keyboard
Add interactive-widget=resizes-content to the viewport meta so the
Android keyboard shrinks the layout viewport through the native inset
path. Chromium's visualViewport.height can under-report the visible
height by the system nav-bar height, which left the editor toolbar
floating above the keyboard; the native resize path is accurate. iOS
ignores the attribute and keeps using the visualViewport tracking in
NoteEditor.

Position toolbar dropdown portals with window.innerHeight instead of
visualViewport.height: CSS bottom on position:fixed and
getBoundingClientRect() both use layout-viewport coordinates, so
mixing in the visual-viewport height pushed dropdowns behind the
keyboard while it was open.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 16:19:19 +02:00
Romain LE DISEZ 5fb0337d03 fix(editor): address PR 67 review feedback
Restore portal dropdown test IDs so the formatting e2e suite can
find heading and link controls again.

Let the mobile formatting toolbar pan horizontally, keep dropdown
portals from drifting by closing them on scroll or resize, and use
visualViewport height for keyboard-aware positioning.

Remove the app-wide maximum-scale lock, keep editor focus targets at
16px on mobile to avoid input auto-zoom, and move toolbar scrollbar
styling to a top-level scoped style block.

Align the remove-link action with the destructive design token, use
the standard 150ms editor slide-up animation, and replace
save/restore body overflow locking with a resilient body class.
2026-07-10 16:19:19 +02:00
Romain LE DISEZandGoogle Gemini ab8b273400 feat(ui): improve mobile editor layout
Make editor full-screen natively on mobile with back button. Add
visualViewport tracking to keep bottom toolbars above the software keyboard.

disable pull-to-refresh for native PWA feel
prevent auto-zoom on input focus in mobile Safari
prevent background scrolling when editor is open
prevent touch scroll on editor toolbars to stop background scrolling
hide visual gap at bottom of editor when iOS keyboard opens
move note actions to top-right menu on mobile devices
wrap background colors in mobile menu to reduce width
make formatting toolbar scrollable on mobile and extract dropdowns
remove empty else block causing svelte compilation error
add missing data-dropdown attributes to fix click interception in toolbar portals
keep mobile keyboard open when using toolbar menus and prevent right-edge overflow

Co-authored-by: Google Gemini <gemini@google.com>
2026-07-10 16:19:19 +02:00
github-actions[bot] 57cbab3cec chore: pin docker-compose to v0.28.4 [skip ci] 2026-06-09 19:17:14 +00:00
github-actions[bot] 760235e667 chore: bump version to 0.28.4 2026-06-09 19:10:42 +00:00
Claude 0cc64fd8f3 fix(deps): patch high/critical security advisories
Bumps runtime- and build-reachable dependencies to patched versions and
adds pnpm overrides for transitive advisories. Clears all high/critical
findings (1 critical + 12 high -> 0); remaining moderates/lows are
dev-tooling only and stripped from the production image by pnpm prune.

Direct bumps:
- drizzle-orm ^0.44.7 -> ^0.45.2  (SQL injection via unescaped identifiers, GHSA-gpj5-g38j-94v9)
- @sveltejs/kit ^2.53.4 -> ^2.63.1 (adapter-node BODY_SIZE_LIMIT bypass, GHSA-2crg-3p73-43xp)
- vite ^7.3.1 -> ^7.3.5, vitest/@vitest/coverage-v8 ^3.2.4 -> ^3.2.6 (dev-server file read; vitest UI RCE)

Transitive overrides:
- devalue >=5.8.1   (DoS via sparse-array deserialization, GHSA-77vg-94rm-hx3p)
- path-to-regexp@8 >=8.4.0 (ReDoS via express/router under @modelcontextprotocol/sdk, GHSA-j3q9-mxjg-w52f)
- fast-uri >=3.1.2  (path traversal / host confusion via ajv, GHSA-q3j6-qgpj-74h6)

Verified: pnpm check (0 errors), 264 unit tests pass, production build succeeds.
2026-06-09 21:08:39 +02:00
Claude dbade3595e ci: run on push to main only to avoid duplicate PR runs
A push to a claude/** branch with an open PR triggered both the push
and pull_request events. The concurrency group keys on github.ref,
which differs between the two (refs/heads/... vs refs/pull/N/merge),
so neither run was cancelled and the whole pipeline (including E2E)
ran twice. Drop claude/** from the push trigger; branch work is now
validated once via the pull_request event.
2026-06-09 20:58:58 +02:00
Claude 585be91be5 chore: pin pnpm via packageManager field for CI and tooling
Adds a packageManager field (pnpm@9.15.9) to package.json so corepack
and pnpm/action-setup@v4 resolve a single pinned pnpm 9 version that
matches lockfileVersion 9.0. Removes the floating 'version: latest' from
the CI and screenshots workflows so they honor the packageManager field
instead of pulling pnpm 10+, which breaks native module builds.

Follow-up to #63.
2026-06-09 20:54:29 +02:00
Romain LE DISEZandGoogle Gemini c2bb6ef75a fix(docker): pin pnpm version to 9 to match lockfile and fix build errors
Co-authored-by: Google Gemini <gemini@google.com>
2026-06-09 20:50:22 +02:00
github-actions[bot] 2fb1237f04 chore: pin docker-compose to v0.28.3 [skip ci] 2026-04-22 19:52:23 +00:00
github-actions[bot] 808217eb55 chore: bump version to 0.28.3 2026-04-22 19:46:17 +00:00
Claude ea403dcbee test: upload a real attachment in the user-deletion coverage test
The "notes, tags, and attachments" scenario previously only created a
note and an API key — no attachment. Upload a PNG via the browser
context so Origin is set correctly for SvelteKit's CSRF check, giving
the atomic-delete path genuine attachment rows to clean up.

https://claude.ai/code/session_015T2HE6VzB64ZmSSyy1Wz4R
2026-04-22 21:45:48 +02:00
Claude 5719fc3459 fix: make admin user deletion atomic and surface errors in UI
deleteUser() ran each child-table cleanup as a separate statement, so a
foreign-key violation on any step (e.g. sync_log rows from other users
referencing the deleted user's notes) would abort mid-way and leave the
database in a partial state. The API swallowed the error and the admin
page silently ignored the non-ok response, so clicking Delete and
confirming appeared to do nothing.

- Wrap the full cascade in a single sqlite transaction so the deletion
  is atomic.
- Also remove rows in sync_log, note_user_state, note_collaborators,
  note_versions, and attachments that belong to other users but
  reference the deleted user's notes, so the final DELETE FROM notes
  cannot trip a foreign-key check.
- Surface delete failures in the users settings UI instead of silently
  swallowing them.
- Add data-testids on the user row and delete button to support tests.
- Add e2e coverage for the admin user deletion flow (happy path,
  cancelled confirmation, and a user with notes / tags / attachments /
  preferences / api keys).

https://claude.ai/code/session_015T2HE6VzB64ZmSSyy1Wz4R
2026-04-22 21:45:48 +02:00
github-actions[bot] b2e7b2c96d chore: pin docker-compose to v0.28.2 [skip ci] 2026-04-10 20:31:53 +00:00
github-actions[bot] d912b8f512 chore: bump version to 0.28.2 2026-04-10 20:26:06 +00:00
Claude ba1b071395 fix: use currentTarget and contains() in blur handler per review
Use e.currentTarget instead of e.target so the reference is always the
contenteditable div, not a descendant <a> tag. Use el.contains() instead
of strict equality to avoid linkifying while focus is still inside the
editable element.

https://claude.ai/code/session_01VZceWPhHMHCA6SELBdCPfN
2026-04-10 21:53:20 +02:00
Claude 86cfc8c5f6 fix: defer contenteditable blur linkification to unblock mobile tap events
When a contenteditable loses focus, the onblur handler immediately modified
innerHTML to linkify URLs. On mobile, this DOM mutation causes a layout reflow
between touchstart and click, making the browser cancel taps on the adjacent
delete button. Deferring via requestAnimationFrame ensures the click event
fires before the DOM is modified.

https://claude.ai/code/session_01VZceWPhHMHCA6SELBdCPfN
2026-04-10 21:53:20 +02:00
github-actions[bot] 76b04549af chore: pin docker-compose to v0.28.1 [skip ci] 2026-04-10 06:57:56 +00:00
github-actions[bot] 880b890cac chore: bump version to 0.28.1 2026-04-10 06:52:06 +00:00
Frederic CabassutandClaude Opus 4.6 0a30145996 fix: decode &nbsp; entities from contenteditable in checklist items
Browsers inject &nbsp; in contenteditable elements, which was not being
decoded by unlinkifyHtml, causing double-escaped "&nbsp;" to appear in
checklist item text.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 08:51:14 +02:00
github-actions[bot] cccdcd2036 chore: pin docker-compose to v0.28.0 [skip ci] 2026-04-08 21:34:12 +00:00
github-actions[bot] 88f2dc6273 chore: bump version to 0.28.0 2026-04-08 21:28:43 +00:00
Frederic Cabassut d3b98fddf2 fix: address PR review feedback
- Add noreferrer to rel attribute to prevent Referer header leaks
- Strip newlines from pasted text to prevent checklist corruption
- Add aria-placeholder for screen reader accessibility
- Dismiss LinkPopover on scroll/resize
- Stop keydown propagation on links in NoteCard preview
2026-04-08 22:36:19 +02:00
Frederic Cabassut 70c258f3b8 chore: remove design spec and plan docs from feature branch 2026-04-08 22:36:19 +02:00
Frederic Cabassut 4c1083e14d chore: remove playwright-mcp logs 2026-04-08 22:36:19 +02:00
Frederic CabassutandClaude Opus 4.6 49c28a33ff chore: remove accidental files from feature branch
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 22:36:19 +02:00
Frederic CabassutandClaude Opus 4.6 4c13d345fc fix: use Svelte action for contenteditable to prevent cursor destruction
The reactive {#html} binding inside contenteditable replaced innerHTML on
every keystroke, destroying the cursor and causing text duplication.

Instead, use a use:editableContent action that sets innerHTML on mount
and only syncs when the element is NOT focused. Linkification happens
on blur, matching Google Keep's behavior. Update e2e tests to blur
before asserting link visibility.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 22:36:19 +02:00
Frederic Cabassut 0ece0b7b7f test: add e2e tests for checklist hyperlink support 2026-04-08 22:36:19 +02:00
Frederic Cabassut f6642ac7b3 test: update e2e tests for contenteditable checklist inputs 2026-04-08 22:36:19 +02:00
Frederic Cabassut 653e990071 feat: linkify URLs in shared note checklist view 2026-04-08 22:36:19 +02:00
Frederic Cabassut 04fdffb111 feat: linkify URLs in NoteCard checklist preview 2026-04-08 22:36:19 +02:00
Frederic Cabassut 5eb7a84a88 feat: linkify URLs in checklist done section 2026-04-08 22:36:19 +02:00
Frederic CabassutandClaude Opus 4.6 beb4209baf fix: decode HTML entities in unlinkifyHtml and fix indentation
- Add entity decoding (&amp; &lt; &gt; &quot;) to unlinkifyHtml to
  prevent double-encoding on contenteditable edit cycles
- Fix indentation of LinkPopover import and state declaration
- Move LinkPopover inside root div
- Add unit test for entity decoding

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 22:36:19 +02:00
Frederic Cabassut 377ce4b72c feat: replace checklist text inputs with contenteditable for inline link rendering 2026-04-08 22:36:19 +02:00
Frederic Cabassut 740a89df63 feat: add LinkPopover component for checklist hyperlinks 2026-04-08 22:36:19 +02:00
Frederic Cabassut 17216bd17d style: add checklist-link CSS and contenteditable placeholder styles 2026-04-08 22:36:19 +02:00
Frederic Cabassut c753c4b5ce feat: add linkifyText and unlinkifyHtml utilities for checklist hyperlinks 2026-04-08 22:36:19 +02:00
github-actions[bot] aee262d70c chore: pin docker-compose to v0.27.1 [skip ci] 2026-03-25 07:32:04 +00:00
github-actions[bot] 53f5bdcbd8 chore: bump version to 0.27.1 2026-03-25 07:26:53 +00:00
Frederic CabassutandClaude Opus 4.6 b6dbdbe9a5 fix: prevent click-through to NoteCards when closing editor overlay
On installed PWAs, closing the editor by tapping the overlay would cause
the browser's delayed synthetic click to land on the NoteCard underneath,
immediately reopening a different note. Fixed by keeping a transparent
shield in the DOM for 400ms after close to absorb the ghost click.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 08:15:58 +01:00
github-actions[bot] 21efb1d811 chore: pin docker-compose to v0.27.0 [skip ci] 2026-03-24 22:56:34 +00:00
24 changed files with 2740 additions and 1354 deletions
+1 -7
View File
@@ -2,7 +2,7 @@ name: CI
on:
push:
branches: [main, 'claude/**']
branches: [main]
pull_request:
branches: [main]
workflow_call:
@@ -19,8 +19,6 @@ jobs:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: latest
- uses: actions/setup-node@v4
with:
@@ -37,8 +35,6 @@ jobs:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: latest
- uses: actions/setup-node@v4
with:
@@ -68,8 +64,6 @@ jobs:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: latest
- uses: actions/setup-node@v4
with:
-2
View File
@@ -14,8 +14,6 @@ jobs:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: latest
- uses: actions/setup-node@v4
with:
+1 -1
View File
@@ -146,7 +146,7 @@ A `Makefile` wraps all common tasks for tool-agnostic usage. Run `make help` to
GitHub Actions with two workflows:
**CI** (`.github/workflows/ci.yml`) — runs on push to `main`/`claude/**` and PRs to `main`:
**CI** (`.github/workflows/ci.yml`) — runs on push to `main` and PRs to `main` (branch work is validated once via the PR event, avoiding duplicate push+PR runs):
1. **Lint & Type Check** — `pnpm check`
2. **Unit Tests** — `pnpm test:unit`
3. **Build** — `pnpm build` (depends on steps 1+2)
+1 -1
View File
@@ -2,7 +2,7 @@
FROM node:22-slim AS build
WORKDIR /app
RUN corepack enable && corepack prepare pnpm@latest --activate
RUN corepack enable && corepack prepare pnpm@9 --activate
# Copy package files
COPY package.json pnpm-lock.yaml ./
+1 -1
View File
@@ -1,6 +1,6 @@
services:
crumbs:
image: ghcr.io/bretzel-app/crumbs:0.26.0
image: ghcr.io/bretzel-app/crumbs:0.28.4
container_name: crumbs
ports:
- "3000:3000"
+11 -7
View File
@@ -1,8 +1,9 @@
{
"name": "crumbs",
"private": true,
"version": "0.27.0",
"version": "0.29.0",
"type": "module",
"packageManager": "pnpm@9.15.9",
"license": "MIT",
"author": "Bretzel (https://bretzel.app)",
"homepage": "https://crumbs.bretzel.app",
@@ -51,7 +52,7 @@
"arctic": "^3.7.0",
"argon2": "^0.43.1",
"better-sqlite3": "^11.10.0",
"drizzle-orm": "^0.44.7",
"drizzle-orm": "^0.45.2",
"idb": "^8.0.3",
"lucide-svelte": "^0.577.0",
"markdown-it": "^14.1.1",
@@ -66,7 +67,7 @@
},
"devDependencies": {
"@playwright/test": "^1.58.2",
"@sveltejs/kit": "^2.53.4",
"@sveltejs/kit": "^2.63.1",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.2.1",
@@ -76,7 +77,7 @@
"@types/uuid": "^10.0.0",
"@types/zxcvbn": "^4.4.5",
"@vite-pwa/sveltekit": "^1.1.0",
"@vitest/coverage-v8": "^3.2.4",
"@vitest/coverage-v8": "^3.2.6",
"drizzle-kit": "^0.31.9",
"jsdom": "^29.0.1",
"nyc": "^18.0.0",
@@ -85,10 +86,10 @@
"tailwindcss": "^4.2.1",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vite": "^7.3.5",
"vite-plugin-istanbul": "^7.2.1",
"vite-plugin-pwa": "^1.2.0",
"vitest": "^3.2.4",
"vitest": "^3.2.6",
"workbox-window": "^7.4.0",
"yaml": "^2.8.2"
},
@@ -102,7 +103,10 @@
"serialize-javascript": ">=7.0.3",
"esbuild@<0.25.0": "0.25.12",
"cookie": ">=0.7.0",
"hono": ">=4.12.7"
"hono": ">=4.12.7",
"devalue": ">=5.8.1",
"path-to-regexp@8": ">=8.4.0",
"fast-uri": ">=3.1.2"
}
}
}
+1546 -1004
View File
File diff suppressed because it is too large Load Diff
+28 -1
View File
@@ -41,9 +41,14 @@
--grid-dot: #e8dcc8;
}
body {
html, body {
font-family: 'JetBrains Mono', monospace;
accent-color: var(--primary);
overscroll-behavior-y: none;
}
body.editor-scroll-locked {
overflow: hidden;
}
body::before {
@@ -68,6 +73,11 @@ body::before {
to { opacity: 1; transform: scale(1) translateY(0); }
}
@keyframes slide-up {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
/* Prose overrides for parchment theme */
.prose {
--tw-prose-body: var(--text);
@@ -206,6 +216,23 @@ body::before {
margin: 0;
}
/* Checklist link styles */
.checklist-link {
color: var(--primary);
text-decoration: underline;
text-underline-offset: 2px;
}
.checklist-link:hover {
color: var(--primary-hover);
}
[data-placeholder]:empty::before {
content: attr(data-placeholder);
color: var(--text-muted);
pointer-events: none;
}
/* Custom tooltips */
.tooltip-popup {
position: fixed;
+5 -1
View File
@@ -2,7 +2,11 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- interactive-widget=resizes-content: on Android the keyboard shrinks the layout viewport via
the native inset path, so bottom-anchored UI (editor toolbar) lands exactly on the keyboard —
Chromium's visualViewport.height can under-report by the nav-bar height. iOS ignores this
attribute and relies on the visualViewport tracking in NoteEditor instead. -->
<meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content" />
<meta name="theme-color" content="#f0e6d3" />
<script>
(function() {
+73 -15
View File
@@ -6,7 +6,8 @@
import { onDestroy } from 'svelte';
import { dragHandleZone, dragHandle, type DndEvent } from 'svelte-dnd-action';
import { flip } from 'svelte/animate';
import { type ChecklistItem, generateId, parseChecklist, serializeChecklist, toggleItemWithCascade, indentItem, outdentItem } from '$lib/utils/checklist.js';
import { type ChecklistItem, generateId, parseChecklist, serializeChecklist, toggleItemWithCascade, indentItem, outdentItem, linkifyText, unlinkifyHtml } from '$lib/utils/checklist.js';
import LinkPopover from './LinkPopover.svelte';
interface Props {
content: string;
@@ -19,6 +20,7 @@
let items = $state<ChecklistItem[]>(parseChecklist(content));
let doneExpanded = $state(true);
const flipDurationMs = 150;
let linkPopover = $state<{ url: string; anchor: DOMRect } | null>(null);
// Track the last content we emitted so we can distinguish self-originated
// changes from external ones (sync, history restore, etc.)
@@ -85,10 +87,10 @@
emitChange();
}
function updateText(id: string, text: string) {
function updateText(id: string, innerHTML: string) {
const item = items.find((i) => i.id === id);
if (item) {
item.text = text;
item.text = unlinkifyHtml(innerHTML);
emitChange();
}
}
@@ -106,7 +108,7 @@
emitChange();
const newId = newItem.id;
setTimeout(() => {
document.querySelector<HTMLInputElement>(`[data-item-id="${newId}"]`)?.focus();
document.querySelector<HTMLElement>(`[data-item-id="${newId}"]`)?.focus();
}, 0);
}
@@ -133,11 +135,11 @@
e.preventDefault();
removeItem(id);
setTimeout(() => {
const inputs = document.querySelectorAll<HTMLInputElement>('[data-testid="checklist-input"]');
const inputs = document.querySelectorAll<HTMLElement>('[data-testid="checklist-input"]');
inputs[Math.max(0, index - 1)]?.focus();
}, 0);
} else if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
const inputs = Array.from(document.querySelectorAll<HTMLInputElement>('[data-testid="checklist-input"]'));
const inputs = Array.from(document.querySelectorAll<HTMLElement>('[data-testid="checklist-input"]'));
const current = inputs.findIndex((el) => el.dataset.itemId === id);
const target = e.key === 'ArrowUp' ? current - 1 : current + 1;
if (target >= 0 && target < inputs.length) {
@@ -155,6 +157,44 @@
}
}
// Svelte action: sets innerHTML on mount and syncs from external changes only.
// While the user is typing (element is focused), we don't re-render — that would
// destroy the cursor. Linkification happens on blur via the onblur handler.
function editableContent(node: HTMLElement, text: string) {
node.innerHTML = linkifyText(text);
return {
update(newText: string) {
if (document.activeElement !== node) {
node.innerHTML = linkifyText(newText);
}
}
};
}
function handleLinkClick(e: MouseEvent) {
const target = e.target as HTMLElement;
const anchor = target.closest('a');
if (anchor) {
e.preventDefault();
e.stopPropagation();
linkPopover = { url: anchor.href, anchor: anchor.getBoundingClientRect() };
}
}
function handlePaste(e: ClipboardEvent) {
e.preventDefault();
const text = (e.clipboardData?.getData('text/plain') ?? '').replace(/[\r\n]+/g, ' ');
const selection = window.getSelection();
if (!selection?.rangeCount) return;
const range = selection.getRangeAt(0);
range.deleteContents();
range.insertNode(document.createTextNode(text));
range.collapse(false);
selection.removeAllRanges();
selection.addRange(range);
(e.target as HTMLElement).dispatchEvent(new Event('input', { bubbles: true }));
}
// --- Directional lock drag handle ---
// Instead of blocking DnD events (which corrupts its internal state),
// we detect horizontal intent and store the pending operation. DnD runs
@@ -276,16 +316,30 @@
class="h-4 w-4 rounded border-[var(--border-subtle)] text-[var(--primary)] focus:ring-[var(--primary)]"
data-testid="checklist-checkbox"
/>
<input
type="text"
value={item.text}
oninput={(e) => updateText(item.id, (e.target as HTMLInputElement).value)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
contenteditable="true"
use:editableContent={item.text}
oninput={(e) => updateText(item.id, (e.target as HTMLElement).innerHTML)}
onkeydown={(e) => handleKeydown(e, item.id)}
class="flex-1 min-w-0 bg-transparent text-sm outline-none {item.checked ? 'text-[var(--text-muted)] line-through' : 'text-[var(--text)]'}"
placeholder="List item"
onclick={handleLinkClick}
onpaste={handlePaste}
onblur={(e) => {
const el = e.currentTarget as HTMLElement;
requestAnimationFrame(() => {
if (el.isConnected && !el.contains(document.activeElement)) {
el.innerHTML = linkifyText(item.text);
}
});
}}
class="flex-1 min-w-0 bg-transparent text-sm outline-none break-words {item.checked ? 'text-[var(--text-muted)] line-through' : 'text-[var(--text)]'}"
data-placeholder="List item"
aria-placeholder="List item"
data-testid="checklist-input"
data-item-id={item.id}
/>
role="textbox"
tabindex="0"
></div>
<button
onclick={() => removeItem(item.id)}
class="max-md:opacity-100 md:opacity-0 md:group-hover:opacity-100 focus:opacity-100"
@@ -324,7 +378,7 @@
{#if group.parentLabel}
<div class="flex items-center gap-2 py-1.5 pl-5" data-testid="checklist-done-parent-label">
<input type="checkbox" disabled class="h-4 w-4 rounded border-[var(--border-subtle)] opacity-50" />
<span class="text-sm text-[var(--text-muted)]">{group.parentLabel.text}</span>
<span class="text-sm text-[var(--text-muted)] break-words min-w-0">{@html linkifyText(group.parentLabel.text)}</span>
</div>
{/if}
{#each group.children as item (item.id)}
@@ -336,7 +390,7 @@
class="h-4 w-4 rounded border-[var(--border-subtle)] text-[var(--primary)] focus:ring-[var(--primary)]"
data-testid="checklist-done-checkbox"
/>
<span class="flex-1 text-sm text-[var(--text-muted)] line-through">{item.text}</span>
<span class="flex-1 text-sm text-[var(--text-muted)] line-through break-words min-w-0">{@html linkifyText(item.text)}</span>
<button
onclick={() => removeItem(item.id)}
class="max-md:opacity-100 md:opacity-0 md:group-hover:opacity-100 focus:opacity-100"
@@ -352,5 +406,9 @@
{/if}
</div>
{/if}
{#if linkPopover}
<LinkPopover url={linkPopover.url} anchor={linkPopover.anchor} onClose={() => (linkPopover = null)} />
{/if}
</div>
+327 -218
View File
@@ -44,24 +44,57 @@
function getAttrs(type: string) { void tick; return editor?.getAttributes(type) ?? {}; }
let openDropdown: string | null = $state(null);
let dropdownAnchorEl: HTMLElement | null = $state(null);
let linkUrl: string = $state('');
let linkInput: HTMLInputElement | undefined = $state();
let ignorePositionInvalidationUntil = 0;
function toggleDropdown(name: string) {
const dropdownGap = 4;
const viewportMargin = 10;
function toggleDropdown(name: string, anchorEl: HTMLElement) {
if (openDropdown === name) {
openDropdown = null;
closeDropdowns();
} else {
ignorePositionInvalidationUntil = performance.now() + 600;
openDropdown = name;
dropdownAnchorEl = anchorEl;
if (name === 'link') {
linkUrl = editor?.getAttributes('link').href ?? '';
// Focus input after Svelte renders the popover
requestAnimationFrame(() => linkInput?.focus());
}
}
}
function handleDropdownClick(name: string, event: MouseEvent) {
toggleDropdown(name, event.currentTarget as HTMLElement);
}
function preventToolbarMouseFocus(event: MouseEvent) {
if (event.button === 0) event.preventDefault();
}
function handleCommandClick(command: () => void) {
command();
}
function closeDropdowns() {
openDropdown = null;
dropdownAnchorEl = null;
}
function closeDropdownsAfterPositionChange() {
if (!openDropdown || performance.now() < ignorePositionInvalidationUntil) return;
closeDropdowns();
}
function getDropdownStyle(rect: DOMRect, width: number): string {
const left = Math.max(viewportMargin, Math.min(rect.left, window.innerWidth - width - viewportMargin));
// CSS `bottom` on position:fixed and getBoundingClientRect() both use layout-viewport
// coordinates, so the reference height must be innerHeight — visualViewport.height is
// smaller while the keyboard is open and would push the dropdown behind the keyboard.
const bottom = window.innerHeight - rect.top + dropdownGap;
return `bottom: ${bottom}px; left: ${left}px;`;
}
function applyLink() {
@@ -112,17 +145,32 @@
closeDropdowns();
}
}
$effect(() => {
if (!openDropdown || !window.visualViewport) return;
window.visualViewport.addEventListener('resize', closeDropdownsAfterPositionChange);
window.visualViewport.addEventListener('scroll', closeDropdownsAfterPositionChange);
return () => {
window.visualViewport?.removeEventListener('resize', closeDropdownsAfterPositionChange);
window.visualViewport?.removeEventListener('scroll', closeDropdownsAfterPositionChange);
};
});
</script>
<svelte:document onpointerdown={handlePointerDown} onkeydown={handleKeydown} />
<svelte:window onscroll={closeDropdownsAfterPositionChange} onresize={closeDropdownsAfterPositionChange} />
<div
class="flex flex-wrap items-center gap-0.5 border-b border-[var(--border-subtle)] px-2 py-1"
class="flex overflow-x-auto md:flex-wrap items-center gap-0.5 border-b border-[var(--border-subtle)] px-2 py-1 scrollbar-hide"
style="scrollbar-width: none; -ms-overflow-style: none;"
data-testid="formatting-toolbar"
onscroll={closeDropdownsAfterPositionChange}
>
<!-- Undo/Redo -->
<button
onclick={() => editor?.chain().focus().undo().run()}
onmousedown={preventToolbarMouseFocus}
onclick={() => handleCommandClick(() => editor?.chain().focus().undo().run())}
disabled={!canUndo()}
class={btnClass(false, !canUndo())}
title="Undo"
@@ -131,7 +179,8 @@
<Undo2 size={iconSize} />
</button>
<button
onclick={() => editor?.chain().focus().redo().run()}
onmousedown={preventToolbarMouseFocus}
onclick={() => handleCommandClick(() => editor?.chain().focus().redo().run())}
disabled={!canRedo()}
class={btnClass(false, !canRedo())}
title="Redo"
@@ -144,7 +193,8 @@
<!-- Inline -->
<button
onclick={() => editor?.chain().focus().toggleBold().run()}
onmousedown={preventToolbarMouseFocus}
onclick={() => handleCommandClick(() => editor?.chain().focus().toggleBold().run())}
class={btnClass(isActive('bold'))}
title="Bold (Ctrl+B)"
data-testid="format-bold"
@@ -152,7 +202,8 @@
<Bold size={iconSize} />
</button>
<button
onclick={() => editor?.chain().focus().toggleItalic().run()}
onmousedown={preventToolbarMouseFocus}
onclick={() => handleCommandClick(() => editor?.chain().focus().toggleItalic().run())}
class={btnClass(isActive('italic'))}
title="Italic (Ctrl+I)"
data-testid="format-italic"
@@ -160,7 +211,8 @@
<Italic size={iconSize} />
</button>
<button
onclick={() => editor?.chain().focus().toggleStrike().run()}
onmousedown={preventToolbarMouseFocus}
onclick={() => handleCommandClick(() => editor?.chain().focus().toggleStrike().run())}
class={btnClass(isActive('strike'))}
title="Strikethrough (Ctrl+Shift+X)"
data-testid="format-strikethrough"
@@ -168,7 +220,8 @@
<Strikethrough size={iconSize} />
</button>
<button
onclick={() => editor?.chain().focus().toggleUnderline().run()}
onmousedown={preventToolbarMouseFocus}
onclick={() => handleCommandClick(() => editor?.chain().focus().toggleUnderline().run())}
class={btnClass(isActive('underline'))}
title="Underline (Ctrl+U)"
data-testid="format-underline"
@@ -179,9 +232,10 @@
<div class="mx-1 h-4 w-px shrink-0 bg-[var(--border-subtle)]"></div>
<!-- Heading dropdown -->
<div class="relative" data-dropdown="heading">
<div data-dropdown="heading">
<button
onclick={() => toggleDropdown('heading')}
onmousedown={preventToolbarMouseFocus}
onclick={(e) => handleDropdownClick('heading', e)}
class={dropdownBtnClass(isActive('heading'))}
title="Heading"
data-testid="format-heading"
@@ -189,40 +243,13 @@
<Heading size={iconSize} />
<ChevronDown size={chevronSize} />
</button>
{#if openDropdown === 'heading'}
<div class="absolute left-0 bottom-full z-50 mb-1 min-w-[150px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1">
<button
onclick={() => { editor?.chain().focus().toggleHeading({ level: 1 }).run(); closeDropdowns(); }}
class={dropdownItemClass(isActive('heading', { level: 1 }))}
data-testid="format-h1"
>
<span class="w-6 text-xs font-semibold text-[var(--text-muted)]">H1</span>
<span class="font-semibold">Heading 1</span>
</button>
<button
onclick={() => { editor?.chain().focus().toggleHeading({ level: 2 }).run(); closeDropdowns(); }}
class={dropdownItemClass(isActive('heading', { level: 2 }))}
data-testid="format-h2"
>
<span class="w-6 text-xs font-semibold text-[var(--text-muted)]">H2</span>
<span>Heading 2</span>
</button>
<button
onclick={() => { editor?.chain().focus().toggleHeading({ level: 3 }).run(); closeDropdowns(); }}
class={dropdownItemClass(isActive('heading', { level: 3 }))}
data-testid="format-h3"
>
<span class="w-6 text-xs font-semibold text-[var(--text-muted)]">H3</span>
<span>Heading 3</span>
</button>
</div>
{/if}
</div>
<!-- List dropdown -->
<div class="relative" data-dropdown="list">
<div data-dropdown="list">
<button
onclick={() => toggleDropdown('list')}
onmousedown={preventToolbarMouseFocus}
onclick={(e) => handleDropdownClick('list', e)}
class={dropdownBtnClass(isActive('bulletList') || isActive('orderedList') || isActive('taskList'))}
title="Lists"
data-testid="format-list"
@@ -230,98 +257,26 @@
<List size={iconSize} />
<ChevronDown size={chevronSize} />
</button>
{#if openDropdown === 'list'}
<div class="absolute left-0 bottom-full z-50 mb-1 min-w-[170px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1">
<button
onclick={() => { editor?.chain().focus().toggleBulletList().run(); closeDropdowns(); }}
class={dropdownItemClass(isActive('bulletList'))}
data-testid="format-bullet-list"
>
<List size={iconSize} />
<span>Bullet list</span>
</button>
<button
onclick={() => { editor?.chain().focus().toggleOrderedList().run(); closeDropdowns(); }}
class={dropdownItemClass(isActive('orderedList'))}
data-testid="format-ordered-list"
>
<ListOrdered size={iconSize} />
<span>Ordered list</span>
</button>
<button
onclick={() => { editor?.chain().focus().toggleTaskList().run(); closeDropdowns(); }}
class={dropdownItemClass(isActive('taskList'))}
data-testid="format-task-list"
>
<ListChecks size={iconSize} />
<span>Task list</span>
</button>
</div>
{/if}
</div>
<!-- Link popover -->
<div class="relative" data-dropdown="link">
<div data-dropdown="link">
<button
onclick={() => toggleDropdown('link')}
onmousedown={preventToolbarMouseFocus}
onclick={(e) => handleDropdownClick('link', e)}
class={btnClass(isActive('link'))}
title="Insert link (Ctrl+K)"
data-testid="format-link"
>
<Link size={iconSize} />
</button>
{#if openDropdown === 'link'}
<div class="absolute left-1/2 bottom-full z-50 mb-1 -translate-x-1/2 rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] px-2 py-1.5">
<form
class="flex items-center gap-1"
onsubmit={(e) => { e.preventDefault(); applyLink(); }}
>
<input
bind:this={linkInput}
bind:value={linkUrl}
type="url"
placeholder="Paste a link..."
class="w-44 bg-transparent px-1.5 py-1 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)]"
data-testid="format-link-input"
/>
<button
type="submit"
class="rounded p-1 text-[var(--text-muted)] hover:bg-[var(--border)]/5 hover:text-[var(--text)]"
title="Apply link"
data-testid="format-link-apply"
>
<CornerDownLeft size={16} />
</button>
<div class="mx-0.5 h-4 w-px bg-[var(--border-subtle)]"></div>
<button
type="button"
onclick={openLink}
disabled={!getAttrs('link').href}
class="rounded p-1 text-[var(--text-muted)] hover:bg-[var(--border)]/5 hover:text-[var(--text)] disabled:opacity-30"
title="Open link"
data-testid="format-link-open"
>
<ExternalLink size={16} />
</button>
<button
type="button"
onclick={removeLink}
disabled={!isActive('link')}
class="rounded p-1 text-[var(--text-muted)] hover:bg-[var(--border)]/5 hover:text-red-500 disabled:opacity-30"
title="Remove link"
data-testid="format-unlink"
>
<Trash2 size={16} />
</button>
</form>
</div>
{/if}
</div>
<!-- Table dropdown -->
<div class="relative" data-dropdown="table">
<div data-dropdown="table">
<button
onclick={() => toggleDropdown('table')}
onmousedown={preventToolbarMouseFocus}
onclick={(e) => handleDropdownClick('table', e)}
class={dropdownBtnClass(isActive('table'))}
title="Table"
data-testid="format-table"
@@ -329,68 +284,13 @@
<Table2 size={iconSize} />
<ChevronDown size={chevronSize} />
</button>
{#if openDropdown === 'table'}
<div class="absolute left-0 bottom-full z-50 mb-1 min-w-[190px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1">
<button
onclick={() => { editor?.chain().focus().insertTable({ rows: 3, cols: 3 }).run(); closeDropdowns(); }}
class={dropdownItemClass()}
data-testid="format-table-insert"
>
<Table2 size={iconSize} />
<span>Insert table</span>
</button>
{#if isActive('table')}
<div class="my-1 h-px bg-[var(--border-subtle)]"></div>
<button
onclick={() => { editor?.chain().focus().addRowAfter().run(); closeDropdowns(); }}
class={dropdownItemClass()}
data-testid="format-table-add-row"
>
<BetweenHorizontalEnd size={iconSize} />
<span>Add row</span>
</button>
<button
onclick={() => { editor?.chain().focus().addColumnAfter().run(); closeDropdowns(); }}
class={dropdownItemClass()}
data-testid="format-table-add-col"
>
<BetweenVerticalEnd size={iconSize} />
<span>Add column</span>
</button>
<div class="my-1 h-px bg-[var(--border-subtle)]"></div>
<button
onclick={() => { editor?.chain().focus().deleteRow().run(); closeDropdowns(); }}
class={dropdownItemClass()}
data-testid="format-table-delete-row"
>
<Minus size={iconSize} />
<span>Delete row</span>
</button>
<button
onclick={() => { editor?.chain().focus().deleteColumn().run(); closeDropdowns(); }}
class={dropdownItemClass()}
data-testid="format-table-delete-col"
>
<Minus size={iconSize} />
<span>Delete column</span>
</button>
<button
onclick={() => { editor?.chain().focus().deleteTable().run(); closeDropdowns(); }}
class={`${dropdownItemClass()} text-[var(--destructive)]`}
data-testid="format-table-delete"
>
<RemoveFormatting size={iconSize} />
<span>Delete table</span>
</button>
{/if}
</div>
{/if}
</div>
<!-- Alignment dropdown -->
<div class="relative" data-dropdown="align">
<div data-dropdown="align">
<button
onclick={() => toggleDropdown('align')}
onmousedown={preventToolbarMouseFocus}
onclick={(e) => handleDropdownClick('align', e)}
class={dropdownBtnClass(isActive({ textAlign: 'center' }) || isActive({ textAlign: 'right' }) || isActive({ textAlign: 'justify' }))}
title="Text alignment"
data-testid="format-align"
@@ -406,49 +306,14 @@
{/if}
<ChevronDown size={chevronSize} />
</button>
{#if openDropdown === 'align'}
<div class="absolute right-0 bottom-full z-50 mb-1 min-w-[160px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1">
<button
onclick={() => { editor?.chain().focus().setTextAlign('left').run(); closeDropdowns(); }}
class={dropdownItemClass(isActive({ textAlign: 'left' }))}
data-testid="format-align-left"
>
<AlignLeft size={iconSize} />
<span>Left</span>
</button>
<button
onclick={() => { editor?.chain().focus().setTextAlign('center').run(); closeDropdowns(); }}
class={dropdownItemClass(isActive({ textAlign: 'center' }))}
data-testid="format-align-center"
>
<AlignCenter size={iconSize} />
<span>Center</span>
</button>
<button
onclick={() => { editor?.chain().focus().setTextAlign('right').run(); closeDropdowns(); }}
class={dropdownItemClass(isActive({ textAlign: 'right' }))}
data-testid="format-align-right"
>
<AlignRight size={iconSize} />
<span>Right</span>
</button>
<button
onclick={() => { editor?.chain().focus().setTextAlign('justify').run(); closeDropdowns(); }}
class={dropdownItemClass(isActive({ textAlign: 'justify' }))}
data-testid="format-align-justify"
>
<AlignJustify size={iconSize} />
<span>Justify</span>
</button>
</div>
{/if}
</div>
<div class="mx-1 h-4 w-px shrink-0 bg-[var(--border-subtle)]"></div>
<!-- Block-level formatting -->
<button
onclick={() => editor?.chain().focus().toggleCode().run()}
onmousedown={preventToolbarMouseFocus}
onclick={() => handleCommandClick(() => editor?.chain().focus().toggleCode().run())}
class={btnClass(isActive('code'))}
title="Inline code"
data-testid="format-code"
@@ -456,7 +321,8 @@
<Code size={iconSize} />
</button>
<button
onclick={() => editor?.chain().focus().toggleBlockquote().run()}
onmousedown={preventToolbarMouseFocus}
onclick={() => handleCommandClick(() => editor?.chain().focus().toggleBlockquote().run())}
class={btnClass(isActive('blockquote'))}
title="Blockquote"
data-testid="format-blockquote"
@@ -464,7 +330,8 @@
<TextQuote size={iconSize} />
</button>
<button
onclick={() => editor?.chain().focus().toggleCodeBlock().run()}
onmousedown={preventToolbarMouseFocus}
onclick={() => handleCommandClick(() => editor?.chain().focus().toggleCodeBlock().run())}
class={btnClass(isActive('codeBlock'))}
title="Code block"
data-testid="format-code-block"
@@ -472,7 +339,8 @@
<CodeXml size={iconSize} />
</button>
<button
onclick={() => editor?.chain().focus().setHorizontalRule().run()}
onmousedown={preventToolbarMouseFocus}
onclick={() => handleCommandClick(() => editor?.chain().focus().setHorizontalRule().run())}
class={btnClass()}
title="Divider"
data-testid="format-hr"
@@ -480,3 +348,244 @@
<Minus size={iconSize} />
</button>
</div>
<!-- Render dropdown menus in a fixed portal so they aren't clipped by overflow-x-auto -->
{#if openDropdown && dropdownAnchorEl}
{@const rect = dropdownAnchorEl.getBoundingClientRect()}
{#if openDropdown === 'heading'}
<div
class="fixed z-50 mb-1 min-w-[150px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1"
style={getDropdownStyle(rect, 170)}
data-dropdown="true"
>
<button
onmousedown={preventToolbarMouseFocus}
onclick={() => { editor?.chain().focus().toggleHeading({ level: 1 }).run(); closeDropdowns(); }}
class={dropdownItemClass(isActive('heading', { level: 1 }))}
data-testid="format-h1"
>
<span class="w-6 text-xs font-semibold text-[var(--text-muted)]">H1</span>
<span class="font-semibold">Heading 1</span>
</button>
<button
onmousedown={preventToolbarMouseFocus}
onclick={() => { editor?.chain().focus().toggleHeading({ level: 2 }).run(); closeDropdowns(); }}
class={dropdownItemClass(isActive('heading', { level: 2 }))}
data-testid="format-h2"
>
<span class="w-6 text-xs font-semibold text-[var(--text-muted)]">H2</span>
<span>Heading 2</span>
</button>
<button
onmousedown={preventToolbarMouseFocus}
onclick={() => { editor?.chain().focus().toggleHeading({ level: 3 }).run(); closeDropdowns(); }}
class={dropdownItemClass(isActive('heading', { level: 3 }))}
data-testid="format-h3"
>
<span class="w-6 text-xs font-semibold text-[var(--text-muted)]">H3</span>
<span>Heading 3</span>
</button>
</div>
{:else if openDropdown === 'list'}
<div
class="fixed z-50 mb-1 min-w-[170px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1"
style={getDropdownStyle(rect, 190)}
data-dropdown="true"
>
<button
onmousedown={preventToolbarMouseFocus}
onclick={() => { editor?.chain().focus().toggleBulletList().run(); closeDropdowns(); }}
class={dropdownItemClass(isActive('bulletList'))}
data-testid="format-bullet-list"
>
<List size={iconSize} />
<span>Bullet list</span>
</button>
<button
onmousedown={preventToolbarMouseFocus}
onclick={() => { editor?.chain().focus().toggleOrderedList().run(); closeDropdowns(); }}
class={dropdownItemClass(isActive('orderedList'))}
data-testid="format-ordered-list"
>
<ListOrdered size={iconSize} />
<span>Ordered list</span>
</button>
<button
onmousedown={preventToolbarMouseFocus}
onclick={() => { editor?.chain().focus().toggleTaskList().run(); closeDropdowns(); }}
class={dropdownItemClass(isActive('taskList'))}
data-testid="format-task-list"
>
<ListChecks size={iconSize} />
<span>Task list</span>
</button>
</div>
{:else if openDropdown === 'link'}
<div
class="fixed z-50 mb-1 min-w-[200px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] px-2 py-1.5"
style={getDropdownStyle(rect, 220)}
data-dropdown="true"
>
<form
class="flex items-center gap-1"
onsubmit={(e) => { e.preventDefault(); applyLink(); }}
>
<input
bind:this={linkInput}
bind:value={linkUrl}
type="url"
placeholder="Paste a link..."
class="w-44 bg-transparent px-1.5 py-1 text-base md:text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)]"
data-testid="format-link-input"
/>
<button
onmousedown={preventToolbarMouseFocus}
type="submit"
class="rounded p-1 text-[var(--text-muted)] hover:bg-[var(--border)]/5 hover:text-[var(--text)]"
title="Apply link"
data-testid="format-link-apply"
>
<CornerDownLeft size={16} />
</button>
<div class="mx-0.5 h-4 w-px bg-[var(--border-subtle)]"></div>
<button
onmousedown={preventToolbarMouseFocus}
type="button"
onclick={openLink}
disabled={!getAttrs('link').href}
class="rounded p-1 text-[var(--text-muted)] hover:bg-[var(--border)]/5 hover:text-[var(--text)] disabled:opacity-30"
title="Open link"
data-testid="format-link-open"
>
<ExternalLink size={16} />
</button>
<button
onmousedown={preventToolbarMouseFocus}
type="button"
onclick={removeLink}
disabled={!isActive('link')}
class="rounded p-1 text-[var(--text-muted)] hover:bg-[var(--border)]/5 hover:text-[var(--destructive)] disabled:opacity-30"
title="Remove link"
data-testid="format-link-remove"
>
<Trash2 size={16} />
</button>
</form>
</div>
{:else if openDropdown === 'table'}
<div
class="fixed z-50 mb-1 min-w-[190px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1"
style={getDropdownStyle(rect, 210)}
data-dropdown="true"
>
<button
onmousedown={preventToolbarMouseFocus}
onclick={() => { editor?.chain().focus().insertTable({ rows: 3, cols: 3 }).run(); closeDropdowns(); }}
class={dropdownItemClass()}
data-testid="format-insert-table"
>
<Table2 size={iconSize} />
<span>Insert table</span>
</button>
{#if isActive('table')}
<div class="my-1 h-px bg-[var(--border-subtle)]"></div>
<button
onmousedown={preventToolbarMouseFocus}
onclick={() => { editor?.chain().focus().addRowAfter().run(); closeDropdowns(); }}
class={dropdownItemClass()}
data-testid="format-add-row"
>
<BetweenHorizontalEnd size={iconSize} />
<span>Add row</span>
</button>
<button
onmousedown={preventToolbarMouseFocus}
onclick={() => { editor?.chain().focus().addColumnAfter().run(); closeDropdowns(); }}
class={dropdownItemClass()}
data-testid="format-add-column"
>
<BetweenVerticalEnd size={iconSize} />
<span>Add column</span>
</button>
<div class="my-1 h-px bg-[var(--border-subtle)]"></div>
<button
onmousedown={preventToolbarMouseFocus}
onclick={() => { editor?.chain().focus().deleteRow().run(); closeDropdowns(); }}
class={dropdownItemClass()}
data-testid="format-delete-row"
>
<Minus size={iconSize} />
<span>Delete row</span>
</button>
<button
onmousedown={preventToolbarMouseFocus}
onclick={() => { editor?.chain().focus().deleteColumn().run(); closeDropdowns(); }}
class={dropdownItemClass()}
data-testid="format-delete-column"
>
<Minus size={iconSize} />
<span>Delete column</span>
</button>
<button
onmousedown={preventToolbarMouseFocus}
onclick={() => { editor?.chain().focus().deleteTable().run(); closeDropdowns(); }}
class={`${dropdownItemClass()} text-[var(--destructive)]`}
data-testid="format-delete-table"
>
<RemoveFormatting size={iconSize} />
<span>Delete table</span>
</button>
{/if}
</div>
{:else if openDropdown === 'align'}
<div
class="fixed z-50 mb-1 min-w-[160px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1"
style={getDropdownStyle(rect, 180)}
data-dropdown="true"
>
<button
onmousedown={preventToolbarMouseFocus}
onclick={() => { editor?.chain().focus().setTextAlign('left').run(); closeDropdowns(); }}
class={dropdownItemClass(isActive({ textAlign: 'left' }))}
data-testid="format-align-left"
>
<AlignLeft size={iconSize} />
<span>Left</span>
</button>
<button
onmousedown={preventToolbarMouseFocus}
onclick={() => { editor?.chain().focus().setTextAlign('center').run(); closeDropdowns(); }}
class={dropdownItemClass(isActive({ textAlign: 'center' }))}
data-testid="format-align-center"
>
<AlignCenter size={iconSize} />
<span>Center</span>
</button>
<button
onmousedown={preventToolbarMouseFocus}
onclick={() => { editor?.chain().focus().setTextAlign('right').run(); closeDropdowns(); }}
class={dropdownItemClass(isActive({ textAlign: 'right' }))}
data-testid="format-align-right"
>
<AlignRight size={iconSize} />
<span>Right</span>
</button>
<button
onmousedown={preventToolbarMouseFocus}
onclick={() => { editor?.chain().focus().setTextAlign('justify').run(); closeDropdowns(); }}
class={dropdownItemClass(isActive({ textAlign: 'justify' }))}
data-testid="format-align-justify"
>
<AlignJustify size={iconSize} />
<span>Justify</span>
</button>
</div>
{/if}
{/if}
<style>
[data-testid="formatting-toolbar"]::-webkit-scrollbar {
display: none;
}
</style>
+73
View File
@@ -0,0 +1,73 @@
<script lang="ts">
import ExternalLink from 'lucide-svelte/icons/external-link';
import { onMount } from 'svelte';
interface Props {
url: string;
anchor: DOMRect;
onClose: () => void;
}
let { url, anchor, onClose }: Props = $props();
let popoverEl: HTMLDivElement | undefined = $state();
let top = $state(0);
let left = $state(0);
onMount(() => {
if (!popoverEl) return;
const rect = popoverEl.getBoundingClientRect();
// Position below the anchor link, or above if near viewport bottom
const spaceBelow = window.innerHeight - anchor.bottom;
if (spaceBelow >= rect.height + 8) {
top = anchor.bottom + 4;
} else {
top = anchor.top - rect.height - 4;
}
left = anchor.left;
// Clamp to viewport
if (left + rect.width > window.innerWidth - 8) {
left = window.innerWidth - rect.width - 8;
}
if (left < 8) left = 8;
});
function handleClickOutside(e: MouseEvent) {
if (popoverEl && !popoverEl.contains(e.target as Node)) {
onClose();
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
onClose();
}
}
function openLink() {
window.open(url, '_blank', 'noopener');
onClose();
}
</script>
<svelte:window onclick={handleClickOutside} onkeydown={handleKeydown} onscroll={onClose} onresize={onClose} />
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={popoverEl}
class="fixed z-50 flex items-center gap-1.5 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] px-2 py-1.5 shadow-[var(--card-shadow)]"
style="top: {top}px; left: {left}px;"
onclick={(e) => e.stopPropagation()}
data-testid="link-popover"
>
<button
onclick={openLink}
class="flex items-center gap-1 text-xs text-[var(--primary)] hover:text-[var(--primary-hover)]"
data-testid="link-popover-open"
>
<ExternalLink class="h-3.5 w-3.5" />
Open
</button>
</div>
+6 -2
View File
@@ -13,6 +13,7 @@
import ArchiveRestore from 'lucide-svelte/icons/archive-restore';
import UserMinus from 'lucide-svelte/icons/user-minus';
import { tooltip } from '$lib/utils/tooltip.js';
import { linkifyText } from '$lib/utils/checklist.js';
interface ChecklistItem {
text: string;
@@ -131,7 +132,10 @@
{/if}
{#if note.checklistMode && checklistItems.length > 0}
<ul class="space-y-2 mb-6" data-testid="note-checklist-preview">
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<ul class="space-y-2 mb-6" data-testid="note-checklist-preview"
onclick={(e) => { if ((e.target as HTMLElement).closest('a')) e.stopPropagation(); }}
onkeydown={(e) => { if ((e.target as HTMLElement).closest('a')) e.stopPropagation(); }}>
{#each sortedChecklistItems.slice(0, 8) as item}
<li class="flex items-start gap-2 text-sm {item.indented ? 'pl-4 ' : ''}{item.checked ? 'text-[var(--text-muted)] line-through' : 'text-[var(--text)]'}"
data-testid={item.indented ? 'card-checklist-child' : undefined}>
@@ -142,7 +146,7 @@
class="mt-0.5 h-3.5 w-3.5 shrink-0 rounded border-[var(--border-subtle)] text-[var(--primary)]"
data-testid="card-checklist-checkbox"
/>
<span class="break-words min-w-0">{item.text}</span>
<span class="break-words min-w-0">{@html linkifyText(item.text)}</span>
</li>
{/each}
{#if checklistItems.length > 8}
+178 -40
View File
@@ -1,3 +1,7 @@
<script module lang="ts">
let bodyScrollLockCount = 0;
</script>
<script lang="ts">
import { onMount } from 'svelte';
import ColorPicker from './ColorPicker.svelte';
@@ -28,6 +32,7 @@
import Trash2 from 'lucide-svelte/icons/trash-2';
import ListX from 'lucide-svelte/icons/list-x';
import Archive from 'lucide-svelte/icons/archive';
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
import { parseChecklist, serializeChecklist } from '$lib/utils/checklist.js';
import { tooltip } from '$lib/utils/tooltip.js';
@@ -160,19 +165,68 @@
// svelte-ignore state_referenced_locally
let attachmentsList = $state<Attachment[]>(note?.attachments ?? []);
let viewportHeight = $state('100dvh');
let viewportTop = $state('0px');
// Guard against mobile ghost clicks: on touch devices, a tap on the note card
// can produce a synthetic click that lands on editor buttons rendered at the same
// coordinates. Suppress pointer events on toolbar controls until mount completes.
let toolbarInteractive = $state(false);
onMount(() => {
function updateViewport() {
if (window.visualViewport) {
viewportHeight = `${window.visualViewport.height}px`;
viewportTop = `${window.visualViewport.offsetTop}px`;
} else {
viewportHeight = `${window.innerHeight}px`;
viewportTop = '0px';
}
}
updateViewport();
const handleVisualViewportChange = () => {
requestAnimationFrame(updateViewport);
};
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', handleVisualViewportChange);
window.visualViewport.addEventListener('scroll', handleVisualViewportChange);
} else {
window.addEventListener('resize', handleVisualViewportChange);
}
const timer = setTimeout(() => { toolbarInteractive = true; }, 150);
return () => clearTimeout(timer);
return () => {
clearTimeout(timer);
if (window.visualViewport) {
window.visualViewport.removeEventListener('resize', handleVisualViewportChange);
window.visualViewport.removeEventListener('scroll', handleVisualViewportChange);
} else {
window.removeEventListener('resize', handleVisualViewportChange);
}
};
});
// Lock body scroll while editor is open. Multiple editor instances can overlap during
// the ghost-click grace period, so keep the class until the last instance unmounts.
$effect(() => {
bodyScrollLockCount += 1;
document.body.classList.add('editor-scroll-locked');
return () => {
bodyScrollLockCount = Math.max(0, bodyScrollLockCount - 1);
if (bodyScrollLockCount === 0) {
document.body.classList.remove('editor-scroll-locked');
}
};
});
let showShareDialog = $state(false);
let showHistory = $state(false);
let showOverflowMenu = $state(false);
let overflowBtnEl: HTMLButtonElement | undefined = $state();
let desktopOverflowBtnEl: HTMLButtonElement | undefined = $state();
let mobileOverflowBtnEl: HTMLButtonElement | undefined = $state();
let overflowAnchorEl: HTMLButtonElement | undefined = $state();
let overflowMenuEl: HTMLDivElement | undefined = $state();
// Close overflow menu when clicking outside
@@ -180,7 +234,7 @@
if (!showOverflowMenu) return;
function handleClickOutside(e: MouseEvent) {
const target = e.target as Node;
if (overflowBtnEl?.contains(target) || overflowMenuEl?.contains(target)) return;
if (overflowAnchorEl?.contains(target) || overflowMenuEl?.contains(target)) return;
showOverflowMenu = false;
}
// Use setTimeout to avoid the current click from closing the menu
@@ -335,11 +389,11 @@
bgStyle = `background-color: ${getNoteColor(color, getIsDarkMode())}`;
});
// Use pointer events to detect backdrop clicks. Unlike mouse events,
// pointer events are never synthetically re-dispatched from touch, so
// the tap that opened the editor on mobile cannot ghost-click the overlay.
// Both pointerdown and pointerup must target the overlay itself to close.
// Close on backdrop tap/click. On close, a transparent shield stays in the DOM
// for 400ms to absorb the browser's delayed synthetic click that would otherwise
// pass through to NoteCards underneath (ghost click).
let pointerdownOnOverlay = false;
let closing = $state(false);
function handleOverlayPointerdown(e: PointerEvent) {
pointerdownOnOverlay = e.target === e.currentTarget;
@@ -350,13 +404,18 @@
pointerdownOnOverlay = false;
}
function dismissOverlay() {
closing = true;
setTimeout(() => onClose(), 400);
}
async function saveAndClose() {
clearTimeout(autoSaveTimer);
// Wait for any in-flight auto-save to finish
if (savingPromise) await savingPromise;
if (!title.trim() && !content.trim()) {
onClose();
dismissOverlay();
return;
}
@@ -364,7 +423,7 @@
if (hasUnsavedChanges()) {
await performSave();
}
onClose();
dismissOverlay();
}
/** Auto-save a new note without closing, returns the new note ID */
@@ -408,7 +467,7 @@
showOverflowMenu = false;
if (noteId) {
await archiveNote(noteId);
onClose();
dismissOverlay();
}
}
@@ -416,7 +475,7 @@
showOverflowMenu = false;
if (noteId) {
await trashNote(noteId);
onClose();
dismissOverlay();
}
}
@@ -435,37 +494,62 @@
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/50 pt-20 pb-10 animate-[fade-in_150ms_ease-out]"
class="fixed left-0 w-full z-40 {closing ? '' : 'flex md:items-start justify-center md:overflow-y-auto md:bg-black/50 md:pt-20 md:pb-10 animate-[fade-in_150ms_ease-out]'}"
style="top: {viewportTop}; height: {viewportHeight};"
onpointerdown={handleOverlayPointerdown}
onpointerup={handleOverlayPointerup}
onkeydown={handleKeydown}
data-testid="note-editor-overlay"
>
{#if !closing}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="mx-4 flex w-full max-w-xl md:max-w-2xl flex-col overflow-hidden rounded-sm border border-[var(--border)] shadow-[var(--card-shadow)] animate-[pop-in_150ms_ease-out]"
class="relative flex h-full w-full flex-col md:overflow-hidden border-0 md:h-auto md:max-w-xl md:mx-4 lg:max-w-2xl md:rounded-sm md:border md:border-[var(--border)] md:shadow-[var(--card-shadow)] animate-[slide-up_150ms_ease-out] md:animate-[pop-in_150ms_ease-out]"
style={bgStyle}
onkeydown={(e) => { e.stopPropagation(); handleKeydown(e); }}
data-testid="note-editor"
>
<!-- Title -->
<input
type="text"
placeholder="Title"
bind:value={title}
class="w-full bg-transparent px-4 pt-4 text-lg font-semibold text-[var(--text)] outline-none placeholder:text-[var(--text-muted)]"
data-testid="note-title-input"
onkeydown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
if (textareaEl) textareaEl.focus();
else if (tiptapEditor) tiptapEditor.commands.focus('start');
}
}}
/>
<!-- Gap filler to hide iOS Safari keyboard animation/viewport lag -->
<div class="absolute top-full left-0 w-full h-[50vh] md:hidden" style={bgStyle}></div>
<!-- Header (Mobile: Back + Title, Desktop: just Title) -->
<div class="flex items-center gap-2 border-b md:border-b-0 border-[var(--border-subtle)] px-2 py-2 md:px-4 md:pt-4 md:pb-0 shrink-0 touch-none">
<!-- Mobile Back Button -->
<button
onclick={saveAndClose}
class="md:hidden rounded-sm p-2 text-[var(--text)] hover:bg-[var(--border)]/10 flex items-center shrink-0"
aria-label="Back"
>
<ArrowLeft class="h-6 w-6" />
</button>
<!-- Title -->
<input
type="text"
placeholder="Title"
bind:value={title}
class="flex-1 min-w-0 bg-transparent px-2 md:px-0 text-lg font-semibold text-[var(--text)] outline-none placeholder:text-[var(--text-muted)]"
data-testid="note-title-input"
onkeydown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
if (textareaEl) textareaEl.focus();
else if (tiptapEditor) tiptapEditor.commands.focus('start');
}
}}
/>
<!-- Mobile Overflow Trigger -->
<button
bind:this={mobileOverflowBtnEl}
onclick={() => { showOverflowMenu = !showOverflowMenu; overflowAnchorEl = mobileOverflowBtnEl; }}
class="md:hidden rounded-sm p-2 hover:bg-[var(--border)]/10 text-[var(--text)] shrink-0"
data-testid="mobile-overflow-menu-btn"
>
<EllipsisVertical class="h-6 w-6" />
</button>
</div>
<!-- Content -->
<div class="max-h-[60vh] overflow-y-auto">
<div class="flex-1 overflow-y-auto overscroll-contain md:max-h-[60vh] md:flex-none">
{#if checklistMode}
<div class="px-4 py-2">
<Checklist {content} onChange={(c) => (content = c)} />
@@ -475,7 +559,7 @@
bind:this={textareaEl}
placeholder="Add a crumb..."
bind:value={content}
class="min-h-[300px] w-full resize-none bg-transparent px-4 py-2 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)]"
class="min-h-[300px] w-full resize-none bg-transparent px-4 py-2 text-base md:text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)]"
rows="12"
data-testid="note-content-input"
></textarea>
@@ -492,7 +576,7 @@
<!-- Image attachments -->
{#if noteId && (showImageUpload || attachmentsList.length > 0)}
<div class="border-t border-[var(--border-subtle)] px-4 py-2">
<div class="border-t border-[var(--border-subtle)] px-4 py-2 shrink-0">
<ImageUpload
noteId={noteId}
attachments={attachmentsList}
@@ -506,14 +590,14 @@
<!-- Formatting toolbar -->
{#if !rawMarkdownMode && !checklistMode}
<div style={toolbarInteractive ? '' : 'pointer-events: none'}>
<div class="shrink-0 touch-pan-x" style={toolbarInteractive ? '' : 'pointer-events: none'}>
<FormattingToolbar editor={tiptapEditor} tick={editorTick} />
</div>
{/if}
<!-- Toolbar -->
<!-- Toolbar (Desktop only) -->
<div
class="flex items-center justify-between border-t border-[var(--border-subtle)] px-2 py-2"
class="hidden md:flex shrink-0 items-center justify-between border-t border-[var(--border-subtle)] px-2 py-2 touch-none"
style={toolbarInteractive ? '' : 'pointer-events: none'}
>
<div class="flex items-center gap-1">
@@ -580,8 +664,8 @@
<!-- Overflow menu trigger -->
<button
bind:this={overflowBtnEl}
onclick={() => (showOverflowMenu = !showOverflowMenu)}
bind:this={desktopOverflowBtnEl}
onclick={() => { showOverflowMenu = !showOverflowMenu; overflowAnchorEl = desktopOverflowBtnEl; }}
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
use:tooltip={"More"}
data-testid="overflow-menu-btn"
@@ -592,7 +676,7 @@
<button
onclick={saveAndClose}
class="rounded-sm px-4 py-1 text-sm font-medium text-[var(--text)] hover:bg-[var(--border)]/10"
class="hidden md:block rounded-sm px-4 py-1 text-sm font-medium text-[var(--text)] hover:bg-[var(--border)]/10"
data-testid="close-editor-btn"
>
Close
@@ -601,14 +685,67 @@
</div>
<!-- Overflow menu dropdown (rendered outside the card to escape overflow-hidden) -->
{#if showOverflowMenu && overflowBtnEl}
{@const rect = overflowBtnEl.getBoundingClientRect()}
{#if showOverflowMenu && overflowAnchorEl}
{@const rect = overflowAnchorEl.getBoundingClientRect()}
{@const isMobileTrigger = overflowAnchorEl === mobileOverflowBtnEl}
<div
bind:this={overflowMenuEl}
class="fixed z-50 w-max whitespace-nowrap rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] py-1 shadow-[var(--card-shadow)]"
style="bottom: {window.innerHeight - rect.top + 4}px; right: {window.innerWidth - rect.right}px;"
style={isMobileTrigger ? `top: ${rect.bottom + 4}px; right: ${window.innerWidth - rect.right}px;` : `bottom: ${window.innerHeight - rect.top + 4}px; right: ${window.innerWidth - rect.right}px;`}
data-testid="overflow-menu"
>
<!-- Mobile-only actions -->
{#if isMobileTrigger}
<!-- Color picker -->
<div class="px-3 py-2 w-56 whitespace-normal">
<span class="mb-2 block text-xs font-semibold text-[var(--text-muted)] uppercase tracking-wider">Color</span>
<ColorPicker selected={color} onSelect={(c) => { handleColorSelect(c); showOverflowMenu = false; }} />
</div>
<!-- Attachments -->
<button
onclick={() => { toggleImageUpload(); showOverflowMenu = false; }}
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--border)]/10"
>
<ImageIcon class="h-4 w-4 {showImageUpload ? 'text-[var(--primary)]' : ''}" />
Attachments
</button>
<!-- Share -->
{#if isOwner}
<button
onclick={() => { toggleShareDialog(); showOverflowMenu = false; }}
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--border)]/10"
>
{#if hasPublicLink}
<Globe class="h-4 w-4 text-[var(--primary)]" />
{:else if isShared}
<Users class="h-4 w-4 text-[var(--primary)]" />
{:else}
<UserPlus class="h-4 w-4" />
{/if}
Share
</button>
{:else if isShared}
<div class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text-muted)]">
<Users class="h-4 w-4" />
Shared note
</div>
{/if}
<!-- Archive -->
{#if !currentlyNew}
<button
onclick={handleArchive}
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--border)]/10"
>
<Archive class="h-4 w-4" />
Archive
</button>
{/if}
<div class="my-1 border-t border-[var(--border-subtle)]"></div>
{/if}
<!-- Checklist mode toggle -->
<button
onclick={() => { checklistMode = !checklistMode; showOverflowMenu = false; }}
@@ -690,6 +827,7 @@
{/if}
</div>
{/if}
{/if}
</div>
{#if showShareDialog && noteId}
+1 -1
View File
@@ -72,7 +72,7 @@
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
bind:this={element}
class="tiptap-wrapper prose prose-sm flex min-h-[300px] max-w-none flex-col px-4 py-2 text-[var(--text)]"
class="tiptap-wrapper prose md:prose-sm flex min-h-[300px] max-w-none flex-col px-4 py-2 text-[var(--text)]"
data-testid="tiptap-editor"
role="textbox"
aria-multiline="true"
+47 -23
View File
@@ -227,29 +227,53 @@ export function getUser(userId: number): User | null {
}
export async function deleteUser(userId: number): Promise<void> {
// Cascade: delete user's data in dependency order
sqlite.prepare('DELETE FROM user_preferences WHERE user_id = ?').run(userId);
sqlite.prepare('DELETE FROM api_keys WHERE user_id = ?').run(userId);
sqlite.prepare('DELETE FROM note_user_state WHERE user_id = ?').run(userId);
sqlite
.prepare('DELETE FROM note_collaborators WHERE user_id = ? OR added_by = ?')
.run(userId, userId);
sqlite.prepare('DELETE FROM sync_log WHERE user_id = ?').run(userId);
sqlite.prepare('DELETE FROM attachments WHERE user_id = ?').run(userId);
sqlite
.prepare(
'DELETE FROM shared_notes WHERE note_id IN (SELECT id FROM notes WHERE user_id = ?)'
)
.run(userId);
sqlite
.prepare(
'DELETE FROM note_tags WHERE note_id IN (SELECT id FROM notes WHERE user_id = ?)'
)
.run(userId);
sqlite.prepare('DELETE FROM notes WHERE user_id = ?').run(userId);
sqlite.prepare('DELETE FROM tags WHERE user_id = ?').run(userId);
sqlite.prepare('DELETE FROM sessions WHERE user_id = ?').run(userId);
db.delete(users).where(eq(users.id, userId)).run();
// Atomic cascade: all-or-nothing. Also clear rows from other users that
// reference the deleted user's notes (e.g. sync_log, note_user_state,
// note_collaborators) so foreign-key checks don't block the final delete.
const tx = sqlite.transaction((uid: number) => {
sqlite.prepare('DELETE FROM user_preferences WHERE user_id = ?').run(uid);
sqlite.prepare('DELETE FROM api_keys WHERE user_id = ?').run(uid);
sqlite
.prepare(
'DELETE FROM note_user_state WHERE user_id = ? OR note_id IN (SELECT id FROM notes WHERE user_id = ?)'
)
.run(uid, uid);
sqlite
.prepare(
'DELETE FROM note_collaborators WHERE user_id = ? OR added_by = ? OR note_id IN (SELECT id FROM notes WHERE user_id = ?)'
)
.run(uid, uid, uid);
sqlite
.prepare(
'DELETE FROM sync_log WHERE user_id = ? OR note_id IN (SELECT id FROM notes WHERE user_id = ?)'
)
.run(uid, uid);
sqlite
.prepare(
'DELETE FROM attachments WHERE user_id = ? OR note_id IN (SELECT id FROM notes WHERE user_id = ?)'
)
.run(uid, uid);
sqlite
.prepare(
'DELETE FROM shared_notes WHERE note_id IN (SELECT id FROM notes WHERE user_id = ?)'
)
.run(uid);
sqlite
.prepare(
'DELETE FROM note_tags WHERE note_id IN (SELECT id FROM notes WHERE user_id = ?)'
)
.run(uid);
sqlite
.prepare(
'DELETE FROM note_versions WHERE note_id IN (SELECT id FROM notes WHERE user_id = ?)'
)
.run(uid);
sqlite.prepare('DELETE FROM notes WHERE user_id = ?').run(uid);
sqlite.prepare('DELETE FROM tags WHERE user_id = ?').run(uid);
sqlite.prepare('DELETE FROM sessions WHERE user_id = ?').run(uid);
sqlite.prepare('DELETE FROM users WHERE id = ?').run(uid);
});
tx(userId);
}
export async function changePassword(
+105 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { parseChecklist, serializeChecklist, toggleItemWithCascade, indentItem, outdentItem, type ChecklistItem } from '$lib/utils/checklist.js';
import { parseChecklist, serializeChecklist, toggleItemWithCascade, indentItem, outdentItem, linkifyText, unlinkifyHtml, type ChecklistItem } from '$lib/utils/checklist.js';
describe('parseChecklist', () => {
it('parses flat checklist items', () => {
@@ -220,3 +220,107 @@ describe('outdentItem', () => {
expect(result.find((i) => i.id === '3')!.parentId).toBe('1');
});
});
describe('linkifyText', () => {
it('converts https URLs to anchor tags', () => {
expect(linkifyText('visit https://example.com today')).toBe(
'visit <a href="https://example.com" target="_blank" rel="noopener noreferrer" class="checklist-link">https://example.com</a> today'
);
});
it('converts http URLs to anchor tags', () => {
expect(linkifyText('go to http://example.com')).toBe(
'go to <a href="http://example.com" target="_blank" rel="noopener noreferrer" class="checklist-link">http://example.com</a>'
);
});
it('converts bare www URLs with https href', () => {
expect(linkifyText('check www.example.com')).toBe(
'check <a href="https://www.example.com" target="_blank" rel="noopener noreferrer" class="checklist-link">www.example.com</a>'
);
});
it('handles multiple URLs in one string', () => {
const result = linkifyText('see https://a.com and https://b.com');
expect(result).toContain('href="https://a.com"');
expect(result).toContain('href="https://b.com"');
});
it('returns plain text unchanged when no URLs present', () => {
expect(linkifyText('just some text')).toBe('just some text');
});
it('HTML-escapes user content to prevent XSS', () => {
expect(linkifyText('<script>alert("xss")</script>')).toBe(
'&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;'
);
});
it('HTML-escapes text around URLs', () => {
expect(linkifyText('<b>bold</b> https://example.com')).toContain('&lt;b&gt;bold&lt;/b&gt;');
expect(linkifyText('<b>bold</b> https://example.com')).toContain('href="https://example.com"');
});
it('handles URL at start of string', () => {
expect(linkifyText('https://example.com is great')).toContain('href="https://example.com"');
});
it('handles URL at end of string', () => {
expect(linkifyText('visit https://example.com')).toContain('href="https://example.com"');
});
it('does not linkify partial matches without protocol or www', () => {
expect(linkifyText('example.com is not linked')).toBe('example.com is not linked');
});
it('handles URLs with paths and query strings', () => {
const url = 'https://example.com/path?q=1&b=2';
const result = linkifyText(url);
expect(result).toContain('href="https://example.com/path?q=1&amp;b=2"');
});
it('strips trailing punctuation from URLs', () => {
expect(linkifyText('see https://example.com.')).toBe(
'see <a href="https://example.com" target="_blank" rel="noopener noreferrer" class="checklist-link">https://example.com</a>.'
);
});
it('returns empty string for empty input', () => {
expect(linkifyText('')).toBe('');
});
});
describe('unlinkifyHtml', () => {
it('strips anchor tags and returns text content', () => {
expect(unlinkifyHtml('visit <a href="https://example.com">https://example.com</a> today'))
.toBe('visit https://example.com today');
});
it('returns plain text unchanged', () => {
expect(unlinkifyHtml('just text')).toBe('just text');
});
it('handles multiple anchor tags', () => {
expect(unlinkifyHtml('<a href="https://a.com">https://a.com</a> and <a href="https://b.com">https://b.com</a>'))
.toBe('https://a.com and https://b.com');
});
it('returns empty string for empty input', () => {
expect(unlinkifyHtml('')).toBe('');
});
it('handles div and br tags from contenteditable', () => {
expect(unlinkifyHtml('line1<br>line2')).toBe('line1line2');
});
it('decodes HTML entities after stripping tags', () => {
expect(unlinkifyHtml('Tom &amp; Jerry')).toBe('Tom & Jerry');
expect(unlinkifyHtml('&lt;b&gt;bold&lt;/b&gt;')).toBe('<b>bold</b>');
expect(unlinkifyHtml('say &quot;hello&quot;')).toBe('say "hello"');
});
it('converts &nbsp; from contenteditable to regular spaces', () => {
expect(unlinkifyHtml('hello&nbsp;world')).toBe('hello world');
expect(unlinkifyHtml('a&nbsp;&nbsp;b')).toBe('a b');
});
});
+47
View File
@@ -150,3 +150,50 @@ export function serializeChecklist(items: ChecklistItem[]): string {
}
return lines.join('\n');
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
const URL_REGEX = /(?:https?:\/\/|www\.)[^\s<]+[^\s<.,;:!?)'"}\]]/g;
export function linkifyText(text: string): string {
if (!text) return '';
const matches: { index: number; match: string; href: string }[] = [];
let m: RegExpExecArray | null;
const regex = new RegExp(URL_REGEX.source, 'g');
while ((m = regex.exec(text)) !== null) {
const match = m[0];
const href = match.startsWith('www.') ? `https://${match}` : match;
matches.push({ index: m.index, match, href });
}
if (matches.length === 0) return escapeHtml(text);
let result = '';
let lastIndex = 0;
for (const { index, match, href } of matches) {
result += escapeHtml(text.slice(lastIndex, index));
result += `<a href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer" class="checklist-link">${escapeHtml(match)}</a>`;
lastIndex = index + match.length;
}
result += escapeHtml(text.slice(lastIndex));
return result;
}
export function unlinkifyHtml(html: string): string {
if (!html) return '';
return html
.replace(/<[^>]*>/g, '')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"');
}
+13 -2
View File
@@ -70,9 +70,16 @@
const res = await fetch(`/api/admin/users/${userId}`, { method: 'DELETE' });
if (res.ok) {
users = users.filter((u) => u.id !== userId);
createMsg = 'User deleted';
createError = false;
} else {
const d = await res.json().catch(() => ({}));
createMsg = d.message || 'Failed to delete user';
createError = true;
}
} catch {
// ignore
createMsg = 'Connection error';
createError = true;
}
}
@@ -167,7 +174,10 @@
<div class="space-y-3">
{#each users as user (user.id)}
<div class="flex flex-col gap-2 rounded-sm border border-[var(--border-subtle)] p-4 sm:flex-row sm:items-center sm:justify-between">
<div
class="flex flex-col gap-2 rounded-sm border border-[var(--border-subtle)] p-4 sm:flex-row sm:items-center sm:justify-between"
data-testid="user-row-{user.id}"
>
<div class="min-w-0">
<p class="truncate font-medium text-[var(--text)]">
{user.displayName || user.email}
@@ -195,6 +205,7 @@
</button>
<button
onclick={() => deleteUser(user.id)}
data-testid="delete-user-btn-{user.id}"
class="rounded-sm px-3 py-1 text-xs text-[var(--destructive)] hover:bg-[var(--error-bg)]"
>
Delete
+2 -1
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { NOTE_COLORS } from '$lib/utils/colors.js';
import { renderMarkdown } from '$lib/utils/markdown.js';
import { linkifyText } from '$lib/utils/checklist.js';
import type { NoteColor, PublicAttachment } from '$lib/types/index.js';
interface ChecklistItem {
@@ -80,7 +81,7 @@
disabled
class="mt-0.5 h-3.5 w-3.5 shrink-0 rounded border-[var(--border-subtle)]"
/>
<span class="break-words min-w-0">{item.text}</span>
<span class="break-words min-w-0">{@html linkifyText(item.text)}</span>
</li>
{/each}
</ul>
+1 -1
View File
@@ -3,7 +3,7 @@
"info": {
"title": "Crumbs API",
"description": "Auto-generated API spec for crumbs",
"version": "0.24.0",
"version": "0.27.1",
"contact": {
"name": "Bretzel",
"url": "https://bretzel.app"
+153
View File
@@ -0,0 +1,153 @@
import { test, expect } from './helpers/fixtures.js';
import { readFileSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import { TEST_CREDENTIALS_FILE } from './global-setup';
const { userPassword } = JSON.parse(readFileSync(TEST_CREDENTIALS_FILE, 'utf-8'));
const __dirname = dirname(fileURLToPath(import.meta.url));
const TEST_IMAGE_PATH = join(__dirname, 'helpers', 'test-image.png');
/** Create a victim user via the admin API, returning the user's id. */
async function createVictim(
page: import('@playwright/test').Page,
email: string,
displayName = email.split('@')[0]
): Promise<number> {
const res = await page.request.post('/api/admin/users', {
data: { email, displayName, password: userPassword, role: 'user' }
});
expect(res.status()).toBe(201);
const user = await res.json();
return user.id;
}
test.describe.serial('Admin — User Deletion', () => {
test('Scenario: Admin deletes a user from the users list', async ({
authenticatedPage: page
}) => {
// Given a disposable user exists
const victimId = await createVictim(page, 'delete-me@test.com', 'Delete Me');
// When the admin navigates to the users settings page
await page.goto('/settings/users');
await page.waitForLoadState('networkidle');
await expect(page.getByTestId(`user-row-${victimId}`)).toBeVisible();
// And accepts the confirmation dialog for deletion
page.once('dialog', (dialog) => dialog.accept());
const deleteResponse = page.waitForResponse(
(res) => res.url().endsWith(`/api/admin/users/${victimId}`) && res.request().method() === 'DELETE'
);
await page.getByTestId(`delete-user-btn-${victimId}`).click();
const res = await deleteResponse;
// Then the delete request succeeds
expect(res.status()).toBe(200);
// And the user disappears from the list
await expect(page.getByTestId(`user-row-${victimId}`)).toHaveCount(0);
// And the list is reloaded correctly on refresh (backend is consistent)
await page.reload();
await page.waitForLoadState('networkidle');
await expect(page.getByTestId(`user-row-${victimId}`)).toHaveCount(0);
});
test('Scenario: Deletion is cancelled when the admin dismisses the confirmation', async ({
authenticatedPage: page
}) => {
// Given a user exists
const victimId = await createVictim(page, 'keep-me@test.com', 'Keep Me');
// When the admin navigates to the users settings page
await page.goto('/settings/users');
await page.waitForLoadState('networkidle');
await expect(page.getByTestId(`user-row-${victimId}`)).toBeVisible();
// And dismisses the confirmation dialog
page.once('dialog', (dialog) => dialog.dismiss());
await page.getByTestId(`delete-user-btn-${victimId}`).click();
// Then the user remains in the list
await expect(page.getByTestId(`user-row-${victimId}`)).toBeVisible();
// Clean up
await page.request.delete(`/api/admin/users/${victimId}`);
});
test('Scenario: Deleting a user with notes, tags, and attachments succeeds', async ({
authenticatedPage: page,
browser
}) => {
// Given a user with full data exists (note, tag, attachment, preferences, api key)
const email = 'heavy-user@test.com';
const victimId = await createVictim(page, email, 'Heavy User');
// Log in as the victim in a fresh context so they create their own data
const ctx = await browser.newContext();
const victimPage = await ctx.newPage();
await victimPage.goto('/login');
await victimPage.getByTestId('email-input').fill(email);
await victimPage.getByTestId('password-input').fill(userPassword);
await victimPage.getByTestId('login-btn').click();
await victimPage.waitForURL('/');
// Create a note with a tag
const noteRes = await victimPage.request.post('/api/notes', {
data: { title: 'Victim Note #todo', content: 'content #work' }
});
expect(noteRes.ok()).toBe(true);
const note = await noteRes.json();
// Upload an attachment on the note. Use a browser-context fetch so the
// Origin header is set correctly for SvelteKit's CSRF check.
const imageBase64 = readFileSync(TEST_IMAGE_PATH).toString('base64');
const attachmentStatus = await victimPage.evaluate(
async ({ noteId, base64 }) => {
const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
const form = new FormData();
form.append('file', new Blob([bytes], { type: 'image/png' }), 'test-image.png');
const r = await fetch(`/api/notes/${noteId}/attachments`, {
method: 'POST',
body: form
});
return r.status;
},
{ noteId: note.id, base64: imageBase64 }
);
expect(attachmentStatus).toBe(201);
// Write a preference and create an API key to exercise every table
await victimPage.request.put('/api/preferences', {
data: { defaultNoteMode: 'markdown' }
});
await victimPage.request.post('/api/settings/api-keys', { data: { name: 'test-key' } });
await ctx.close();
// When the admin deletes the user with all their data
await page.goto('/settings/users');
await page.waitForLoadState('networkidle');
await expect(page.getByTestId(`user-row-${victimId}`)).toBeVisible();
page.once('dialog', (dialog) => dialog.accept());
const deleteResponse = page.waitForResponse(
(res) => res.url().endsWith(`/api/admin/users/${victimId}`) && res.request().method() === 'DELETE'
);
await page.getByTestId(`delete-user-btn-${victimId}`).click();
const res = await deleteResponse;
// Then the delete succeeds atomically (no partial state)
expect(res.status()).toBe(200);
await expect(page.getByTestId(`user-row-${victimId}`)).toHaveCount(0);
// And the user cannot log in afterwards
const loginRes = await page.request.post('/api/auth/login', {
data: { email, password: userPassword }
});
expect(loginRes.status()).toBe(401);
});
});
+119 -24
View File
@@ -13,7 +13,8 @@ async function createChecklistNote(page: Page, title: string, items: string[]) {
await page.getByTestId('note-title-input').fill(title);
await toggleChecklistMode(page);
for (let i = 0; i < items.length; i++) {
await page.getByTestId('checklist-input').nth(i).fill(items[i]);
await page.getByTestId('checklist-input').nth(i).focus();
await page.keyboard.type(items[i]);
if (i < items.length - 1) {
await page.getByTestId('checklist-input').nth(i).press('Enter');
}
@@ -46,7 +47,7 @@ test.describe('Checklist', () => {
// Then the checklist is displayed with the saved item
await expect(page.getByTestId('checklist')).toBeVisible();
await expect(page.getByTestId('checklist-input').first()).toHaveValue('Buy milk');
await expect(page.getByTestId('checklist-input').first()).toHaveText('Buy milk');
});
test('Scenario: Checked item moves to the done section', async ({ authenticatedPage: page }) => {
@@ -88,14 +89,15 @@ test.describe('Checklist', () => {
// Then the unchecked state also persists (not reverted by 3-way merge)
await noteCard(page, 'Tasks2').click();
await expect(page.getByTestId('checklist-checkbox')).toHaveCount(1);
await expect(page.getByTestId('checklist-input').first()).toHaveValue('Buy milk');
await expect(page.getByTestId('checklist-input').first()).toHaveText('Buy milk');
});
test('Scenario: Enter key adds a new checklist item', async ({ authenticatedPage: page }) => {
// Given a checklist with one item
await page.getByTestId('new-note-btn').click();
await toggleChecklistMode(page);
await page.getByTestId('checklist-input').first().fill('First item');
await page.getByTestId('checklist-input').first().focus();
await page.keyboard.type('First item');
// When the user presses Enter
await page.getByTestId('checklist-input').first().press('Enter');
@@ -127,14 +129,15 @@ test.describe('Checklist', () => {
// Then focus moves to the newly created empty item
await expect(page.getByTestId('checklist-input')).toHaveCount(3);
await expect(page.getByTestId('checklist-input').nth(1)).toBeFocused();
await expect(page.getByTestId('checklist-input').nth(1)).toHaveValue('');
await expect(page.getByTestId('checklist-input').nth(1)).toHaveText('');
});
test('Scenario: Backspace on empty item removes it from the list', async ({ authenticatedPage: page }) => {
// Given a checklist with two items where the second is empty
await page.getByTestId('new-note-btn').click();
await toggleChecklistMode(page);
await page.getByTestId('checklist-input').first().fill('First item');
await page.getByTestId('checklist-input').first().focus();
await page.keyboard.type('First item');
await page.getByTestId('checklist-input').first().press('Enter');
await expect(page.getByTestId('checklist-input')).toHaveCount(2);
@@ -150,16 +153,18 @@ test.describe('Checklist', () => {
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Hide Done Test');
await toggleChecklistMode(page);
await page.getByTestId('checklist-input').first().fill('Done task');
await page.getByTestId('checklist-input').first().focus();
await page.keyboard.type('Done task');
await page.getByTestId('checklist-input').first().press('Enter');
await page.getByTestId('checklist-input').nth(1).fill('Pending task');
await page.getByTestId('checklist-input').nth(1).focus();
await page.keyboard.type('Pending task');
// When the user completes the first item
await page.getByTestId('checklist-checkbox').first().click();
// Then only the pending item remains in the active list
await expect(page.getByTestId('checklist-input')).toHaveCount(1);
await expect(page.getByTestId('checklist-input').first()).toHaveValue('Pending task');
await expect(page.getByTestId('checklist-input').first()).toHaveText('Pending task');
// And the done section shows the completed item
await expect(page.getByTestId('checklist-toggle-done')).toContainText('1 done');
@@ -171,11 +176,14 @@ test.describe('Checklist', () => {
// Given a checklist with three items
await page.getByTestId('new-note-btn').click();
await toggleChecklistMode(page);
await page.getByTestId('checklist-input').first().fill('First');
await page.getByTestId('checklist-input').first().focus();
await page.keyboard.type('First');
await page.getByTestId('checklist-input').first().press('Enter');
await page.getByTestId('checklist-input').nth(1).fill('Second');
await page.getByTestId('checklist-input').nth(1).focus();
await page.keyboard.type('Second');
await page.getByTestId('checklist-input').nth(1).press('Enter');
await page.getByTestId('checklist-input').nth(2).fill('Third');
await page.getByTestId('checklist-input').nth(2).focus();
await page.keyboard.type('Third');
// When the user presses ArrowUp from the third item
await page.getByTestId('checklist-input').nth(2).press('ArrowUp');
@@ -194,9 +202,11 @@ test.describe('Checklist', () => {
// Given a checklist with two top-level items
await page.getByTestId('new-note-btn').click();
await toggleChecklistMode(page);
await page.getByTestId('checklist-input').first().fill('Parent');
await page.getByTestId('checklist-input').first().focus();
await page.keyboard.type('Parent');
await page.getByTestId('checklist-input').first().press('Enter');
await page.getByTestId('checklist-input').nth(1).fill('Child');
await page.getByTestId('checklist-input').nth(1).focus();
await page.keyboard.type('Child');
// When the user presses Tab on the second item
await page.getByTestId('checklist-input').nth(1).press('Tab');
@@ -215,12 +225,15 @@ test.describe('Checklist', () => {
// Given a checklist with a parent and two children
await page.getByTestId('new-note-btn').click();
await toggleChecklistMode(page);
await page.getByTestId('checklist-input').first().fill('Buy groceries');
await page.getByTestId('checklist-input').first().focus();
await page.keyboard.type('Buy groceries');
await page.getByTestId('checklist-input').first().press('Enter');
await page.getByTestId('checklist-input').nth(1).fill('Milk');
await page.getByTestId('checklist-input').nth(1).focus();
await page.keyboard.type('Milk');
await page.getByTestId('checklist-input').nth(1).press('Tab');
await page.getByTestId('checklist-input').nth(1).press('Enter');
await page.getByTestId('checklist-input').nth(2).fill('Eggs');
await page.getByTestId('checklist-input').nth(2).focus();
await page.keyboard.type('Eggs');
// When the user checks the parent
await page.getByTestId('checklist-checkbox').first().click();
@@ -234,9 +247,11 @@ test.describe('Checklist', () => {
// Given a checklist with a parent and a child
await page.getByTestId('new-note-btn').click();
await toggleChecklistMode(page);
await page.getByTestId('checklist-input').first().fill('Groceries');
await page.getByTestId('checklist-input').first().focus();
await page.keyboard.type('Groceries');
await page.getByTestId('checklist-input').first().press('Enter');
await page.getByTestId('checklist-input').nth(1).fill('Milk');
await page.getByTestId('checklist-input').nth(1).focus();
await page.keyboard.type('Milk');
await page.getByTestId('checklist-input').nth(1).press('Tab');
// When the user checks only the child
@@ -252,9 +267,11 @@ test.describe('Checklist', () => {
// Given a checklist with a parent and child, all checked
await page.getByTestId('new-note-btn').click();
await toggleChecklistMode(page);
await page.getByTestId('checklist-input').first().fill('Shopping');
await page.getByTestId('checklist-input').first().focus();
await page.keyboard.type('Shopping');
await page.getByTestId('checklist-input').first().press('Enter');
await page.getByTestId('checklist-input').nth(1).fill('Milk');
await page.getByTestId('checklist-input').nth(1).focus();
await page.keyboard.type('Milk');
await page.getByTestId('checklist-input').nth(1).press('Tab');
await page.getByTestId('checklist-checkbox').first().click();
await expect(page.getByTestId('checklist-toggle-done')).toContainText('2 done');
@@ -288,9 +305,11 @@ test.describe('Checklist', () => {
// Given a checklist with a parent and a child
await page.getByTestId('new-note-btn').click();
await toggleChecklistMode(page);
await page.getByTestId('checklist-input').first().fill('Parent');
await page.getByTestId('checklist-input').first().focus();
await page.keyboard.type('Parent');
await page.getByTestId('checklist-input').first().press('Enter');
await page.getByTestId('checklist-input').nth(1).fill('Child 1');
await page.getByTestId('checklist-input').nth(1).focus();
await page.keyboard.type('Child 1');
await page.getByTestId('checklist-input').nth(1).press('Tab');
// When the user presses Enter on the child
@@ -341,7 +360,7 @@ test.describe('Checklist', () => {
// Then only the unchecked item remains
await expect(page.getByTestId('checklist-input')).toHaveCount(1);
await expect(page.getByTestId('checklist-input').first()).toHaveValue('Keep me');
await expect(page.getByTestId('checklist-input').first()).toHaveText('Keep me');
await expect(page.getByTestId('checklist-toggle-done')).not.toBeVisible();
});
@@ -361,4 +380,80 @@ test.describe('Checklist', () => {
await expect(page.getByTestId('checklist-input')).toHaveCount(2);
await expect(page.getByTestId('checklist-toggle-done')).not.toBeVisible();
});
test('Scenario: URL typed in a checklist item becomes a clickable link', async ({ authenticatedPage: page }) => {
// Given a new checklist note
await page.getByTestId('new-note-btn').click();
await toggleChecklistMode(page);
// When the user types a URL and leaves the field
await page.getByTestId('checklist-input').first().focus();
await page.keyboard.type('check https://example.com today');
await page.getByTestId('note-title-input').focus();
// Then the URL is rendered as a link
const link = page.getByTestId('checklist-input').first().locator('a');
await expect(link).toBeVisible();
await expect(link).toHaveAttribute('href', 'https://example.com');
});
test('Scenario: Clicking a link in a checklist item shows the link popover', async ({ authenticatedPage: page }) => {
// Given a checklist item with a URL
await page.getByTestId('new-note-btn').click();
await toggleChecklistMode(page);
await page.getByTestId('checklist-input').first().focus();
await page.keyboard.type('https://example.com');
await page.getByTestId('note-title-input').focus();
// When the user clicks the link
await page.getByTestId('checklist-input').first().locator('a').click();
// Then the link popover appears with an Open button
await expect(page.getByTestId('link-popover')).toBeVisible();
await expect(page.getByTestId('link-popover-open')).toBeVisible();
});
test('Scenario: Link popover dismisses on Escape', async ({ authenticatedPage: page }) => {
// Given a link popover is open
await page.getByTestId('new-note-btn').click();
await toggleChecklistMode(page);
await page.getByTestId('checklist-input').first().focus();
await page.keyboard.type('https://example.com');
await page.getByTestId('note-title-input').focus();
await page.getByTestId('checklist-input').first().locator('a').click();
await expect(page.getByTestId('link-popover')).toBeVisible();
// When the user presses Escape
await page.keyboard.press('Escape');
// Then the popover is dismissed
await expect(page.getByTestId('link-popover')).not.toBeVisible();
});
test('Scenario: Links in done section are clickable', async ({ authenticatedPage: page }) => {
// Given a checklist item with a URL is checked
await page.getByTestId('new-note-btn').click();
await toggleChecklistMode(page);
await page.getByTestId('checklist-input').first().focus();
await page.keyboard.type('https://example.com');
await page.getByTestId('checklist-checkbox').first().click();
// Then the done section shows the URL as a link
await expect(page.getByTestId('checklist-done-section').locator('a.checklist-link')).toBeVisible();
});
test('Scenario: Links in NoteCard preview are rendered', async ({ authenticatedPage: page }) => {
// Given a checklist note with a URL
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Link Note');
await toggleChecklistMode(page);
await page.getByTestId('checklist-input').first().focus();
await page.keyboard.type('visit https://example.com');
await page.getByTestId('close-editor-btn').click();
// Then the NoteCard preview shows the URL as a link
const card = noteCard(page, 'Link Note');
await expect(card.locator('a.checklist-link')).toBeVisible();
await expect(card.locator('a.checklist-link')).toHaveAttribute('href', 'https://example.com');
});
});
+1 -1
View File
@@ -292,7 +292,7 @@
<li><a href="https://github.com/bretzel-app/crumbs/blob/main/docs/DEPLOYMENT.md" target="_blank" rel="noopener">Docs</a></li>
<li><a href="https://github.com/bretzel-app/crumbs/blob/main/LICENSE" target="_blank" rel="noopener">License</a></li>
</ul>
<span class="footer-version">v0.27.0</span>
<span class="footer-version">v0.29.0</span>
</div>
</footer>