forked from mirror/crumbs-original
Compare commits
@@ -59,7 +59,7 @@ jobs:
|
||||
name: E2E Tests
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.58.0-noble
|
||||
image: mcr.microsoft.com/playwright:v1.62.1-noble
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
|
||||
@@ -37,9 +37,41 @@ All colors are CSS variables in `src/app.css`. Never use hardcoded Tailwind colo
|
||||
| `--success-bg/text` | warm greens | Success states |
|
||||
| `--card-shadow` | `2px 2px 0px` | Hard-offset retro shadow (no blur) |
|
||||
| `--card-shadow-hover` | `3px 3px 0px` | Hover shadow in primary gold |
|
||||
| `--accent-check` | `var(--primary)` (light) / `#c8a44a` (dark) | Checkbox accent color — dimmed gold in dark mode so a long done-list doesn't shout |
|
||||
| `--hover-wash` | `var(--border)` (light) / `#ece3d3` (dark) | Tint source for hover/active washes (`bg-[var(--hover-wash)]/N`) — kept cream in dark mode since `--border`'s mid-grey is invisible at low opacity |
|
||||
|
||||
Note colors for cards are defined in `src/lib/utils/colors.ts`.
|
||||
|
||||
### Dark theme
|
||||
|
||||
Dark mode overrides every colour variable in `[data-theme="dark"]` (`src/app.css`). It is not a
|
||||
mechanical inversion of light mode — two differences are deliberate:
|
||||
|
||||
- **`--primary` differs per theme.** `#C8860A` on parchment, `#e0a030` on ink. The light gold reads
|
||||
mustard on a dark background.
|
||||
- **`--border` is a warm mid-grey (`#57514b`), not mirrored cream.** A bright 1px line on a dark
|
||||
background reads as glare rather than structure; the hard offset shadow carries the retro framing.
|
||||
|
||||
Note colours have two representations, and they are not interchangeable:
|
||||
|
||||
| Map | Function | Used by | Role |
|
||||
|---|---|---|---|
|
||||
| `NOTE_COLORS` / `NOTE_COLORS_DARK` | `getNoteColor(color, isDark)` | Cards, editor, share page | Quiet **surface** you read text on |
|
||||
| `NOTE_CHIPS_DARK` | `getNoteChip(color, isDark)` | `ColorPicker` only | Vivid **label** you identify at a glance |
|
||||
|
||||
A dark card sits at low lightness, where a 28px picker circle would read as a grey dot — small
|
||||
patches lose apparent saturation. Never fill a picker swatch with `getNoteColor()`. In light mode
|
||||
`getNoteChip()` returns the pastel card colour, because there the pastels *are* the cards.
|
||||
|
||||
When changing any dark note colour, keep the invariants asserted in `src/lib/utils/colors.test.ts`:
|
||||
AA contrast (4.5:1) against both `--text` and `--text-muted` on every dark surface, no two dark card
|
||||
surfaces closer than an RGB distance of 8, no two dark chips closer than an RGB distance of 25, and
|
||||
`getNoteChip()` returning the card colour in light mode but a distinct chip in dark mode.
|
||||
|
||||
`--bg-base` is duplicated as a literal in `src/app.html` (FOUC script) and
|
||||
`src/lib/utils/theme.svelte.ts` (`DARK_THEME_COLOR`) for the browser theme-colour. Update all three
|
||||
together.
|
||||
|
||||
### Typography
|
||||
|
||||
- **Body**: `JetBrains Mono` (monospace) — the hacker/dev aesthetic
|
||||
@@ -53,7 +85,7 @@ Note colors for cards are defined in `src/lib/utils/colors.ts`.
|
||||
- **Borders**: Default card borders use `--border-subtle`. `--border` is for strong emphasis (editor, login card). Hover borders use `--primary`.
|
||||
- **Animations**: Crisp and fast (150ms `ease-out`). No spring physics, no bounce. The retro feel comes from snappy transitions.
|
||||
- **Background texture**: A subtle 4px pixel grid overlay at 3% opacity (defined in `body::before`).
|
||||
- **Checkboxes**: Use `accent-color: var(--primary)` globally — gold checkboxes match the theme.
|
||||
- **Checkboxes**: Use `accent-color: var(--accent-check)` globally — resolves to `--primary` in light mode and to a dimmed gold (`#c8a44a`) in dark mode so a long done-list doesn't shout.
|
||||
- **Icons**: Lucide icons throughout. Keep at 16-20px size.
|
||||
- **Hover actions**: Never use `hidden group-hover:block` for action buttons — they're invisible on mobile (no hover). Use the opacity pattern instead: `max-md:opacity-100 md:opacity-0 transition-opacity md:group-hover:opacity-100`. This keeps actions always visible on touch devices and hover-revealed on desktop.
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
crumbs:
|
||||
image: ghcr.io/bretzel-app/crumbs:0.28.3
|
||||
image: ghcr.io/bretzel-app/crumbs:0.31.0
|
||||
container_name: crumbs
|
||||
ports:
|
||||
- "3000:3000"
|
||||
|
||||
@@ -159,7 +159,10 @@ Theme switching uses CSS variable overrides on the `<html>` element:
|
||||
- **Persistence**: preference stored as `theme` in the existing `userPreferences` key-value system (values: `"system"` | `"light"` | `"dark"`)
|
||||
- **FOUC prevention**: a blocking inline `<script>` in `app.html` reads the preference from localStorage and sets `data-theme` before any content renders
|
||||
- **Reactivity**: a `$effect` in the root `+layout.svelte` watches the theme store and updates `document.documentElement.dataset.theme` on change
|
||||
- **Note card colors**: `getNoteColor()` in `src/lib/utils/colors.ts` accepts a `dark` boolean and returns the appropriate color variant per theme
|
||||
- **Note card colors**: `getNoteColor()` in `src/lib/utils/colors.ts` accepts a `dark` boolean and
|
||||
returns the appropriate surface variant per theme. Picker swatches use `getNoteChip()` instead —
|
||||
in dark mode the card surfaces are too dark to be identifiable as small circles, so the picker
|
||||
draws from a separate vivid `NOTE_CHIPS_DARK` map.
|
||||
|
||||
## File Organization
|
||||
|
||||
|
||||
+1
-1
@@ -71,7 +71,7 @@
|
||||
|
||||
### Dark Mode
|
||||
- Three-way theme toggle: System / Light / Dark (in Settings > Preferences)
|
||||
- Dark mode uses a warm parchment variant — retro aesthetic preserved in both themes
|
||||
- Dark mode uses near-neutral warm ink surfaces (not a sepia-tinted parchment) — retro aesthetic preserved in both themes
|
||||
- Respects system `prefers-color-scheme` preference by default
|
||||
- FOUC-free: theme applied before first paint via a blocking inline script
|
||||
|
||||
|
||||
@@ -0,0 +1,557 @@
|
||||
# Dark Theme Palette Rework Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace the dark theme's palette with a systematically generated one, and decouple the colour-picker swatch from the note card surface so the twelve colours are distinguishable.
|
||||
|
||||
**Architecture:** Purely a colour-data and CSS-variable change. `NOTE_COLORS_DARK` in `src/lib/utils/colors.ts` gets new values; a new `NOTE_CHIPS_DARK` map plus `getNoteChip()` serves the picker only. The `[data-theme="dark"]` block in `src/app.css` is rewritten. Consumers (`NoteCard`, `NoteEditor`, the public share page) already resolve colour through `getNoteColor()` and need no changes — only `ColorPicker.svelte` switches functions.
|
||||
|
||||
**Tech Stack:** SvelteKit + Svelte 5 runes, Tailwind CSS v4, Vitest (unit), Playwright (e2e), pnpm.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-07-31-dark-palette-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Light mode must not change. Any diff touching `NOTE_COLORS` (the light map) or `:root` colour values other than the new `--accent-check` is out of scope.
|
||||
- Never hardcode Tailwind colours (`text-gray-500`, `bg-red-600`). Always reference CSS variables. (Project rule, `CLAUDE.md`.)
|
||||
- Run `pnpm check` before every commit — not just tests. (Project rule.)
|
||||
- Run `pnpm build` before `pnpm test:e2e` locally, or e2e tests run against a stale bundle.
|
||||
- Conventional commit prefixes: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`, `test:`.
|
||||
- Exact dark palette values are given verbatim in each task. Do not re-derive or "improve" them — they were generated to hold WCAG AA against `--text` and `--text-muted` on all twelve surfaces, and hand-tweaking breaks that.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: New dark note-card colours
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/lib/utils/colors.ts:18-31` (the `NOTE_COLORS_DARK` map)
|
||||
- Test: `src/lib/utils/colors.test.ts`
|
||||
- Modify: `tests/e2e/public-sharing.spec.ts` (asserted dark `fog` value)
|
||||
- Modify: `tests/e2e/dark-mode.spec.ts:84` (stale comment)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: nothing from earlier tasks.
|
||||
- Produces: `NOTE_COLORS_DARK` with the values below. `getNoteColor(color: NoteColor, isDark: boolean): string` keeps its existing signature. Task 2 relies on `getNoteColor` being unchanged; Task 2's chip values must differ from every value here.
|
||||
|
||||
- [ ] **Step 1: Write the failing invariant tests**
|
||||
|
||||
Add to `src/lib/utils/colors.test.ts`. These assert the *properties* that broke, not the hex strings — a hex-only test would have passed happily on the old broken palette.
|
||||
|
||||
```ts
|
||||
/** WCAG relative luminance. */
|
||||
function relativeLuminance(hex: string): number {
|
||||
const n = parseInt(hex.slice(1), 16);
|
||||
const channels = [(n >> 16) & 255, (n >> 8) & 255, n & 255].map((v) => {
|
||||
const s = v / 255;
|
||||
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
|
||||
});
|
||||
return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2];
|
||||
}
|
||||
|
||||
function contrastRatio(a: string, b: string): number {
|
||||
const [l1, l2] = [relativeLuminance(a), relativeLuminance(b)];
|
||||
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
|
||||
}
|
||||
|
||||
describe('dark note colours', () => {
|
||||
// Must track [data-theme="dark"] in src/app.css.
|
||||
const DARK_TEXT = '#ece3d3';
|
||||
const DARK_TEXT_MUTED = '#b3a695';
|
||||
|
||||
it('keeps body text above the AA threshold on every dark card', () => {
|
||||
for (const [name, { bg }] of Object.entries(NOTE_COLORS_DARK)) {
|
||||
expect(contrastRatio(bg, DARK_TEXT), `${name} (${bg})`).toBeGreaterThanOrEqual(4.5);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps muted text above the AA threshold on every dark card', () => {
|
||||
// This is the invariant the old palette broke: muted/struck-through text
|
||||
// became unreadable on the lighter surfaces.
|
||||
for (const [name, { bg }] of Object.entries(NOTE_COLORS_DARK)) {
|
||||
expect(contrastRatio(bg, DARK_TEXT_MUTED), `${name} (${bg})`).toBeGreaterThanOrEqual(4.5);
|
||||
}
|
||||
});
|
||||
|
||||
it('holds the twelve surfaces within a narrow lightness band so they read as a family', () => {
|
||||
const luminances = Object.values(NOTE_COLORS_DARK).map((c) => relativeLuminance(c.bg));
|
||||
expect(Math.max(...luminances) - Math.min(...luminances)).toBeLessThan(0.03);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the tests to verify they fail**
|
||||
|
||||
Run: `pnpm test:unit colors`
|
||||
Expected: FAIL. Only the two hex-pinning assertions fail (they still pin the old values). The AA
|
||||
tests are forward guards, not regressions of the old palette — the old palette actually clears
|
||||
both AA thresholds. The distinguishability test is the one that would fail on the old palette
|
||||
(its closest pair, `fog`/`storm`, sits at RGB distance 7.5).
|
||||
|
||||
- [ ] **Step 3: Replace the dark palette**
|
||||
|
||||
In `src/lib/utils/colors.ts`, replace the whole `NOTE_COLORS_DARK` map with these values exactly:
|
||||
|
||||
```ts
|
||||
export const NOTE_COLORS_DARK: Record<NoteColor, { bg: string; label: string }> = {
|
||||
default: { bg: '#21201f', label: 'Default' },
|
||||
coral: { bg: '#3f1f1c', label: 'Coral' },
|
||||
peach: { bg: '#3f2b1c', label: 'Peach' },
|
||||
sand: { bg: '#3c371b', label: 'Sand' },
|
||||
mint: { bg: '#2a3d1d', label: 'Mint' },
|
||||
sage: { bg: '#1f3d36', label: 'Sage' },
|
||||
fog: { bg: '#253946', label: 'Fog' },
|
||||
storm: { bg: '#1d283d', label: 'Storm' },
|
||||
dusk: { bg: '#3d2843', label: 'Dusk' },
|
||||
blossom: { bg: '#3d2129', label: 'Blossom' },
|
||||
clay: { bg: '#363126', label: 'Clay' },
|
||||
chalk: { bg: '#303036', label: 'Chalk' }
|
||||
};
|
||||
```
|
||||
|
||||
Note for the implementer: `blossom` and `storm` intentionally shift hue relative to their light counterparts (toward pink and true blue). At higher chroma the original hues collided with `coral`/`peach` and `fog`. This is deliberate — see the spec.
|
||||
|
||||
- [ ] **Step 4: Update the two hex assertions that pin the old values**
|
||||
|
||||
In `src/lib/utils/colors.test.ts`, the existing `getNoteColor` tests assert old hexes. Change them:
|
||||
|
||||
```ts
|
||||
it('returns dark color when isDark is true', () => {
|
||||
expect(getNoteColor('default', true)).toBe('#21201f');
|
||||
expect(getNoteColor('coral', true)).toBe('#3f1f1c');
|
||||
});
|
||||
|
||||
it('returns default color for unknown color name', () => {
|
||||
expect(getNoteColor('unknown' as any, false)).toBe('#faf5eb');
|
||||
expect(getNoteColor('unknown' as any, true)).toBe('#21201f');
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run the unit tests to verify they pass**
|
||||
|
||||
Run: `pnpm test:unit colors`
|
||||
Expected: PASS, all tests in the file.
|
||||
|
||||
- [ ] **Step 6: Update the e2e test that asserts a dark note colour**
|
||||
|
||||
`tests/e2e/public-sharing.spec.ts` — the "Shared note follows a dark-mode visitor theme" scenario asserts dark `fog`. `#253946` is `rgb(37, 57, 70)`:
|
||||
|
||||
```ts
|
||||
await expect(publicPage.getByTestId('shared-note')).toHaveCSS(
|
||||
'background-color',
|
||||
'rgb(37, 57, 70)'
|
||||
);
|
||||
```
|
||||
|
||||
In `tests/e2e/dark-mode.spec.ts:84`, the assertion itself is a negative one (`not.toBe` light default) and needs no change, but its comment names the old hex. Update it:
|
||||
|
||||
```ts
|
||||
// Dark default color is #21201f = rgb(33, 32, 31); NOT light default rgb(250, 245, 235)
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Verify types, then the full e2e suites that touch colour**
|
||||
|
||||
Run: `pnpm check`
|
||||
Expected: 0 errors.
|
||||
|
||||
Run: `pnpm build && pnpm test:e2e public-sharing dark-mode organization`
|
||||
Expected: PASS. (`organization.spec.ts` exercises the picker but asserts no hex, so it should be unaffected — it is in the list to prove that.)
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add src/lib/utils/colors.ts src/lib/utils/colors.test.ts tests/e2e/public-sharing.spec.ts tests/e2e/dark-mode.spec.ts
|
||||
git commit -m "feat(theme): rebuild dark note colours as a consistent family"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Decouple picker swatches from card surfaces
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/lib/utils/colors.ts` (add `NOTE_CHIPS_DARK` and `getNoteChip`)
|
||||
- Modify: `src/lib/components/ColorPicker.svelte:2,19`
|
||||
- Test: `src/lib/utils/colors.test.ts`
|
||||
- Test: `tests/e2e/dark-mode.spec.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `NOTE_COLORS_DARK` and `getNoteColor(color: NoteColor, isDark: boolean): string` from Task 1.
|
||||
- Produces: `NOTE_CHIPS_DARK: Record<NoteColor, string>` and `getNoteChip(color: NoteColor, isDark: boolean): string`. No later task depends on these.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Add to `src/lib/utils/colors.test.ts`:
|
||||
|
||||
```ts
|
||||
describe('getNoteChip', () => {
|
||||
it('returns the card colour in light mode, where pastels already are the cards', () => {
|
||||
for (const color of Object.keys(NOTE_COLORS) as NoteColor[]) {
|
||||
expect(getNoteChip(color, false)).toBe(getNoteColor(color, false));
|
||||
}
|
||||
});
|
||||
|
||||
it('returns a distinct vivid chip in dark mode, not the card surface', () => {
|
||||
for (const color of Object.keys(NOTE_COLORS_DARK) as NoteColor[]) {
|
||||
expect(getNoteChip(color, true)).not.toBe(getNoteColor(color, true));
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to default for an unknown colour', () => {
|
||||
expect(getNoteChip('unknown' as any, true)).toBe(NOTE_CHIPS_DARK.default);
|
||||
});
|
||||
|
||||
it('has matching keys with the colour maps', () => {
|
||||
expect(Object.keys(NOTE_CHIPS_DARK).sort()).toEqual(Object.keys(NOTE_COLORS_DARK).sort());
|
||||
});
|
||||
|
||||
it('keeps every pair of chips far enough apart to tell apart at 28px', () => {
|
||||
// The regression this guards: at low chroma coral/peach/blossom and
|
||||
// fog/storm collapse into each other.
|
||||
const entries = Object.entries(NOTE_CHIPS_DARK);
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
for (let j = i + 1; j < entries.length; j++) {
|
||||
const [[nameA, a], [nameB, b]] = [entries[i], entries[j]];
|
||||
const toRgb = (hex: string) => {
|
||||
const n = parseInt(hex.slice(1), 16);
|
||||
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
||||
};
|
||||
const [ra, ga, ba] = toRgb(a);
|
||||
const [rb, gb, bb] = toRgb(b);
|
||||
const distance = Math.hypot(ra - rb, ga - gb, ba - bb);
|
||||
expect(distance, `${nameA} vs ${nameB}`).toBeGreaterThan(25);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Extend the import at the top of the file:
|
||||
|
||||
```ts
|
||||
import {
|
||||
getNoteColor,
|
||||
getNoteChip,
|
||||
NOTE_COLORS,
|
||||
NOTE_COLORS_DARK,
|
||||
NOTE_CHIPS_DARK
|
||||
} from '$lib/utils/colors.js';
|
||||
import type { NoteColor } from '$lib/types/index.js';
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the tests to verify they fail**
|
||||
|
||||
Run: `pnpm test:unit colors`
|
||||
Expected: FAIL — `getNoteChip is not a function` / `NOTE_CHIPS_DARK` undefined.
|
||||
|
||||
- [ ] **Step 3: Add the chip map and accessor**
|
||||
|
||||
Append to `src/lib/utils/colors.ts`, after `getNoteColor`:
|
||||
|
||||
```ts
|
||||
/**
|
||||
* Picker swatches are labels, not surfaces. A 28px circle at card lightness
|
||||
* reads as a grey dot — small patches lose apparent saturation — so the dark
|
||||
* picker uses vivid mid-lightness chips that share the card's hue but not its
|
||||
* lightness. Light mode needs no equivalent: there the pastels are the cards.
|
||||
*/
|
||||
export const NOTE_CHIPS_DARK: Record<NoteColor, string> = {
|
||||
default: '#8d857c',
|
||||
coral: '#d04f43',
|
||||
peach: '#cd7c42',
|
||||
sand: '#c7b138',
|
||||
mint: '#7bbd4c',
|
||||
sage: '#47b89d',
|
||||
fog: '#609abe',
|
||||
storm: '#547bc0',
|
||||
dusk: '#a965bd',
|
||||
blossom: '#c05d78',
|
||||
clay: '#b5954a',
|
||||
chalk: '#c3c3c8'
|
||||
};
|
||||
|
||||
export function getNoteChip(color: NoteColor, isDark: boolean): string {
|
||||
if (!isDark) return getNoteColor(color, false);
|
||||
return NOTE_CHIPS_DARK[color] ?? NOTE_CHIPS_DARK.default;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the tests to verify they pass**
|
||||
|
||||
Run: `pnpm test:unit colors`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Point the picker at the chips**
|
||||
|
||||
In `src/lib/components/ColorPicker.svelte`, change the import on line 2 and the style on line 19:
|
||||
|
||||
```svelte
|
||||
import { COLOR_OPTIONS, getNoteChip } from '$lib/utils/colors.js';
|
||||
```
|
||||
|
||||
```svelte
|
||||
style="background-color: {getNoteChip(option.value, getIsDarkMode())}"
|
||||
```
|
||||
|
||||
Leave everything else alone — the 2px `--border-subtle` ring stays, and it's what gives each swatch its boundary regardless of fill.
|
||||
|
||||
- [ ] **Step 6: Add the e2e scenario**
|
||||
|
||||
Append inside the `test.describe.serial('Dark mode', ...)` block in `tests/e2e/dark-mode.spec.ts`:
|
||||
|
||||
```ts
|
||||
test('Scenario: Colour picker swatches are identifiable in dark mode', async ({
|
||||
authenticatedPage: page
|
||||
}) => {
|
||||
// Given dark theme is active
|
||||
await page.goto('/settings/preferences');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.getByTestId('pref-theme-dark').click();
|
||||
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
|
||||
|
||||
// When the user opens the colour picker while editing a note
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Swatch Test');
|
||||
await page.getByTestId('color-picker-toggle').click();
|
||||
await expect(page.getByTestId('color-picker')).toBeVisible();
|
||||
|
||||
// Then a swatch shows its vivid chip rather than the muted card surface
|
||||
await expect(page.getByTestId('color-coral')).toHaveCSS(
|
||||
'background-color',
|
||||
'rgb(208, 79, 67)'
|
||||
);
|
||||
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Verify**
|
||||
|
||||
Run: `pnpm check`
|
||||
Expected: 0 errors.
|
||||
|
||||
Run: `pnpm build && pnpm test:e2e dark-mode organization`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add src/lib/utils/colors.ts src/lib/utils/colors.test.ts src/lib/components/ColorPicker.svelte tests/e2e/dark-mode.spec.ts
|
||||
git commit -m "feat(theme): give the dark colour picker vivid identifiable chips"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: New dark CSS variables and checkbox accent
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/app.css:6-24` (`:root` — add `--accent-check` only), `src/app.css:26-42` (the `[data-theme="dark"]` block), `src/app.css:44-48` (`accent-color`)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: nothing. The `--text`/`--text-muted` values here must match the constants asserted in Task 1's tests (`#ece3d3`, `#b3a695`).
|
||||
- Produces: `--accent-check` custom property, available in both themes.
|
||||
|
||||
- [ ] **Step 1: Add `--accent-check` to `:root`**
|
||||
|
||||
In the `:root` block in `src/app.css`, after `--grid-dot`:
|
||||
|
||||
```css
|
||||
--grid-dot: #1a1a2e;
|
||||
--accent-check: var(--primary);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace the dark block**
|
||||
|
||||
Replace the entire `[data-theme="dark"]` block with:
|
||||
|
||||
```css
|
||||
[data-theme="dark"] {
|
||||
--bg-base: #141312;
|
||||
--bg-surface: #1e1c1b;
|
||||
--primary: #e0a030;
|
||||
--primary-hover: #f2b855;
|
||||
--text: #ece3d3;
|
||||
--text-muted: #b3a695;
|
||||
--border: #57514b;
|
||||
--border-subtle: #302d2b;
|
||||
--destructive: #e2705c;
|
||||
--error-bg: #3a2320;
|
||||
--error-border: #5c3a34;
|
||||
--error-text: #f0a898;
|
||||
--success-bg: #1f2e1f;
|
||||
--success-text: #8fc97a;
|
||||
--card-shadow: 2px 2px 0px var(--border-subtle);
|
||||
--card-shadow-hover: 3px 3px 0px var(--primary);
|
||||
--grid-dot: #ece3d3;
|
||||
/* Dimmed so a long done-list of gold checkboxes doesn't shout. */
|
||||
--accent-check: #c8a44a;
|
||||
}
|
||||
```
|
||||
|
||||
Two of these reverse earlier decisions on purpose: `--primary` is no longer shared with light mode (`#C8860A` reads mustard on ink), and `--border` is a warm mid-grey rather than the mirrored cream `#e8dcc8` (a bright 1px line on a dark background reads as glare, not structure).
|
||||
|
||||
- [ ] **Step 3: Point `accent-color` at the new variable**
|
||||
|
||||
In the `html, body` rule:
|
||||
|
||||
```css
|
||||
html, body {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
accent-color: var(--accent-check);
|
||||
overscroll-behavior-y: none;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify light mode is untouched and dark mode renders**
|
||||
|
||||
Run: `pnpm check`
|
||||
Expected: 0 errors.
|
||||
|
||||
Run: `pnpm build && pnpm test:e2e dark-mode public-sharing`
|
||||
Expected: PASS. Task 1's e2e assertions cover the dark note colours; nothing asserts the chrome variables, so this step is proving no regression rather than proving the change.
|
||||
|
||||
Then look at it: `pnpm preview`, open the app, switch to dark in Settings → Preferences, and check the notes grid, the sidebar, an open editor, and a checklist with done items. Contrast maths can't judge the pixel-grid overlay or the gold hover states.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/app.css
|
||||
git commit -m "feat(theme): replace dark surfaces, borders and accents with the warm ink palette"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Sync the browser theme-colour with the new base
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/lib/utils/theme.svelte.ts:4`
|
||||
- Modify: `src/app.html:21`
|
||||
- Test: `src/lib/utils/theme.test.ts:40`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `--bg-base` `#141312` from Task 3. These two hardcoded copies must equal it.
|
||||
- Produces: nothing.
|
||||
|
||||
- [ ] **Step 1: Update the failing test first**
|
||||
|
||||
In `src/lib/utils/theme.test.ts`:
|
||||
|
||||
```ts
|
||||
it('updates meta theme-color for dark mode', () => {
|
||||
applyTheme('dark');
|
||||
const meta = document.querySelector('meta[name="theme-color"]');
|
||||
expect(meta?.getAttribute('content')).toBe('#141312');
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run it to verify it fails**
|
||||
|
||||
Run: `pnpm test:unit theme`
|
||||
Expected: FAIL — received `#1a1715`.
|
||||
|
||||
- [ ] **Step 3: Update both hardcoded copies**
|
||||
|
||||
`src/lib/utils/theme.svelte.ts` line 4:
|
||||
|
||||
```ts
|
||||
const DARK_THEME_COLOR = '#141312';
|
||||
```
|
||||
|
||||
`src/app.html` line 21, inside the FOUC script:
|
||||
|
||||
```js
|
||||
if (m) m.content = '#141312';
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the test to verify it passes**
|
||||
|
||||
Run: `pnpm test:unit theme`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Verify and commit**
|
||||
|
||||
Run: `pnpm check && pnpm test:unit`
|
||||
Expected: 0 errors, all unit tests pass.
|
||||
|
||||
```bash
|
||||
git add src/lib/utils/theme.svelte.ts src/app.html src/lib/utils/theme.test.ts
|
||||
git commit -m "fix(theme): sync browser theme-colour with the new dark base"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Document the palette
|
||||
|
||||
**Files:**
|
||||
- Modify: `CLAUDE.md` (the "Design system: Retro Parchment" section)
|
||||
- Modify: `docs/ARCHITECTURE.md:155-165` (the dark mode section)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: everything from Tasks 1-4.
|
||||
- Produces: nothing.
|
||||
|
||||
- [ ] **Step 1: Document the dark palette in `CLAUDE.md`**
|
||||
|
||||
The colour table there lists only light values, which is why the dark palette drifted in the first place. After the existing table and the note about `src/lib/utils/colors.ts`, add:
|
||||
|
||||
```markdown
|
||||
### Dark theme
|
||||
|
||||
Dark mode overrides every colour variable in `[data-theme="dark"]` (`src/app.css`). It is not a
|
||||
mechanical inversion of light mode — two differences are deliberate:
|
||||
|
||||
- **`--primary` differs per theme.** `#C8860A` on parchment, `#e0a030` on ink. The light gold reads
|
||||
mustard on a dark background.
|
||||
- **`--border` is a warm mid-grey (`#57514b`), not mirrored cream.** A bright 1px line on a dark
|
||||
background reads as glare rather than structure; the hard offset shadow carries the retro framing.
|
||||
|
||||
Note colours have two representations, and they are not interchangeable:
|
||||
|
||||
| Map | Function | Used by | Role |
|
||||
|---|---|---|---|
|
||||
| `NOTE_COLORS` / `NOTE_COLORS_DARK` | `getNoteColor(color, isDark)` | Cards, editor, share page | Quiet **surface** you read text on |
|
||||
| `NOTE_CHIPS_DARK` | `getNoteChip(color, isDark)` | `ColorPicker` only | Vivid **label** you identify at a glance |
|
||||
|
||||
A dark card sits at ~18% lightness, where a 28px picker circle would read as a grey dot — small
|
||||
patches lose apparent saturation. Never fill a picker swatch with `getNoteColor()`. In light mode
|
||||
`getNoteChip()` returns the pastel card colour, because there the pastels *are* the cards.
|
||||
|
||||
When changing any dark note colour, keep the invariants asserted in `src/lib/utils/colors.test.ts`:
|
||||
AA contrast against both `--text` and `--text-muted` on every surface, a narrow lightness band across
|
||||
the twelve, and no two chips closer than an RGB distance of 25.
|
||||
|
||||
`--bg-base` is duplicated as a literal in `src/app.html` (FOUC script) and
|
||||
`src/lib/utils/theme.svelte.ts` (`DARK_THEME_COLOR`) for the browser theme-colour. Update all three
|
||||
together.
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update `docs/ARCHITECTURE.md`**
|
||||
|
||||
In the dark mode section, the line describing note colours currently mentions only `getNoteColor()`. Replace it with:
|
||||
|
||||
```markdown
|
||||
- **Note card colors**: `getNoteColor()` in `src/lib/utils/colors.ts` accepts a `dark` boolean and
|
||||
returns the appropriate surface variant per theme. Picker swatches use `getNoteChip()` instead —
|
||||
in dark mode the card surfaces are too dark to be identifiable as small circles, so the picker
|
||||
draws from a separate vivid `NOTE_CHIPS_DARK` map.
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify the docs match the code**
|
||||
|
||||
Re-read both edited sections against `src/lib/utils/colors.ts` and `src/app.css`. Every function name, map name, file path and hex value must exist as written. No verification command can catch a wrong hex in prose — check it by eye.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add CLAUDE.md docs/ARCHITECTURE.md
|
||||
git commit -m "docs: document the dark palette and the card-vs-chip distinction"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Final verification
|
||||
|
||||
- [ ] `pnpm check` — 0 errors
|
||||
- [ ] `pnpm test:unit` — all pass
|
||||
- [ ] `pnpm build && pnpm test:e2e` — full suite passes
|
||||
- [ ] Manual pass in dark mode: notes grid, sidebar, header, open editor, checklist with done items, colour picker, a public share link, an error state (e.g. a failed login), and light mode unchanged
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,205 @@
|
||||
# Dark Theme Palette Rework — Design Spec
|
||||
|
||||
**Date:** 2026-07-31
|
||||
**Status:** Approved
|
||||
**Supersedes the palette portion of:** `2026-03-20-dark-mode-design.md`
|
||||
|
||||
## Summary
|
||||
|
||||
The dark theme's colours were authored one at a time rather than as a set. The result: twelve note
|
||||
colours that collapse into indistinguishable dark mud, a picker of twelve grey dots, sepia-tinted
|
||||
surfaces, and a near-white editor outline that reads as glare. This spec replaces the dark palette
|
||||
with a systematically generated one, and splits the picker swatch from the card surface — they are
|
||||
different jobs and should not share a value.
|
||||
|
||||
Light mode is unchanged.
|
||||
|
||||
## Problems being fixed
|
||||
|
||||
1. **Note colours are indistinguishable.** `coral #4a2522`, `peach #4a3020` and `clay #302e28` differ
|
||||
by a few percent lightness with almost no chroma. Lightness wanders across the set (17%–19%) with
|
||||
no consistent relationship, so they don't read as a family.
|
||||
2. **Several cards don't separate from the page.** `clay`, `chalk` and `default` sit within a few
|
||||
percent of `--bg-base`, so cards lose their edges.
|
||||
3. **The picker is unreadable.** `ColorPicker.svelte` fills each swatch with the card background. A
|
||||
28px circle at 18% lightness cannot read as a colour — small patches lose apparent saturation, so
|
||||
twelve swatches become twelve grey dots.
|
||||
4. **Surfaces are sepia.** `--bg-base #1a1715` / `--bg-surface #2a2520` are brown-tinted; combined
|
||||
with gold and cream on top, the UI reads dim rather than crisp.
|
||||
5. **The editor frame glares.** `--border` mirrors light mode's near-black ink as near-white cream.
|
||||
The mirror is false: dark-on-light edges absorb, light-on-dark edges emit. At equal contrast the
|
||||
inverted border reads as a glowing rectangle, not as structure.
|
||||
|
||||
## Decisions
|
||||
|
||||
| # | Decision | Choice |
|
||||
|---|---|---|
|
||||
| 1 | Base surfaces | **Warm ink** — pull the brown out, keep a trace of warmth |
|
||||
| 2 | Card lightness | **Deep tinted (~18%)** — colours identifiable, page still reads dark |
|
||||
| 3 | Picker swatches | **Vivid mid-lightness chips**, decoupled from the card colour |
|
||||
| 4 | Chip scope | **Dark mode only** — light mode keeps its pastel swatches |
|
||||
| 5 | Chrome | **Quiet frame** — `--border` becomes warm mid-grey, not cream |
|
||||
|
||||
On (4): in light mode the pastels genuinely *are* the card colours, so they preview correctly there.
|
||||
Only dark mode needs the split.
|
||||
|
||||
## 1. CSS variables (`src/app.css`)
|
||||
|
||||
Replace the `[data-theme="dark"]` block:
|
||||
|
||||
```css
|
||||
[data-theme="dark"] {
|
||||
--bg-base: #141312;
|
||||
--bg-surface: #1e1c1b;
|
||||
--primary: #e0a030;
|
||||
--primary-hover: #f2b855;
|
||||
--text: #ece3d3;
|
||||
--text-muted: #b3a695;
|
||||
--border: #57514b;
|
||||
--border-subtle: #302d2b;
|
||||
--destructive: #e2705c;
|
||||
--error-bg: #3a2320;
|
||||
--error-border: #5c3a34;
|
||||
--error-text: #f0a898;
|
||||
--success-bg: #1f2e1f;
|
||||
--success-text: #8fc97a;
|
||||
--card-shadow: 2px 2px 0px var(--border-subtle);
|
||||
--card-shadow-hover: 3px 3px 0px var(--primary);
|
||||
--grid-dot: #ece3d3;
|
||||
--accent-check: #c8a44a;
|
||||
}
|
||||
```
|
||||
|
||||
Three of these are notable changes rather than tweaks:
|
||||
|
||||
- **`--primary` is no longer shared across themes.** `#C8860A` was chosen for parchment; on ink it
|
||||
reads mustard. Dark mode gets `#e0a030` (8.17:1 on the page).
|
||||
- **`--border` becomes `#57514b`** instead of cream `#e8dcc8`. Frames the editor without glare; the
|
||||
hard offset shadow still carries the retro structure.
|
||||
- **`--text-muted` lifts `#9a8e7e` → `#b3a695`.** Required for AA on the tinted cards. This is the
|
||||
widest-reaching change in the spec — every muted element in dark mode shifts slightly.
|
||||
|
||||
### Checkbox accent
|
||||
|
||||
`accent-color: var(--primary)` makes a long done-list shout in dark mode. Add a dedicated variable so
|
||||
the checkbox can be dimmed independently of links and the brand:
|
||||
|
||||
```css
|
||||
:root { --accent-check: var(--primary); } /* light: unchanged */
|
||||
html, body { accent-color: var(--accent-check); }
|
||||
```
|
||||
|
||||
## 2. Note card colours (`src/lib/utils/colors.ts`)
|
||||
|
||||
Replace `NOTE_COLORS_DARK`. Every entry keeps its light counterpart's hue, holds lightness
|
||||
near-constant against today's values (measured average change: +4.1 saturation points, −0.18
|
||||
lightness points), and nudges two crowded hues apart (`blossom` and `storm` shift toward pink
|
||||
and true blue respectively) so the twelve read as distinguishable siblings rather than a smudge.
|
||||
The visible improvement in dark mode comes from the base surfaces, the picker chips, the
|
||||
softened border, and the lifted gold — not from the card surfaces, which are close in chroma to
|
||||
the previous dark values.
|
||||
|
||||
```ts
|
||||
export const NOTE_COLORS_DARK: Record<NoteColor, { bg: string; label: string }> = {
|
||||
default: { bg: '#21201f', label: 'Default' },
|
||||
coral: { bg: '#3f1f1c', label: 'Coral' },
|
||||
peach: { bg: '#3f2b1c', label: 'Peach' },
|
||||
sand: { bg: '#3c371b', label: 'Sand' },
|
||||
mint: { bg: '#2a3d1d', label: 'Mint' },
|
||||
sage: { bg: '#1f3d36', label: 'Sage' },
|
||||
fog: { bg: '#253946', label: 'Fog' },
|
||||
storm: { bg: '#1d283d', label: 'Storm' },
|
||||
dusk: { bg: '#3d2843', label: 'Dusk' },
|
||||
blossom: { bg: '#3d2129', label: 'Blossom' },
|
||||
clay: { bg: '#363126', label: 'Clay' },
|
||||
chalk: { bg: '#303036', label: 'Chalk' }
|
||||
};
|
||||
```
|
||||
|
||||
**Two hues are deliberately nudged apart.** At low chroma, `coral`/`peach`/`blossom` are three
|
||||
barely-different oranges and `fog`/`storm` are two barely-different blues — invisible in light mode,
|
||||
but they collide once chroma rises. So in dark mode `blossom` moves toward pink (H344) and `storm`
|
||||
toward true blue (H218). Their light-mode values are untouched, so the same note is a slightly
|
||||
different hue in each theme. This is an accepted trade: distinguishable beats identical.
|
||||
|
||||
Measured on these values: body text ≥ 9.25:1, muted text ≥ 4.94:1, links ≥ 5.18:1 on every card.
|
||||
All clear WCAG AA.
|
||||
|
||||
## 3. Picker chips (`src/lib/utils/colors.ts`)
|
||||
|
||||
New map, used only by the picker, only in dark mode:
|
||||
|
||||
```ts
|
||||
export const NOTE_CHIPS_DARK: Record<NoteColor, string> = {
|
||||
default: '#8d857c', coral: '#d04f43', peach: '#cd7c42', sand: '#c7b138',
|
||||
mint: '#7bbd4c', sage: '#47b89d', fog: '#609abe', storm: '#547bc0',
|
||||
dusk: '#a965bd', blossom: '#c05d78', clay: '#b5954a', chalk: '#c3c3c8'
|
||||
};
|
||||
|
||||
/** Picker swatch colour: a vivid label, not the card surface. */
|
||||
export function getNoteChip(color: NoteColor, isDark: boolean): string {
|
||||
if (!isDark) return getNoteColor(color, false); // light mode: pastel == card
|
||||
return NOTE_CHIPS_DARK[color] ?? NOTE_CHIPS_DARK.default;
|
||||
}
|
||||
```
|
||||
|
||||
`default` and `chalk` stay deliberately neutral (grey and off-white) — they are the "no colour"
|
||||
slots. Chips are non-text UI, so WCAG's bar is 3:1, not 4:1; every chip clears that comfortably
|
||||
against `--bg-surface`, with the lowest at `coral` 3.95:1 (`storm` 4.02:1, `blossom` 4.10:1). The
|
||||
closest pair (`fog`/`storm`) is separated by an RGB distance of 33, which is comfortably tellable
|
||||
apart at 28px.
|
||||
|
||||
`ColorPicker.svelte` switches from `getNoteColor` to `getNoteChip`. Nothing else uses chips — cards,
|
||||
the editor and the public share page keep calling `getNoteColor`.
|
||||
|
||||
## 4. Theme-colour meta tag
|
||||
|
||||
`#1a1715` is hardcoded in two places that must stay in sync with `--bg-base`:
|
||||
|
||||
- `src/app.html` (the FOUC script, `m.content = '#1a1715'`)
|
||||
- `src/lib/utils/theme.svelte.ts` (`DARK_THEME_COLOR`)
|
||||
|
||||
Both become `#141312`.
|
||||
|
||||
## Files to modify
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `src/app.css` | Rewrite `[data-theme="dark"]`; add `--accent-check` to `:root`; point `accent-color` at it |
|
||||
| `src/lib/utils/colors.ts` | New `NOTE_COLORS_DARK`; add `NOTE_CHIPS_DARK` + `getNoteChip()` |
|
||||
| `src/lib/components/ColorPicker.svelte` | Use `getNoteChip()` instead of `getNoteColor()` |
|
||||
| `src/app.html` | Theme-colour `#1a1715` → `#141312` |
|
||||
| `src/lib/utils/theme.svelte.ts` | `DARK_THEME_COLOR` → `#141312` |
|
||||
| `src/lib/utils/colors.test.ts` | Update dark `default` assertions; add `getNoteChip` coverage |
|
||||
| `src/lib/utils/theme.test.ts` | Update theme-colour assertion |
|
||||
| `tests/e2e/dark-mode.spec.ts` | Update asserted dark default `rgb(42,37,32)` → `rgb(33,32,31)` |
|
||||
| `tests/e2e/public-sharing.spec.ts` | Update asserted dark `fog` `rgb(34,46,58)` → `rgb(37,57,70)` |
|
||||
| `CLAUDE.md` | Document the dark palette and the card-vs-chip distinction in the design system section |
|
||||
| `docs/ARCHITECTURE.md` | Update the dark mode section for `getNoteChip` |
|
||||
|
||||
Nothing needs to change in `NoteCard`, `NoteEditor`, or the share page — they already resolve colour
|
||||
through `getNoteColor`, so they inherit the new palette.
|
||||
|
||||
## Testing
|
||||
|
||||
**Unit (`colors.test.ts`)** — the palette is data, so test its invariants rather than its hex values:
|
||||
|
||||
- `getNoteChip(c, false)` equals `getNoteColor(c, false)` for all twelve (light mode is unchanged)
|
||||
- `getNoteChip(c, true)` differs from `getNoteColor(c, true)` for all twelve (chip ≠ surface)
|
||||
- unknown colour falls back to `default` in both maps
|
||||
- every dark card holds ≥ 4.5:1 against `--text-muted #b3a695` — this is the invariant that broke
|
||||
before, so it gets asserted rather than eyeballed
|
||||
- no two dark chips are within an RGB distance of 25 (guards the crowded-hue regression)
|
||||
|
||||
**E2E (`dark-mode.spec.ts`)** — extend the existing "note cards use dark colors" scenario to assert a
|
||||
picker swatch is *not* the card background, which is the bug in user-visible terms.
|
||||
|
||||
**Manual** — the pixel-grid overlay and gold hover states want a real look on device; contrast maths
|
||||
can't judge those.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Light mode, including its picker
|
||||
- The 12-colour set itself (no colours added, removed, or renamed)
|
||||
- Per-note custom colours
|
||||
- `prefers-contrast` / high-contrast variants
|
||||
@@ -0,0 +1,129 @@
|
||||
# Note Linking — Design Spec
|
||||
|
||||
**Issue**: [#74 — feat: link to other crumbs (notes) inside the app](https://github.com/bretzel-app/crumbs/issues/74)
|
||||
**Date**: 2026-08-03
|
||||
|
||||
## Overview
|
||||
|
||||
Let a note reference another note by ID, so clicking it opens the target note in the editor. Reuses the existing "insert link" toolbar entry point (Ctrl+K) rather than introducing a new `[[wikilink]]`-style typing syntax. The displayed link text always tracks the target note's current title — it's a live reference, not frozen text — and the target note gets a "Referenced by" backlinks section.
|
||||
|
||||
## Decisions
|
||||
|
||||
| Decision | Choice | Rationale |
|
||||
|---|---|---|
|
||||
| Link creation UX | Extend the existing link toolbar dropdown with a note-search mode | Matches the issue reporter's own proposed solution; reuses the Link dropdown, `/api/search`, and toolbar infra as-is instead of building a new inline autocomplete |
|
||||
| Click behavior | Opens the target note in the editor overlay, same as clicking a note card | Matches how every other note-opening interaction already works in the app |
|
||||
| Link label | Always the target note's live title | A stale link label after a rename would be confusing; this requires modeling the link as an atomic node rather than a mark (see Architecture) |
|
||||
| Broken/inaccessible links | Grey out, inert, content untouched | Nothing is silently rewritten; if the note is restored from trash the link works again automatically |
|
||||
| Backlinks | Included in v1 | Requested explicitly; reuses the same extract-and-sync pattern tags already use, so the incremental cost is a reverse query, not new infrastructure |
|
||||
| Public share page | Note-links never resolve, even if the target is also shared | Simplest, most conservative option — zero chance of leaking the existence of a private note to an anonymous visitor |
|
||||
| Link picker scope | Owned notes + notes shared with you | Same visibility scope the main notes list already uses; `searchNotes()` already implements this |
|
||||
| Removing an existing link | Hover-reveal a small × on the chip | Click already navigates instantly, so it can't also open an edit/remove popover the way external links do; a hover-revealed remove button solves this without touching the click behavior. Repointing a link is just remove + re-insert — no separate "edit target" UI |
|
||||
|
||||
## Architecture
|
||||
|
||||
**Data model** — a new self-referential join table, mirroring `noteTags`/`tags`:
|
||||
|
||||
```typescript
|
||||
export const noteLinks = sqliteTable(
|
||||
'note_links',
|
||||
{
|
||||
sourceNoteId: text('source_note_id')
|
||||
.references(() => notes.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
targetNoteId: text('target_note_id')
|
||||
.references(() => notes.id, { onDelete: 'cascade' })
|
||||
.notNull()
|
||||
},
|
||||
(table) => [
|
||||
index('note_links_source_idx').on(table.sourceNoteId),
|
||||
index('note_links_target_idx').on(table.targetNoteId)
|
||||
]
|
||||
);
|
||||
```
|
||||
|
||||
**Markdown representation** — a note-link round-trips through raw markdown as a normal-looking link with a custom URI scheme:
|
||||
|
||||
```
|
||||
[Grocery List](crumb-note://<note-id>)
|
||||
```
|
||||
|
||||
This keeps the existing raw-markdown-mode toggle showing something readable, and keeps exported/copied content sensible outside the app. The title baked into the markdown text is only ever a fallback for external tools — inside the app, parsing recognizes the `crumb-note://` scheme and rebuilds the live node, discarding whatever static text was there.
|
||||
|
||||
**Editor node** — a new tiptap **node** (not a mark) named `NoteLink`, with a single attribute `{ noteId: string }`. Unlike a mark, a node fully owns its own rendering, which is what makes "always show the live title" possible — a mark decorates existing typed text and has no clean way to swap that text later.
|
||||
|
||||
## Components
|
||||
|
||||
### Client
|
||||
|
||||
| Component | Change |
|
||||
|---|---|
|
||||
| `src/lib/components/tiptap/NoteLink.ts` (new) | Inline atomic tiptap node extension. `NodeView` looks up the target's title via `getNote(id)` from `src/lib/sync/idb.ts` — **not** the reactive `notes` writable store, which only holds whatever the current filter (all/archived/trashed) last loaded and would wrongly grey out a valid note just because it's outside the current view. IndexedDB is kept complete for all accessible notes by the existing `/api/sync` incremental sync, independent of which filter is on screen, so it's the correct source of truth here — and it works offline too. Renders a chip (note icon + title), greys out and disables click if the note isn't found. Click opens the note editor overlay for that ID. A small × button is layered on the chip using the app's existing hover-action pattern (`max-md:opacity-100 md:opacity-0 transition-opacity md:group-hover:opacity-100` — always visible on touch, hover-revealed on desktop) that deletes the node from the document without navigating; there's no separate "change target" action, since deleting and re-inserting is simple enough that a dedicated repoint UI isn't worth building. |
|
||||
| `src/lib/components/TiptapEditor.svelte` | Register the `NoteLink` node extension alongside the existing ones. |
|
||||
| `src/lib/components/FormattingToolbar.svelte` | The existing link dropdown gets a note-search results list below the URL input, live-filtered by whatever's typed there (debounced, via `/api/search`, excluding the currently-open note) — no heuristic needed to guess whether the typed text is a URL or a note search; both affordances are just available side by side. Applying the URL input still applies the normal external `Link` mark; picking a search result instead inserts a `NoteLink` node at the cursor (replacing any selection). |
|
||||
| `src/lib/components/NoteEditor.svelte` | New "Referenced by" section, shown when `note.backlinks` is non-empty — a small chip list near where tags render, each chip opens that referencing note. |
|
||||
|
||||
### Server
|
||||
|
||||
| Function | Location | Description |
|
||||
|---|---|---|
|
||||
| `extractNoteLinks(content): string[]` | `src/lib/utils/note-links.ts` (new) | Regex-extracts `crumb-note://<id>` references from markdown content, same code-block/URL exclusions `extractTags` already applies. |
|
||||
| `syncNoteLinks(db, noteId, targetIds, userId)` | `src/lib/server/note-links.ts` (new) | Mirrors `syncNoteTags`: clears existing rows for the note, re-inserts the current set, within the same save transaction. |
|
||||
| `fetchBacklinksForNote(db, noteId, userId)` | `src/lib/server/note-links.ts` (new) | Reverse query on `noteLinks.targetNoteId`, joined through the same access rules as everything else (owner or collaborator on the *source* note), returning `{id, title}` pairs. |
|
||||
|
||||
`getNote()` in `notes-service.ts` calls `fetchBacklinksForNote` and attaches the result as `backlinks`. This is **not** added to `hydrateNotes()` (which also backs list views) — computing backlinks for every note in a list load would be wasted work for something only the open editor displays.
|
||||
|
||||
## Data Flow
|
||||
|
||||
1. **Create** — pick a note in the toolbar's link dropdown → a `NoteLink` node is inserted at the cursor.
|
||||
2. **Save** — content round-trips to markdown as usual; the note-save transaction additionally runs `extractNoteLinks` + `syncNoteLinks`, alongside the existing tag sync.
|
||||
3. **Reopen** — markdown parses back into the doc; a custom parse rule recognizes `crumb-note://` and produces a `NoteLink` node; its `NodeView` looks up the live title via IndexedDB (`getNote(id)`), not the filter-scoped reactive store (see Components).
|
||||
4. **Click** — opens the target note in the editor overlay.
|
||||
5. **Rename** — no action needed anywhere: the title is looked up live at render time, so the next render already shows the new title.
|
||||
6. **Trash / permanent delete / no access** — all three are treated identically: the note simply isn't found in IndexedDB (or, server-side, fails the backlink query's access check), so the link renders inert. The referencing note's content is never rewritten.
|
||||
7. **Public share page** — doesn't use the tiptap editor; content is rendered server-side via `markdown-it`. That render path strips `crumb-note://` links down to plain, non-clickable text, regardless of whether the target is also shared.
|
||||
|
||||
## Error Handling
|
||||
|
||||
- **Malformed `crumb-note://` reference** during markdown parse falls back to plain text rather than crashing the parse.
|
||||
- **Search race conditions** in the toolbar's note-picker are debounced and guarded against stale results overwriting newer ones, same pattern the existing search feature (`tests/e2e/search.spec.ts`) already covers.
|
||||
- **Self-links** — the currently-open note is filtered out of its own picker results.
|
||||
- **Circular references** (A ↔ B) are a non-issue — clicking only ever opens one note at a time, nothing recurses.
|
||||
- **Cross-user link privacy** — the picker only offers notes you can already access, so under normal use a link can only point at something you had legitimate access to when you created it. Hand-editing raw markdown with a guessed note ID is the only way around that, and note IDs are unguessable UUIDs — the same "if you have the ID, you were meant to have it" trust model the app already applies to session/share tokens. Accepted as a low-severity edge case rather than adding extra access-control plumbing for it.
|
||||
|
||||
## Testing
|
||||
|
||||
**Unit (Vitest)**
|
||||
- `extractNoteLinks` — extracts IDs, ignores code blocks/URLs, dedupes, handles empty content
|
||||
- `syncNoteLinks` — clears + re-inserts correctly
|
||||
- `fetchBacklinksForNote` — returns only notes the requesting user can access
|
||||
- Markdown round-trip — a `NoteLink` node serializes to `[title](crumb-note://id)` and parses back to the same node with the right `noteId`
|
||||
|
||||
**E2E (Playwright)**
|
||||
- Linking to a note from the toolbar renders a live chip; clicking it opens that note
|
||||
- Renaming the linked note updates the displayed link text next time it's viewed
|
||||
- A link to a trashed/deleted/inaccessible note renders inert and doesn't navigate
|
||||
- The linked-to note shows a "Referenced by" backlink to the note that links to it
|
||||
- A public share page never renders a note-link as clickable, even if the target is also shared
|
||||
- The note-picker excludes the currently-open note from its own search results
|
||||
- Hovering a note-link chip reveals a × button that removes it from the document without navigating; the × is always visible (not hover-gated) on touch devices
|
||||
|
||||
## Out of Scope (v1)
|
||||
|
||||
- Inline `[[wikilink]]`/`@mention` autocomplete while typing (toolbar-only for now; can be added later without touching the data model)
|
||||
- Note graph / visual map view
|
||||
- Ranking or sorting backlinks beyond simple list order
|
||||
- Cross-user access-control tightening for hand-crafted `crumb-note://` references (see Error Handling)
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Link toolbar dropdown supports searching and picking another note, in addition to pasting a URL
|
||||
- [ ] Picking a note inserts a live `NoteLink` node showing the target's current title
|
||||
- [ ] Clicking a note-link opens the target note in the editor overlay
|
||||
- [ ] Renaming a note updates its link's displayed text everywhere, without editing referencing notes' content
|
||||
- [ ] Trashed, deleted, or inaccessible link targets render greyed-out and inert
|
||||
- [ ] Note editor shows a "Referenced by" section listing notes that link to it
|
||||
- [ ] Public share page never renders a note-link as clickable
|
||||
- [ ] Note-links round-trip correctly through raw markdown mode as `[title](crumb-note://id)`
|
||||
- [ ] The link picker excludes the current note and includes both owned and shared-with-you notes
|
||||
- [ ] Hovering a note-link chip reveals a × that removes it without navigating; always visible on touch
|
||||
@@ -0,0 +1,9 @@
|
||||
CREATE TABLE `note_links` (
|
||||
`source_note_id` text NOT NULL,
|
||||
`target_note_id` text NOT NULL,
|
||||
FOREIGN KEY (`source_note_id`) REFERENCES `notes`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`target_note_id`) REFERENCES `notes`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `note_links_source_idx` ON `note_links` (`source_note_id`);--> statement-breakpoint
|
||||
CREATE INDEX `note_links_target_idx` ON `note_links` (`target_note_id`);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,13 @@
|
||||
"when": 1774037379518,
|
||||
"tag": "0003_uneven_tomorrow_man",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "6",
|
||||
"when": 1785824271578,
|
||||
"tag": "0004_violet_nicolaos",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
+41
-41
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "crumbs",
|
||||
"private": true,
|
||||
"version": "0.28.4",
|
||||
"version": "0.32.0",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@9.15.9",
|
||||
"license": "MIT",
|
||||
@@ -34,64 +34,64 @@
|
||||
"docs:api": "tsx scripts/generate-api-docs.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||
"@sveltejs/adapter-node": "^5.5.4",
|
||||
"@tiptap/core": "^3.20.1",
|
||||
"@tiptap/extension-link": "^3.20.1",
|
||||
"@tiptap/extension-placeholder": "^3.20.1",
|
||||
"@tiptap/extension-table": "^3.20.1",
|
||||
"@tiptap/extension-table-cell": "^3.20.1",
|
||||
"@tiptap/extension-table-header": "^3.20.1",
|
||||
"@tiptap/extension-table-row": "^3.20.1",
|
||||
"@tiptap/extension-task-item": "^3.20.1",
|
||||
"@tiptap/extension-task-list": "^3.20.1",
|
||||
"@tiptap/extension-text-align": "^3.20.1",
|
||||
"@tiptap/extension-underline": "^3.20.1",
|
||||
"@tiptap/pm": "^3.20.1",
|
||||
"@tiptap/starter-kit": "^3.20.1",
|
||||
"@modelcontextprotocol/sdk": "^1.30.0",
|
||||
"@sveltejs/adapter-node": "^5.5.7",
|
||||
"@tiptap/core": "^3.29.2",
|
||||
"@tiptap/extension-link": "^3.29.2",
|
||||
"@tiptap/extension-placeholder": "^3.29.2",
|
||||
"@tiptap/extension-table": "^3.29.2",
|
||||
"@tiptap/extension-table-cell": "^3.29.2",
|
||||
"@tiptap/extension-table-header": "^3.29.2",
|
||||
"@tiptap/extension-table-row": "^3.29.2",
|
||||
"@tiptap/extension-task-item": "^3.29.2",
|
||||
"@tiptap/extension-task-list": "^3.29.2",
|
||||
"@tiptap/extension-text-align": "^3.29.2",
|
||||
"@tiptap/extension-underline": "^3.29.2",
|
||||
"@tiptap/pm": "^3.29.2",
|
||||
"@tiptap/starter-kit": "^3.29.2",
|
||||
"arctic": "^3.7.0",
|
||||
"argon2": "^0.43.1",
|
||||
"better-sqlite3": "^11.10.0",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"idb": "^8.0.3",
|
||||
"lucide-svelte": "^0.577.0",
|
||||
"markdown-it": "^14.1.1",
|
||||
"nanoid": "^5.1.7",
|
||||
"node-diff3": "^3.2.0",
|
||||
"nodemailer": "^8.0.2",
|
||||
"svelte-dnd-action": "^0.9.69",
|
||||
"markdown-it": "^14.3.0",
|
||||
"nanoid": "^5.1.16",
|
||||
"node-diff3": "^3.2.1",
|
||||
"nodemailer": "^9.0.3",
|
||||
"svelte-dnd-action": "^0.9.77",
|
||||
"tiptap-markdown": "^0.9.0",
|
||||
"uuid": "^11.1.0",
|
||||
"zod": "^4.3.6",
|
||||
"uuid": "^11.1.1",
|
||||
"zod": "^4.4.3",
|
||||
"zxcvbn": "^4.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@sveltejs/kit": "^2.63.1",
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@sveltejs/kit": "^2.70.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@tailwindcss/typography": "^0.5.20",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@types/zxcvbn": "^4.4.5",
|
||||
"@vite-pwa/sveltekit": "^1.1.0",
|
||||
"@vitest/coverage-v8": "^3.2.6",
|
||||
"drizzle-kit": "^0.31.9",
|
||||
"jsdom": "^29.0.1",
|
||||
"@vitest/coverage-v8": "^3.2.7",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"jsdom": "^29.1.1",
|
||||
"nyc": "^18.0.0",
|
||||
"svelte": "^5.53.7",
|
||||
"svelte-check": "^4.4.4",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"tsx": "^4.21.0",
|
||||
"svelte": "^5.56.8",
|
||||
"svelte-check": "^4.7.4",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"tsx": "^4.23.5",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.5",
|
||||
"vite": "^7.3.6",
|
||||
"vite-plugin-istanbul": "^7.2.1",
|
||||
"vite-plugin-pwa": "^1.2.0",
|
||||
"vitest": "^3.2.6",
|
||||
"workbox-window": "^7.4.0",
|
||||
"yaml": "^2.8.2"
|
||||
"vite-plugin-pwa": "^1.3.0",
|
||||
"vitest": "^3.2.7",
|
||||
"workbox-window": "^7.4.1",
|
||||
"yaml": "^2.9.0"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
@@ -102,7 +102,7 @@
|
||||
"overrides": {
|
||||
"serialize-javascript": ">=7.0.3",
|
||||
"esbuild@<0.25.0": "0.25.12",
|
||||
"cookie": ">=0.7.0",
|
||||
"cookie": ">=0.7.0 <1",
|
||||
"hono": ">=4.12.7",
|
||||
"devalue": ">=5.8.1",
|
||||
"path-to-regexp@8": ">=8.4.0",
|
||||
|
||||
Generated
+1372
-1936
File diff suppressed because it is too large
Load Diff
+35
-15
@@ -21,29 +21,44 @@
|
||||
--card-shadow: 2px 2px 0px var(--border-subtle);
|
||||
--card-shadow-hover: 3px 3px 0px var(--primary);
|
||||
--grid-dot: #1a1a2e;
|
||||
--accent-check: var(--primary);
|
||||
/* Tint source for hover/active washes. Light mode reuses the ink border, so
|
||||
light styling is byte-identical; dark mode keeps the cream that --border
|
||||
used to supply, because a mid-grey wash is invisible on a dark surface. */
|
||||
--hover-wash: var(--border);
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--bg-base: #1a1715;
|
||||
--bg-surface: #2a2520;
|
||||
--text: #e8dcc8;
|
||||
--text-muted: #9a8e7e;
|
||||
--border: #e8dcc8;
|
||||
--border-subtle: #3a3530;
|
||||
--destructive: #d4604e;
|
||||
--error-bg: #3a2020;
|
||||
--error-border: #5a3030;
|
||||
--error-text: #e8a090;
|
||||
--success-bg: #203020;
|
||||
--success-text: #90c880;
|
||||
--bg-base: #141312;
|
||||
--bg-surface: #1e1c1b;
|
||||
--primary: #e0a030;
|
||||
--primary-hover: #f2b855;
|
||||
--text: #ece3d3;
|
||||
--text-muted: #b3a695;
|
||||
--border: #57514b;
|
||||
--border-subtle: #302d2b;
|
||||
--destructive: #e2705c;
|
||||
--error-bg: #3a2320;
|
||||
--error-border: #5c3a34;
|
||||
--error-text: #f0a898;
|
||||
--success-bg: #1f2e1f;
|
||||
--success-text: #8fc97a;
|
||||
--card-shadow: 2px 2px 0px var(--border-subtle);
|
||||
--card-shadow-hover: 3px 3px 0px var(--primary);
|
||||
--grid-dot: #e8dcc8;
|
||||
--grid-dot: #ece3d3;
|
||||
/* Dimmed so a long done-list of gold checkboxes doesn't shout. */
|
||||
--accent-check: #c8a44a;
|
||||
--hover-wash: #ece3d3;
|
||||
}
|
||||
|
||||
body {
|
||||
html, body {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
accent-color: var(--primary);
|
||||
accent-color: var(--accent-check);
|
||||
overscroll-behavior-y: none;
|
||||
}
|
||||
|
||||
body.editor-scroll-locked {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body::before {
|
||||
@@ -68,6 +83,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);
|
||||
|
||||
Vendored
+3
-1
@@ -15,7 +15,9 @@ declare global {
|
||||
} | null;
|
||||
}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
interface PageState {
|
||||
noteEditorOpen?: boolean;
|
||||
}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
+6
-2
@@ -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() {
|
||||
@@ -14,7 +18,7 @@
|
||||
if (t === 'dark') {
|
||||
document.documentElement.setAttribute('data-theme', 'dark');
|
||||
var m = document.querySelector('meta[name="theme-color"]');
|
||||
if (m) m.content = '#1a1715';
|
||||
if (m) m.content = '#141312';
|
||||
}
|
||||
} catch(e) {}
|
||||
})();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { COLOR_OPTIONS, getNoteColor } from '$lib/utils/colors.js';
|
||||
import { COLOR_OPTIONS, getNoteChip } from '$lib/utils/colors.js';
|
||||
import { getIsDarkMode } from '$lib/utils/theme.svelte.js';
|
||||
import type { NoteColor } from '$lib/types/index.js';
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<button
|
||||
onclick={() => onSelect(option.value)}
|
||||
class="h-8 w-8 rounded-full border-2 transition-transform hover:scale-110 {selected === option.value ? 'border-[var(--text)]' : 'border-[var(--border-subtle)]'}"
|
||||
style="background-color: {getNoteColor(option.value, getIsDarkMode())}"
|
||||
style="background-color: {getNoteChip(option.value, getIsDarkMode())}"
|
||||
title={option.label}
|
||||
data-testid="color-{option.value}"
|
||||
></button>
|
||||
|
||||
@@ -33,35 +33,108 @@
|
||||
interface Props {
|
||||
tick?: number;
|
||||
editor: Editor | undefined;
|
||||
currentNoteId?: string | null;
|
||||
}
|
||||
|
||||
let { editor, tick }: Props = $props();
|
||||
let { editor, tick, currentNoteId = null }: Props = $props();
|
||||
|
||||
function canUndo() { void tick; return editor?.can().chain().focus().undo().run() ?? false; }
|
||||
function canRedo() { void tick; return editor?.can().chain().focus().redo().run() ?? false; }
|
||||
// A stale `editor` reference can still point at an instance mid-teardown (e.g. while
|
||||
// NoteEditor swaps in a fresh Editor across a markdown-mode toggle); calling its
|
||||
// commands after `destroy()` throws, so every accessor here must check isDestroyed too.
|
||||
function canUndo() { void tick; return (!editor?.isDestroyed && editor?.can().chain().focus().undo().run()) ?? false; }
|
||||
function canRedo() { void tick; return (!editor?.isDestroyed && editor?.can().chain().focus().redo().run()) ?? false; }
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function isActive(nameOrAttrs: any, attrs?: Record<string, any>): boolean { void tick; return editor?.isActive(nameOrAttrs, attrs) ?? false; }
|
||||
function getAttrs(type: string) { void tick; return editor?.getAttributes(type) ?? {}; }
|
||||
function isActive(nameOrAttrs: any, attrs?: Record<string, any>): boolean { void tick; return (!editor?.isDestroyed && editor?.isActive(nameOrAttrs, attrs)) ?? false; }
|
||||
function getAttrs(type: string) { void tick; return (!editor?.isDestroyed && 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) {
|
||||
let noteSearchResults: { id: string; title: string }[] = $state([]);
|
||||
let noteSearchTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
async function searchNotesForLink(query: string) {
|
||||
const requestId = ++noteSearchRequestId;
|
||||
if (!query.trim()) {
|
||||
noteSearchResults = [];
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
|
||||
if (requestId !== noteSearchRequestId) return;
|
||||
if (!res.ok) return;
|
||||
const results: { id: string; title: string }[] = await res.json();
|
||||
noteSearchResults = results.filter((n) => n.id !== currentNoteId);
|
||||
} catch {
|
||||
// Search failed silently
|
||||
}
|
||||
}
|
||||
|
||||
let noteSearchRequestId = 0;
|
||||
|
||||
function handleLinkInput() {
|
||||
clearTimeout(noteSearchTimer);
|
||||
noteSearchTimer = setTimeout(() => searchNotesForLink(linkUrl), 200);
|
||||
}
|
||||
|
||||
function insertNoteLink(noteId: string) {
|
||||
if (!editor) return;
|
||||
editor.chain().focus().insertContent({ type: 'noteLink', attrs: { noteId } }).run();
|
||||
noteSearchResults = [];
|
||||
closeDropdowns();
|
||||
}
|
||||
|
||||
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;
|
||||
clearTimeout(noteSearchTimer);
|
||||
noteSearchResults = [];
|
||||
}
|
||||
|
||||
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() {
|
||||
@@ -88,15 +161,15 @@
|
||||
const chevronSize = 14;
|
||||
|
||||
function btnClass(active: boolean = false, disabled: boolean = false): string {
|
||||
return `shrink-0 rounded p-1.5 text-[var(--text)] hover:bg-[var(--border)]/10 ${active ? 'text-[var(--primary)] bg-[var(--border)]/10' : ''} ${disabled ? 'opacity-30' : ''}`;
|
||||
return `shrink-0 rounded p-1.5 text-[var(--text)] hover:bg-[var(--hover-wash)]/10 ${active ? 'text-[var(--primary)] bg-[var(--hover-wash)]/10' : ''} ${disabled ? 'opacity-30' : ''}`;
|
||||
}
|
||||
|
||||
function dropdownBtnClass(active: boolean = false): string {
|
||||
return `shrink-0 flex items-center gap-0.5 rounded p-1.5 text-[var(--text)] hover:bg-[var(--border)]/10 ${active ? 'text-[var(--primary)] bg-[var(--border)]/10' : ''}`;
|
||||
return `shrink-0 flex items-center gap-0.5 rounded p-1.5 text-[var(--text)] hover:bg-[var(--hover-wash)]/10 ${active ? 'text-[var(--primary)] bg-[var(--hover-wash)]/10' : ''}`;
|
||||
}
|
||||
|
||||
function dropdownItemClass(active: boolean = false): string {
|
||||
return `flex w-full items-center gap-2 rounded px-3 py-1.5 text-sm text-[var(--text)] hover:bg-[var(--border)]/5 ${active ? 'bg-[var(--border)]/5' : ''}`;
|
||||
return `flex w-full items-center gap-2 rounded px-3 py-1.5 text-sm text-[var(--text)] hover:bg-[var(--hover-wash)]/5 ${active ? 'bg-[var(--hover-wash)]/5' : ''}`;
|
||||
}
|
||||
|
||||
function handlePointerDown(event: PointerEvent) {
|
||||
@@ -112,17 +185,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 +219,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 +233,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 +242,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 +251,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 +260,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 +272,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 +283,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 +297,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 +324,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 +346,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 +361,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 +370,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 +379,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 +388,260 @@
|
||||
<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}
|
||||
oninput={handleLinkInput}
|
||||
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(--hover-wash)]/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(--hover-wash)]/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(--hover-wash)]/5 hover:text-[var(--destructive)] disabled:opacity-30"
|
||||
title="Remove link"
|
||||
data-testid="format-link-remove"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</form>
|
||||
{#if noteSearchResults.length > 0}
|
||||
<div class="mt-1 max-h-40 overflow-y-auto border-t border-[var(--border-subtle)] pt-1">
|
||||
{#each noteSearchResults as result (result.id)}
|
||||
<button
|
||||
type="button"
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={() => insertNoteLink(result.id)}
|
||||
class="block w-full truncate rounded-sm px-1.5 py-1 text-left text-sm text-[var(--text)] hover:bg-[var(--hover-wash)]/10"
|
||||
data-testid="format-link-note-result"
|
||||
>
|
||||
{result.title || 'Untitled'}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</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>
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
{:else}
|
||||
<button
|
||||
onclick={onMenuToggle}
|
||||
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
|
||||
class="rounded-sm p-2 hover:bg-[var(--hover-wash)]/10"
|
||||
aria-label="Toggle sidebar"
|
||||
>
|
||||
<Menu class="h-6 w-6 text-[var(--text)]" />
|
||||
@@ -40,7 +40,7 @@
|
||||
<!-- Mobile search icon -->
|
||||
<button
|
||||
onclick={() => (mobileSearchOpen = true)}
|
||||
class="ml-auto rounded-sm p-2 hover:bg-[var(--border)]/10 lg:hidden"
|
||||
class="ml-auto rounded-sm p-2 hover:bg-[var(--hover-wash)]/10 lg:hidden"
|
||||
aria-label="Search"
|
||||
>
|
||||
<Search class="h-5 w-5 text-[var(--text)]" />
|
||||
|
||||
@@ -118,7 +118,7 @@
|
||||
{#if note.pinned}
|
||||
<button
|
||||
onclick={stop(() => togglePin(note.id, note.pinned))}
|
||||
class="rounded-sm p-1 text-[var(--primary)] hover:bg-[var(--border)]/10"
|
||||
class="rounded-sm p-1 text-[var(--primary)] hover:bg-[var(--hover-wash)]/10"
|
||||
use:tooltip={"Unpin"}
|
||||
data-testid="pin-indicator"
|
||||
>
|
||||
@@ -164,7 +164,7 @@
|
||||
{#if $currentFilter === 'trashed'}
|
||||
<button
|
||||
onclick={stop(() => restoreNote(note.id))}
|
||||
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
|
||||
class="rounded-sm p-1.5 hover:bg-[var(--hover-wash)]/10"
|
||||
use:tooltip={"Restore"}
|
||||
data-testid="restore-btn"
|
||||
>
|
||||
@@ -172,7 +172,7 @@
|
||||
</button>
|
||||
<button
|
||||
onclick={stop(() => deleteNote(note.id))}
|
||||
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
|
||||
class="rounded-sm p-1.5 hover:bg-[var(--hover-wash)]/10"
|
||||
use:tooltip={"Delete forever"}
|
||||
data-testid="delete-forever-btn"
|
||||
>
|
||||
@@ -182,7 +182,7 @@
|
||||
{#if !note.pinned}
|
||||
<button
|
||||
onclick={stop(() => togglePin(note.id, note.pinned))}
|
||||
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
|
||||
class="rounded-sm p-1.5 hover:bg-[var(--hover-wash)]/10"
|
||||
use:tooltip={"Pin"}
|
||||
data-testid="pin-btn"
|
||||
>
|
||||
@@ -192,7 +192,7 @@
|
||||
{#if $currentFilter === 'archived'}
|
||||
<button
|
||||
onclick={stop(() => unarchiveNote(note.id))}
|
||||
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
|
||||
class="rounded-sm p-1.5 hover:bg-[var(--hover-wash)]/10"
|
||||
use:tooltip={"Unarchive"}
|
||||
data-testid="unarchive-btn"
|
||||
>
|
||||
@@ -201,7 +201,7 @@
|
||||
{:else}
|
||||
<button
|
||||
onclick={stop(() => archiveNote(note.id))}
|
||||
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
|
||||
class="rounded-sm p-1.5 hover:bg-[var(--hover-wash)]/10"
|
||||
use:tooltip={"Archive"}
|
||||
data-testid="archive-btn"
|
||||
>
|
||||
@@ -211,7 +211,7 @@
|
||||
{#if note.isShared && !note.isOwner}
|
||||
<button
|
||||
onclick={stop(() => leaveNote(note.id))}
|
||||
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
|
||||
class="rounded-sm p-1.5 hover:bg-[var(--hover-wash)]/10"
|
||||
use:tooltip={"Leave note"}
|
||||
data-testid="leave-btn"
|
||||
>
|
||||
@@ -220,7 +220,7 @@
|
||||
{:else}
|
||||
<button
|
||||
onclick={stop(() => trashNote(note.id))}
|
||||
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
|
||||
class="rounded-sm p-1.5 hover:bg-[var(--hover-wash)]/10"
|
||||
use:tooltip={"Trash"}
|
||||
data-testid="trash-btn"
|
||||
>
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<script module lang="ts">
|
||||
let bodyScrollLockCount = 0;
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { pushState } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import ColorPicker from './ColorPicker.svelte';
|
||||
import Checklist from './Checklist.svelte';
|
||||
import FormattingToolbar from './FormattingToolbar.svelte';
|
||||
@@ -13,7 +19,7 @@
|
||||
import { getIsDarkMode } from '$lib/utils/theme.svelte.js';
|
||||
import { getPreferences } from '$lib/stores/preferences.svelte.js';
|
||||
import type { Editor } from '@tiptap/core';
|
||||
import type { Note, NoteColor, Attachment, Collaborator } from '$lib/types/index.js';
|
||||
import type { Note, NoteColor, Attachment, Collaborator, NoteBacklink } from '$lib/types/index.js';
|
||||
import Palette from 'lucide-svelte/icons/palette';
|
||||
import SquareCheck from 'lucide-svelte/icons/square-check';
|
||||
import ImageIcon from 'lucide-svelte/icons/image';
|
||||
@@ -28,6 +34,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';
|
||||
|
||||
@@ -36,9 +43,10 @@
|
||||
isNew?: boolean;
|
||||
initialChecklistMode?: boolean;
|
||||
onClose: () => void;
|
||||
onOpenNote: (noteId: string) => void;
|
||||
}
|
||||
|
||||
const { note, isNew = false, initialChecklistMode = false, onClose }: Props = $props();
|
||||
const { note, isNew = false, initialChecklistMode = false, onClose, onOpenNote }: Props = $props();
|
||||
const prefs = getPreferences();
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
@@ -157,22 +165,85 @@
|
||||
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
});
|
||||
|
||||
// Safety net for note-link/backlink navigation: NotesView.svelte remounts this
|
||||
// component (rather than reusing the instance) whenever the user switches to a
|
||||
// different note while the editor is open. That teardown skips the debounced
|
||||
// autosave timer's cleanup (it only clears the timer, it doesn't flush) and
|
||||
// beforeunload (which never fires for an in-SPA unmount), so a very recent edit
|
||||
// could otherwise be silently dropped.
|
||||
onDestroy(() => {
|
||||
if (hasUnsavedChanges() && (title.trim() || content.trim())) {
|
||||
performSave();
|
||||
}
|
||||
});
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
let attachmentsList = $state<Attachment[]>(note?.attachments ?? []);
|
||||
// svelte-ignore state_referenced_locally
|
||||
let backlinksList = $state<NoteBacklink[]>(note?.backlinks ?? []);
|
||||
|
||||
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 +251,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
|
||||
@@ -269,15 +340,45 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch attachments for existing notes if not pre-populated (e.g. loaded from IDB)
|
||||
// Fetch attachments for existing notes if not pre-populated (e.g. loaded from IDB).
|
||||
// Guarded against post-teardown writes: since NotesView.svelte now remounts this
|
||||
// component on every note-switch (rather than reusing the instance), a fetch that
|
||||
// resolves after teardown would otherwise write to $state on an already-destroyed
|
||||
// instance racing the newly-mounted one.
|
||||
$effect(() => {
|
||||
if (noteId && !currentlyNew && (!note?.attachments || note.attachments.length === 0)) {
|
||||
let destroyed = false;
|
||||
fetch(`/api/notes/${noteId}/attachments`)
|
||||
.then((res) => res.ok ? res.json() : [])
|
||||
.then((data: Attachment[]) => {
|
||||
if (destroyed) return;
|
||||
if (data.length > 0) attachmentsList = data;
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
destroyed = true;
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Fetch backlinks for existing notes if not pre-populated (e.g. loaded from IDB) —
|
||||
// note.backlinks is only ever attached server-side by getNote(), which nothing
|
||||
// else in the client calls when opening a note (the bulk notes store and
|
||||
// IndexedDB never carry it), so this mirrors the attachments effect above to
|
||||
// fill it in on open. Same post-teardown guard as above.
|
||||
$effect(() => {
|
||||
if (noteId && !currentlyNew && (!note?.backlinks || note.backlinks.length === 0)) {
|
||||
let destroyed = false;
|
||||
fetch(`/api/notes/${noteId}`)
|
||||
.then((res) => res.ok ? res.json() : null)
|
||||
.then((data: Note | null) => {
|
||||
if (destroyed) return;
|
||||
if (data?.backlinks && data.backlinks.length > 0) backlinksList = data.backlinks;
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
destroyed = true;
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
@@ -350,8 +451,34 @@
|
||||
pointerdownOnOverlay = false;
|
||||
}
|
||||
|
||||
// Close on browser back — Android back button / iOS back swipe. The editor is
|
||||
// fullscreen on mobile with no backdrop to tap, so back must dismiss it instead of
|
||||
// leaving the page. On mount, push a shallow-routing history entry; navigating back
|
||||
// pops it and closes the editor. UI closes pop the entry themselves via
|
||||
// history.back() so no stale entry is left behind.
|
||||
let historyEntryPushed = false;
|
||||
|
||||
onMount(() => {
|
||||
// Reuse an existing entry (e.g. back-navigating to a note restored from the
|
||||
// URL hash) instead of stacking a second one.
|
||||
if (!page.state.noteEditorOpen) {
|
||||
pushState('', { noteEditorOpen: true });
|
||||
}
|
||||
historyEntryPushed = true;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (page.state.noteEditorOpen || !historyEntryPushed || closing) return;
|
||||
historyEntryPushed = false;
|
||||
saveAndClose();
|
||||
});
|
||||
|
||||
function dismissOverlay() {
|
||||
closing = true;
|
||||
if (historyEntryPushed) {
|
||||
historyEntryPushed = false;
|
||||
history.back();
|
||||
}
|
||||
setTimeout(() => onClose(), 400);
|
||||
}
|
||||
|
||||
@@ -440,7 +567,8 @@
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-40 {closing ? '' : '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}
|
||||
@@ -449,29 +577,52 @@
|
||||
{#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(--hover-wash)]/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(--hover-wash)]/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)} />
|
||||
@@ -481,7 +632,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>
|
||||
@@ -491,6 +642,7 @@
|
||||
onUpdate={(md) => (content = md)}
|
||||
onEditor={(e) => (tiptapEditor = e)}
|
||||
onTransaction={() => editorTick++}
|
||||
{onOpenNote}
|
||||
placeholder="Add a crumb..."
|
||||
/>
|
||||
{/if}
|
||||
@@ -498,7 +650,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}
|
||||
@@ -510,16 +662,35 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Formatting toolbar -->
|
||||
{#if !rawMarkdownMode && !checklistMode}
|
||||
<div style={toolbarInteractive ? '' : 'pointer-events: none'}>
|
||||
<FormattingToolbar editor={tiptapEditor} tick={editorTick} />
|
||||
<!-- Backlinks -->
|
||||
{#if backlinksList.length > 0}
|
||||
<div class="border-t border-[var(--border-subtle)] px-4 py-2 shrink-0">
|
||||
<p class="mb-1 text-xs font-medium text-[var(--text-muted)]">Referenced by</p>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each backlinksList as backlink (backlink.id)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onOpenNote(backlink.id)}
|
||||
class="rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] px-2 py-0.5 text-xs text-[var(--text)] hover:border-[var(--primary)]"
|
||||
data-testid="backlink-chip"
|
||||
>
|
||||
{backlink.title || 'Untitled'}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Toolbar -->
|
||||
<!-- Formatting toolbar -->
|
||||
{#if !rawMarkdownMode && !checklistMode}
|
||||
<div class="shrink-0 touch-pan-x" style={toolbarInteractive ? '' : 'pointer-events: none'}>
|
||||
<FormattingToolbar editor={tiptapEditor} tick={editorTick} currentNoteId={noteId} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- 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">
|
||||
@@ -527,7 +698,7 @@
|
||||
<div class="relative">
|
||||
<button
|
||||
onclick={() => (showColorPicker = !showColorPicker)}
|
||||
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
|
||||
class="rounded-sm p-2 hover:bg-[var(--hover-wash)]/10"
|
||||
use:tooltip={"Background color"}
|
||||
data-testid="color-picker-toggle"
|
||||
>
|
||||
@@ -543,7 +714,7 @@
|
||||
<!-- Image attachment toggle -->
|
||||
<button
|
||||
onclick={toggleImageUpload}
|
||||
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
|
||||
class="rounded-sm p-2 hover:bg-[var(--hover-wash)]/10"
|
||||
use:tooltip={"Attachments"}
|
||||
data-testid="image-toggle"
|
||||
>
|
||||
@@ -554,7 +725,7 @@
|
||||
{#if isOwner}
|
||||
<button
|
||||
onclick={toggleShareDialog}
|
||||
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
|
||||
class="rounded-sm p-2 hover:bg-[var(--hover-wash)]/10"
|
||||
use:tooltip={"Share"}
|
||||
data-testid="share-toggle"
|
||||
>
|
||||
@@ -576,7 +747,7 @@
|
||||
{#if !currentlyNew}
|
||||
<button
|
||||
onclick={handleArchive}
|
||||
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
|
||||
class="rounded-sm p-2 hover:bg-[var(--hover-wash)]/10"
|
||||
use:tooltip={"Archive"}
|
||||
data-testid="archive-note-btn"
|
||||
>
|
||||
@@ -586,9 +757,9 @@
|
||||
|
||||
<!-- Overflow menu trigger -->
|
||||
<button
|
||||
bind:this={overflowBtnEl}
|
||||
onclick={() => (showOverflowMenu = !showOverflowMenu)}
|
||||
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
|
||||
bind:this={desktopOverflowBtnEl}
|
||||
onclick={() => { showOverflowMenu = !showOverflowMenu; overflowAnchorEl = desktopOverflowBtnEl; }}
|
||||
class="rounded-sm p-2 hover:bg-[var(--hover-wash)]/10"
|
||||
use:tooltip={"More"}
|
||||
data-testid="overflow-menu-btn"
|
||||
>
|
||||
@@ -598,7 +769,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(--hover-wash)]/10"
|
||||
data-testid="close-editor-btn"
|
||||
>
|
||||
Close
|
||||
@@ -607,18 +778,71 @@
|
||||
</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(--hover-wash)]/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(--hover-wash)]/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(--hover-wash)]/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; }}
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--border)]/10"
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--hover-wash)]/10"
|
||||
data-testid="checklist-toggle"
|
||||
>
|
||||
{#if checklistMode}
|
||||
@@ -634,7 +858,7 @@
|
||||
{#if !checklistMode}
|
||||
<button
|
||||
onclick={() => { toggleMarkdownMode(); 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"
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--hover-wash)]/10"
|
||||
data-testid="markdown-toggle"
|
||||
>
|
||||
{#if rawMarkdownMode}
|
||||
@@ -651,7 +875,7 @@
|
||||
{#if !currentlyNew}
|
||||
<button
|
||||
onclick={() => { toggleHistory(); 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"
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--hover-wash)]/10"
|
||||
data-testid="history-toggle"
|
||||
>
|
||||
<History class="h-4 w-4 {showHistory ? 'text-[var(--primary)]' : ''}" />
|
||||
@@ -664,7 +888,7 @@
|
||||
<div class="my-1 border-t border-[var(--border-subtle)]"></div>
|
||||
<button
|
||||
onclick={handleTrash}
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--destructive)] hover:bg-[var(--border)]/10"
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--destructive)] hover:bg-[var(--hover-wash)]/10"
|
||||
data-testid="trash-note-btn"
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
@@ -678,7 +902,7 @@
|
||||
{#if hasDoneItems}
|
||||
<button
|
||||
onclick={deleteCheckedItems}
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--border)]/10"
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--hover-wash)]/10"
|
||||
data-testid="delete-checked-btn"
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
@@ -687,7 +911,7 @@
|
||||
{/if}
|
||||
<button
|
||||
onclick={uncheckAllItems}
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--border)]/10"
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--hover-wash)]/10"
|
||||
data-testid="uncheck-all-btn"
|
||||
>
|
||||
<ListX class="h-4 w-4" />
|
||||
|
||||
@@ -116,7 +116,7 @@
|
||||
</div>
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="rounded-sm p-1 hover:bg-[var(--border)]/10"
|
||||
class="rounded-sm p-1 hover:bg-[var(--hover-wash)]/10"
|
||||
title="Close history"
|
||||
data-testid="close-history-btn"
|
||||
>
|
||||
@@ -140,7 +140,7 @@
|
||||
{#each versions as v (v.id)}
|
||||
<li>
|
||||
<button
|
||||
class="w-full px-3 py-3 text-left hover:bg-[var(--border)]/10 transition-colors {selectedVersion?.id === v.id ? 'bg-[var(--primary)]/10 border-l-2 border-[var(--primary)]' : ''}"
|
||||
class="w-full px-3 py-3 text-left hover:bg-[var(--hover-wash)]/10 transition-colors {selectedVersion?.id === v.id ? 'bg-[var(--primary)]/10 border-l-2 border-[var(--primary)]' : ''}"
|
||||
onclick={() => selectVersion(v.id)}
|
||||
data-testid="version-item"
|
||||
>
|
||||
@@ -195,7 +195,7 @@
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (showConfirm = false)}
|
||||
class="rounded-sm border border-[var(--border-subtle)] px-3 py-1.5 text-xs font-medium text-[var(--text)] hover:bg-[var(--border)]/10"
|
||||
class="rounded-sm border border-[var(--border-subtle)] px-3 py-1.5 text-xs font-medium text-[var(--text)] hover:bg-[var(--hover-wash)]/10"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
@@ -4,10 +4,12 @@
|
||||
import TagFilter from '$lib/components/TagFilter.svelte';
|
||||
import { pinnedNotes, unpinnedNotes, selectedTag, currentFilter, notes, notesLoaded, loadNotes, updateSortOrders } from '$lib/stores/notes.js';
|
||||
import { onMount } from 'svelte';
|
||||
import { replaceState } from '$app/navigation';
|
||||
import type { Note, NoteFilter } from '$lib/types/index.js';
|
||||
import Plus from 'lucide-svelte/icons/plus';
|
||||
import SquareCheck from 'lucide-svelte/icons/square-check';
|
||||
import { tooltip } from '$lib/utils/tooltip.js';
|
||||
import { getNote as getIdbNote } from '$lib/sync/idb.js';
|
||||
|
||||
interface Props {
|
||||
filter: NoteFilter;
|
||||
@@ -27,16 +29,33 @@
|
||||
loadNotes(filter);
|
||||
});
|
||||
|
||||
// Use SvelteKit's replaceState (not the raw history API) so the router keeps
|
||||
// ownership of the entry — NoteEditor's close-on-back relies on popstate being
|
||||
// handled by SvelteKit.
|
||||
function openEditor(note: Note) {
|
||||
editingNote = note;
|
||||
history.replaceState(null, '', `#${note.id}`);
|
||||
replaceState(`#${note.id}`, {});
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
editingNote = null;
|
||||
showNewNote = false;
|
||||
newNoteChecklist = false;
|
||||
history.replaceState(null, '', location.pathname);
|
||||
replaceState(location.pathname, {});
|
||||
}
|
||||
|
||||
// Reads from the local IndexedDB cache rather than fetching over the network —
|
||||
// this keeps note-link navigation instant and offline-safe (matching how the
|
||||
// rest of the app opens notes), and backlinks are filled in progressively by
|
||||
// NoteEditor's own fetch-if-not-pre-populated effect once the editor is open,
|
||||
// the same pattern already used there for attachments.
|
||||
let openNoteRequestId = 0;
|
||||
|
||||
async function openNoteById(noteId: string) {
|
||||
const requestId = ++openNoteRequestId;
|
||||
const note = await getIdbNote(noteId);
|
||||
if (requestId !== openNoteRequestId) return; // a newer navigation superseded this one
|
||||
if (note) openEditor(note);
|
||||
}
|
||||
|
||||
function handleReorder(noteIds: string[]) {
|
||||
@@ -133,9 +152,17 @@
|
||||
{/if}
|
||||
|
||||
{#if showNewNote}
|
||||
<NoteEditor note={null} isNew={true} initialChecklistMode={newNoteChecklist} onClose={closeEditor} />
|
||||
<NoteEditor note={null} isNew={true} initialChecklistMode={newNoteChecklist} onClose={closeEditor} onOpenNote={openNoteById} />
|
||||
{/if}
|
||||
|
||||
{#if editingNote}
|
||||
<NoteEditor note={editingNote} onClose={closeEditor} />
|
||||
<!-- Forces a full destroy+remount on note-switch (e.g. clicking a note-link
|
||||
while the editor is already open) instead of reusing the same instance
|
||||
with a new `note` prop — a persisting instance's own back-navigation
|
||||
effect misreads `openEditor`'s replaceState call as "browser back was
|
||||
pressed" and closes the editor, and its once-only local state never
|
||||
resyncs to the new note otherwise. -->
|
||||
{#key editingNote.id}
|
||||
<NoteEditor note={editingNote} onClose={closeEditor} onOpenNote={openNoteById} />
|
||||
{/key}
|
||||
{/if}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { notes, loadNotes, currentFilter } from '$lib/stores/notes.js';
|
||||
import { searchQuery, searchResults } from '$lib/stores/notes.js';
|
||||
import type { Note } from '$lib/types/index.js';
|
||||
import Search from 'lucide-svelte/icons/search';
|
||||
import X from 'lucide-svelte/icons/x';
|
||||
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
|
||||
import { onDestroy } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
onClose?: () => void;
|
||||
@@ -12,41 +13,64 @@
|
||||
let { onClose }: Props = $props();
|
||||
|
||||
let query = $state('');
|
||||
let originalNotes: Note[] = [];
|
||||
let isSearching = $state(false);
|
||||
let inputEl: HTMLInputElement | undefined = $state();
|
||||
let searchController: AbortController | undefined;
|
||||
|
||||
$effect(() => {
|
||||
if (onClose) inputEl?.focus();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
searchController?.abort();
|
||||
searchQuery.set('');
|
||||
searchResults.set([]);
|
||||
});
|
||||
|
||||
async function handleSearch() {
|
||||
if (!query.trim()) {
|
||||
if (isSearching) {
|
||||
notes.set(originalNotes);
|
||||
isSearching = false;
|
||||
}
|
||||
searchController?.abort();
|
||||
searchController = undefined;
|
||||
|
||||
const trimmedQuery = query.trim();
|
||||
|
||||
if (!trimmedQuery) {
|
||||
searchQuery.set('');
|
||||
searchResults.set([]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isSearching) {
|
||||
notes.subscribe((n) => (originalNotes = n))();
|
||||
isSearching = true;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
searchController = controller;
|
||||
|
||||
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
|
||||
if (res.ok) {
|
||||
const results = await res.json();
|
||||
notes.set(results);
|
||||
try {
|
||||
const res = await fetch(`/api/search?q=${encodeURIComponent(trimmedQuery)}`, {
|
||||
signal: controller.signal
|
||||
});
|
||||
if (res.ok) {
|
||||
const results: Note[] = await res.json();
|
||||
if (searchController !== controller || query.trim() !== trimmedQuery) return;
|
||||
// Commit the query alongside its results so the view switches to the
|
||||
// (possibly empty) result set only once it's ready — this avoids
|
||||
// flashing an empty state over the current notes while the request is
|
||||
// in flight, and keeps searchQuery consistent with searchResults on
|
||||
// a failed request (neither updates).
|
||||
searchResults.set(results);
|
||||
searchQuery.set(trimmedQuery);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!(error instanceof DOMException && error.name === 'AbortError')) {
|
||||
console.error('Search failed:', error);
|
||||
}
|
||||
} finally {
|
||||
if (searchController === controller) searchController = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function clearSearch() {
|
||||
searchController?.abort();
|
||||
searchController = undefined;
|
||||
query = '';
|
||||
if (isSearching) {
|
||||
notes.set(originalNotes);
|
||||
isSearching = false;
|
||||
}
|
||||
searchQuery.set('');
|
||||
searchResults.set([]);
|
||||
}
|
||||
|
||||
function close() {
|
||||
|
||||
@@ -168,7 +168,7 @@
|
||||
<h2 class="text-sm font-semibold text-[var(--text)]">Share note</h2>
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="rounded-sm p-1 hover:bg-[var(--border)]/10"
|
||||
class="rounded-sm p-1 hover:bg-[var(--hover-wash)]/10"
|
||||
title="Close"
|
||||
>
|
||||
<X class="h-4 w-4" />
|
||||
@@ -277,7 +277,7 @@
|
||||
</li>
|
||||
<!-- Collaborators -->
|
||||
{#each collaborators as collab}
|
||||
<li class="flex items-center justify-between rounded-sm px-2 py-1.5 hover:bg-[var(--border)]/10" data-testid="share-collaborator">
|
||||
<li class="flex items-center justify-between rounded-sm px-2 py-1.5 hover:bg-[var(--hover-wash)]/10" data-testid="share-collaborator">
|
||||
<span class="text-sm text-[var(--text)]">
|
||||
{collab.displayName || collab.email}
|
||||
</span>
|
||||
|
||||
@@ -13,58 +13,134 @@
|
||||
import TaskList from '@tiptap/extension-task-list';
|
||||
import TaskItem from '@tiptap/extension-task-item';
|
||||
import { Markdown } from 'tiptap-markdown';
|
||||
import { NoteLink } from './tiptap/NoteLink.js';
|
||||
import { getAllNotes } from '$lib/sync/idb.js';
|
||||
|
||||
interface Props {
|
||||
content: string;
|
||||
onUpdate: (markdown: string) => void;
|
||||
onEditor?: (editor: Editor) => void;
|
||||
onTransaction?: () => void;
|
||||
onOpenNote?: (noteId: string) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
let { content, onUpdate, onEditor, onTransaction, placeholder = 'Add a crumb...' }: Props = $props();
|
||||
let { content, onUpdate, onEditor, onTransaction, onOpenNote, placeholder = 'Add a crumb...' }: Props = $props();
|
||||
|
||||
let element: HTMLDivElement | undefined = $state();
|
||||
let editor: Editor | undefined = $state();
|
||||
|
||||
onMount(() => {
|
||||
editor = new Editor({
|
||||
element: element!,
|
||||
extensions: [
|
||||
StarterKit,
|
||||
Link.configure({ openOnClick: false }),
|
||||
Underline,
|
||||
TextAlign.configure({ types: ['heading', 'paragraph'] }),
|
||||
Placeholder.configure({ placeholder }),
|
||||
Table.configure({ resizable: false }),
|
||||
TableRow,
|
||||
TableHeader,
|
||||
TableCell,
|
||||
TaskList,
|
||||
TaskItem.configure({ nested: true }),
|
||||
Markdown
|
||||
],
|
||||
content,
|
||||
onUpdate: ({ editor: e }) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
onUpdate((e.storage as Record<string, any>).markdown.getMarkdown());
|
||||
},
|
||||
onTransaction: () => {
|
||||
// Trigger Svelte reactivity for active state checks
|
||||
editor = editor;
|
||||
onTransaction?.();
|
||||
}
|
||||
});
|
||||
const titleIndexRef = { index: new Map<string, string>() };
|
||||
|
||||
// Expose editor on DOM element for e2e testing
|
||||
if (element) {
|
||||
(element as any).__tiptapEditor = editor;
|
||||
function isTaskCheckboxTarget(target: EventTarget | null): boolean {
|
||||
if (target instanceof Element) {
|
||||
const checkbox = target.matches('input[type="checkbox"]')
|
||||
? target
|
||||
: target.closest('label')?.querySelector('input[type="checkbox"]');
|
||||
return checkbox !== null && checkbox !== undefined;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isTouchDevice(): boolean {
|
||||
return window.matchMedia('(pointer: coarse)').matches || navigator.maxTouchPoints > 0;
|
||||
}
|
||||
|
||||
function handleWrapperPointerDown(event: PointerEvent) {
|
||||
if (event.pointerType === 'touch' && isTaskCheckboxTarget(event.target)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
function handleWrapperClick(event: MouseEvent) {
|
||||
if (isTaskCheckboxTarget(event.target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
onEditor?.(editor);
|
||||
editor?.commands.focus();
|
||||
}
|
||||
|
||||
function handleWrapperChangeCapture(event: Event) {
|
||||
if (!isTouchDevice() || !(event.target instanceof HTMLInputElement)) return;
|
||||
if (event.target.type !== 'checkbox' || !editor) return;
|
||||
|
||||
const taskItem = event.target.closest('li[data-checked]');
|
||||
if (!taskItem) return;
|
||||
|
||||
const taskItemPosition = editor.view.posAtDOM(taskItem, 0) - 1;
|
||||
const taskItemNode = editor.state.doc.nodeAt(taskItemPosition);
|
||||
if (taskItemNode?.type.name !== 'taskItem') return;
|
||||
|
||||
event.stopPropagation();
|
||||
editor.view.dispatch(
|
||||
editor.state.tr.setNodeMarkup(taskItemPosition, undefined, {
|
||||
...taskItemNode.attrs,
|
||||
checked: event.target.checked
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
let destroyed = false;
|
||||
let editorInstance: Editor | undefined;
|
||||
|
||||
(async () => {
|
||||
const allNotes = await getAllNotes();
|
||||
if (destroyed) return;
|
||||
titleIndexRef.index = new Map(allNotes.filter((n) => !n.trashed).map((n) => [n.id, n.title]));
|
||||
|
||||
editorInstance = new Editor({
|
||||
element: element!,
|
||||
extensions: [
|
||||
StarterKit.configure({ link: false, underline: false }),
|
||||
Link.configure({ openOnClick: false }),
|
||||
Underline,
|
||||
TextAlign.configure({ types: ['heading', 'paragraph'] }),
|
||||
Placeholder.configure({ placeholder }),
|
||||
Table.configure({ resizable: false }),
|
||||
TableRow,
|
||||
TableHeader,
|
||||
TableCell,
|
||||
TaskList,
|
||||
TaskItem.configure({ nested: true }),
|
||||
NoteLink.configure({ titleIndexRef, onOpenNote: onOpenNote ?? (() => {}) }),
|
||||
Markdown
|
||||
],
|
||||
content,
|
||||
onUpdate: ({ editor: e }) => {
|
||||
if (destroyed) return;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
onUpdate((e.storage as Record<string, any>).markdown.getMarkdown());
|
||||
},
|
||||
onTransaction: () => {
|
||||
// Removing this component's DOM node (e.g. a {#key}-driven remount
|
||||
// in NotesView.svelte when switching to a different note) fires a
|
||||
// native blur on the still-focused contenteditable, which
|
||||
// ProseMirror's blur plugin turns into one last transaction —
|
||||
// synchronously, from inside Svelte's own render/effect pass that's
|
||||
// doing the removal. Mutating $state right there crashes with
|
||||
// state_unsafe_mutation; the `destroyed` check alone isn't enough
|
||||
// since this can fire before onMount's own cleanup runs. Defer to a
|
||||
// microtask so the write lands after Svelte's current pass finishes.
|
||||
queueMicrotask(() => {
|
||||
if (destroyed) return;
|
||||
editor = editorInstance;
|
||||
onTransaction?.();
|
||||
});
|
||||
}
|
||||
});
|
||||
editor = editorInstance;
|
||||
|
||||
if (element && !destroyed) {
|
||||
(element as any).__tiptapEditor = editorInstance;
|
||||
}
|
||||
|
||||
onEditor?.(editorInstance);
|
||||
})();
|
||||
|
||||
return () => {
|
||||
editor?.destroy();
|
||||
destroyed = true;
|
||||
editorInstance?.destroy();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
@@ -72,12 +148,14 @@
|
||||
<!-- 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"
|
||||
tabindex="0"
|
||||
onclick={() => editor?.commands.focus()}
|
||||
onpointerdown={handleWrapperPointerDown}
|
||||
onclick={handleWrapperClick}
|
||||
onchangecapture={handleWrapperChangeCapture}
|
||||
></div>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { Node, mergeAttributes } from '@tiptap/core';
|
||||
import { parseNoteLinkHref, serializeNoteLinkMarkdown } from './note-link-markdown.js';
|
||||
|
||||
export interface NoteLinkOptions {
|
||||
titleIndexRef: { index: Map<string, string> };
|
||||
onOpenNote: (noteId: string) => void;
|
||||
}
|
||||
|
||||
export const NoteLink = Node.create<NoteLinkOptions>({
|
||||
name: 'noteLink',
|
||||
group: 'inline',
|
||||
inline: true,
|
||||
atom: true,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
titleIndexRef: { index: new Map<string, string>() },
|
||||
onOpenNote: () => {}
|
||||
};
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
noteId: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute('data-note-id'),
|
||||
renderHTML: (attributes) => ({ 'data-note-id': attributes.noteId })
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'a[href^="crumb-note://"]',
|
||||
priority: 100,
|
||||
getAttrs: (element) => {
|
||||
const href = (element as HTMLElement).getAttribute('href') ?? '';
|
||||
const noteId = parseNoteLinkHref(href);
|
||||
return noteId ? { noteId } : false;
|
||||
}
|
||||
}
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ['a', mergeAttributes(HTMLAttributes, { href: `crumb-note://${HTMLAttributes['data-note-id']}` })];
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
markdown: {
|
||||
serialize: (state: { write: (s: string) => void }, node: { attrs: { noteId: string } }) => {
|
||||
state.write(serializeNoteLinkMarkdown(node.attrs.noteId, this.options.titleIndexRef.index));
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ({ node, editor, getPos }) => {
|
||||
const noteId = node.attrs.noteId as string;
|
||||
const title = this.options.titleIndexRef.index.get(noteId);
|
||||
|
||||
const dom = document.createElement('span');
|
||||
dom.setAttribute('data-note-id', noteId);
|
||||
dom.className =
|
||||
'group relative inline-flex items-center gap-1 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] px-1.5 py-0.5 text-sm';
|
||||
|
||||
const label = document.createElement('span');
|
||||
if (title !== undefined) {
|
||||
label.textContent = title;
|
||||
label.className = 'cursor-pointer text-[var(--primary)] hover:text-[var(--primary-hover)]';
|
||||
label.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
this.options.onOpenNote(noteId);
|
||||
});
|
||||
} else {
|
||||
label.textContent = 'Note not found';
|
||||
label.className = 'text-[var(--text-muted)] opacity-60';
|
||||
}
|
||||
dom.appendChild(label);
|
||||
|
||||
const removeButton = document.createElement('button');
|
||||
removeButton.type = 'button';
|
||||
removeButton.textContent = '×';
|
||||
removeButton.setAttribute('aria-label', 'Remove note link');
|
||||
removeButton.className =
|
||||
'max-md:opacity-100 md:opacity-0 transition-opacity md:group-hover:opacity-100 text-[var(--text-muted)] hover:text-[var(--destructive)]';
|
||||
removeButton.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
const pos = getPos();
|
||||
if (pos === undefined) return;
|
||||
editor.chain().focus().deleteRange({ from: pos, to: pos + node.nodeSize }).run();
|
||||
});
|
||||
dom.appendChild(removeButton);
|
||||
|
||||
return { dom };
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseNoteLinkHref, serializeNoteLinkMarkdown } from './note-link-markdown.js';
|
||||
|
||||
describe('parseNoteLinkHref', () => {
|
||||
it('should extract the note ID from a crumb-note:// href', () => {
|
||||
expect(parseNoteLinkHref('crumb-note://abc-123')).toBe('abc-123');
|
||||
});
|
||||
|
||||
it('should return null for a non-crumb-note href', () => {
|
||||
expect(parseNoteLinkHref('https://example.com')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for an empty string', () => {
|
||||
expect(parseNoteLinkHref('')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('serializeNoteLinkMarkdown', () => {
|
||||
it('should produce a markdown link with the title from the index', () => {
|
||||
const titleIndex = new Map([['abc-123', 'Grocery List']]);
|
||||
expect(serializeNoteLinkMarkdown('abc-123', titleIndex)).toBe('[Grocery List](crumb-note://abc-123)');
|
||||
});
|
||||
|
||||
it('should fall back to Untitled when the ID is not in the index', () => {
|
||||
const titleIndex = new Map<string, string>();
|
||||
expect(serializeNoteLinkMarkdown('missing-id', titleIndex)).toBe('[Untitled](crumb-note://missing-id)');
|
||||
});
|
||||
|
||||
it('should escape markdown-special characters in the title', () => {
|
||||
const titleIndex = new Map([['id-1', 'Buy [milk] today']]);
|
||||
expect(serializeNoteLinkMarkdown('id-1', titleIndex)).toBe('[Buy \\[milk\\] today](crumb-note://id-1)');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
const NOTE_LINK_SCHEME = 'crumb-note://';
|
||||
|
||||
/** Extracts the note ID from a crumb-note:// href, or null if it's not that scheme. */
|
||||
export function parseNoteLinkHref(href: string): string | null {
|
||||
if (!href.startsWith(NOTE_LINK_SCHEME)) return null;
|
||||
return href.slice(NOTE_LINK_SCHEME.length) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escapes markdown-special characters the same way a plain link's visible
|
||||
* text is escaped, so a title containing `[`, `]`, `*`, etc. can't corrupt
|
||||
* the surrounding markdown syntax.
|
||||
*/
|
||||
function escapeMarkdownText(text: string): string {
|
||||
return text.replace(/[[\]\\*_`]/g, '\\$&');
|
||||
}
|
||||
|
||||
/** Produces the `[title](crumb-note://id)` markdown representation of a note-link. */
|
||||
export function serializeNoteLinkMarkdown(noteId: string, titleIndex: Map<string, string>): string {
|
||||
const title = titleIndex.get(noteId) ?? 'Untitled';
|
||||
return `[${escapeMarkdownText(title)}](${NOTE_LINK_SCHEME}${noteId})`;
|
||||
}
|
||||
@@ -226,6 +226,13 @@ sqlite.exec(`
|
||||
created_at INTEGER NOT NULL,
|
||||
last_used_at INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS note_links (
|
||||
source_note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
|
||||
target_note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS note_links_source_idx ON note_links(source_note_id);
|
||||
CREATE INDEX IF NOT EXISTS note_links_target_idx ON note_links(target_note_id);
|
||||
`);
|
||||
|
||||
// Replace old tags unique index with compound index including user_id
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { createTestDb } from './test-helpers.js';
|
||||
import { notes, noteLinks, users } from './schema.js';
|
||||
|
||||
describe('noteLinks schema', () => {
|
||||
it('should store a source/target link row', () => {
|
||||
const { db } = createTestDb();
|
||||
db.insert(users)
|
||||
.values({ email: 'a@test.com', displayName: 'A', role: 'user', authProvider: 'password', createdAt: new Date() })
|
||||
.run();
|
||||
|
||||
db.insert(notes)
|
||||
.values([
|
||||
{ id: 'source-note', userId: 1, title: 'Source', content: '', createdAt: new Date(), updatedAt: new Date() },
|
||||
{ id: 'target-note', userId: 1, title: 'Target', content: '', createdAt: new Date(), updatedAt: new Date() }
|
||||
])
|
||||
.run();
|
||||
|
||||
db.insert(noteLinks).values({ sourceNoteId: 'source-note', targetNoteId: 'target-note' }).run();
|
||||
|
||||
const rows = db.select().from(noteLinks).all();
|
||||
expect(rows).toEqual([{ sourceNoteId: 'source-note', targetNoteId: 'target-note' }]);
|
||||
});
|
||||
|
||||
it('should cascade-delete link rows when the source note is deleted', () => {
|
||||
const { db, sqlite } = createTestDb();
|
||||
sqlite.pragma('foreign_keys = ON');
|
||||
db.insert(users)
|
||||
.values({ email: 'a@test.com', displayName: 'A', role: 'user', authProvider: 'password', createdAt: new Date() })
|
||||
.run();
|
||||
db.insert(notes)
|
||||
.values([
|
||||
{ id: 'source-note', userId: 1, title: 'Source', content: '', createdAt: new Date(), updatedAt: new Date() },
|
||||
{ id: 'target-note', userId: 1, title: 'Target', content: '', createdAt: new Date(), updatedAt: new Date() }
|
||||
])
|
||||
.run();
|
||||
db.insert(noteLinks).values({ sourceNoteId: 'source-note', targetNoteId: 'target-note' }).run();
|
||||
|
||||
db.delete(notes).where(eq(notes.id, 'source-note')).run();
|
||||
|
||||
expect(db.select().from(noteLinks).all()).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -80,6 +80,22 @@ export const noteTags = sqliteTable(
|
||||
]
|
||||
);
|
||||
|
||||
export const noteLinks = sqliteTable(
|
||||
'note_links',
|
||||
{
|
||||
sourceNoteId: text('source_note_id')
|
||||
.references(() => notes.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
targetNoteId: text('target_note_id')
|
||||
.references(() => notes.id, { onDelete: 'cascade' })
|
||||
.notNull()
|
||||
},
|
||||
(table) => [
|
||||
index('note_links_source_idx').on(table.sourceNoteId),
|
||||
index('note_links_target_idx').on(table.targetNoteId)
|
||||
]
|
||||
);
|
||||
|
||||
export const attachments = sqliteTable('attachments', {
|
||||
id: text('id').primaryKey(),
|
||||
userId: integer('user_id')
|
||||
|
||||
@@ -68,6 +68,13 @@ export function createTestDb(options?: { seedUser?: boolean }) {
|
||||
CREATE INDEX note_tags_note_id_idx ON note_tags(note_id);
|
||||
CREATE INDEX note_tags_tag_id_idx ON note_tags(tag_id);
|
||||
|
||||
CREATE TABLE note_links (
|
||||
source_note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
|
||||
target_note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX note_links_source_idx ON note_links(source_note_id);
|
||||
CREATE INDEX note_links_target_idx ON note_links(target_note_id);
|
||||
|
||||
CREATE TABLE note_collaborators (
|
||||
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { z } from 'zod';
|
||||
import { lookup } from 'node:dns/promises';
|
||||
import { request as httpRequest } from 'node:http';
|
||||
import { request as httpsRequest } from 'node:https';
|
||||
import type { NoteFilter } from '$lib/types/index.js';
|
||||
import {
|
||||
listNotes,
|
||||
@@ -29,6 +32,134 @@ const NOTE_COLORS = [
|
||||
'chalk'
|
||||
] as const;
|
||||
|
||||
const MAX_IMAGE_BYTES = 10 * 1024 * 1024; // 10MB, matches the manual attachment upload limit
|
||||
const MAX_IMAGE_REDIRECTS = 5;
|
||||
|
||||
// Blocks loopback, unspecified, link-local (incl. cloud metadata 169.254.169.254),
|
||||
// RFC1918/ULA private ranges, 6to4/IPv4-mapped tunnels, and multicast/broadcast so
|
||||
// upload_image can't be used to reach internal network services.
|
||||
function isPrivateOrReservedIp(address: string): boolean {
|
||||
if (address.includes(':')) {
|
||||
const normalized = address.toLowerCase();
|
||||
if (normalized === '::' || normalized === '::1') return true;
|
||||
if (normalized.startsWith('fe80:')) return true;
|
||||
if (normalized.startsWith('fc') || normalized.startsWith('fd')) return true;
|
||||
if (normalized.startsWith('2002:')) return true; // 6to4 tunnel, can embed a private IPv4
|
||||
if (normalized.startsWith('::ffff:')) return isPrivateOrReservedIp(normalized.split(':').pop()!);
|
||||
// Legacy IPv4-compatible form, e.g. "::1.2.3.4" (distinct from "::ffff:1.2.3.4")
|
||||
const ipv4Compatible = normalized.match(/^::(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
|
||||
if (ipv4Compatible) return isPrivateOrReservedIp(ipv4Compatible[1]);
|
||||
return false;
|
||||
}
|
||||
const parts = address.split('.').map(Number);
|
||||
if (parts.length !== 4 || parts.some((p) => Number.isNaN(p))) return true;
|
||||
const [a, b] = parts;
|
||||
if (a === 10 || a === 127 || a === 0) return true;
|
||||
if (a === 169 && b === 254) return true;
|
||||
if (a === 172 && b >= 16 && b <= 31) return true;
|
||||
if (a === 192 && b === 168) return true;
|
||||
if (a === 100 && b >= 64 && b <= 127) return true;
|
||||
if (a >= 224) return true; // multicast (224.0.0.0/4) and broadcast/reserved (240.0.0.0/4, 255.255.255.255)
|
||||
return false;
|
||||
}
|
||||
|
||||
// Resolves every A/AAAA record for a hostname and rejects if ANY is private/reserved
|
||||
// (not just the first), so a validated hostname can't have a private record hiding
|
||||
// behind a public one in the same DNS answer.
|
||||
async function resolveValidatedAddress(hostname: string): Promise<{ address: string; family: number }> {
|
||||
const records = await lookup(hostname, { all: true });
|
||||
if (records.length === 0) throw new Error('DNS lookup returned no addresses');
|
||||
for (const record of records) {
|
||||
if (isPrivateOrReservedIp(record.address)) {
|
||||
throw new Error('URL resolves to a private or reserved address');
|
||||
}
|
||||
}
|
||||
return records[0];
|
||||
}
|
||||
|
||||
interface PinnedResponse {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
statusText: string;
|
||||
headers: { get(name: string): string | null };
|
||||
arrayBuffer(): Promise<ArrayBuffer>;
|
||||
}
|
||||
|
||||
// Issues the request against the exact address validated by resolveValidatedAddress,
|
||||
// bypassing a second DNS resolution at connect time — otherwise a rebinding attacker
|
||||
// could serve a public address to the validator and a private one to the real connect.
|
||||
function requestPinnedAddress(url: URL, address: string, family: number): Promise<PinnedResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const requestFn = url.protocol === 'https:' ? httpsRequest : httpRequest;
|
||||
const req = requestFn(
|
||||
{
|
||||
hostname: url.hostname,
|
||||
servername: url.hostname,
|
||||
port: url.port || (url.protocol === 'https:' ? 443 : 80),
|
||||
path: url.pathname + url.search,
|
||||
method: 'GET',
|
||||
timeout: 15000,
|
||||
lookup: (_hostname: string, _options: unknown, callback: (err: null, address: string, family: number) => void) => {
|
||||
callback(null, address, family);
|
||||
}
|
||||
},
|
||||
(res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
let total = 0;
|
||||
res.on('data', (chunk: Buffer) => {
|
||||
total += chunk.length;
|
||||
if (total > MAX_IMAGE_BYTES) {
|
||||
req.destroy(new Error('Image too large (max 10MB)'));
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
res.on('end', () => {
|
||||
const buffer = Buffer.concat(chunks);
|
||||
const status = res.statusCode ?? 0;
|
||||
resolve({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
statusText: res.statusMessage ?? '',
|
||||
headers: {
|
||||
get: (name: string) => {
|
||||
const value = res.headers[name.toLowerCase()];
|
||||
return Array.isArray(value) ? (value[0] ?? null) : (value ?? null);
|
||||
}
|
||||
},
|
||||
arrayBuffer: async () =>
|
||||
buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) as ArrayBuffer
|
||||
});
|
||||
});
|
||||
res.on('error', reject);
|
||||
}
|
||||
);
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// Fetches an attacker-supplied URL for the MCP upload_image tool, re-validating the
|
||||
// target on every redirect hop so a public URL can't SSRF via a 3xx to an internal address.
|
||||
async function fetchPublicImage(imageUrl: string): Promise<PinnedResponse> {
|
||||
let currentUrl = new URL(imageUrl);
|
||||
for (let hop = 0; hop <= MAX_IMAGE_REDIRECTS; hop++) {
|
||||
if (currentUrl.protocol !== 'http:' && currentUrl.protocol !== 'https:') {
|
||||
throw new Error('Only http/https URLs are allowed');
|
||||
}
|
||||
const { address, family } = await resolveValidatedAddress(currentUrl.hostname);
|
||||
const response = await requestPinnedAddress(currentUrl, address, family);
|
||||
if (response.status >= 300 && response.status < 400) {
|
||||
const location = response.headers.get('location');
|
||||
if (!location) return response;
|
||||
currentUrl = new URL(location, currentUrl);
|
||||
continue;
|
||||
}
|
||||
return response;
|
||||
}
|
||||
throw new Error('Too many redirects');
|
||||
}
|
||||
|
||||
export function createMcpServer(userId: number): McpServer {
|
||||
const server = new McpServer({
|
||||
name: 'crumbs',
|
||||
@@ -302,7 +433,7 @@ export function createMcpServer(userId: number): McpServer {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(imageUrl);
|
||||
const response = await fetchPublicImage(imageUrl);
|
||||
if (!response.ok) {
|
||||
return {
|
||||
content: [
|
||||
@@ -315,8 +446,30 @@ export function createMcpServer(userId: number): McpServer {
|
||||
};
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') || 'image/png';
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
if (!contentType.startsWith('image/')) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: 'URL did not return an image' }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
|
||||
const contentLength = Number(response.headers.get('content-length'));
|
||||
if (contentLength > MAX_IMAGE_BYTES) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: 'Image too large (max 10MB)' }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
if (buffer.byteLength > MAX_IMAGE_BYTES) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: 'Image too large (max 10MB)' }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
|
||||
const ext = contentType.split('/')[1]?.split(';')[0] || 'png';
|
||||
const filename = `upload_${Date.now()}.${ext}`;
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { createTestDb } from './db/test-helpers.js';
|
||||
import { notes, noteLinks, noteCollaborators, users } from './db/schema.js';
|
||||
import type { Db } from './db/index.js';
|
||||
import { syncNoteLinks, fetchBacklinksForNote } from './note-links.js';
|
||||
|
||||
let db: Db;
|
||||
const OWNER_ID = 1;
|
||||
const OTHER_USER_ID = 2;
|
||||
|
||||
function seedUser(id: number, email: string) {
|
||||
db.insert(users)
|
||||
.values({ id, email, displayName: email, role: 'user', authProvider: 'password', createdAt: new Date() })
|
||||
.run();
|
||||
}
|
||||
|
||||
function seedNote(id: string, userId: number, title = '') {
|
||||
db.insert(notes)
|
||||
.values({ id, userId, title, content: '', createdAt: new Date(), updatedAt: new Date() })
|
||||
.run();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
const testDb = createTestDb();
|
||||
db = testDb.db;
|
||||
seedUser(OWNER_ID, 'owner@test.com');
|
||||
seedUser(OTHER_USER_ID, 'other@test.com');
|
||||
});
|
||||
|
||||
describe('syncNoteLinks', () => {
|
||||
it('should create link rows for each target', () => {
|
||||
seedNote('source', OWNER_ID);
|
||||
seedNote('target-1', OWNER_ID);
|
||||
seedNote('target-2', OWNER_ID);
|
||||
|
||||
syncNoteLinks(db, 'source', ['target-1', 'target-2']);
|
||||
|
||||
const rows = db.select().from(noteLinks).all();
|
||||
expect(rows).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should remove old links and add new ones on re-sync', () => {
|
||||
seedNote('source', OWNER_ID);
|
||||
seedNote('target-1', OWNER_ID);
|
||||
seedNote('target-2', OWNER_ID);
|
||||
|
||||
syncNoteLinks(db, 'source', ['target-1']);
|
||||
syncNoteLinks(db, 'source', ['target-2']);
|
||||
|
||||
const rows = db.select().from(noteLinks).all();
|
||||
expect(rows).toEqual([{ sourceNoteId: 'source', targetNoteId: 'target-2' }]);
|
||||
});
|
||||
|
||||
it('should handle an empty target list (removes all links)', () => {
|
||||
seedNote('source', OWNER_ID);
|
||||
seedNote('target-1', OWNER_ID);
|
||||
|
||||
syncNoteLinks(db, 'source', ['target-1']);
|
||||
syncNoteLinks(db, 'source', []);
|
||||
|
||||
expect(db.select().from(noteLinks).all()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchBacklinksForNote', () => {
|
||||
it('should return notes that link to the given note', () => {
|
||||
seedNote('source', OWNER_ID, 'Source Note');
|
||||
seedNote('target', OWNER_ID, 'Target Note');
|
||||
syncNoteLinks(db, 'source', ['target']);
|
||||
|
||||
const backlinks = fetchBacklinksForNote(db, 'target', OWNER_ID);
|
||||
expect(backlinks).toEqual([{ id: 'source', title: 'Source Note' }]);
|
||||
});
|
||||
|
||||
it('should return an empty array when nothing links to the note', () => {
|
||||
seedNote('target', OWNER_ID, 'Target Note');
|
||||
expect(fetchBacklinksForNote(db, 'target', OWNER_ID)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should exclude a backlink from a source note the requesting user cannot access', () => {
|
||||
seedNote('source', OTHER_USER_ID, 'Other Users Note');
|
||||
seedNote('target', OWNER_ID, 'Target Note');
|
||||
syncNoteLinks(db, 'source', ['target']);
|
||||
|
||||
expect(fetchBacklinksForNote(db, 'target', OWNER_ID)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should include a backlink from a source note shared with the requesting user', () => {
|
||||
seedNote('source', OTHER_USER_ID, 'Shared Source');
|
||||
seedNote('target', OWNER_ID, 'Target Note');
|
||||
syncNoteLinks(db, 'source', ['target']);
|
||||
db.insert(noteCollaborators)
|
||||
.values({ noteId: 'source', userId: OWNER_ID, addedBy: OTHER_USER_ID, addedAt: new Date() })
|
||||
.run();
|
||||
|
||||
expect(fetchBacklinksForNote(db, 'target', OWNER_ID)).toEqual([{ id: 'source', title: 'Shared Source' }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Db } from './db/index.js';
|
||||
import { noteLinks, notes } from './db/schema.js';
|
||||
import { eq, inArray } from 'drizzle-orm';
|
||||
import { canAccessNote } from './api-utils.js';
|
||||
|
||||
/**
|
||||
* Sync link rows for a note: removes old associations, inserts the current set.
|
||||
* Mirrors syncNoteTags — called right after a note's content is saved.
|
||||
*/
|
||||
export function syncNoteLinks(db: Db, noteId: string, targetIds: string[]): void {
|
||||
db.delete(noteLinks).where(eq(noteLinks.sourceNoteId, noteId)).run();
|
||||
|
||||
for (const targetId of targetIds) {
|
||||
db.insert(noteLinks).values({ sourceNoteId: noteId, targetNoteId: targetId }).run();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find notes that link to the given note, scoped to what the requesting user
|
||||
* can access — this both respects normal sharing boundaries and, as a side
|
||||
* effect, prevents an unrelated note from appearing in someone's backlinks
|
||||
* just because it happens to reference a note ID it was never shared to see.
|
||||
*/
|
||||
export function fetchBacklinksForNote(
|
||||
db: Db,
|
||||
noteId: string,
|
||||
userId: number
|
||||
): { id: string; title: string }[] {
|
||||
const linkRows = db
|
||||
.select({ sourceNoteId: noteLinks.sourceNoteId })
|
||||
.from(noteLinks)
|
||||
.where(eq(noteLinks.targetNoteId, noteId))
|
||||
.all();
|
||||
|
||||
if (linkRows.length === 0) return [];
|
||||
|
||||
const sourceIds = linkRows.map((r) => r.sourceNoteId);
|
||||
const accessibleIds = sourceIds.filter((id) => canAccessNote(db, id, userId).canAccess);
|
||||
if (accessibleIds.length === 0) return [];
|
||||
|
||||
const sourceNotes = db
|
||||
.select({ id: notes.id, title: notes.title })
|
||||
.from(notes)
|
||||
.where(inArray(notes.id, accessibleIds))
|
||||
.all();
|
||||
|
||||
return sourceNotes;
|
||||
}
|
||||
@@ -263,6 +263,46 @@ describe('updateNote', () => {
|
||||
const updated = updateNote(db, OWNER_ID, 'n1', { title: 'Now with #newtag' });
|
||||
expect(updated!.tags).toContain('newtag');
|
||||
});
|
||||
|
||||
it('should merge concurrent content edits using the snapshot created by the first update', () => {
|
||||
seedNote('n1', {
|
||||
title: 'Shopping',
|
||||
content: '- [ ] Milk\n- [ ] Bread',
|
||||
checklistMode: true,
|
||||
version: 1
|
||||
});
|
||||
shareNote('n1');
|
||||
|
||||
const ownerUpdate = updateNote(db, OWNER_ID, 'n1', {
|
||||
content: '- [ ] Milk\n- [x] Bread',
|
||||
baseVersion: 1
|
||||
});
|
||||
expect(ownerUpdate!.version).toBe(2);
|
||||
|
||||
const collabUpdate = updateNote(db, COLLAB_ID, 'n1', {
|
||||
content: '- [x] Milk\n- [ ] Bread',
|
||||
baseVersion: 1
|
||||
});
|
||||
|
||||
expect(collabUpdate!.content).toBe('- [x] Milk\n- [x] Bread');
|
||||
const snapshots = db.select().from(noteVersions).where(eq(noteVersions.noteId, 'n1')).all();
|
||||
expect(snapshots.some((snapshot) => snapshot.version === 1)).toBe(true);
|
||||
});
|
||||
|
||||
it('should keep the incoming edit when a metadata-only bump left no base snapshot', () => {
|
||||
seedNote('n1', { title: 'Note', content: 'hello', version: 1 });
|
||||
|
||||
// A metadata-only change bumps the version but creates no snapshot.
|
||||
updateNote(db, OWNER_ID, 'n1', { pinned: true });
|
||||
const snapshots = db.select().from(noteVersions).where(eq(noteVersions.noteId, 'n1')).all();
|
||||
expect(snapshots).toHaveLength(0);
|
||||
|
||||
// A later concurrent content edit based on the pre-bump version therefore has
|
||||
// no base to merge against — the incoming edit must be preserved, not dropped.
|
||||
const edited = updateNote(db, OWNER_ID, 'n1', { content: 'hello world', baseVersion: 1 });
|
||||
|
||||
expect(edited!.content).toBe('hello world');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteNote', () => {
|
||||
@@ -334,3 +374,45 @@ describe('reorderNotes', () => {
|
||||
expect(collabView!.sortOrder).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('note links', () => {
|
||||
it('should sync note-links when a note is created with a reference', () => {
|
||||
const target = createNote(db, OWNER_ID, { title: 'Target' });
|
||||
const source = createNote(db, OWNER_ID, {
|
||||
title: 'Source',
|
||||
content: `See [Target](crumb-note://${target.id})`
|
||||
});
|
||||
|
||||
const reopened = getNote(db, OWNER_ID, target.id);
|
||||
expect(reopened?.backlinks).toEqual([{ id: source.id, title: 'Source' }]);
|
||||
});
|
||||
|
||||
it('should sync note-links when a note is updated with a reference', () => {
|
||||
const target = createNote(db, OWNER_ID, { title: 'Target' });
|
||||
const source = createNote(db, OWNER_ID, { title: 'Source', content: '' });
|
||||
|
||||
updateNote(db, OWNER_ID, source.id, { content: `See [Target](crumb-note://${target.id})` });
|
||||
|
||||
const reopened = getNote(db, OWNER_ID, target.id);
|
||||
expect(reopened?.backlinks).toEqual([{ id: source.id, title: 'Source' }]);
|
||||
});
|
||||
|
||||
it('should remove a backlink when the referencing note is edited to drop the reference', () => {
|
||||
const target = createNote(db, OWNER_ID, { title: 'Target' });
|
||||
const source = createNote(db, OWNER_ID, {
|
||||
title: 'Source',
|
||||
content: `See [Target](crumb-note://${target.id})`
|
||||
});
|
||||
|
||||
updateNote(db, OWNER_ID, source.id, { content: 'No longer references anything' });
|
||||
|
||||
const reopened = getNote(db, OWNER_ID, target.id);
|
||||
expect(reopened?.backlinks).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return an empty backlinks array for a note nothing links to', () => {
|
||||
const note = createNote(db, OWNER_ID, { title: 'Lonely note' });
|
||||
const reopened = getNote(db, OWNER_ID, note.id);
|
||||
expect(reopened?.backlinks).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import type { Db } from './db/index.js';
|
||||
import { notes, noteTags, tags, noteCollaborators, noteUserState, noteVersions } from './db/schema.js';
|
||||
import { eq, and, desc, like, or, inArray, sql, lte } from 'drizzle-orm';
|
||||
import { notes, noteTags, tags, noteCollaborators, noteUserState } from './db/schema.js';
|
||||
import { eq, and, like, or, inArray, sql } from 'drizzle-orm';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { extractTags } from '$lib/utils/tags.js';
|
||||
import { extractNoteLinks } from '$lib/utils/note-links.js';
|
||||
import { fetchTagsForNotes, syncNoteTags } from './tags.js';
|
||||
import { syncNoteLinks, fetchBacklinksForNote } from './note-links.js';
|
||||
import { fetchAttachmentsForNotes } from './attachments.js';
|
||||
import { fetchCollaboratorsForNotes } from './collaborators.js';
|
||||
import { fetchSharesForNotes } from './shares-service.js';
|
||||
import { canAccessNote } from './api-utils.js';
|
||||
import { createSnapshot } from './versions-service.js';
|
||||
import { mergeContent } from '$lib/utils/content-merge.js';
|
||||
import { createSnapshot, getBaseContent } from './versions-service.js';
|
||||
import { mergeContentUpdate } from '$lib/utils/content-merge.js';
|
||||
import type { NoteFilter } from '$lib/types/index.js';
|
||||
|
||||
interface NoteRow {
|
||||
@@ -155,7 +157,10 @@ export function getNote(db: Db, userId: number, id: string) {
|
||||
}
|
||||
|
||||
const result = hydrateNotes(db, [effectiveNote], userId);
|
||||
return result[0] ?? null;
|
||||
const hydrated = result[0];
|
||||
if (!hydrated) return null;
|
||||
|
||||
return { ...hydrated, backlinks: fetchBacklinksForNote(db, id, userId) };
|
||||
}
|
||||
|
||||
export interface CreateNoteInput {
|
||||
@@ -197,6 +202,9 @@ export function createNote(db: Db, userId: number, input: CreateNoteInput) {
|
||||
});
|
||||
syncNoteTags(db, id, extractedTags, userId);
|
||||
|
||||
const extractedLinks = extractNoteLinks(content);
|
||||
syncNoteLinks(db, id, extractedLinks);
|
||||
|
||||
return { ...newNote, tags: extractedTags };
|
||||
}
|
||||
|
||||
@@ -238,29 +246,18 @@ export function updateNote(db: Db, userId: number, id: string, input: UpdateNote
|
||||
|
||||
if (input.title !== undefined) { sharedUpdates.title = input.title; hasSharedUpdates = true; }
|
||||
if (input.content !== undefined) {
|
||||
// 3-way merge to preserve concurrent edits from other users.
|
||||
// Only attempt merge when there's a concurrent edit: the client's
|
||||
// baseVersion is behind the current version, meaning someone else
|
||||
// saved between the client's load and this save.
|
||||
// Preserve concurrent edits at line/block granularity when the client
|
||||
// saved from an older note version.
|
||||
const hasConcurrentEdit = input.baseVersion !== undefined
|
||||
&& input.baseVersion < existing.version;
|
||||
|
||||
if (hasConcurrentEdit && input.content !== existing.content) {
|
||||
const baseSnapshot = db.select({ content: noteVersions.content })
|
||||
.from(noteVersions)
|
||||
.where(and(eq(noteVersions.noteId, id), lte(noteVersions.version, input.baseVersion!)))
|
||||
.orderBy(desc(noteVersions.version))
|
||||
.limit(1)
|
||||
.get();
|
||||
|
||||
if (baseSnapshot && baseSnapshot.content !== existing.content) {
|
||||
sharedUpdates.content = mergeContent(baseSnapshot.content, input.content, existing.content);
|
||||
} else {
|
||||
sharedUpdates.content = input.content;
|
||||
}
|
||||
} else {
|
||||
sharedUpdates.content = input.content;
|
||||
}
|
||||
sharedUpdates.content = hasConcurrentEdit
|
||||
? mergeContentUpdate({
|
||||
baseContent: getBaseContent(db, id, input.baseVersion),
|
||||
incomingContent: input.content,
|
||||
currentContent: existing.content
|
||||
})
|
||||
: input.content;
|
||||
hasSharedUpdates = true;
|
||||
}
|
||||
if (input.color !== undefined) { sharedUpdates.color = input.color; hasSharedUpdates = true; }
|
||||
@@ -278,6 +275,22 @@ export function updateNote(db: Db, userId: number, id: string, input: UpdateNote
|
||||
if (input.sortOrder !== undefined) { sharedUpdates.sortOrder = input.sortOrder; hasSharedUpdates = true; }
|
||||
}
|
||||
|
||||
const hasActualContentChange =
|
||||
(input.title !== undefined && input.title !== existing.title) ||
|
||||
(sharedUpdates.content !== undefined && sharedUpdates.content !== existing.content) ||
|
||||
(input.color !== undefined && input.color !== existing.color) ||
|
||||
(input.checklistMode !== undefined && input.checklistMode !== existing.checklistMode);
|
||||
|
||||
if (hasActualContentChange) {
|
||||
createSnapshot(db, id, {
|
||||
version: existing.version,
|
||||
title: existing.title,
|
||||
content: existing.content,
|
||||
checklistMode: existing.checklistMode,
|
||||
color: existing.color
|
||||
});
|
||||
}
|
||||
|
||||
if (hasSharedUpdates || isOwner) {
|
||||
db.update(notes).set(sharedUpdates).where(eq(notes.id, id)).run();
|
||||
}
|
||||
@@ -321,26 +334,11 @@ export function updateNote(db: Db, userId: number, id: string, input: UpdateNote
|
||||
const content = `${updated.title} ${updated.content}`;
|
||||
const extractedTags = extractTags(content);
|
||||
syncNoteTags(db, id, extractedTags, existing.userId);
|
||||
const extractedLinks = extractNoteLinks(content);
|
||||
syncNoteLinks(db, id, extractedLinks);
|
||||
}
|
||||
}
|
||||
|
||||
// Create a version snapshot only when at least one content field actually changed
|
||||
const hasActualContentChange =
|
||||
(input.title !== undefined && input.title !== existing.title) ||
|
||||
(input.content !== undefined && input.content !== existing.content) ||
|
||||
(input.color !== undefined && input.color !== existing.color) ||
|
||||
(input.checklistMode !== undefined && input.checklistMode !== existing.checklistMode);
|
||||
|
||||
if (hasActualContentChange) {
|
||||
createSnapshot(db, id, {
|
||||
version: existing.version,
|
||||
title: existing.title,
|
||||
content: existing.content,
|
||||
checklistMode: existing.checklistMode,
|
||||
color: existing.color
|
||||
});
|
||||
}
|
||||
|
||||
return getNote(db, userId, id);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createTestDb } from './db/test-helpers.js';
|
||||
import { users, notes, noteVersions } from './db/schema.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import type { Db } from './db/index.js';
|
||||
import { createSnapshot, listVersions, getVersion, snapshotCurrentNote } from './versions-service.js';
|
||||
import { createSnapshot, listVersions, getVersion, snapshotCurrentNote, getBaseContent } from './versions-service.js';
|
||||
|
||||
let db: Db;
|
||||
|
||||
@@ -44,6 +44,32 @@ beforeEach(() => {
|
||||
seedUser();
|
||||
});
|
||||
|
||||
describe('getBaseContent', () => {
|
||||
it('returns null when the base version is unknown', () => {
|
||||
seedNote();
|
||||
expect(getBaseContent(db, NOTE_ID, undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the snapshot content at or before the base version', () => {
|
||||
seedNote();
|
||||
createSnapshot(db, NOTE_ID, { version: 1, title: 'T', content: 'v1', checklistMode: false, color: 'default' });
|
||||
createSnapshot(db, NOTE_ID, { version: 3, title: 'T', content: 'v3', checklistMode: false, color: 'default' });
|
||||
|
||||
expect(getBaseContent(db, NOTE_ID, 2)).toBe('v1');
|
||||
expect(getBaseContent(db, NOTE_ID, 3)).toBe('v3');
|
||||
});
|
||||
|
||||
it('returns null instead of a newer snapshot when none exists at or before the base version', () => {
|
||||
seedNote();
|
||||
// Only a snapshot NEWER than the requested base exists (e.g. the real base was
|
||||
// pruned). Using it as the merge base would treat unseen lines as deletions,
|
||||
// so getBaseContent must return null rather than fall back to it.
|
||||
createSnapshot(db, NOTE_ID, { version: 5, title: 'T', content: 'v5', checklistMode: false, color: 'default' });
|
||||
|
||||
expect(getBaseContent(db, NOTE_ID, 2)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createSnapshot', () => {
|
||||
it('should store a snapshot for a note', () => {
|
||||
seedNote();
|
||||
|
||||
@@ -1,18 +1,46 @@
|
||||
import type { Db } from './db/index.js';
|
||||
import { noteVersions, notes } from './db/schema.js';
|
||||
import { eq, and, desc } from 'drizzle-orm';
|
||||
import { eq, and, desc, lte } from 'drizzle-orm';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import type { NoteVersion, NoteVersionSummary, NoteColor } from '$lib/types/index.js';
|
||||
|
||||
const MAX_VERSIONS_PER_NOTE = 50;
|
||||
const CONTENT_PREVIEW_LENGTH = 80;
|
||||
|
||||
export type Transaction = Parameters<Parameters<Db['transaction']>[0]>[0];
|
||||
export type SnapshotDb = Db | Transaction;
|
||||
|
||||
/**
|
||||
* Resolve the 3-way merge base for a concurrent edit: the content of the snapshot
|
||||
* at or before the client's base version. Returns null when the base version is
|
||||
* unknown or no snapshot at/before it exists. Callers must NOT substitute a newer
|
||||
* snapshot as the base — a base newer than what the client actually saw makes the
|
||||
* merge treat other users' lines as deletions and silently drop them.
|
||||
*/
|
||||
export function getBaseContent(
|
||||
db: SnapshotDb,
|
||||
noteId: string,
|
||||
baseVersion: number | undefined
|
||||
): string | null {
|
||||
if (baseVersion === undefined) return null;
|
||||
|
||||
const snapshot = db
|
||||
.select({ content: noteVersions.content })
|
||||
.from(noteVersions)
|
||||
.where(and(eq(noteVersions.noteId, noteId), lte(noteVersions.version, baseVersion)))
|
||||
.orderBy(desc(noteVersions.version))
|
||||
.limit(1)
|
||||
.get();
|
||||
|
||||
return snapshot?.content ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a snapshot of a note's current state.
|
||||
* Only called when content actually changed. Prunes snapshots beyond MAX_VERSIONS_PER_NOTE.
|
||||
*/
|
||||
export function createSnapshot(
|
||||
db: Db,
|
||||
db: SnapshotDb,
|
||||
noteId: string,
|
||||
snapshot: {
|
||||
version: number;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mergeNotesByVersion } from './notes.js';
|
||||
import { mergeNotesByVersion, reconcileSearchResults } from './notes.js';
|
||||
import type { Note } from '$lib/types/index.js';
|
||||
|
||||
function makeNote(overrides: Partial<Note> = {}): Note {
|
||||
@@ -97,3 +97,49 @@ describe('mergeNotesByVersion', () => {
|
||||
expect(result[1].content).toBe('updated on server');
|
||||
});
|
||||
});
|
||||
|
||||
describe('reconcileSearchResults', () => {
|
||||
it('replaces a stale result with the canonical note', () => {
|
||||
const canonical = makeNote({ id: 'n1', title: 'Updated', version: 2 });
|
||||
|
||||
const result = reconcileSearchResults(
|
||||
[canonical],
|
||||
[makeNote({ id: 'n1', title: 'Stale', version: 1 })]
|
||||
);
|
||||
|
||||
expect(result).toEqual([canonical]);
|
||||
});
|
||||
|
||||
it('keeps a result absent from the canonical store (hit not yet loaded locally)', () => {
|
||||
const serverHit = makeNote({ id: 'n2', title: 'From another device' });
|
||||
|
||||
const result = reconcileSearchResults([makeNote({ id: 'n1' })], [serverHit]);
|
||||
|
||||
expect(result).toEqual([serverHit]);
|
||||
});
|
||||
|
||||
it('drops a result that is trashed in the canonical store', () => {
|
||||
const trashed = makeNote({ id: 'n1', trashed: true });
|
||||
|
||||
const result = reconcileSearchResults([trashed], [makeNote({ id: 'n1', trashed: false })]);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('preserves result order while reconciling a mix of cases', () => {
|
||||
const canonical = makeNote({ id: 'n1', title: 'Canonical', version: 2 });
|
||||
const trashed = makeNote({ id: 'n2', trashed: true });
|
||||
const absent = makeNote({ id: 'n3', title: 'Absent' });
|
||||
|
||||
const result = reconcileSearchResults(
|
||||
[canonical, trashed],
|
||||
[makeNote({ id: 'n1', title: 'Stale', version: 1 }), trashed, absent]
|
||||
);
|
||||
|
||||
expect(result).toEqual([canonical, absent]);
|
||||
});
|
||||
|
||||
it('returns an empty array when there are no results', () => {
|
||||
expect(reconcileSearchResults([makeNote({ id: 'n1' })], [])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
+36
-3
@@ -8,11 +8,41 @@ export const notes = writable<Note[]>([]);
|
||||
export const currentFilter = writable<NoteFilter>('all');
|
||||
export const selectedTag = writable<string | null>(null);
|
||||
export const notesLoaded = writable(false);
|
||||
export const searchQuery = writable<string>('');
|
||||
export const searchResults = writable<Note[]>([]);
|
||||
|
||||
/**
|
||||
* Reconcile search results against the canonical notes store so local edits and
|
||||
* trashing propagate into the active result set without re-querying the server.
|
||||
*
|
||||
* A result absent from the canonical store is kept as-is — it's a server hit not
|
||||
* yet loaded locally (e.g. created on another device), and dropping it would make
|
||||
* valid matches disappear mid-session. Notes removed locally (deleted or left) are
|
||||
* pruned explicitly at their call sites; trashed notes are dropped here.
|
||||
*/
|
||||
export function reconcileSearchResults(canonicalNotes: Note[], results: Note[]): Note[] {
|
||||
const notesMap = new Map(canonicalNotes.map((note) => [note.id, note]));
|
||||
return results.flatMap((result) => {
|
||||
const canonical = notesMap.get(result.id);
|
||||
if (!canonical) return [result];
|
||||
return canonical.trashed ? [] : [canonical];
|
||||
});
|
||||
}
|
||||
|
||||
// Keep searchResults in sync with edits/trashing in the notes store. These search
|
||||
// stores (like the others in this module) are client-only state; the subscription
|
||||
// lives for the app's lifetime and is intentionally never unsubscribed.
|
||||
notes.subscribe(($notes) => {
|
||||
searchResults.update(($results) => {
|
||||
if ($results.length === 0) return $results;
|
||||
return reconcileSearchResults($notes, $results);
|
||||
});
|
||||
});
|
||||
|
||||
export const filteredNotes = derived(
|
||||
[notes, selectedTag, currentFilter],
|
||||
([$notes, $selectedTag, $filter]) => {
|
||||
let result = $notes;
|
||||
[notes, selectedTag, currentFilter, searchQuery, searchResults],
|
||||
([$notes, $selectedTag, $filter, $searchQuery, $searchResults]) => {
|
||||
let result = $searchQuery.trim() ? $searchResults : $notes;
|
||||
if ($filter === 'all') {
|
||||
result = result.filter((n) => !n.trashed && !n.archived);
|
||||
} else if ($filter === 'archived') {
|
||||
@@ -218,6 +248,7 @@ export async function deleteNote(id: string): Promise<boolean> {
|
||||
const res = await fetch(`/api/notes/${id}`, { method: 'DELETE' });
|
||||
if (res.ok) {
|
||||
notes.update((list) => list.filter((n) => n.id !== id));
|
||||
searchResults.update((list) => list.filter((n) => n.id !== id));
|
||||
await deleteNoteFromIdb(id);
|
||||
return true;
|
||||
}
|
||||
@@ -225,6 +256,7 @@ export async function deleteNote(id: string): Promise<boolean> {
|
||||
} catch (err) {
|
||||
if (isNetworkError(err)) {
|
||||
notes.update((list) => list.filter((n) => n.id !== id));
|
||||
searchResults.update((list) => list.filter((n) => n.id !== id));
|
||||
await deleteNoteFromIdb(id);
|
||||
await addToSyncQueue({ noteId: id, operation: 'delete', timestamp: Date.now() });
|
||||
showToast('Saved offline — will sync when reconnected', 'info');
|
||||
@@ -263,6 +295,7 @@ export async function leaveNote(id: string): Promise<boolean> {
|
||||
const res = await fetch(`/api/notes/${id}/collaborators?userId=self`, { method: 'DELETE' });
|
||||
if (res.ok) {
|
||||
notes.update((list) => list.filter((n) => n.id !== id));
|
||||
searchResults.update((list) => list.filter((n) => n.id !== id));
|
||||
await deleteNoteFromIdb(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { createTestDb } from '../server/db/test-helpers.js';
|
||||
import { notes, noteCollaborators, noteUserState, users, syncLog } from '../server/db/schema.js';
|
||||
import { notes, noteCollaborators, noteUserState, users, syncLog, noteVersions } from '../server/db/schema.js';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
|
||||
import type * as schema from '../server/db/schema.js';
|
||||
@@ -289,6 +289,64 @@ describe('processSyncPush — shared notes', () => {
|
||||
expect(note.content).toBe('- [x] Milk\n- [x] Bread');
|
||||
});
|
||||
|
||||
it('should create a base snapshot during sync so later queued edits can merge', async () => {
|
||||
const t1 = new Date('2024-01-01T00:00:00Z');
|
||||
seedSharedNote('n1', {
|
||||
updatedAt: t1,
|
||||
createdAt: t1,
|
||||
content: '- [ ] Milk\n- [ ] Bread',
|
||||
checklistMode: true,
|
||||
version: 1
|
||||
});
|
||||
|
||||
await processSyncPush(db, [{
|
||||
noteId: 'n1',
|
||||
operation: 'update',
|
||||
timestamp: t1.getTime() + 1000,
|
||||
data: { content: '- [ ] Milk\n- [x] Bread' },
|
||||
baseVersion: 1
|
||||
}], OWNER_ID);
|
||||
|
||||
await processSyncPush(db, [{
|
||||
noteId: 'n1',
|
||||
operation: 'update',
|
||||
timestamp: t1.getTime() + 2000,
|
||||
data: { content: '- [x] Milk\n- [ ] Bread' },
|
||||
baseVersion: 1
|
||||
}], COLLAB_ID);
|
||||
|
||||
const note = db.select().from(notes).where(eq(notes.id, 'n1')).get()!;
|
||||
expect(note.content).toBe('- [x] Milk\n- [x] Bread');
|
||||
|
||||
const snapshots = db.select().from(noteVersions).where(eq(noteVersions.noteId, 'n1')).all();
|
||||
expect(snapshots.some((snapshot) => snapshot.version === 1)).toBe(true);
|
||||
});
|
||||
|
||||
it('should retain at most 50 snapshots created by sync updates', async () => {
|
||||
const createdAt = new Date('2024-01-01T00:00:00Z');
|
||||
seedSharedNote('n1', {
|
||||
updatedAt: createdAt,
|
||||
createdAt,
|
||||
content: 'version 1',
|
||||
version: 1
|
||||
});
|
||||
|
||||
for (let version = 2; version <= 52; version++) {
|
||||
await processSyncPush(db, [{
|
||||
noteId: 'n1',
|
||||
operation: 'update',
|
||||
timestamp: createdAt.getTime() + version,
|
||||
data: { content: `version ${version}` },
|
||||
baseVersion: version - 1
|
||||
}], OWNER_ID);
|
||||
}
|
||||
|
||||
const snapshots = db.select().from(noteVersions).where(eq(noteVersions.noteId, 'n1')).all();
|
||||
expect(snapshots).toHaveLength(50);
|
||||
expect(snapshots.some((snapshot) => snapshot.version === 1)).toBe(false);
|
||||
expect(snapshots.some((snapshot) => snapshot.version === 51)).toBe(true);
|
||||
});
|
||||
|
||||
it('should not allow non-collaborator to update the note', async () => {
|
||||
const t1 = new Date('2024-01-01T00:00:00Z');
|
||||
// Create note without sharing with user 3
|
||||
|
||||
+30
-38
@@ -1,37 +1,12 @@
|
||||
import type { Db } from '$lib/server/db/index.js';
|
||||
import { notes, noteCollaborators, noteUserState, noteVersions, syncLog } from '$lib/server/db/schema.js';
|
||||
import { eq, gt, and, or, sql, desc, lte } from 'drizzle-orm';
|
||||
import { notes, noteCollaborators, noteUserState, syncLog } from '$lib/server/db/schema.js';
|
||||
import { eq, gt, and, sql } from 'drizzle-orm';
|
||||
import { extractTags } from '$lib/utils/tags.js';
|
||||
import { syncNoteTags } from '$lib/server/tags.js';
|
||||
import { canAccessNote } from '$lib/server/api-utils.js';
|
||||
import { mergeContent } from '$lib/utils/content-merge.js';
|
||||
import { createSnapshot, getBaseContent } from '$lib/server/versions-service.js';
|
||||
import { mergeContentUpdate } from '$lib/utils/content-merge.js';
|
||||
import type { SyncQueueItem } from './idb.js';
|
||||
|
||||
/**
|
||||
* Find the base content for 3-way merge from version snapshots.
|
||||
* Returns the content from the snapshot at or before the given version,
|
||||
* or the current note content if no snapshot is found.
|
||||
*/
|
||||
function getBaseContent(tx: Parameters<Parameters<Db['transaction']>[0]>[0], noteId: string, baseVersion?: number): string | null {
|
||||
if (baseVersion !== undefined) {
|
||||
const snapshot = tx.select({ content: noteVersions.content })
|
||||
.from(noteVersions)
|
||||
.where(and(eq(noteVersions.noteId, noteId), lte(noteVersions.version, baseVersion)))
|
||||
.orderBy(desc(noteVersions.version))
|
||||
.limit(1)
|
||||
.get();
|
||||
if (snapshot) return snapshot.content;
|
||||
}
|
||||
// Fallback: get the most recent snapshot before the current version
|
||||
const latest = tx.select({ content: noteVersions.content })
|
||||
.from(noteVersions)
|
||||
.where(eq(noteVersions.noteId, noteId))
|
||||
.orderBy(desc(noteVersions.version))
|
||||
.limit(1)
|
||||
.get();
|
||||
return latest?.content ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process incoming sync changes from client.
|
||||
*/
|
||||
@@ -69,16 +44,17 @@ export async function processSyncPush(db: Db, changes: SyncQueueItem[], userId:
|
||||
if (change.data.color !== undefined) sharedData.color = change.data.color;
|
||||
if (change.data.checklistMode !== undefined) sharedData.checklistMode = change.data.checklistMode;
|
||||
|
||||
// Content: 3-way merge to preserve concurrent edits from other users
|
||||
// Content: merge only when the client saved from an older note version.
|
||||
if (change.data.content !== undefined && change.data.content !== note.content) {
|
||||
const baseContent = getBaseContent(tx, change.noteId, change.baseVersion);
|
||||
if (baseContent !== null && baseContent !== note.content) {
|
||||
// 3-way merge: base, incoming (local), server current (remote)
|
||||
sharedData.content = mergeContent(baseContent, change.data.content, note.content);
|
||||
} else {
|
||||
// No base or base matches server — take incoming content
|
||||
sharedData.content = change.data.content;
|
||||
}
|
||||
const hasConcurrentEdit = change.baseVersion !== undefined
|
||||
&& change.baseVersion < note.version;
|
||||
sharedData.content = hasConcurrentEdit
|
||||
? mergeContentUpdate({
|
||||
baseContent: getBaseContent(tx, change.noteId, change.baseVersion),
|
||||
incomingContent: change.data.content,
|
||||
currentContent: note.content
|
||||
})
|
||||
: change.data.content;
|
||||
}
|
||||
|
||||
if (isOwner) {
|
||||
@@ -92,6 +68,22 @@ export async function processSyncPush(db: Db, changes: SyncQueueItem[], userId:
|
||||
if (change.data.sortOrder !== undefined) sharedData.sortOrder = change.data.sortOrder;
|
||||
}
|
||||
|
||||
const hasActualContentChange =
|
||||
(change.data.title !== undefined && change.data.title !== note.title) ||
|
||||
(sharedData.content !== undefined && sharedData.content !== note.content) ||
|
||||
(change.data.color !== undefined && change.data.color !== note.color) ||
|
||||
(change.data.checklistMode !== undefined && change.data.checklistMode !== note.checklistMode);
|
||||
|
||||
if (hasActualContentChange) {
|
||||
createSnapshot(tx, change.noteId, {
|
||||
version: note.version,
|
||||
title: note.title,
|
||||
content: note.content,
|
||||
checklistMode: note.checklistMode,
|
||||
color: note.color
|
||||
});
|
||||
}
|
||||
|
||||
tx.update(notes)
|
||||
.set(sharedData)
|
||||
.where(eq(notes.id, change.noteId))
|
||||
|
||||
@@ -18,6 +18,12 @@ export interface Note {
|
||||
isOwner?: boolean;
|
||||
isShared?: boolean;
|
||||
shareToken?: string;
|
||||
backlinks?: NoteBacklink[];
|
||||
}
|
||||
|
||||
export interface NoteBacklink {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface Collaborator {
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { getNoteColor, NOTE_COLORS, NOTE_COLORS_DARK } from '$lib/utils/colors.js';
|
||||
import {
|
||||
getNoteColor,
|
||||
getNoteChip,
|
||||
NOTE_COLORS,
|
||||
NOTE_COLORS_DARK,
|
||||
NOTE_CHIPS_DARK
|
||||
} from '$lib/utils/colors.js';
|
||||
import type { NoteColor } from '$lib/types/index.js';
|
||||
|
||||
describe('getNoteColor', () => {
|
||||
it('returns light color when isDark is false', () => {
|
||||
@@ -8,13 +15,13 @@ describe('getNoteColor', () => {
|
||||
});
|
||||
|
||||
it('returns dark color when isDark is true', () => {
|
||||
expect(getNoteColor('default', true)).toBe('#2a2520');
|
||||
expect(getNoteColor('coral', true)).toBe('#4a2522');
|
||||
expect(getNoteColor('default', true)).toBe('#21201f');
|
||||
expect(getNoteColor('coral', true)).toBe('#3f1f1c');
|
||||
});
|
||||
|
||||
it('returns default color for unknown color name', () => {
|
||||
expect(getNoteColor('unknown' as any, false)).toBe('#faf5eb');
|
||||
expect(getNoteColor('unknown' as any, true)).toBe('#2a2520');
|
||||
expect(getNoteColor('unknown' as any, true)).toBe('#21201f');
|
||||
});
|
||||
|
||||
it('has matching keys in light and dark color maps', () => {
|
||||
@@ -23,3 +30,115 @@ describe('getNoteColor', () => {
|
||||
expect(lightKeys).toEqual(darkKeys);
|
||||
});
|
||||
});
|
||||
|
||||
function toRgb(hex: string): [number, number, number] {
|
||||
const n = parseInt(hex.slice(1), 16);
|
||||
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
||||
}
|
||||
|
||||
/** WCAG relative luminance. */
|
||||
function relativeLuminance(hex: string): number {
|
||||
const channels = toRgb(hex).map((v) => {
|
||||
const s = v / 255;
|
||||
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
|
||||
});
|
||||
return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2];
|
||||
}
|
||||
|
||||
function contrastRatio(a: string, b: string): number {
|
||||
const [l1, l2] = [relativeLuminance(a), relativeLuminance(b)];
|
||||
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
|
||||
}
|
||||
|
||||
function rgbDistance(a: string, b: string): number {
|
||||
const [ra, ga, ba] = toRgb(a);
|
||||
const [rb, gb, bb] = toRgb(b);
|
||||
return Math.hypot(ra - rb, ga - gb, ba - bb);
|
||||
}
|
||||
|
||||
describe('dark note colours', () => {
|
||||
// Must track [data-theme="dark"] in src/app.css.
|
||||
const DARK_TEXT = '#ece3d3';
|
||||
const DARK_TEXT_MUTED = '#b3a695';
|
||||
|
||||
it('keeps body text above the AA threshold on every dark card', () => {
|
||||
for (const [name, { bg }] of Object.entries(NOTE_COLORS_DARK)) {
|
||||
expect(contrastRatio(bg, DARK_TEXT), `${name} (${bg})`).toBeGreaterThanOrEqual(4.5);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps muted text above the AA threshold on every dark card', () => {
|
||||
// The shipped dark theme failed this at 3.77:1 with its previous muted
|
||||
// colour (#9a8e7e). This guard exists so a future palette edit cannot
|
||||
// reintroduce it — it does not discriminate the old surfaces, which were
|
||||
// darker and so scored higher against the lifted muted colour.
|
||||
for (const [name, { bg }] of Object.entries(NOTE_COLORS_DARK)) {
|
||||
expect(contrastRatio(bg, DARK_TEXT_MUTED), `${name} (${bg})`).toBeGreaterThanOrEqual(4.5);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps every pair of dark surfaces distinguishable', () => {
|
||||
// The old palette failed this: fog and storm sat 7.55 apart, so the twelve
|
||||
// colours read as one dark smudge. Threshold 8 clears that while leaving
|
||||
// headroom above the current closest pair, coral/peach at 12.0.
|
||||
const entries = Object.entries(NOTE_COLORS_DARK);
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
for (let j = i + 1; j < entries.length; j++) {
|
||||
const [[nameA, a], [nameB, b]] = [entries[i], entries[j]];
|
||||
const distance = rgbDistance(a.bg, b.bg);
|
||||
expect(distance, `${nameA} vs ${nameB}`).toBeGreaterThan(8);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNoteChip', () => {
|
||||
it('returns the card colour in light mode, where pastels already are the cards', () => {
|
||||
for (const color of Object.keys(NOTE_COLORS) as NoteColor[]) {
|
||||
expect(getNoteChip(color, false)).toBe(getNoteColor(color, false));
|
||||
}
|
||||
});
|
||||
|
||||
it('returns a distinct vivid chip in dark mode, not the card surface', () => {
|
||||
for (const color of Object.keys(NOTE_COLORS_DARK) as NoteColor[]) {
|
||||
expect(getNoteChip(color, true)).not.toBe(getNoteColor(color, true));
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to default for an unknown colour', () => {
|
||||
expect(getNoteChip('unknown' as any, true)).toBe(NOTE_CHIPS_DARK.default);
|
||||
});
|
||||
|
||||
it('has matching keys with the colour maps', () => {
|
||||
expect(Object.keys(NOTE_CHIPS_DARK).sort()).toEqual(Object.keys(NOTE_COLORS_DARK).sort());
|
||||
});
|
||||
|
||||
it('keeps every pair of chips far enough apart to tell apart at 28px', () => {
|
||||
// The regression this guards: at low chroma coral/peach/blossom and
|
||||
// fog/storm collapse into each other.
|
||||
const entries = Object.entries(NOTE_CHIPS_DARK);
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
for (let j = i + 1; j < entries.length; j++) {
|
||||
const [[nameA, a], [nameB, b]] = [entries[i], entries[j]];
|
||||
const distance = rgbDistance(a, b);
|
||||
expect(distance, `${nameA} vs ${nameB}`).toBeGreaterThan(25);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps every chip lighter than every card, so chips read as vivid dots', () => {
|
||||
// A chip set to card-lightness values could still pass the pairwise
|
||||
// distance test above (25 apart) while being just as dark as the cards,
|
||||
// reintroducing the "twelve grey dots" bug this palette fixed. Measured:
|
||||
// the lightest card (mint) has luminance 0.0392; the darkest chip (coral)
|
||||
// has luminance 0.1941 — almost 5x higher, comfortable headroom either
|
||||
// side of the 0.1 threshold below.
|
||||
const cardLuminances = Object.values(NOTE_COLORS_DARK).map(({ bg }) => relativeLuminance(bg));
|
||||
const maxCardLuminance = Math.max(...cardLuminances);
|
||||
expect(maxCardLuminance).toBeLessThan(0.1);
|
||||
|
||||
for (const [name, chip] of Object.entries(NOTE_CHIPS_DARK)) {
|
||||
expect(relativeLuminance(chip), name).toBeGreaterThan(0.1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+38
-12
@@ -16,18 +16,18 @@ export const NOTE_COLORS: Record<NoteColor, { bg: string; label: string }> = {
|
||||
};
|
||||
|
||||
export const NOTE_COLORS_DARK: Record<NoteColor, { bg: string; label: string }> = {
|
||||
default: { bg: '#2a2520', label: 'Default' },
|
||||
coral: { bg: '#4a2522', label: 'Coral' },
|
||||
peach: { bg: '#4a3020', label: 'Peach' },
|
||||
sand: { bg: '#3a3520', label: 'Sand' },
|
||||
mint: { bg: '#2a3a22', label: 'Mint' },
|
||||
sage: { bg: '#223a32', label: 'Sage' },
|
||||
fog: { bg: '#222e3a', label: 'Fog' },
|
||||
storm: { bg: '#1e2a35', label: 'Storm' },
|
||||
dusk: { bg: '#352540', label: 'Dusk' },
|
||||
blossom: { bg: '#3a2830', label: 'Blossom' },
|
||||
clay: { bg: '#302e28', label: 'Clay' },
|
||||
chalk: { bg: '#2e2e30', label: 'Chalk' }
|
||||
default: { bg: '#21201f', label: 'Default' },
|
||||
coral: { bg: '#3f1f1c', label: 'Coral' },
|
||||
peach: { bg: '#3f2b1c', label: 'Peach' },
|
||||
sand: { bg: '#3c371b', label: 'Sand' },
|
||||
mint: { bg: '#2a3d1d', label: 'Mint' },
|
||||
sage: { bg: '#1f3d36', label: 'Sage' },
|
||||
fog: { bg: '#253946', label: 'Fog' },
|
||||
storm: { bg: '#1d283d', label: 'Storm' },
|
||||
dusk: { bg: '#3d2843', label: 'Dusk' },
|
||||
blossom: { bg: '#3d2129', label: 'Blossom' },
|
||||
clay: { bg: '#363126', label: 'Clay' },
|
||||
chalk: { bg: '#303036', label: 'Chalk' }
|
||||
};
|
||||
|
||||
export function getNoteColor(color: NoteColor, isDark: boolean): string {
|
||||
@@ -39,3 +39,29 @@ export const COLOR_OPTIONS = Object.entries(NOTE_COLORS).map(([value, { label }]
|
||||
value: value as NoteColor,
|
||||
label
|
||||
}));
|
||||
|
||||
/**
|
||||
* Picker swatches are labels, not surfaces. A 28px circle at card lightness
|
||||
* reads as a grey dot — small patches lose apparent saturation — so the dark
|
||||
* picker uses vivid mid-lightness chips that share the card's hue but not its
|
||||
* lightness. Light mode needs no equivalent: there the pastels are the cards.
|
||||
*/
|
||||
export const NOTE_CHIPS_DARK: Record<NoteColor, string> = {
|
||||
default: '#8d857c',
|
||||
coral: '#d04f43',
|
||||
peach: '#cd7c42',
|
||||
sand: '#c7b138',
|
||||
mint: '#7bbd4c',
|
||||
sage: '#47b89d',
|
||||
fog: '#609abe',
|
||||
storm: '#547bc0',
|
||||
dusk: '#a965bd',
|
||||
blossom: '#c05d78',
|
||||
clay: '#b5954a',
|
||||
chalk: '#c3c3c8'
|
||||
};
|
||||
|
||||
export function getNoteChip(color: NoteColor, isDark: boolean): string {
|
||||
if (!isDark) return getNoteColor(color, false);
|
||||
return NOTE_CHIPS_DARK[color] ?? NOTE_CHIPS_DARK.default;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mergeContent, mergeContentTwoWay } from './content-merge.js';
|
||||
import { mergeContent, mergeContentUpdate } from './content-merge.js';
|
||||
|
||||
describe('mergeContent (3-way)', () => {
|
||||
it('should return remote when local is unchanged', () => {
|
||||
@@ -89,17 +89,56 @@ describe('mergeContent (3-way)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeContentTwoWay', () => {
|
||||
it('should return remote when both are identical', () => {
|
||||
expect(mergeContentTwoWay('hello', 'hello')).toBe('hello');
|
||||
describe('mergeContentUpdate', () => {
|
||||
it('should merge an incoming update against current content when a base is available', () => {
|
||||
const result = mergeContentUpdate({
|
||||
baseContent: '- [ ] Milk\n- [ ] Bread',
|
||||
incomingContent: '- [x] Milk\n- [ ] Bread',
|
||||
currentContent: '- [ ] Milk\n- [x] Bread'
|
||||
});
|
||||
|
||||
expect(result).toBe('- [x] Milk\n- [x] Bread');
|
||||
});
|
||||
|
||||
it('should attempt merge when contents differ', () => {
|
||||
const local = '- [x] Milk\n- [ ] Bread';
|
||||
const remote = '- [ ] Milk\n- [ ] Bread\n- [ ] Eggs';
|
||||
it('should keep the incoming edit when no base snapshot is available', () => {
|
||||
// Without a common ancestor a 3-way merge is impossible; dropping the
|
||||
// incoming edit would silently lose the user's changes.
|
||||
const result = mergeContentUpdate({
|
||||
baseContent: null,
|
||||
incomingContent: '- [x] Milk',
|
||||
currentContent: '- [ ] Bread'
|
||||
});
|
||||
|
||||
const result = mergeContentTwoWay(local, remote);
|
||||
// Without a base, conflict resolution prefers remote
|
||||
expect(result).toContain('- [ ] Bread');
|
||||
expect(result).toBe('- [x] Milk');
|
||||
});
|
||||
|
||||
it('should keep current content when the incoming edit equals it', () => {
|
||||
const result = mergeContentUpdate({
|
||||
baseContent: null,
|
||||
incomingContent: 'same',
|
||||
currentContent: 'same'
|
||||
});
|
||||
|
||||
expect(result).toBe('same');
|
||||
});
|
||||
|
||||
it('should keep current content when the incoming edit matches the base (no local change)', () => {
|
||||
const result = mergeContentUpdate({
|
||||
baseContent: 'base',
|
||||
incomingContent: 'base',
|
||||
currentContent: 'server changed'
|
||||
});
|
||||
|
||||
expect(result).toBe('server changed');
|
||||
});
|
||||
|
||||
it('should take the incoming edit when the server is unchanged from the base', () => {
|
||||
const result = mergeContentUpdate({
|
||||
baseContent: 'base',
|
||||
incomingContent: 'incoming change',
|
||||
currentContent: 'base'
|
||||
});
|
||||
|
||||
expect(result).toBe('incoming change');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -78,11 +78,28 @@ export function mergeContent(base: string, local: string, remote: string): strin
|
||||
}
|
||||
|
||||
/**
|
||||
* 2-way merge fallback when no base is available.
|
||||
* Uses an empty string as the base, so all lines from both sides appear
|
||||
* as additions. For conflicts (same region), prefers remote.
|
||||
* Merge an incoming content snapshot into the current server content.
|
||||
*
|
||||
* Uses a 3-way merge when a base (common ancestor) is available. Without a base —
|
||||
* e.g. the base snapshot was pruned, or the version was bumped by a metadata-only
|
||||
* change that created no snapshot — a 3-way merge is impossible, so keep the
|
||||
* incoming edit rather than silently discarding the user's changes.
|
||||
*/
|
||||
export function mergeContentTwoWay(local: string, remote: string): string {
|
||||
if (local === remote) return remote;
|
||||
return mergeContent('', local, remote);
|
||||
export function mergeContentUpdate(input: {
|
||||
baseContent: string | null;
|
||||
incomingContent: string;
|
||||
currentContent: string;
|
||||
}): string {
|
||||
const { baseContent, incomingContent, currentContent } = input;
|
||||
|
||||
if (incomingContent === currentContent) return currentContent;
|
||||
if (baseContent === incomingContent) return currentContent;
|
||||
if (baseContent === currentContent) return incomingContent;
|
||||
|
||||
if (baseContent === null) {
|
||||
console.warn('[content-merge] no base snapshot for concurrent edit; keeping incoming content');
|
||||
return incomingContent;
|
||||
}
|
||||
|
||||
return mergeContent(baseContent, incomingContent, currentContent);
|
||||
}
|
||||
|
||||
@@ -85,3 +85,16 @@ describe('stripMarkdown', () => {
|
||||
expect(stripMarkdown('')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderMarkdown note-link handling', () => {
|
||||
it('should render a crumb-note:// link as plain text, not a clickable anchor', () => {
|
||||
const html = renderMarkdown('See [Grocery List](crumb-note://abc-123) for details');
|
||||
expect(html).not.toContain('<a');
|
||||
expect(html).toContain('Grocery List');
|
||||
});
|
||||
|
||||
it('should still render a normal external link as a real anchor', () => {
|
||||
const html = renderMarkdown('See [Example](https://example.com) for details');
|
||||
expect(html).toContain('<a href="https://example.com"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,6 +39,25 @@ md.core.ruler.after('inline', 'task-lists', (state) => {
|
||||
}
|
||||
});
|
||||
|
||||
md.core.ruler.after('task-lists', 'note-links', (state) => {
|
||||
for (const token of state.tokens) {
|
||||
if (token.type !== 'inline' || !token.children) continue;
|
||||
|
||||
const children = token.children;
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
if (children[i].type !== 'link_open') continue;
|
||||
const href = children[i].attrGet('href') ?? '';
|
||||
if (!href.startsWith('crumb-note://')) continue;
|
||||
|
||||
let closeIndex = i + 1;
|
||||
while (closeIndex < children.length && children[closeIndex].type !== 'link_close') closeIndex++;
|
||||
children.splice(closeIndex, 1);
|
||||
children.splice(i, 1);
|
||||
i -= 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export function renderMarkdown(content: string): string {
|
||||
return md.render(content);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { extractNoteLinks } from './note-links.js';
|
||||
|
||||
describe('extractNoteLinks', () => {
|
||||
it('should extract a single note-link reference', () => {
|
||||
expect(extractNoteLinks('See [Grocery List](crumb-note://abc-123)')).toEqual(['abc-123']);
|
||||
});
|
||||
|
||||
it('should extract multiple distinct references', () => {
|
||||
const content = '[A](crumb-note://id-1) and [B](crumb-note://id-2)';
|
||||
expect(extractNoteLinks(content)).toEqual(['id-1', 'id-2']);
|
||||
});
|
||||
|
||||
it('should deduplicate repeated references', () => {
|
||||
const content = '[A](crumb-note://id-1) again: [A again](crumb-note://id-1)';
|
||||
expect(extractNoteLinks(content)).toEqual(['id-1']);
|
||||
});
|
||||
|
||||
it('should return an empty array for content with no note-links', () => {
|
||||
expect(extractNoteLinks('Just [a normal link](https://example.com)')).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return an empty array for empty content', () => {
|
||||
expect(extractNoteLinks('')).toEqual([]);
|
||||
});
|
||||
|
||||
it('should not match crumb-note:// occurring inside a code block', () => {
|
||||
const content = '```\n[A](crumb-note://id-1)\n```';
|
||||
expect(extractNoteLinks(content)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should not match crumb-note:// occurring inside inline code', () => {
|
||||
expect(extractNoteLinks('Use `crumb-note://id-1` as an example')).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Extract crumb-note://<id> references from markdown content.
|
||||
* Mirrors extractTags' code-block/inline-code exclusion so a reference
|
||||
* mentioned as an example inside a code sample isn't treated as a real link.
|
||||
*/
|
||||
export function extractNoteLinks(content: string): string[] {
|
||||
if (!content) return [];
|
||||
|
||||
const withoutCodeBlocks = content.replace(/```[\s\S]*?```/g, '');
|
||||
const withoutInlineCode = withoutCodeBlocks.replace(/`[^`]*`/g, '');
|
||||
|
||||
const matches = withoutInlineCode.match(/crumb-note:\/\/([\w-]+)/g);
|
||||
if (!matches) return [];
|
||||
|
||||
const ids = matches.map((m) => m.replace('crumb-note://', ''));
|
||||
return [...new Set(ids)];
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
const LIGHT_THEME_COLOR = '#f0e6d3';
|
||||
const DARK_THEME_COLOR = '#1a1715';
|
||||
const DARK_THEME_COLOR = '#141312';
|
||||
|
||||
let darkMode = $state(false);
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ describe('applyTheme', () => {
|
||||
it('updates meta theme-color for dark mode', () => {
|
||||
applyTheme('dark');
|
||||
const meta = document.querySelector('meta[name="theme-color"]');
|
||||
expect(meta?.getAttribute('content')).toBe('#1a1715');
|
||||
expect(meta?.getAttribute('content')).toBe('#141312');
|
||||
});
|
||||
|
||||
it('updates meta theme-color for light mode', () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { NOTE_COLORS } from '$lib/utils/colors.js';
|
||||
import { getNoteColor } 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';
|
||||
@@ -12,8 +12,12 @@
|
||||
|
||||
const { data } = $props();
|
||||
|
||||
// Both palettes are emitted as custom properties so the card follows the visitor's theme on the
|
||||
// first paint — the CSS variables in app.css switch on [data-theme], which app.html sets before
|
||||
// hydration, so a JS-resolved single colour would flash the wrong palette on this SSR page.
|
||||
const noteColor = data.color as NoteColor;
|
||||
const bgColor = NOTE_COLORS[noteColor]?.bg ?? NOTE_COLORS.default.bg;
|
||||
const bgLight = getNoteColor(noteColor, false);
|
||||
const bgDark = getNoteColor(noteColor, true);
|
||||
|
||||
const renderedContent = renderMarkdown(data.content);
|
||||
|
||||
@@ -55,8 +59,8 @@
|
||||
|
||||
<div class="mx-4 w-full max-w-2xl py-10">
|
||||
<article
|
||||
class="rounded-sm border border-[var(--border-subtle)] shadow-[var(--card-shadow)] overflow-hidden"
|
||||
style="background-color: {bgColor}"
|
||||
class="note-surface rounded-sm border border-[var(--border-subtle)] shadow-[var(--card-shadow)] overflow-hidden"
|
||||
style="--note-bg: {bgLight}; --note-bg-dark: {bgDark}"
|
||||
data-testid="shared-note"
|
||||
>
|
||||
<div class="px-6 py-5">
|
||||
@@ -128,3 +132,13 @@
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.note-surface {
|
||||
background-color: var(--note-bg);
|
||||
}
|
||||
|
||||
:global([data-theme='dark']) .note-surface {
|
||||
background-color: var(--note-bg-dark);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -15,6 +15,9 @@ export const PATCH: RequestHandler = async ({ params, request, ...event }) => {
|
||||
const origin = process.env.ORIGIN || 'http://localhost:3000';
|
||||
|
||||
if (body.role !== undefined) {
|
||||
if (body.role !== 'admin' && body.role !== 'user') {
|
||||
throw error(400, 'Invalid role');
|
||||
}
|
||||
// Prevent admin from demoting themselves
|
||||
if (userId === admin.id && body.role !== 'admin') {
|
||||
throw error(400, 'Cannot change your own role');
|
||||
|
||||
@@ -23,6 +23,7 @@ export const GET: RequestHandler = async ({ params, url, ...event }) => {
|
||||
return new Response(buffer, {
|
||||
headers: {
|
||||
'Content-Type': mimeType,
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'Content-Disposition': `inline; filename="${attachment.filename}"`,
|
||||
'Cache-Control': 'public, max-age=31536000, immutable'
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ export const GET: RequestHandler = async ({ params, url, getClientAddress }) =>
|
||||
return new Response(buffer, {
|
||||
headers: {
|
||||
'Content-Type': mimeType,
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'Content-Disposition': `inline; filename="${attachment.filename}"`,
|
||||
'Cache-Control': 'public, max-age=3600'
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"info": {
|
||||
"title": "Crumbs API",
|
||||
"description": "Auto-generated API spec for crumbs",
|
||||
"version": "0.27.1",
|
||||
"version": "0.31.0",
|
||||
"contact": {
|
||||
"name": "Bretzel",
|
||||
"url": "https://bretzel.app"
|
||||
|
||||
@@ -81,7 +81,33 @@ test.describe.serial('Dark mode', () => {
|
||||
const noteCard = page.getByTestId('note-card').first();
|
||||
await expect(noteCard).toBeVisible();
|
||||
const bg = await noteCard.evaluate((el) => getComputedStyle(el).backgroundColor);
|
||||
// Dark default color is #2a2520 = rgb(42, 37, 32); NOT light default rgb(250, 245, 235)
|
||||
// Dark default color is #21201f = rgb(33, 32, 31); NOT light default rgb(250, 245, 235)
|
||||
expect(bg).not.toBe('rgb(250, 245, 235)');
|
||||
});
|
||||
|
||||
test('Scenario: Colour picker swatches are identifiable in dark mode', async ({
|
||||
authenticatedPage: page
|
||||
}) => {
|
||||
// Given dark theme is active
|
||||
await page.goto('/settings/preferences');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.getByTestId('pref-theme-dark').click();
|
||||
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
|
||||
|
||||
// When the user opens the colour picker while editing a note
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Swatch Test');
|
||||
await page.getByTestId('color-picker-toggle').click();
|
||||
await expect(page.getByTestId('color-picker')).toBeVisible();
|
||||
|
||||
// Then a swatch shows its vivid chip rather than the muted card surface
|
||||
await expect(page.getByTestId('color-coral')).toHaveCSS(
|
||||
'background-color',
|
||||
'rgb(208, 79, 67)'
|
||||
);
|
||||
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect, noteCard } from './helpers/fixtures.js';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { devices, type Page } from '@playwright/test';
|
||||
|
||||
/** Run a TipTap command chain via the exposed editor instance on the DOM element */
|
||||
async function runTiptapCommand(page: Page, commandFn: string) {
|
||||
@@ -13,6 +13,37 @@ async function runTiptapCommand(page: Page, commandFn: string) {
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForTiptapEditor(page: Page) {
|
||||
await page.getByTestId('tiptap-editor').waitFor({ state: 'visible' });
|
||||
await page.waitForFunction(() => {
|
||||
const el = document.querySelector('[data-testid="tiptap-editor"]');
|
||||
return el && (el as any).__tiptapEditor != null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the `checked` attribute of the first task item from the live ProseMirror
|
||||
* document. This reflects the editor's document state, not just the native
|
||||
* checkbox widget — so it catches a widget/document desync where the DOM
|
||||
* checkbox toggles but the transaction never updates the node.
|
||||
*/
|
||||
async function firstTaskItemChecked(page: Page): Promise<boolean> {
|
||||
return page.getByTestId('tiptap-editor').evaluate((el) => {
|
||||
const editor = (el as any).__tiptapEditor;
|
||||
if (!editor) throw new Error('TipTap editor not found on element');
|
||||
let checked: boolean | undefined;
|
||||
editor.state.doc.descendants((node: any) => {
|
||||
if (checked === undefined && node.type.name === 'taskItem') {
|
||||
checked = Boolean(node.attrs.checked);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (checked === undefined) throw new Error('No task item found in document');
|
||||
return checked;
|
||||
});
|
||||
}
|
||||
|
||||
/** Toggle markdown mode via the overflow menu. */
|
||||
async function toggleMarkdownMode(page: Page) {
|
||||
await page.getByTestId('overflow-menu-btn').click();
|
||||
@@ -34,6 +65,7 @@ test.describe('Rich text formatting', () => {
|
||||
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
|
||||
|
||||
// When the user selects "world" and applies bold formatting
|
||||
await waitForTiptapEditor(page);
|
||||
await runTiptapCommand(page, 'editor.chain().focus().setTextSelection({from:7,to:12}).toggleBold().run()');
|
||||
|
||||
// Then the selected text appears bold in the editor
|
||||
@@ -76,6 +108,7 @@ test.describe('Rich text formatting', () => {
|
||||
// Given the user is editing a note with selected text
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await typeViaMarkdown(page, 'visit example');
|
||||
await waitForTiptapEditor(page);
|
||||
await runTiptapCommand(page, 'editor.chain().focus().setTextSelection({from:7,to:14}).run()');
|
||||
|
||||
// When the user opens the link popover and enters a URL
|
||||
@@ -109,3 +142,52 @@ test.describe('Rich text formatting', () => {
|
||||
await expect(page.getByTestId('note-content-input')).toHaveValue('Hello **bold** world');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Rich text formatting on mobile', () => {
|
||||
const pixel7 = devices['Pixel 7'];
|
||||
test.use({
|
||||
viewport: pixel7.viewport,
|
||||
userAgent: pixel7.userAgent,
|
||||
deviceScaleFactor: pixel7.deviceScaleFactor,
|
||||
isMobile: pixel7.isMobile,
|
||||
hasTouch: pixel7.hasTouch
|
||||
});
|
||||
|
||||
test('Scenario: Tapping a task-list checkbox does not focus the editor', async ({ authenticatedPage: page }) => {
|
||||
// Given a regular text note contains a task list in the rich-text editor
|
||||
await page.getByTestId('new-note-fab').click();
|
||||
await page.getByTestId('mobile-overflow-menu-btn').click();
|
||||
await page.getByTestId('markdown-toggle').click();
|
||||
await page.getByTestId('note-content-input').fill('- [ ] Mobile task');
|
||||
await page.getByTestId('mobile-overflow-menu-btn').click();
|
||||
await page.getByTestId('markdown-toggle').click();
|
||||
|
||||
const richTextEditor = page.getByTestId('tiptap-editor').locator('.tiptap');
|
||||
const checkbox = richTextEditor.locator('input[type="checkbox"]');
|
||||
await expect(checkbox).not.toBeChecked();
|
||||
await expect(richTextEditor).not.toBeFocused();
|
||||
await richTextEditor.evaluate((element) => {
|
||||
element.dataset.focusCount = '0';
|
||||
element.addEventListener('focus', () => {
|
||||
element.dataset.focusCount = String(Number(element.dataset.focusCount) + 1);
|
||||
});
|
||||
});
|
||||
|
||||
// When the checkbox is tapped, the change propagates to the editor's document
|
||||
// without focus ever entering the editor (which would open the mobile keyboard)
|
||||
await checkbox.tap();
|
||||
|
||||
await expect(checkbox).toBeChecked();
|
||||
expect(await firstTaskItemChecked(page)).toBe(true);
|
||||
await expect(richTextEditor).not.toBeFocused();
|
||||
await expect(richTextEditor).toHaveAttribute('data-focus-count', '0');
|
||||
|
||||
// And unchecking propagates to the document too, still without focusing the editor
|
||||
await checkbox.tap();
|
||||
|
||||
await expect(checkbox).not.toBeChecked();
|
||||
expect(await firstTaskItemChecked(page)).toBe(false);
|
||||
await expect(richTextEditor).not.toBeFocused();
|
||||
await expect(richTextEditor).toHaveAttribute('data-focus-count', '0');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,9 +13,18 @@ const COLLAB_EMAIL = 'collab@test.com';
|
||||
const COLLAB_PASSWORD = collabPassword;
|
||||
const COLLAB_NAME = 'Collaborator User';
|
||||
|
||||
/** Find a specific note card by its title text */
|
||||
/**
|
||||
* Find a specific note card by its exact title text. Matches only the
|
||||
* card's <h3> title, not its full text content — with note-linking, a
|
||||
* card's content preview can render another note's title as plain text
|
||||
* (e.g. a stripped note-link), so a substring match against the whole
|
||||
* card can ambiguously resolve to more than one card.
|
||||
*/
|
||||
export function noteCard(page: Page, title: string): Locator {
|
||||
return page.locator('[data-testid="note-card"]', { hasText: title });
|
||||
const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
return page.locator('[data-testid="note-card"]').filter({
|
||||
has: page.locator('h3', { hasText: new RegExp(`^${escaped}$`) })
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -104,6 +113,11 @@ export async function createNote(page: Page, title: string, content?: string) {
|
||||
await editor.pressSequentially(content);
|
||||
}
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
// Wait for the editor to fully unmount, not just start closing. The close is
|
||||
// animated and dismisses via an async history.back() (see NoteEditor); returning
|
||||
// before it settles lets a following page.goto race that navigation and abort it
|
||||
// (net::ERR_ABORTED). The outer overlay is removed only when the close completes.
|
||||
await expect(page.getByTestId('note-editor-overlay')).toHaveCount(0);
|
||||
await expect(noteCard(page, title)).toBeVisible();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { test, expect, createNote, noteCard } from './helpers/fixtures.js';
|
||||
|
||||
test.describe('Note Linking', () => {
|
||||
test('Scenario: Linking to a note renders a live chip and clicking it opens that note', async ({ authenticatedPage: page }) => {
|
||||
await createNote(page, 'Target Note', 'target content');
|
||||
await createNote(page, 'Source Note', 'source content');
|
||||
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Linker Note');
|
||||
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
|
||||
await editor.click();
|
||||
await page.getByTestId('format-link').click();
|
||||
await page.getByTestId('format-link-input').fill('Target');
|
||||
await page.getByTestId('format-link-note-result').filter({ hasText: 'Target Note' }).click();
|
||||
|
||||
await expect(page.getByTestId('tiptap-editor').locator('[data-note-id]')).toBeVisible();
|
||||
await page.getByTestId('tiptap-editor').locator('[data-note-id]').click();
|
||||
|
||||
await expect(page.getByTestId('note-title-input').first()).toHaveValue('Target Note');
|
||||
});
|
||||
|
||||
test('Scenario: Renaming a linked note updates the displayed link text', async ({ authenticatedPage: page }) => {
|
||||
await createNote(page, 'Original Title', '');
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Linker Note');
|
||||
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
|
||||
await editor.click();
|
||||
await page.getByTestId('format-link').click();
|
||||
await page.getByTestId('format-link-input').fill('Original');
|
||||
await page.getByTestId('format-link-note-result').filter({ hasText: 'Original Title' }).click();
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
|
||||
await noteCard(page, 'Original Title').click();
|
||||
await page.getByTestId('note-title-input').fill('Renamed Title');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
|
||||
await noteCard(page, 'Linker Note').click();
|
||||
await expect(editor.getByText('Renamed Title')).toBeVisible();
|
||||
await expect(editor.getByText('Original Title')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: A link to a trashed note renders inert and does not navigate', async ({ authenticatedPage: page }) => {
|
||||
await createNote(page, 'Doomed Note', '');
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Linker Note');
|
||||
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
|
||||
await editor.click();
|
||||
await page.getByTestId('format-link').click();
|
||||
await page.getByTestId('format-link-input').fill('Doomed');
|
||||
await page.getByTestId('format-link-note-result').filter({ hasText: 'Doomed Note' }).click();
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
|
||||
await noteCard(page, 'Doomed Note').click();
|
||||
await page.getByTestId('overflow-menu-btn').click();
|
||||
await page.getByTestId('trash-note-btn').click();
|
||||
await expect(page.getByTestId('note-editor-overlay')).toHaveCount(0);
|
||||
|
||||
await noteCard(page, 'Linker Note').click();
|
||||
await expect(editor.getByText('Note not found')).toBeVisible();
|
||||
await editor.getByText('Note not found').click();
|
||||
await expect(page.getByTestId('note-title-input').first()).toHaveValue('Linker Note');
|
||||
});
|
||||
|
||||
test('Scenario: The linked-to note shows a Referenced by backlink', async ({ authenticatedPage: page }) => {
|
||||
await createNote(page, 'Popular Note', '');
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Referencing Note');
|
||||
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
|
||||
await editor.click();
|
||||
await page.getByTestId('format-link').click();
|
||||
await page.getByTestId('format-link-input').fill('Popular');
|
||||
await page.getByTestId('format-link-note-result').filter({ hasText: 'Popular Note' }).click();
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
|
||||
await noteCard(page, 'Popular Note').click();
|
||||
|
||||
await expect(page.getByTestId('backlink-chip').filter({ hasText: 'Referencing Note' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: The note-picker excludes the currently-open note from its own search results', async ({ authenticatedPage: page }) => {
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Self Reference Test');
|
||||
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
|
||||
await editor.click();
|
||||
|
||||
await page.getByTestId('format-link').click();
|
||||
await page.getByTestId('format-link-input').fill('Self Reference');
|
||||
|
||||
await expect(page.getByTestId('format-link-note-result').filter({ hasText: 'Self Reference Test' })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('Scenario: Hovering a note-link chip reveals a remove button that deletes it without navigating', async ({ authenticatedPage: page }) => {
|
||||
await createNote(page, 'Removable Target', '');
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Has A Link');
|
||||
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
|
||||
await editor.click();
|
||||
await page.getByTestId('format-link').click();
|
||||
await page.getByTestId('format-link-input').fill('Removable');
|
||||
await page.getByTestId('format-link-note-result').filter({ hasText: 'Removable Target' }).click();
|
||||
|
||||
const chip = page.getByTestId('tiptap-editor').locator('[data-note-id]');
|
||||
await expect(chip).toBeVisible();
|
||||
await chip.hover();
|
||||
await page.getByRole('button', { name: 'Remove note link' }).click();
|
||||
|
||||
await expect(chip).toHaveCount(0);
|
||||
await expect(page.getByTestId('note-title-input').first()).toHaveValue('Has A Link');
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,40 @@ test.describe('Notes CRUD', () => {
|
||||
await expect(page.getByText('Updated Title')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Browser back closes the open editor instead of leaving the page', async ({ authenticatedPage: page }) => {
|
||||
// Given a note titled "Back Closes Me" exists
|
||||
await createNote(page, 'Back Closes Me', 'Some content');
|
||||
|
||||
// Given the note is open in the editor
|
||||
await noteCard(page, 'Back Closes Me').click();
|
||||
await expect(page.getByTestId('note-editor')).toBeVisible();
|
||||
|
||||
// When the user navigates back
|
||||
await page.goBack();
|
||||
|
||||
// Then the editor is closed and the notes list is still shown
|
||||
await expect(page.getByTestId('note-editor')).not.toBeVisible();
|
||||
await expect(noteCard(page, 'Back Closes Me')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Closing the editor leaves browser history clean', async ({ authenticatedPage: page }) => {
|
||||
// Given a note titled "Clean History" exists
|
||||
await createNote(page, 'Clean History');
|
||||
|
||||
// Given the user opened and closed the note
|
||||
await noteCard(page, 'Clean History').click();
|
||||
await expect(page.getByTestId('note-editor')).toBeVisible();
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
await expect(page.getByTestId('note-editor')).not.toBeVisible();
|
||||
|
||||
// When the user navigates back
|
||||
await page.goBack();
|
||||
|
||||
// Then the editor does not reopen and the note's URL is gone from history
|
||||
await expect(page.getByTestId('note-editor')).not.toBeVisible();
|
||||
expect(page.url()).not.toContain('#');
|
||||
});
|
||||
|
||||
test('Scenario: Trashed note disappears from the main view', async ({ authenticatedPage: page }) => {
|
||||
// Given a note titled "Delete Me" exists
|
||||
await createNote(page, 'Delete Me');
|
||||
|
||||
@@ -121,6 +121,54 @@ test.describe('Public Note Sharing', () => {
|
||||
await expect(card.getByTestId('sharing-indicator')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Shared note follows a dark-mode visitor theme', async ({
|
||||
authenticatedPage: page,
|
||||
browser
|
||||
}) => {
|
||||
// Given a publicly shared note colored "fog"
|
||||
const note = await createNoteViaApi(page, 'Dark Visitor Note', 'Readable in dark mode');
|
||||
await page.request.patch(`/api/notes/${note.id}`, { data: { color: 'fog' } });
|
||||
const token = await createShareViaApi(page, note.id);
|
||||
|
||||
// When a visitor whose system prefers dark mode opens the share URL
|
||||
const context = await browser.newContext({ colorScheme: 'dark' });
|
||||
const publicPage = await context.newPage();
|
||||
await publicPage.goto(`/s/${token}`);
|
||||
|
||||
// Then the note is rendered on the dark "fog" surface, so its text stays legible
|
||||
await expect(publicPage.locator('html')).toHaveAttribute('data-theme', 'dark');
|
||||
await expect(publicPage.getByTestId('shared-note')).toHaveCSS(
|
||||
'background-color',
|
||||
'rgb(37, 57, 70)'
|
||||
);
|
||||
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test('Scenario: Shared note follows a light-mode visitor theme', async ({
|
||||
authenticatedPage: page,
|
||||
browser
|
||||
}) => {
|
||||
// Given a publicly shared note colored "fog"
|
||||
const note = await createNoteViaApi(page, 'Light Visitor Note', 'Readable in light mode');
|
||||
await page.request.patch(`/api/notes/${note.id}`, { data: { color: 'fog' } });
|
||||
const token = await createShareViaApi(page, note.id);
|
||||
|
||||
// When a visitor whose system prefers light mode opens the share URL
|
||||
const context = await browser.newContext({ colorScheme: 'light' });
|
||||
const publicPage = await context.newPage();
|
||||
await publicPage.goto(`/s/${token}`);
|
||||
|
||||
// Then the note is rendered on the light "fog" surface
|
||||
await expect(publicPage.locator('html')).not.toHaveAttribute('data-theme', 'dark');
|
||||
await expect(publicPage.getByTestId('shared-note')).toHaveCSS(
|
||||
'background-color',
|
||||
'rgb(212, 228, 237)'
|
||||
);
|
||||
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test('Scenario: Copy link button copies to clipboard', async ({
|
||||
authenticatedPage: page
|
||||
}) => {
|
||||
|
||||
@@ -31,4 +31,84 @@ test.describe('Search', () => {
|
||||
// Then no notes are displayed
|
||||
await expect(page.getByTestId('note-card')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('Scenario: Background sync preserves active search results', async ({ authenticatedPage: page }) => {
|
||||
await createNote(page, 'Sync Search Target 3812925');
|
||||
await createNote(page, 'Sync Search Other 3812925');
|
||||
|
||||
await page.getByTestId('search-input').fill('Sync Search Target 3812925');
|
||||
await expect(page.getByText('Sync Search Target 3812925')).toBeVisible();
|
||||
await expect(page.getByText('Sync Search Other 3812925')).not.toBeVisible();
|
||||
|
||||
const notesReloaded = page.waitForResponse(
|
||||
(response) => response.url().includes('/api/notes?filter=all') && response.ok()
|
||||
);
|
||||
await page.getByTestId('sync-indicator').click();
|
||||
await notesReloaded;
|
||||
|
||||
await expect(page.getByText('Sync Search Target 3812925')).toBeVisible();
|
||||
await expect(page.getByText('Sync Search Other 3812925')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: A stale response cannot replace newer search results', async ({ authenticatedPage: page }) => {
|
||||
await createNote(page, 'Race Slow 3812925');
|
||||
await createNote(page, 'Race Fast 3812925');
|
||||
|
||||
const slowResponse = await page.request.get('/api/search?q=Race%20Slow%203812925');
|
||||
const fastResponse = await page.request.get('/api/search?q=Race%20Fast%203812925');
|
||||
const slowResults = await slowResponse.json();
|
||||
const fastResults = await fastResponse.json();
|
||||
|
||||
await page.route('**/api/search?*', async (route) => {
|
||||
const requestQuery = new URL(route.request().url()).searchParams.get('q');
|
||||
if (requestQuery === 'Race Slow 3812925') {
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
await route.fulfill({ json: slowResults });
|
||||
return;
|
||||
}
|
||||
await route.fulfill({ json: fastResults });
|
||||
});
|
||||
|
||||
const searchInput = page.getByTestId('search-input');
|
||||
const slowRequestStarted = page.waitForRequest(
|
||||
(request) => new URL(request.url()).searchParams.get('q') === 'Race Slow 3812925'
|
||||
);
|
||||
await searchInput.fill('Race Slow 3812925');
|
||||
await slowRequestStarted;
|
||||
await searchInput.fill('Race Fast 3812925');
|
||||
|
||||
await expect(page.getByText('Race Fast 3812925')).toBeVisible();
|
||||
await expect(page.getByText('Race Slow 3812925')).not.toBeVisible();
|
||||
await page.waitForTimeout(300);
|
||||
await expect(page.getByText('Race Fast 3812925')).toBeVisible();
|
||||
await expect(page.getByText('Race Slow 3812925')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Current notes stay visible while a search request is in flight', async ({ authenticatedPage: page }) => {
|
||||
// Given two notes exist
|
||||
await createNote(page, 'Flash Alpha 771903');
|
||||
await createNote(page, 'Flash Beta 771903');
|
||||
|
||||
// And the search request is held open before it can respond
|
||||
let releaseSearch: () => void = () => {};
|
||||
const searchGate = new Promise<void>((resolve) => {
|
||||
releaseSearch = resolve;
|
||||
});
|
||||
await page.route('**/api/search?*', async (route) => {
|
||||
await searchGate;
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
// When the user starts a search
|
||||
await page.getByTestId('search-input').fill('Flash Alpha 771903');
|
||||
|
||||
// Then the current notes stay on screen instead of flashing an empty state
|
||||
await expect(page.getByText('Flash Beta 771903')).toBeVisible();
|
||||
await expect(page.getByText('Flash Alpha 771903')).toBeVisible();
|
||||
|
||||
// And once the results arrive, only the match remains
|
||||
releaseSearch();
|
||||
await expect(page.getByText('Flash Alpha 771903')).toBeVisible();
|
||||
await expect(page.getByText('Flash Beta 771903')).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -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.28.4</span>
|
||||
<span class="footer-version">v0.32.0</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user