forked from mirror/crumbs-original
Compare commits
+75
-18
@@ -48,13 +48,20 @@ jobs:
|
||||
needs: bump
|
||||
uses: ./.github/workflows/ci.yml
|
||||
|
||||
release:
|
||||
name: Build & Push Docker Image
|
||||
runs-on: ubuntu-latest
|
||||
build:
|
||||
name: Build Docker (${{ matrix.platform }})
|
||||
needs: [bump, ci]
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -65,13 +72,6 @@ jobs:
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}},value=${{ needs.bump.outputs.tag }}
|
||||
type=semver,pattern={{major}}.{{minor}},value=${{ needs.bump.outputs.tag }}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Set up QEMU (for multi-platform builds)
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
@@ -83,17 +83,74 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
provenance: false
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
cache-from: type=gha,scope=${{ matrix.platform }}
|
||||
cache-to: type=gha,scope=${{ matrix.platform }},mode=max
|
||||
outputs: type=image,"name=ghcr.io/${{ github.repository }}",push-by-digest=true,name-canonical=true,push=true
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p ${{ runner.temp }}/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digest-${{ matrix.runner }}
|
||||
path: ${{ runner.temp }}/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
merge:
|
||||
name: Merge manifests & release
|
||||
runs-on: ubuntu-latest
|
||||
needs: [bump, build]
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: ${{ runner.temp }}/digests
|
||||
pattern: digest-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}},value=${{ needs.bump.outputs.tag }}
|
||||
type=semver,pattern={{major}}.{{minor}},value=${{ needs.bump.outputs.tag }}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Create manifest list and push
|
||||
working-directory: ${{ runner.temp }}/digests
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf 'ghcr.io/${{ github.repository }}@sha256:%s ' *)
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
@@ -104,7 +161,7 @@ jobs:
|
||||
pin-docker-compose:
|
||||
name: Pin docker-compose to released version
|
||||
runs-on: ubuntu-latest
|
||||
needs: [bump, release]
|
||||
needs: [bump, merge]
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
|
||||
@@ -121,5 +121,15 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build Docker image
|
||||
run: docker build -t crumbs:ci .
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: false
|
||||
provenance: false
|
||||
tags: crumbs:ci
|
||||
cache-from: type=gha,scope=ci-docker
|
||||
cache-to: type=gha,scope=ci-docker,mode=max
|
||||
|
||||
@@ -13,10 +13,18 @@ jobs:
|
||||
ci:
|
||||
uses: ./.github/workflows/ci.yml
|
||||
|
||||
release:
|
||||
name: Build & Push Docker Image
|
||||
runs-on: ubuntu-latest
|
||||
build:
|
||||
name: Build Docker (${{ matrix.platform }})
|
||||
needs: ci
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -25,13 +33,6 @@ jobs:
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Set up QEMU (for multi-platform builds)
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
@@ -43,17 +44,71 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
provenance: false
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
cache-from: type=gha,scope=${{ matrix.platform }}
|
||||
cache-to: type=gha,scope=${{ matrix.platform }},mode=max
|
||||
outputs: type=image,"name=ghcr.io/${{ github.repository }}",push-by-digest=true,name-canonical=true,push=true
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p ${{ runner.temp }}/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digest-${{ matrix.runner }}
|
||||
path: ${{ runner.temp }}/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
merge:
|
||||
name: Merge manifests & release
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: ${{ runner.temp }}/digests
|
||||
pattern: digest-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Create manifest list and push
|
||||
working-directory: ${{ runner.temp }}/digests
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf 'ghcr.io/${{ github.repository }}@sha256:%s ' *)
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
@@ -63,7 +118,7 @@ jobs:
|
||||
pin-docker-compose:
|
||||
name: Pin docker-compose to released version
|
||||
runs-on: ubuntu-latest
|
||||
needs: release
|
||||
needs: merge
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
crumbs:
|
||||
image: ghcr.io/bretzel-app/crumbs:0.23.0
|
||||
image: ghcr.io/bretzel-app/crumbs:0.28.1
|
||||
container_name: crumbs
|
||||
ports:
|
||||
- "3000:3000"
|
||||
|
||||
@@ -103,6 +103,21 @@ Key tables:
|
||||
- `shared_notes` - Public share links (noteId → token, 1:1 with cascade delete)
|
||||
- `sync_log` - Sync operation history
|
||||
|
||||
### Nested Checklist Serialization
|
||||
|
||||
Checklist items are stored as plain text in the note `content` field, one item per line. Child items use a 2-space indent prefix:
|
||||
|
||||
```
|
||||
- [ ] Parent item
|
||||
- [ ] Child item
|
||||
- [x] Checked child item
|
||||
- [x] Another parent
|
||||
```
|
||||
|
||||
- A line starting with ` - [ ]` or ` - [x]` (2 spaces) is parsed as a child of the preceding top-level item.
|
||||
- The `parentId` field on `ChecklistItem` is **runtime-only** — it is derived from position during parsing and never stored explicitly.
|
||||
- This format is fully backward compatible: flat checklists (no indented lines) parse identically to before.
|
||||
|
||||
### Collaboration Access Control
|
||||
|
||||
Notes support sharing with other users on the same instance:
|
||||
|
||||
@@ -37,6 +37,11 @@
|
||||
- Check/uncheck items with checkboxes
|
||||
- Checked items get strikethrough styling
|
||||
- Enter key adds new item, Backspace removes empty items
|
||||
- **Nested checklists**: single-level nesting for sub-tasks
|
||||
- Tab / Shift+Tab to indent or outdent an item (keyboard)
|
||||
- Drag handle horizontal swipe for mobile indentation
|
||||
- Checking a parent item checks all its children
|
||||
- Done section groups checked children under their parent label
|
||||
|
||||
### Image Attachments
|
||||
- Upload images via file picker or drag-and-drop
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "crumbs",
|
||||
"private": true,
|
||||
"version": "0.24.0",
|
||||
"version": "0.28.2",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"author": "Bretzel (https://bretzel.app)",
|
||||
|
||||
@@ -8,11 +8,11 @@ export const MOBILE_VIEWPORT = { width: 390, height: 844 };
|
||||
export const ADMIN = {
|
||||
email: 'admin@bretzel.app',
|
||||
displayName: 'Bretzel Admin',
|
||||
password: 'bretzel2026!'
|
||||
password: crypto.randomUUID()
|
||||
};
|
||||
|
||||
export const COLLABORATOR = {
|
||||
email: 'salzig@bretzel.app',
|
||||
displayName: 'Salzig Bretzel',
|
||||
password: 'salzig2026!!'
|
||||
password: crypto.randomUUID()
|
||||
};
|
||||
|
||||
+49
-1
@@ -135,12 +135,20 @@ body::before {
|
||||
.tiptap ul[data-type="taskList"] li,
|
||||
.prose ul.task-list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Nested task lists take full width below the parent item */
|
||||
.tiptap ul[data-type="taskList"] li > ul[data-type="taskList"],
|
||||
.prose ul.task-list li > ul.task-list {
|
||||
flex-basis: 100%;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.tiptap ul[data-type="taskList"] li::marker,
|
||||
.prose ul.task-list li::marker {
|
||||
content: none;
|
||||
@@ -151,6 +159,7 @@ body::before {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
|
||||
/* Shared checkbox styles */
|
||||
@@ -158,6 +167,7 @@ body::before {
|
||||
.prose ul.task-list .task-checkbox {
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
margin-top: 0.2em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -195,3 +205,41 @@ body::before {
|
||||
.prose ul.task-list li p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Checklist link styles */
|
||||
.checklist-link {
|
||||
color: var(--primary);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.checklist-link:hover {
|
||||
color: var(--primary-hover);
|
||||
}
|
||||
|
||||
[data-placeholder]:empty::before {
|
||||
content: attr(data-placeholder);
|
||||
color: var(--text-muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Custom tooltips */
|
||||
.tooltip-popup {
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.7rem;
|
||||
line-height: 1.2;
|
||||
color: var(--bg-surface);
|
||||
background: var(--text);
|
||||
border-radius: 2px;
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
transition: opacity 100ms ease-out;
|
||||
}
|
||||
|
||||
.tooltip-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -3,14 +3,11 @@
|
||||
import Plus from 'lucide-svelte/icons/plus';
|
||||
import GripVertical from 'lucide-svelte/icons/grip-vertical';
|
||||
import ChevronRight from 'lucide-svelte/icons/chevron-right';
|
||||
import { onDestroy } from 'svelte';
|
||||
import { dragHandleZone, dragHandle, type DndEvent } from 'svelte-dnd-action';
|
||||
import { flip } from 'svelte/animate';
|
||||
|
||||
interface ChecklistItem {
|
||||
id: string;
|
||||
text: string;
|
||||
checked: boolean;
|
||||
}
|
||||
import { type ChecklistItem, generateId, parseChecklist, serializeChecklist, toggleItemWithCascade, indentItem, outdentItem, linkifyText, unlinkifyHtml } from '$lib/utils/checklist.js';
|
||||
import LinkPopover from './LinkPopover.svelte';
|
||||
|
||||
interface Props {
|
||||
content: string;
|
||||
@@ -23,67 +20,106 @@
|
||||
let items = $state<ChecklistItem[]>(parseChecklist(content));
|
||||
let doneExpanded = $state(true);
|
||||
const flipDurationMs = 150;
|
||||
let linkPopover = $state<{ url: string; anchor: DOMRect } | null>(null);
|
||||
|
||||
// Track the last content we emitted so we can distinguish self-originated
|
||||
// changes from external ones (sync, history restore, etc.)
|
||||
let lastEmitted = content;
|
||||
|
||||
// Re-parse items when content changes externally (not from our own emitChange)
|
||||
$effect(() => {
|
||||
if (content !== lastEmitted) {
|
||||
items = parseChecklist(content);
|
||||
lastEmitted = content;
|
||||
}
|
||||
});
|
||||
|
||||
let activeItems = $derived(items.filter((i) => !i.checked));
|
||||
let doneItems = $derived(items.filter((i) => i.checked));
|
||||
let doneCount = $derived(doneItems.length);
|
||||
|
||||
function generateId(): string {
|
||||
return crypto.randomUUID();
|
||||
interface DoneSectionGroup {
|
||||
parentLabel: ChecklistItem | null;
|
||||
children: ChecklistItem[];
|
||||
}
|
||||
|
||||
function parseChecklist(text: string): ChecklistItem[] {
|
||||
if (!text.trim()) return [{ id: generateId(), text: '', checked: false }];
|
||||
const lines = text.split('\n');
|
||||
return lines.map((line) => {
|
||||
if (line.startsWith('- [x] ')) return { id: generateId(), text: line.slice(6), checked: true };
|
||||
if (line.startsWith('- [ ] ')) return { id: generateId(), text: line.slice(6), checked: false };
|
||||
return { id: generateId(), text: line, checked: false };
|
||||
});
|
||||
}
|
||||
let doneSectionGroups = $derived.by(() => {
|
||||
const groups: DoneSectionGroup[] = [];
|
||||
const childrenByParent = new Map<string, ChecklistItem[]>();
|
||||
const topLevelDone: ChecklistItem[] = [];
|
||||
|
||||
function serializeChecklist(list: ChecklistItem[]): string {
|
||||
return list
|
||||
.map((item) => `- [${item.checked ? 'x' : ' '}] ${item.text}`)
|
||||
.join('\n');
|
||||
}
|
||||
for (const item of doneItems) {
|
||||
if (item.parentId) {
|
||||
const siblings = childrenByParent.get(item.parentId) ?? [];
|
||||
siblings.push(item);
|
||||
childrenByParent.set(item.parentId, siblings);
|
||||
} else {
|
||||
topLevelDone.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of topLevelDone) {
|
||||
const doneChildren = childrenByParent.get(item.id) ?? [];
|
||||
groups.push({ parentLabel: null, children: [item, ...doneChildren] });
|
||||
childrenByParent.delete(item.id);
|
||||
}
|
||||
|
||||
for (const [parentId, children] of childrenByParent) {
|
||||
const parent = items.find((i) => i.id === parentId);
|
||||
if (parent) {
|
||||
groups.push({ parentLabel: parent, children });
|
||||
} else {
|
||||
groups.push({ parentLabel: null, children });
|
||||
}
|
||||
}
|
||||
|
||||
return groups;
|
||||
});
|
||||
|
||||
function emitChange() {
|
||||
onChange(serializeChecklist(items));
|
||||
const serialized = serializeChecklist(items);
|
||||
lastEmitted = serialized;
|
||||
onChange(serialized);
|
||||
}
|
||||
|
||||
function toggleItem(id: string) {
|
||||
const item = items.find((i) => i.id === id);
|
||||
if (item) {
|
||||
item.checked = !item.checked;
|
||||
emitChange();
|
||||
}
|
||||
items = toggleItemWithCascade(id, items);
|
||||
emitChange();
|
||||
}
|
||||
|
||||
function updateText(id: string, text: string) {
|
||||
function updateText(id: string, innerHTML: string) {
|
||||
const item = items.find((i) => i.id === id);
|
||||
if (item) {
|
||||
item.text = text;
|
||||
item.text = unlinkifyHtml(innerHTML);
|
||||
emitChange();
|
||||
}
|
||||
}
|
||||
|
||||
function addItem(afterIndex: number) {
|
||||
const newItem = { id: generateId(), text: '', checked: false };
|
||||
const afterItem = items[afterIndex];
|
||||
const newItem: ChecklistItem = {
|
||||
id: generateId(),
|
||||
text: '',
|
||||
checked: false,
|
||||
parentId: afterItem?.parentId ?? null
|
||||
};
|
||||
items.splice(afterIndex + 1, 0, newItem);
|
||||
items = [...items];
|
||||
emitChange();
|
||||
// Focus by unique ID — positional indexing breaks when checked items
|
||||
// are interleaved in items[] (e.g. reopened notes) because the DOM
|
||||
// only renders active items as inputs
|
||||
const newId = newItem.id;
|
||||
setTimeout(() => {
|
||||
document.querySelector<HTMLInputElement>(`[data-item-id="${newId}"]`)?.focus();
|
||||
document.querySelector<HTMLElement>(`[data-item-id="${newId}"]`)?.focus();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function removeItem(id: string) {
|
||||
if (items.length <= 1) return;
|
||||
if (items.length <= 1) {
|
||||
if (items[0].parentId) items[0].parentId = null;
|
||||
items = [...items];
|
||||
emitChange();
|
||||
return;
|
||||
}
|
||||
items = items.map((i) => (i.parentId === id ? { ...i, parentId: null } : i));
|
||||
const index = items.findIndex((i) => i.id === id);
|
||||
items.splice(index, 1);
|
||||
items = [...items];
|
||||
@@ -99,41 +135,178 @@
|
||||
e.preventDefault();
|
||||
removeItem(id);
|
||||
setTimeout(() => {
|
||||
const inputs = document.querySelectorAll<HTMLInputElement>('[data-testid="checklist-input"]');
|
||||
const inputs = document.querySelectorAll<HTMLElement>('[data-testid="checklist-input"]');
|
||||
inputs[Math.max(0, index - 1)]?.focus();
|
||||
}, 0);
|
||||
} else if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
||||
const inputs = Array.from(document.querySelectorAll<HTMLInputElement>('[data-testid="checklist-input"]'));
|
||||
const inputs = Array.from(document.querySelectorAll<HTMLElement>('[data-testid="checklist-input"]'));
|
||||
const current = inputs.findIndex((el) => el.dataset.itemId === id);
|
||||
const target = e.key === 'ArrowUp' ? current - 1 : current + 1;
|
||||
if (target >= 0 && target < inputs.length) {
|
||||
e.preventDefault();
|
||||
inputs[target].focus();
|
||||
}
|
||||
} else if (e.key === 'Tab' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
items = indentItem(id, items, activeItems);
|
||||
emitChange();
|
||||
} else if (e.key === 'Tab' && e.shiftKey) {
|
||||
e.preventDefault();
|
||||
items = outdentItem(id, items);
|
||||
emitChange();
|
||||
}
|
||||
}
|
||||
|
||||
// Svelte action: sets innerHTML on mount and syncs from external changes only.
|
||||
// While the user is typing (element is focused), we don't re-render — that would
|
||||
// destroy the cursor. Linkification happens on blur via the onblur handler.
|
||||
function editableContent(node: HTMLElement, text: string) {
|
||||
node.innerHTML = linkifyText(text);
|
||||
return {
|
||||
update(newText: string) {
|
||||
if (document.activeElement !== node) {
|
||||
node.innerHTML = linkifyText(newText);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function handleLinkClick(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement;
|
||||
const anchor = target.closest('a');
|
||||
if (anchor) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
linkPopover = { url: anchor.href, anchor: anchor.getBoundingClientRect() };
|
||||
}
|
||||
}
|
||||
|
||||
function handlePaste(e: ClipboardEvent) {
|
||||
e.preventDefault();
|
||||
const text = (e.clipboardData?.getData('text/plain') ?? '').replace(/[\r\n]+/g, ' ');
|
||||
const selection = window.getSelection();
|
||||
if (!selection?.rangeCount) return;
|
||||
const range = selection.getRangeAt(0);
|
||||
range.deleteContents();
|
||||
range.insertNode(document.createTextNode(text));
|
||||
range.collapse(false);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
(e.target as HTMLElement).dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
// --- Directional lock drag handle ---
|
||||
// Instead of blocking DnD events (which corrupts its internal state),
|
||||
// we detect horizontal intent and store the pending operation. DnD runs
|
||||
// its full course unimpeded. After DnD finalizes, we apply the indent.
|
||||
let dragStartX = 0;
|
||||
let dragStartY = 0;
|
||||
let pendingIndent: { id: string; direction: 'indent' | 'outdent' } | null = null;
|
||||
let directionLocked = false;
|
||||
const LOCK_THRESHOLD = 10;
|
||||
const INDENT_THRESHOLD = 32;
|
||||
|
||||
function windowPointerMove(e: PointerEvent) {
|
||||
if (directionLocked) return;
|
||||
|
||||
const dx = Math.abs(e.clientX - dragStartX);
|
||||
const dy = Math.abs(e.clientY - dragStartY);
|
||||
if (dx >= LOCK_THRESHOLD || dy >= LOCK_THRESHOLD) {
|
||||
directionLocked = true;
|
||||
if (dx <= dy) {
|
||||
// Vertical — DnD handles it, we're done
|
||||
removeWindowListeners();
|
||||
}
|
||||
// Horizontal — keep listening for pointerup to measure total dx
|
||||
}
|
||||
}
|
||||
|
||||
function windowPointerUp(e: PointerEvent) {
|
||||
const dx = e.clientX - dragStartX;
|
||||
if (Math.abs(dx) > INDENT_THRESHOLD && directionLocked && currentDragItemId) {
|
||||
pendingIndent = {
|
||||
id: currentDragItemId,
|
||||
direction: dx > 0 ? 'indent' : 'outdent'
|
||||
};
|
||||
}
|
||||
removeWindowListeners();
|
||||
directionLocked = false;
|
||||
}
|
||||
|
||||
function addWindowListeners() {
|
||||
window.addEventListener('pointermove', windowPointerMove, { capture: true });
|
||||
window.addEventListener('pointerup', windowPointerUp, { capture: true });
|
||||
}
|
||||
|
||||
function removeWindowListeners() {
|
||||
window.removeEventListener('pointermove', windowPointerMove, { capture: true });
|
||||
window.removeEventListener('pointerup', windowPointerUp, { capture: true });
|
||||
}
|
||||
|
||||
let currentDragItemId = '';
|
||||
function handleDragPointerDown(e: PointerEvent, id: string) {
|
||||
dragStartX = e.clientX;
|
||||
dragStartY = e.clientY;
|
||||
directionLocked = false;
|
||||
pendingIndent = null;
|
||||
currentDragItemId = id;
|
||||
addWindowListeners();
|
||||
}
|
||||
|
||||
onDestroy(removeWindowListeners);
|
||||
|
||||
// --- DnD handlers ---
|
||||
function recomputeParentIds(reorderedItems: ChecklistItem[]): ChecklistItem[] {
|
||||
let lastTopLevelId: string | null = null;
|
||||
return reorderedItems.map((item) => {
|
||||
if (item.parentId === null) {
|
||||
lastTopLevelId = item.id;
|
||||
return item;
|
||||
}
|
||||
return { ...item, parentId: lastTopLevelId };
|
||||
});
|
||||
}
|
||||
|
||||
function handleDndConsider(e: CustomEvent<DndEvent<ChecklistItem>>) {
|
||||
// Merge reordered active items with done items
|
||||
items = [...e.detail.items, ...items.filter((i) => i.checked)];
|
||||
}
|
||||
|
||||
function handleDndFinalize(e: CustomEvent<DndEvent<ChecklistItem>>) {
|
||||
items = [...e.detail.items, ...items.filter((i) => i.checked)];
|
||||
const reorderedActive = recomputeParentIds(e.detail.items);
|
||||
items = [...reorderedActive, ...items.filter((i) => i.checked)];
|
||||
emitChange();
|
||||
|
||||
// Apply pending indent/outdent from horizontal drag (after DnD fully cleans up)
|
||||
if (pendingIndent && pendingIndent.id) {
|
||||
const { id, direction } = pendingIndent;
|
||||
pendingIndent = null;
|
||||
if (direction === 'indent') {
|
||||
items = indentItem(id, items, activeItems);
|
||||
} else {
|
||||
items = outdentItem(id, items);
|
||||
}
|
||||
emitChange();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-0.5" data-testid="checklist">
|
||||
<section
|
||||
use:dragHandleZone={{ items: activeItems, flipDurationMs, dropTargetStyle: {} }}
|
||||
use:dragHandleZone={{ items: activeItems, flipDurationMs, dropTargetStyle: { outline: 'none' } }}
|
||||
onconsider={handleDndConsider}
|
||||
onfinalize={handleDndFinalize}
|
||||
class="space-y-0.5"
|
||||
>
|
||||
{#each activeItems as item (item.id)}
|
||||
<div class="group flex items-center gap-2 py-1.5" animate:flip={{ duration: flipDurationMs }}>
|
||||
<div use:dragHandle aria-label="drag handle" class="drag-handle cursor-grab max-md:opacity-100 md:opacity-0 md:group-hover:opacity-100 focus:opacity-100 transition-opacity duration-150" data-testid="checklist-drag-handle">
|
||||
<div class="group flex items-center gap-2 py-1.5 outline-none {item.parentId ? 'pl-8' : ''}"
|
||||
data-testid={item.parentId ? 'checklist-child-row' : undefined}
|
||||
animate:flip={{ duration: flipDurationMs }}>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div use:dragHandle
|
||||
aria-label="drag handle"
|
||||
onpointerdown={(e) => handleDragPointerDown(e, item.id)}
|
||||
class="drag-handle cursor-grab max-md:opacity-100 md:opacity-0 md:group-hover:opacity-100 focus:opacity-100 transition-opacity duration-150 touch-none outline-none"
|
||||
data-testid="checklist-drag-handle">
|
||||
<GripVertical class="h-4 w-4 text-[var(--text-muted)]" />
|
||||
</div>
|
||||
<input
|
||||
@@ -143,16 +316,30 @@
|
||||
class="h-4 w-4 rounded border-[var(--border-subtle)] text-[var(--primary)] focus:ring-[var(--primary)]"
|
||||
data-testid="checklist-checkbox"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={item.text}
|
||||
oninput={(e) => updateText(item.id, (e.target as HTMLInputElement).value)}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
contenteditable="true"
|
||||
use:editableContent={item.text}
|
||||
oninput={(e) => updateText(item.id, (e.target as HTMLElement).innerHTML)}
|
||||
onkeydown={(e) => handleKeydown(e, item.id)}
|
||||
class="flex-1 min-w-0 bg-transparent text-sm outline-none {item.checked ? 'text-[var(--text-muted)] line-through' : 'text-[var(--text)]'}"
|
||||
placeholder="List item"
|
||||
onclick={handleLinkClick}
|
||||
onpaste={handlePaste}
|
||||
onblur={(e) => {
|
||||
const el = e.currentTarget as HTMLElement;
|
||||
requestAnimationFrame(() => {
|
||||
if (el.isConnected && !el.contains(document.activeElement)) {
|
||||
el.innerHTML = linkifyText(item.text);
|
||||
}
|
||||
});
|
||||
}}
|
||||
class="flex-1 min-w-0 bg-transparent text-sm outline-none break-words {item.checked ? 'text-[var(--text-muted)] line-through' : 'text-[var(--text)]'}"
|
||||
data-placeholder="List item"
|
||||
aria-placeholder="List item"
|
||||
data-testid="checklist-input"
|
||||
data-item-id={item.id}
|
||||
/>
|
||||
role="textbox"
|
||||
tabindex="0"
|
||||
></div>
|
||||
<button
|
||||
onclick={() => removeItem(item.id)}
|
||||
class="max-md:opacity-100 md:opacity-0 md:group-hover:opacity-100 focus:opacity-100"
|
||||
@@ -186,29 +373,42 @@
|
||||
</button>
|
||||
|
||||
{#if doneExpanded}
|
||||
<div class="mt-1 space-y-0.5 pl-5" data-testid="checklist-done-section">
|
||||
{#each doneItems as item (item.id)}
|
||||
<div class="group flex items-center gap-2 py-1.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={item.checked}
|
||||
onchange={() => toggleItem(item.id)}
|
||||
class="h-4 w-4 rounded border-[var(--border-subtle)] text-[var(--primary)] focus:ring-[var(--primary)]"
|
||||
data-testid="checklist-done-checkbox"
|
||||
/>
|
||||
<span class="flex-1 text-sm text-[var(--text-muted)] line-through">{item.text}</span>
|
||||
<button
|
||||
onclick={() => removeItem(item.id)}
|
||||
class="max-md:opacity-100 md:opacity-0 md:group-hover:opacity-100 focus:opacity-100"
|
||||
aria-label="Remove item"
|
||||
data-testid="checklist-done-remove"
|
||||
>
|
||||
<XIcon class="h-4 w-4 text-[var(--text-muted)]" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-1 space-y-0.5" data-testid="checklist-done-section">
|
||||
{#each doneSectionGroups as group}
|
||||
{#if group.parentLabel}
|
||||
<div class="flex items-center gap-2 py-1.5 pl-5" data-testid="checklist-done-parent-label">
|
||||
<input type="checkbox" disabled class="h-4 w-4 rounded border-[var(--border-subtle)] opacity-50" />
|
||||
<span class="text-sm text-[var(--text-muted)] break-words min-w-0">{@html linkifyText(group.parentLabel.text)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#each group.children as item (item.id)}
|
||||
<div class="group flex items-center gap-2 py-1.5 {item.parentId ? 'pl-10' : 'pl-5'}">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={item.checked}
|
||||
onchange={() => toggleItem(item.id)}
|
||||
class="h-4 w-4 rounded border-[var(--border-subtle)] text-[var(--primary)] focus:ring-[var(--primary)]"
|
||||
data-testid="checklist-done-checkbox"
|
||||
/>
|
||||
<span class="flex-1 text-sm text-[var(--text-muted)] line-through break-words min-w-0">{@html linkifyText(item.text)}</span>
|
||||
<button
|
||||
onclick={() => removeItem(item.id)}
|
||||
class="max-md:opacity-100 md:opacity-0 md:group-hover:opacity-100 focus:opacity-100"
|
||||
aria-label="Remove item"
|
||||
data-testid="checklist-done-remove"
|
||||
>
|
||||
<XIcon class="h-4 w-4 text-[var(--text-muted)]" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if linkPopover}
|
||||
<LinkPopover url={linkPopover.url} anchor={linkPopover.anchor} onClose={() => (linkPopover = null)} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
<script lang="ts">
|
||||
import ExternalLink from 'lucide-svelte/icons/external-link';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
url: string;
|
||||
anchor: DOMRect;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { url, anchor, onClose }: Props = $props();
|
||||
|
||||
let popoverEl: HTMLDivElement | undefined = $state();
|
||||
let top = $state(0);
|
||||
let left = $state(0);
|
||||
|
||||
onMount(() => {
|
||||
if (!popoverEl) return;
|
||||
const rect = popoverEl.getBoundingClientRect();
|
||||
|
||||
// Position below the anchor link, or above if near viewport bottom
|
||||
const spaceBelow = window.innerHeight - anchor.bottom;
|
||||
if (spaceBelow >= rect.height + 8) {
|
||||
top = anchor.bottom + 4;
|
||||
} else {
|
||||
top = anchor.top - rect.height - 4;
|
||||
}
|
||||
|
||||
left = anchor.left;
|
||||
// Clamp to viewport
|
||||
if (left + rect.width > window.innerWidth - 8) {
|
||||
left = window.innerWidth - rect.width - 8;
|
||||
}
|
||||
if (left < 8) left = 8;
|
||||
});
|
||||
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (popoverEl && !popoverEl.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function openLink() {
|
||||
window.open(url, '_blank', 'noopener');
|
||||
onClose();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onclick={handleClickOutside} onkeydown={handleKeydown} onscroll={onClose} onresize={onClose} />
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
bind:this={popoverEl}
|
||||
class="fixed z-50 flex items-center gap-1.5 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] px-2 py-1.5 shadow-[var(--card-shadow)]"
|
||||
style="top: {top}px; left: {left}px;"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
data-testid="link-popover"
|
||||
>
|
||||
<button
|
||||
onclick={openLink}
|
||||
class="flex items-center gap-1 text-xs text-[var(--primary)] hover:text-[var(--primary-hover)]"
|
||||
data-testid="link-popover-open"
|
||||
>
|
||||
<ExternalLink class="h-3.5 w-3.5" />
|
||||
Open
|
||||
</button>
|
||||
</div>
|
||||
@@ -12,10 +12,13 @@
|
||||
import Archive from 'lucide-svelte/icons/archive';
|
||||
import ArchiveRestore from 'lucide-svelte/icons/archive-restore';
|
||||
import UserMinus from 'lucide-svelte/icons/user-minus';
|
||||
import { tooltip } from '$lib/utils/tooltip.js';
|
||||
import { linkifyText } from '$lib/utils/checklist.js';
|
||||
|
||||
interface ChecklistItem {
|
||||
text: string;
|
||||
checked: boolean;
|
||||
indented: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -38,10 +41,14 @@
|
||||
|
||||
const checklistItems = $derived<ChecklistItem[]>(
|
||||
note.checklistMode
|
||||
? note.content.split('\n').filter(l => l.trim()).map(line => ({
|
||||
text: line.replace(/^- \[[ x]\] /, ''),
|
||||
checked: line.startsWith('- [x] ')
|
||||
}))
|
||||
? note.content.split('\n').filter(l => l.trim()).map(line => {
|
||||
const indented = line.startsWith(' - [');
|
||||
return {
|
||||
text: line.replace(/^ {0,2}- \[[ x]\] /, ''),
|
||||
checked: /^ {0,2}- \[x\] /.test(line),
|
||||
indented
|
||||
};
|
||||
})
|
||||
: []
|
||||
);
|
||||
|
||||
@@ -112,7 +119,7 @@
|
||||
<button
|
||||
onclick={stop(() => togglePin(note.id, note.pinned))}
|
||||
class="rounded-sm p-1 text-[var(--primary)] hover:bg-[var(--border)]/10"
|
||||
title="Unpin"
|
||||
use:tooltip={"Unpin"}
|
||||
data-testid="pin-indicator"
|
||||
>
|
||||
<Bookmark class="h-4 w-4 fill-[var(--primary)]" />
|
||||
@@ -125,9 +132,13 @@
|
||||
{/if}
|
||||
|
||||
{#if note.checklistMode && checklistItems.length > 0}
|
||||
<ul class="space-y-2 mb-6" data-testid="note-checklist-preview">
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<ul class="space-y-2 mb-6" data-testid="note-checklist-preview"
|
||||
onclick={(e) => { if ((e.target as HTMLElement).closest('a')) e.stopPropagation(); }}
|
||||
onkeydown={(e) => { if ((e.target as HTMLElement).closest('a')) e.stopPropagation(); }}>
|
||||
{#each sortedChecklistItems.slice(0, 8) as item}
|
||||
<li class="flex items-start gap-2 text-sm {item.checked ? 'text-[var(--text-muted)] line-through' : 'text-[var(--text)]'}">
|
||||
<li class="flex items-start gap-2 text-sm {item.indented ? 'pl-4 ' : ''}{item.checked ? 'text-[var(--text-muted)] line-through' : 'text-[var(--text)]'}"
|
||||
data-testid={item.indented ? 'card-checklist-child' : undefined}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={item.checked}
|
||||
@@ -135,7 +146,7 @@
|
||||
class="mt-0.5 h-3.5 w-3.5 shrink-0 rounded border-[var(--border-subtle)] text-[var(--primary)]"
|
||||
data-testid="card-checklist-checkbox"
|
||||
/>
|
||||
<span class="break-words min-w-0">{item.text}</span>
|
||||
<span class="break-words min-w-0">{@html linkifyText(item.text)}</span>
|
||||
</li>
|
||||
{/each}
|
||||
{#if checklistItems.length > 8}
|
||||
@@ -154,7 +165,7 @@
|
||||
<button
|
||||
onclick={stop(() => restoreNote(note.id))}
|
||||
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
|
||||
title="Restore"
|
||||
use:tooltip={"Restore"}
|
||||
data-testid="restore-btn"
|
||||
>
|
||||
<Undo2 class="h-4 w-4" />
|
||||
@@ -162,7 +173,7 @@
|
||||
<button
|
||||
onclick={stop(() => deleteNote(note.id))}
|
||||
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
|
||||
title="Delete forever"
|
||||
use:tooltip={"Delete forever"}
|
||||
data-testid="delete-forever-btn"
|
||||
>
|
||||
<Trash2 class="h-4 w-4 text-[var(--destructive)]" />
|
||||
@@ -172,7 +183,7 @@
|
||||
<button
|
||||
onclick={stop(() => togglePin(note.id, note.pinned))}
|
||||
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
|
||||
title="Pin"
|
||||
use:tooltip={"Pin"}
|
||||
data-testid="pin-btn"
|
||||
>
|
||||
<Bookmark class="h-4 w-4" />
|
||||
@@ -182,7 +193,7 @@
|
||||
<button
|
||||
onclick={stop(() => unarchiveNote(note.id))}
|
||||
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
|
||||
title="Unarchive"
|
||||
use:tooltip={"Unarchive"}
|
||||
data-testid="unarchive-btn"
|
||||
>
|
||||
<ArchiveRestore class="h-4 w-4" />
|
||||
@@ -191,7 +202,7 @@
|
||||
<button
|
||||
onclick={stop(() => archiveNote(note.id))}
|
||||
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
|
||||
title="Archive"
|
||||
use:tooltip={"Archive"}
|
||||
data-testid="archive-btn"
|
||||
>
|
||||
<Archive class="h-4 w-4" />
|
||||
@@ -201,7 +212,7 @@
|
||||
<button
|
||||
onclick={stop(() => leaveNote(note.id))}
|
||||
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
|
||||
title="Leave note"
|
||||
use:tooltip={"Leave note"}
|
||||
data-testid="leave-btn"
|
||||
>
|
||||
<UserMinus class="h-4 w-4" />
|
||||
@@ -210,7 +221,7 @@
|
||||
<button
|
||||
onclick={stop(() => trashNote(note.id))}
|
||||
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
|
||||
title="Delete"
|
||||
use:tooltip={"Trash"}
|
||||
data-testid="trash-btn"
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import ImageUpload from './ImageUpload.svelte';
|
||||
import ShareDialog from './ShareDialog.svelte';
|
||||
import NoteHistory from './NoteHistory.svelte';
|
||||
import { updateNote, createNote } from '$lib/stores/notes.js';
|
||||
import { updateNote, createNote, trashNote, archiveNote } from '$lib/stores/notes.js';
|
||||
import { notes } from '$lib/stores/notes.js';
|
||||
import { getNoteColor } from '$lib/utils/colors.js';
|
||||
import { getIsDarkMode } from '$lib/utils/theme.svelte.js';
|
||||
@@ -24,14 +24,21 @@
|
||||
import Users from 'lucide-svelte/icons/users';
|
||||
import Globe from 'lucide-svelte/icons/globe';
|
||||
import History from 'lucide-svelte/icons/history';
|
||||
import EllipsisVertical from 'lucide-svelte/icons/ellipsis-vertical';
|
||||
import Trash2 from 'lucide-svelte/icons/trash-2';
|
||||
import ListX from 'lucide-svelte/icons/list-x';
|
||||
import Archive from 'lucide-svelte/icons/archive';
|
||||
import { parseChecklist, serializeChecklist } from '$lib/utils/checklist.js';
|
||||
import { tooltip } from '$lib/utils/tooltip.js';
|
||||
|
||||
interface Props {
|
||||
note: Note | null;
|
||||
isNew?: boolean;
|
||||
initialChecklistMode?: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const { note, isNew = false, onClose }: Props = $props();
|
||||
const { note, isNew = false, initialChecklistMode = false, onClose }: Props = $props();
|
||||
const prefs = getPreferences();
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
@@ -41,7 +48,7 @@
|
||||
// svelte-ignore state_referenced_locally
|
||||
let color = $state<NoteColor>(note?.color ?? prefs.defaultNoteColor);
|
||||
// svelte-ignore state_referenced_locally
|
||||
let checklistMode = $state(note?.checklistMode ?? false);
|
||||
let checklistMode = $state(note?.checklistMode ?? initialChecklistMode);
|
||||
let showColorPicker = $state(false);
|
||||
let showImageUpload = $state(false);
|
||||
// svelte-ignore state_referenced_locally
|
||||
@@ -56,6 +63,100 @@
|
||||
// svelte-ignore state_referenced_locally
|
||||
let currentlyNew = $state(isNew);
|
||||
|
||||
// Auto-save: track last-saved state to detect real changes
|
||||
let lastSavedTitle = note?.title ?? '';
|
||||
let lastSavedContent = note?.content ?? '';
|
||||
let lastSavedColor: NoteColor = note?.color ?? prefs.defaultNoteColor;
|
||||
let lastSavedChecklistMode = note?.checklistMode ?? initialChecklistMode;
|
||||
let isSaving = false;
|
||||
let savingPromise: Promise<void> | null = null;
|
||||
let autoSaveTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
// TiptapEditor fires onUpdate on init with round-tripped markdown that may differ
|
||||
// from the original. Track whether auto-save should be active yet.
|
||||
let autoSaveReady = isNew;
|
||||
|
||||
function hasUnsavedChanges(): boolean {
|
||||
return (
|
||||
title !== lastSavedTitle ||
|
||||
content !== lastSavedContent ||
|
||||
color !== lastSavedColor ||
|
||||
checklistMode !== lastSavedChecklistMode
|
||||
);
|
||||
}
|
||||
|
||||
async function performSave() {
|
||||
if (isSaving) return;
|
||||
// Snapshot current fields to avoid races with user edits during await
|
||||
const snap = { title, content, color, checklistMode };
|
||||
if (!snap.title.trim() && !snap.content.trim()) return;
|
||||
if (
|
||||
snap.title === lastSavedTitle &&
|
||||
snap.content === lastSavedContent &&
|
||||
snap.color === lastSavedColor &&
|
||||
snap.checklistMode === lastSavedChecklistMode
|
||||
) return;
|
||||
|
||||
isSaving = true;
|
||||
try {
|
||||
if (currentlyNew) {
|
||||
const created = await createNote(snap);
|
||||
if (created) {
|
||||
noteId = created.id;
|
||||
currentlyNew = false;
|
||||
lastSavedTitle = snap.title;
|
||||
lastSavedContent = snap.content;
|
||||
lastSavedColor = snap.color;
|
||||
lastSavedChecklistMode = snap.checklistMode;
|
||||
}
|
||||
} else if (noteId) {
|
||||
const updated = await updateNote(noteId, snap);
|
||||
if (updated) {
|
||||
lastSavedTitle = snap.title;
|
||||
lastSavedContent = snap.content;
|
||||
lastSavedColor = snap.color;
|
||||
lastSavedChecklistMode = snap.checklistMode;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
isSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Debounced auto-save: triggers 2s after the user stops editing
|
||||
$effect(() => {
|
||||
const _t = title;
|
||||
const _c = content;
|
||||
const _col = color;
|
||||
const _cm = checklistMode;
|
||||
|
||||
if (!autoSaveReady) {
|
||||
// Absorb TiptapEditor's initial content normalization as the baseline
|
||||
lastSavedContent = _c;
|
||||
autoSaveReady = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (_t === lastSavedTitle && _c === lastSavedContent && _col === lastSavedColor && _cm === lastSavedChecklistMode) return;
|
||||
if (!_t.trim() && !_c.trim()) return;
|
||||
|
||||
autoSaveTimer = setTimeout(() => {
|
||||
savingPromise = performSave();
|
||||
}, 2000);
|
||||
|
||||
return () => clearTimeout(autoSaveTimer);
|
||||
});
|
||||
|
||||
// Safety net: save on tab close / navigation
|
||||
$effect(() => {
|
||||
function handleBeforeUnload() {
|
||||
if (hasUnsavedChanges() && (title.trim() || content.trim())) {
|
||||
performSave();
|
||||
}
|
||||
}
|
||||
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
});
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
let attachmentsList = $state<Attachment[]>(note?.attachments ?? []);
|
||||
|
||||
@@ -70,6 +171,29 @@
|
||||
|
||||
let showShareDialog = $state(false);
|
||||
let showHistory = $state(false);
|
||||
let showOverflowMenu = $state(false);
|
||||
let overflowBtnEl: HTMLButtonElement | undefined = $state();
|
||||
let overflowMenuEl: HTMLDivElement | undefined = $state();
|
||||
|
||||
// Close overflow menu when clicking outside
|
||||
$effect(() => {
|
||||
if (!showOverflowMenu) return;
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
const target = e.target as Node;
|
||||
if (overflowBtnEl?.contains(target) || overflowMenuEl?.contains(target)) return;
|
||||
showOverflowMenu = false;
|
||||
}
|
||||
// Use setTimeout to avoid the current click from closing the menu
|
||||
const timer = setTimeout(() => document.addEventListener('click', handleClickOutside), 0);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
document.removeEventListener('click', handleClickOutside);
|
||||
};
|
||||
});
|
||||
|
||||
const hasDoneItems = $derived(
|
||||
checklistMode && content.includes('[x]')
|
||||
);
|
||||
// svelte-ignore state_referenced_locally
|
||||
let collaboratorsList = $state<Collaborator[]>(note?.collaborators ?? []);
|
||||
// svelte-ignore state_referenced_locally
|
||||
@@ -158,7 +282,13 @@
|
||||
});
|
||||
|
||||
function handleAttachmentUpload(attachment: Attachment) {
|
||||
attachmentsList = [...attachmentsList, attachment];
|
||||
const updated = [...attachmentsList, attachment];
|
||||
attachmentsList = updated;
|
||||
if (noteId) {
|
||||
notes.update((list) =>
|
||||
list.map((n) => (n.id === noteId ? { ...n, attachments: updated } : n))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleFeatured(attachmentId: string, featured: boolean) {
|
||||
@@ -170,9 +300,14 @@
|
||||
body: JSON.stringify({ featured })
|
||||
});
|
||||
if (res.ok) {
|
||||
attachmentsList = attachmentsList.map((a) =>
|
||||
const updated = attachmentsList.map((a) =>
|
||||
a.id === attachmentId ? { ...a, featured } : a
|
||||
);
|
||||
attachmentsList = updated;
|
||||
// Keep the notes store in sync so the card reflects featured state
|
||||
notes.update((list) =>
|
||||
list.map((n) => (n.id === noteId ? { ...n, attachments: updated } : n))
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to toggle featured:', err);
|
||||
@@ -185,7 +320,11 @@
|
||||
await fetch(`/api/notes/${noteId}/attachments?attachmentId=${attachmentId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
attachmentsList = attachmentsList.filter((a) => a.id !== attachmentId);
|
||||
const updated = attachmentsList.filter((a) => a.id !== attachmentId);
|
||||
attachmentsList = updated;
|
||||
notes.update((list) =>
|
||||
list.map((n) => (n.id === noteId ? { ...n, attachments: updated } : n))
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('Failed to remove attachment:', err);
|
||||
}
|
||||
@@ -196,11 +335,11 @@
|
||||
bgStyle = `background-color: ${getNoteColor(color, getIsDarkMode())}`;
|
||||
});
|
||||
|
||||
// Use pointer events to detect backdrop clicks. Unlike mouse events,
|
||||
// pointer events are never synthetically re-dispatched from touch, so
|
||||
// the tap that opened the editor on mobile cannot ghost-click the overlay.
|
||||
// Both pointerdown and pointerup must target the overlay itself to close.
|
||||
// Close on backdrop tap/click. On close, a transparent shield stays in the DOM
|
||||
// for 400ms to absorb the browser's delayed synthetic click that would otherwise
|
||||
// pass through to NoteCards underneath (ghost click).
|
||||
let pointerdownOnOverlay = false;
|
||||
let closing = $state(false);
|
||||
|
||||
function handleOverlayPointerdown(e: PointerEvent) {
|
||||
pointerdownOnOverlay = e.target === e.currentTarget;
|
||||
@@ -211,35 +350,37 @@
|
||||
pointerdownOnOverlay = false;
|
||||
}
|
||||
|
||||
function dismissOverlay() {
|
||||
closing = true;
|
||||
setTimeout(() => onClose(), 400);
|
||||
}
|
||||
|
||||
async function saveAndClose() {
|
||||
clearTimeout(autoSaveTimer);
|
||||
// Wait for any in-flight auto-save to finish
|
||||
if (savingPromise) await savingPromise;
|
||||
|
||||
if (!title.trim() && !content.trim()) {
|
||||
onClose();
|
||||
dismissOverlay();
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentlyNew) {
|
||||
await createNote({ title, content, color, checklistMode });
|
||||
} else if (noteId) {
|
||||
await updateNote(noteId, { title, content, color, checklistMode });
|
||||
// Final save if auto-save hasn't caught up yet
|
||||
if (hasUnsavedChanges()) {
|
||||
await performSave();
|
||||
}
|
||||
onClose();
|
||||
dismissOverlay();
|
||||
}
|
||||
|
||||
/** Auto-save a new note without closing, returns the new note ID */
|
||||
async function autoSave(): Promise<string | null> {
|
||||
if (!currentlyNew) return noteId;
|
||||
const created = await createNote({
|
||||
title: title || 'Untitled',
|
||||
content,
|
||||
color,
|
||||
checklistMode
|
||||
});
|
||||
if (created) {
|
||||
noteId = created.id;
|
||||
currentlyNew = false;
|
||||
return created.id;
|
||||
}
|
||||
return null;
|
||||
clearTimeout(autoSaveTimer);
|
||||
if (savingPromise) await savingPromise;
|
||||
// For feature-triggered saves (image upload, share), use 'Untitled' fallback
|
||||
if (!title.trim()) title = 'Untitled';
|
||||
await performSave();
|
||||
return noteId;
|
||||
}
|
||||
|
||||
async function toggleImageUpload() {
|
||||
@@ -267,16 +408,45 @@
|
||||
requestAnimationFrame(() => textareaEl?.focus());
|
||||
}
|
||||
}
|
||||
|
||||
async function handleArchive() {
|
||||
showOverflowMenu = false;
|
||||
if (noteId) {
|
||||
await archiveNote(noteId);
|
||||
dismissOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTrash() {
|
||||
showOverflowMenu = false;
|
||||
if (noteId) {
|
||||
await trashNote(noteId);
|
||||
dismissOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
function deleteCheckedItems() {
|
||||
const items = parseChecklist(content).filter((i) => !i.checked);
|
||||
content = serializeChecklist(items);
|
||||
showOverflowMenu = false;
|
||||
}
|
||||
|
||||
function uncheckAllItems() {
|
||||
const items = parseChecklist(content).map((i) => ({ ...i, checked: false }));
|
||||
content = serializeChecklist(items);
|
||||
showOverflowMenu = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/50 pt-20 pb-10 animate-[fade-in_150ms_ease-out]"
|
||||
class="fixed 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]'}"
|
||||
onpointerdown={handleOverlayPointerdown}
|
||||
onpointerup={handleOverlayPointerup}
|
||||
onkeydown={handleKeydown}
|
||||
data-testid="note-editor-overlay"
|
||||
>
|
||||
{#if !closing}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="mx-4 flex w-full max-w-xl md:max-w-2xl flex-col overflow-hidden rounded-sm border border-[var(--border)] shadow-[var(--card-shadow)] animate-[pop-in_150ms_ease-out]"
|
||||
@@ -358,7 +528,7 @@
|
||||
<button
|
||||
onclick={() => (showColorPicker = !showColorPicker)}
|
||||
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
|
||||
title="Background color"
|
||||
use:tooltip={"Background color"}
|
||||
data-testid="color-picker-toggle"
|
||||
>
|
||||
<Palette class="h-5 w-5 text-[var(--text-muted)]" />
|
||||
@@ -370,51 +540,22 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Checklist mode toggle -->
|
||||
<button
|
||||
onclick={() => (checklistMode = !checklistMode)}
|
||||
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
|
||||
title={checklistMode ? 'Switch to text' : 'Checklist mode'}
|
||||
data-testid="checklist-toggle"
|
||||
>
|
||||
{#if checklistMode}
|
||||
<Type class="h-5 w-5" />
|
||||
{:else}
|
||||
<SquareCheck class="h-5 w-5" />
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Image attachment toggle -->
|
||||
<button
|
||||
onclick={toggleImageUpload}
|
||||
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
|
||||
title="Image attachments"
|
||||
use:tooltip={"Attachments"}
|
||||
data-testid="image-toggle"
|
||||
>
|
||||
<ImageIcon class="h-5 w-5 {showImageUpload ? 'text-[var(--primary)]' : ''}" />
|
||||
</button>
|
||||
|
||||
<!-- Raw markdown mode toggle -->
|
||||
<button
|
||||
onclick={toggleMarkdownMode}
|
||||
class="rounded-sm p-2 hover:bg-[var(--border)]/10 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
title={rawMarkdownMode ? 'Rich text mode' : 'Markdown mode'}
|
||||
disabled={checklistMode}
|
||||
data-testid="markdown-toggle"
|
||||
>
|
||||
{#if rawMarkdownMode}
|
||||
<FileText class="h-5 w-5" />
|
||||
{:else}
|
||||
<FileCode class="h-5 w-5" />
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Share button (owner only for new shares, all for viewing) -->
|
||||
<!-- Share button -->
|
||||
{#if isOwner}
|
||||
<button
|
||||
onclick={toggleShareDialog}
|
||||
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
|
||||
title="Share note"
|
||||
use:tooltip={"Share"}
|
||||
data-testid="share-toggle"
|
||||
>
|
||||
{#if hasPublicLink}
|
||||
@@ -426,22 +567,33 @@
|
||||
{/if}
|
||||
</button>
|
||||
{:else if isShared}
|
||||
<span class="flex items-center gap-1 rounded-sm p-2 text-[var(--text-muted)]" title="Shared note">
|
||||
<span class="flex items-center gap-1 rounded-sm p-2 text-[var(--text-muted)]" use:tooltip={"Shared note"}>
|
||||
<Users class="h-5 w-5" />
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<!-- History button (only for saved notes) -->
|
||||
<!-- Archive button (only for saved notes) -->
|
||||
{#if !currentlyNew}
|
||||
<button
|
||||
onclick={toggleHistory}
|
||||
onclick={handleArchive}
|
||||
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
|
||||
title="Version history"
|
||||
data-testid="history-toggle"
|
||||
use:tooltip={"Archive"}
|
||||
data-testid="archive-note-btn"
|
||||
>
|
||||
<History class="h-5 w-5 {showHistory ? 'text-[var(--primary)]' : ''}" />
|
||||
<Archive class="h-5 w-5" />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Overflow menu trigger -->
|
||||
<button
|
||||
bind:this={overflowBtnEl}
|
||||
onclick={() => (showOverflowMenu = !showOverflowMenu)}
|
||||
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
|
||||
use:tooltip={"More"}
|
||||
data-testid="overflow-menu-btn"
|
||||
>
|
||||
<EllipsisVertical class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@@ -453,6 +605,98 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Overflow menu dropdown (rendered outside the card to escape overflow-hidden) -->
|
||||
{#if showOverflowMenu && overflowBtnEl}
|
||||
{@const rect = overflowBtnEl.getBoundingClientRect()}
|
||||
<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;"
|
||||
data-testid="overflow-menu"
|
||||
>
|
||||
<!-- 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"
|
||||
data-testid="checklist-toggle"
|
||||
>
|
||||
{#if checklistMode}
|
||||
<Type class="h-4 w-4" />
|
||||
Switch to text
|
||||
{:else}
|
||||
<SquareCheck class="h-4 w-4" />
|
||||
Checklist mode
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Markdown mode toggle (not available in checklist mode) -->
|
||||
{#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"
|
||||
data-testid="markdown-toggle"
|
||||
>
|
||||
{#if rawMarkdownMode}
|
||||
<FileText class="h-4 w-4" />
|
||||
Rich text mode
|
||||
{:else}
|
||||
<FileCode class="h-4 w-4" />
|
||||
Markdown mode
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- History -->
|
||||
{#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"
|
||||
data-testid="history-toggle"
|
||||
>
|
||||
<History class="h-4 w-4 {showHistory ? 'text-[var(--primary)]' : ''}" />
|
||||
Version history
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Trash (only for saved notes) -->
|
||||
{#if !currentlyNew}
|
||||
<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"
|
||||
data-testid="trash-note-btn"
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
Move to trash
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Checklist-specific actions -->
|
||||
{#if checklistMode}
|
||||
<div class="my-1 border-t border-[var(--border-subtle)]"></div>
|
||||
{#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"
|
||||
data-testid="delete-checked-btn"
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
Delete checked
|
||||
</button>
|
||||
{/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"
|
||||
data-testid="uncheck-all-btn"
|
||||
>
|
||||
<ListX class="h-4 w-4" />
|
||||
Uncheck all
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showShareDialog && noteId}
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
import { onMount } from 'svelte';
|
||||
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';
|
||||
|
||||
interface Props {
|
||||
filter: NoteFilter;
|
||||
@@ -16,6 +18,7 @@
|
||||
|
||||
let editingNote: Note | null = $state(null);
|
||||
let showNewNote = $state(false);
|
||||
let newNoteChecklist = $state(false);
|
||||
|
||||
// Sync store with route props and reload notes when filter changes
|
||||
$effect(() => {
|
||||
@@ -32,6 +35,7 @@
|
||||
function closeEditor() {
|
||||
editingNote = null;
|
||||
showNewNote = false;
|
||||
newNoteChecklist = false;
|
||||
history.replaceState(null, '', location.pathname);
|
||||
}
|
||||
|
||||
@@ -55,14 +59,24 @@
|
||||
|
||||
{#if filter === 'all' && !tag}
|
||||
<div class="mx-auto mb-6 hidden max-w-xl md:block">
|
||||
<button
|
||||
onclick={() => (showNewNote = true)}
|
||||
class="flex w-full items-center gap-3 rounded-sm border border-dashed border-[var(--border-subtle)] bg-[var(--bg-surface)] px-4 py-3 text-left text-sm text-[var(--text-muted)] transition-colors hover:border-[var(--primary)] hover:text-[var(--primary)]"
|
||||
data-testid="new-note-btn"
|
||||
>
|
||||
<Plus class="h-4 w-4" />
|
||||
Add a crumb...
|
||||
</button>
|
||||
<div class="flex cursor-pointer items-center gap-0 rounded-sm border border-dashed border-[var(--border-subtle)] bg-[var(--bg-surface)] transition-colors hover:border-[var(--primary)]">
|
||||
<button
|
||||
onclick={() => (showNewNote = true)}
|
||||
class="flex flex-1 cursor-pointer items-center gap-3 px-4 py-3 text-left text-sm text-[var(--text-muted)] hover:text-[var(--primary)]"
|
||||
data-testid="new-note-btn"
|
||||
>
|
||||
<Plus class="h-4 w-4" />
|
||||
Add a crumb...
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { newNoteChecklist = true; showNewNote = true; }}
|
||||
class="cursor-pointer rounded-sm p-3 text-[var(--text-muted)] hover:text-[var(--primary)]"
|
||||
use:tooltip={"New checklist"}
|
||||
data-testid="new-checklist-btn"
|
||||
>
|
||||
<SquareCheck class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onclick={() => (showNewNote = true)}
|
||||
@@ -119,7 +133,7 @@
|
||||
{/if}
|
||||
|
||||
{#if showNewNote}
|
||||
<NoteEditor note={null} isNew={true} onClose={closeEditor} />
|
||||
<NoteEditor note={null} isNew={true} initialChecklistMode={newNoteChecklist} onClose={closeEditor} />
|
||||
{/if}
|
||||
|
||||
{#if editingNote}
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
|
||||
{#if showPopover}
|
||||
<div
|
||||
class="absolute right-0 bottom-full z-20 mb-1 min-w-[180px] rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-2 shadow-[var(--card-shadow)] animate-[fade-in_150ms_ease-out]"
|
||||
class="absolute right-0 top-full z-20 mt-1 min-w-[180px] rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-2 shadow-[var(--card-shadow)] animate-[fade-in_150ms_ease-out]"
|
||||
data-testid="sharing-popover"
|
||||
>
|
||||
{#if hasPublicLink}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createTestDb } from './db/test-helpers.js';
|
||||
import { users, sessions } from './db/schema.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import * as argon2 from 'argon2';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { randomBytes, randomUUID } from 'crypto';
|
||||
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
|
||||
import type * as schema from './db/schema.js';
|
||||
|
||||
@@ -16,16 +16,16 @@ beforeEach(() => {
|
||||
|
||||
describe('Auth - Password Hashing', () => {
|
||||
it('should hash and verify a password correctly', async () => {
|
||||
const password = 'mysecurepassword';
|
||||
const password = randomUUID();
|
||||
const hash = await argon2.hash(password);
|
||||
|
||||
expect(hash).not.toBe(password);
|
||||
expect(await argon2.verify(hash, password)).toBe(true);
|
||||
expect(await argon2.verify(hash, 'wrongpassword')).toBe(false);
|
||||
expect(await argon2.verify(hash, randomUUID())).toBe(false);
|
||||
});
|
||||
|
||||
it('should produce different hashes for the same password', async () => {
|
||||
const password = 'mysecurepassword';
|
||||
const password = randomUUID();
|
||||
const hash1 = await argon2.hash(password);
|
||||
const hash2 = await argon2.hash(password);
|
||||
|
||||
@@ -37,7 +37,7 @@ describe('Auth - Password Hashing', () => {
|
||||
|
||||
describe('Auth - User Setup', () => {
|
||||
it('should create a user with hashed password', async () => {
|
||||
const password = 'testpassword123';
|
||||
const password = randomUUID();
|
||||
const hash = await argon2.hash(password);
|
||||
|
||||
await db.insert(users).values({
|
||||
|
||||
@@ -238,21 +238,20 @@ 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
|
||||
if (input.content !== existing.content) {
|
||||
const baseSnapshot = input.baseVersion !== undefined
|
||||
? 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()
|
||||
: db.select({ content: noteVersions.content })
|
||||
.from(noteVersions)
|
||||
.where(eq(noteVersions.noteId, id))
|
||||
.orderBy(desc(noteVersions.version))
|
||||
.limit(1)
|
||||
.get();
|
||||
// 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.
|
||||
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);
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mergeNotesByVersion } from './notes.js';
|
||||
import type { Note } from '$lib/types/index.js';
|
||||
|
||||
function makeNote(overrides: Partial<Note> = {}): Note {
|
||||
return {
|
||||
id: 'note-1',
|
||||
title: 'Test',
|
||||
content: '- [ ] Item',
|
||||
color: 'default',
|
||||
pinned: false,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
checklistMode: true,
|
||||
sortOrder: 0,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
version: 1,
|
||||
tags: [],
|
||||
attachments: [],
|
||||
collaborators: [],
|
||||
isOwner: true,
|
||||
isShared: false,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('mergeNotesByVersion', () => {
|
||||
it('keeps the local note when it has a higher version than the incoming note', () => {
|
||||
const local = makeNote({ id: 'n1', version: 3, content: '- [x] Checked' });
|
||||
const incoming = makeNote({ id: 'n1', version: 2, content: '- [ ] Unchecked' });
|
||||
|
||||
const result = mergeNotesByVersion([local], [incoming]);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].content).toBe('- [x] Checked');
|
||||
expect(result[0].version).toBe(3);
|
||||
});
|
||||
|
||||
it('uses the incoming note when it has a higher or equal version', () => {
|
||||
const local = makeNote({ id: 'n1', version: 2, content: '- [ ] Old' });
|
||||
const incoming = makeNote({ id: 'n1', version: 3, content: '- [x] New' });
|
||||
|
||||
const result = mergeNotesByVersion([local], [incoming]);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].content).toBe('- [x] New');
|
||||
expect(result[0].version).toBe(3);
|
||||
});
|
||||
|
||||
it('uses the incoming note when versions are equal', () => {
|
||||
const local = makeNote({ id: 'n1', version: 2, content: 'local' });
|
||||
const incoming = makeNote({ id: 'n1', version: 2, content: 'incoming' });
|
||||
|
||||
const result = mergeNotesByVersion([local], [incoming]);
|
||||
|
||||
expect(result[0].content).toBe('incoming');
|
||||
});
|
||||
|
||||
it('handles notes not present in the local store', () => {
|
||||
const incoming = makeNote({ id: 'n2', version: 1 });
|
||||
|
||||
const result = mergeNotesByVersion([], [incoming]);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe('n2');
|
||||
});
|
||||
|
||||
it('preserves local-only notes not present in the incoming data', () => {
|
||||
const localOnly = makeNote({ id: 'n1', version: 1, content: 'just created' });
|
||||
const serverNote = makeNote({ id: 'n2', version: 1, content: 'from server' });
|
||||
|
||||
const result = mergeNotesByVersion([localOnly], [serverNote]);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.find((n) => n.id === 'n1')?.content).toBe('just created');
|
||||
expect(result.find((n) => n.id === 'n2')?.content).toBe('from server');
|
||||
});
|
||||
|
||||
it('preserves multiple notes correctly during a race condition', () => {
|
||||
const localNotes = [
|
||||
makeNote({ id: 'n1', version: 5, content: '- [x] Just saved' }),
|
||||
makeNote({ id: 'n2', version: 1, content: 'unchanged' })
|
||||
];
|
||||
const incomingNotes = [
|
||||
makeNote({ id: 'n1', version: 4, content: '- [ ] Stale from server' }),
|
||||
makeNote({ id: 'n2', version: 2, content: 'updated on server' })
|
||||
];
|
||||
|
||||
const result = mergeNotesByVersion(localNotes, incomingNotes);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
// n1: local version 5 > incoming version 4 → keep local
|
||||
expect(result[0].content).toBe('- [x] Just saved');
|
||||
// n2: local version 1 < incoming version 2 → use incoming
|
||||
expect(result[1].content).toBe('updated on server');
|
||||
});
|
||||
});
|
||||
+25
-2
@@ -53,12 +53,35 @@ function isNetworkError(err: unknown): boolean {
|
||||
return err instanceof TypeError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge incoming notes with current store, keeping the higher-version note
|
||||
* when a conflict exists. This prevents a stale loadNotes response from
|
||||
* overwriting a just-saved note (race between sync interval and updateNote).
|
||||
*/
|
||||
export function mergeNotesByVersion(current: Note[], incoming: Note[]): Note[] {
|
||||
const currentMap = new Map(current.map((n) => [n.id, n]));
|
||||
const incomingIds = new Set<string>();
|
||||
const merged = incoming.map((incomingNote) => {
|
||||
incomingIds.add(incomingNote.id);
|
||||
const local = currentMap.get(incomingNote.id);
|
||||
if (local && local.version > incomingNote.version) return local;
|
||||
return incomingNote;
|
||||
});
|
||||
// Preserve local-only notes (e.g. just created, not yet in server/IDB response)
|
||||
for (const local of current) {
|
||||
if (!incomingIds.has(local.id)) {
|
||||
merged.push(local);
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
export async function loadNotes(filter: NoteFilter = 'all') {
|
||||
// Load from IDB first for instant display
|
||||
try {
|
||||
const cached = await getAllNotes();
|
||||
if (cached.length > 0) {
|
||||
notes.set(cached);
|
||||
notes.update((current) => current.length > 0 ? mergeNotesByVersion(current, cached) : cached);
|
||||
}
|
||||
} catch {
|
||||
// IDB unavailable — continue with server fetch
|
||||
@@ -71,7 +94,7 @@ export async function loadNotes(filter: NoteFilter = 'all') {
|
||||
const res = await fetch(`/api/notes?filter=${filter}`);
|
||||
if (res.ok) {
|
||||
const data: Note[] = await res.json();
|
||||
notes.set(data);
|
||||
notes.update((current) => mergeNotesByVersion(current, data));
|
||||
currentFilter.set(filter);
|
||||
|
||||
// Update IDB with server state
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseChecklist, serializeChecklist, toggleItemWithCascade, indentItem, outdentItem, linkifyText, unlinkifyHtml, type ChecklistItem } from '$lib/utils/checklist.js';
|
||||
|
||||
describe('parseChecklist', () => {
|
||||
it('parses flat checklist items', () => {
|
||||
const items = parseChecklist('- [ ] Milk\n- [x] Eggs');
|
||||
expect(items).toHaveLength(2);
|
||||
expect(items[0]).toMatchObject({ text: 'Milk', checked: false, parentId: null });
|
||||
expect(items[1]).toMatchObject({ text: 'Eggs', checked: true, parentId: null });
|
||||
});
|
||||
|
||||
it('parses nested children with 2-space prefix', () => {
|
||||
const items = parseChecklist('- [ ] Groceries\n - [ ] Milk\n - [x] Eggs');
|
||||
expect(items).toHaveLength(3);
|
||||
expect(items[0]).toMatchObject({ text: 'Groceries', checked: false, parentId: null });
|
||||
expect(items[1]).toMatchObject({ text: 'Milk', checked: false, parentId: items[0].id });
|
||||
expect(items[2]).toMatchObject({ text: 'Eggs', checked: true, parentId: items[0].id });
|
||||
});
|
||||
|
||||
it('treats orphaned children as top-level', () => {
|
||||
const items = parseChecklist(' - [ ] Orphan\n- [ ] Parent');
|
||||
expect(items[0]).toMatchObject({ text: 'Orphan', parentId: null });
|
||||
});
|
||||
|
||||
it('returns one empty item for blank content', () => {
|
||||
const items = parseChecklist('');
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]).toMatchObject({ text: '', checked: false, parentId: null });
|
||||
});
|
||||
|
||||
it('handles mixed top-level and nested items', () => {
|
||||
const content = '- [ ] A\n - [ ] A1\n - [ ] A2\n- [ ] B\n - [x] B1';
|
||||
const items = parseChecklist(content);
|
||||
expect(items).toHaveLength(5);
|
||||
expect(items[1].parentId).toBe(items[0].id);
|
||||
expect(items[2].parentId).toBe(items[0].id);
|
||||
expect(items[4].parentId).toBe(items[3].id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('serializeChecklist', () => {
|
||||
it('serializes flat items', () => {
|
||||
const items: ChecklistItem[] = [
|
||||
{ id: '1', text: 'Milk', checked: false, parentId: null },
|
||||
{ id: '2', text: 'Eggs', checked: true, parentId: null }
|
||||
];
|
||||
expect(serializeChecklist(items)).toBe('- [ ] Milk\n- [x] Eggs');
|
||||
});
|
||||
|
||||
it('serializes nested items with 2-space prefix', () => {
|
||||
const items: ChecklistItem[] = [
|
||||
{ id: '1', text: 'Groceries', checked: false, parentId: null },
|
||||
{ id: '2', text: 'Milk', checked: false, parentId: '1' },
|
||||
{ id: '3', text: 'Eggs', checked: true, parentId: '1' }
|
||||
];
|
||||
expect(serializeChecklist(items)).toBe('- [ ] Groceries\n - [ ] Milk\n - [x] Eggs');
|
||||
});
|
||||
|
||||
it('groups children under parents regardless of array order', () => {
|
||||
const items: ChecklistItem[] = [
|
||||
{ id: '1', text: 'A', checked: false, parentId: null },
|
||||
{ id: '3', text: 'B', checked: false, parentId: null },
|
||||
{ id: '2', text: 'A1', checked: false, parentId: '1' }
|
||||
];
|
||||
const result = serializeChecklist(items);
|
||||
expect(result).toBe('- [ ] A\n - [ ] A1\n- [ ] B');
|
||||
});
|
||||
|
||||
it('round-trips parse then serialize', () => {
|
||||
const original = '- [ ] Groceries\n - [ ] Milk\n - [x] Eggs\n- [ ] Clean';
|
||||
const items = parseChecklist(original);
|
||||
expect(serializeChecklist(items)).toBe(original);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleItemWithCascade', () => {
|
||||
it('checking a parent checks all children', () => {
|
||||
const items: ChecklistItem[] = [
|
||||
{ id: '1', text: 'Parent', checked: false, parentId: null },
|
||||
{ id: '2', text: 'Child A', checked: false, parentId: '1' },
|
||||
{ id: '3', text: 'Child B', checked: false, parentId: '1' }
|
||||
];
|
||||
const result = toggleItemWithCascade('1', items);
|
||||
expect(result.every((i) => i.checked)).toBe(true);
|
||||
});
|
||||
|
||||
it('unchecking a parent unchecks all children', () => {
|
||||
const items: ChecklistItem[] = [
|
||||
{ id: '1', text: 'Parent', checked: true, parentId: null },
|
||||
{ id: '2', text: 'Child A', checked: true, parentId: '1' },
|
||||
{ id: '3', text: 'Child B', checked: true, parentId: '1' }
|
||||
];
|
||||
const result = toggleItemWithCascade('1', items);
|
||||
expect(result.every((i) => !i.checked)).toBe(true);
|
||||
});
|
||||
|
||||
it('checking a child does not affect parent', () => {
|
||||
const items: ChecklistItem[] = [
|
||||
{ id: '1', text: 'Parent', checked: false, parentId: null },
|
||||
{ id: '2', text: 'Child', checked: false, parentId: '1' }
|
||||
];
|
||||
const result = toggleItemWithCascade('2', items);
|
||||
expect(result.find((i) => i.id === '1')!.checked).toBe(false);
|
||||
expect(result.find((i) => i.id === '2')!.checked).toBe(true);
|
||||
});
|
||||
|
||||
it('unchecking a child with deleted parent orphans it', () => {
|
||||
const items: ChecklistItem[] = [
|
||||
{ id: '2', text: 'Orphan', checked: true, parentId: 'deleted-id' }
|
||||
];
|
||||
const result = toggleItemWithCascade('2', items);
|
||||
expect(result[0].checked).toBe(false);
|
||||
expect(result[0].parentId).toBeNull();
|
||||
});
|
||||
|
||||
it('unchecking a child repositions it after its parent and siblings', () => {
|
||||
// Simulates: parent + 2 children, one child checked (at end of array as done item)
|
||||
const items: ChecklistItem[] = [
|
||||
{ id: '1', text: 'Parent', checked: false, parentId: null },
|
||||
{ id: '3', text: 'Child B', checked: false, parentId: '1' },
|
||||
{ id: '4', text: 'Other', checked: false, parentId: null },
|
||||
{ id: '2', text: 'Child A', checked: true, parentId: '1' } // done, at end
|
||||
];
|
||||
const result = toggleItemWithCascade('2', items);
|
||||
// Child A should be repositioned after Child B (last sibling), not stay at end
|
||||
const ids = result.map(i => i.id);
|
||||
expect(ids).toEqual(['1', '3', '2', '4']);
|
||||
expect(result.find(i => i.id === '2')!.checked).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('indentItem', () => {
|
||||
it('sets parentId to nearest preceding top-level item', () => {
|
||||
const items: ChecklistItem[] = [
|
||||
{ id: '1', text: 'A', checked: false, parentId: null },
|
||||
{ id: '2', text: 'B', checked: false, parentId: null }
|
||||
];
|
||||
const active = items.filter(i => !i.checked);
|
||||
const result = indentItem('2', items, active);
|
||||
expect(result.find((i) => i.id === '2')!.parentId).toBe('1');
|
||||
});
|
||||
|
||||
it('no-op if already a child', () => {
|
||||
const items: ChecklistItem[] = [
|
||||
{ id: '1', text: 'A', checked: false, parentId: null },
|
||||
{ id: '2', text: 'B', checked: false, parentId: '1' }
|
||||
];
|
||||
const active = items.filter(i => !i.checked);
|
||||
const result = indentItem('2', items, active);
|
||||
expect(result).toEqual(items);
|
||||
});
|
||||
|
||||
it('no-op if first item (no parent above)', () => {
|
||||
const items: ChecklistItem[] = [
|
||||
{ id: '1', text: 'A', checked: false, parentId: null }
|
||||
];
|
||||
const active = items.filter(i => !i.checked);
|
||||
const result = indentItem('1', items, active);
|
||||
expect(result[0].parentId).toBeNull();
|
||||
});
|
||||
|
||||
it('reparents children to grandparent when indenting a parent', () => {
|
||||
const items: ChecklistItem[] = [
|
||||
{ id: '1', text: 'A', checked: false, parentId: null },
|
||||
{ id: '2', text: 'B', checked: false, parentId: null },
|
||||
{ id: '3', text: 'C', checked: false, parentId: '2' },
|
||||
{ id: '4', text: 'D', checked: false, parentId: '2' }
|
||||
];
|
||||
const active = items.filter(i => !i.checked);
|
||||
const result = indentItem('2', items, active);
|
||||
expect(result.find((i) => i.id === '2')!.parentId).toBe('1');
|
||||
expect(result.find((i) => i.id === '3')!.parentId).toBe('1');
|
||||
expect(result.find((i) => i.id === '4')!.parentId).toBe('1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('outdentItem', () => {
|
||||
it('sets parentId to null', () => {
|
||||
const items: ChecklistItem[] = [
|
||||
{ id: '1', text: 'A', checked: false, parentId: null },
|
||||
{ id: '2', text: 'B', checked: false, parentId: '1' }
|
||||
];
|
||||
const result = outdentItem('2', items);
|
||||
expect(result.find((i) => i.id === '2')!.parentId).toBeNull();
|
||||
});
|
||||
|
||||
it('no-op if already top-level', () => {
|
||||
const items: ChecklistItem[] = [
|
||||
{ id: '1', text: 'A', checked: false, parentId: null }
|
||||
];
|
||||
const result = outdentItem('1', items);
|
||||
expect(result[0].parentId).toBeNull();
|
||||
});
|
||||
|
||||
it('adopts consecutive siblings below with same parentId', () => {
|
||||
const items: ChecklistItem[] = [
|
||||
{ id: '1', text: 'A', checked: false, parentId: null },
|
||||
{ id: '2', text: 'B', checked: false, parentId: '1' },
|
||||
{ id: '3', text: 'C', checked: false, parentId: '1' },
|
||||
{ id: '4', text: 'D', checked: false, parentId: '1' },
|
||||
{ id: '5', text: 'E', checked: false, parentId: null }
|
||||
];
|
||||
const result = outdentItem('2', items);
|
||||
expect(result.find((i) => i.id === '2')!.parentId).toBeNull();
|
||||
expect(result.find((i) => i.id === '3')!.parentId).toBe('2');
|
||||
expect(result.find((i) => i.id === '4')!.parentId).toBe('2');
|
||||
expect(result.find((i) => i.id === '5')!.parentId).toBeNull();
|
||||
});
|
||||
|
||||
it('stops adoption at first non-sibling', () => {
|
||||
const items: ChecklistItem[] = [
|
||||
{ id: '1', text: 'A', checked: false, parentId: null },
|
||||
{ id: '2', text: 'B', checked: false, parentId: '1' },
|
||||
{ id: '5', text: 'E', checked: false, parentId: null },
|
||||
{ id: '3', text: 'C', checked: false, parentId: '1' }
|
||||
];
|
||||
const result = outdentItem('2', items);
|
||||
expect(result.find((i) => i.id === '2')!.parentId).toBeNull();
|
||||
expect(result.find((i) => i.id === '5')!.parentId).toBeNull();
|
||||
expect(result.find((i) => i.id === '3')!.parentId).toBe('1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('linkifyText', () => {
|
||||
it('converts https URLs to anchor tags', () => {
|
||||
expect(linkifyText('visit https://example.com today')).toBe(
|
||||
'visit <a href="https://example.com" target="_blank" rel="noopener noreferrer" class="checklist-link">https://example.com</a> today'
|
||||
);
|
||||
});
|
||||
|
||||
it('converts http URLs to anchor tags', () => {
|
||||
expect(linkifyText('go to http://example.com')).toBe(
|
||||
'go to <a href="http://example.com" target="_blank" rel="noopener noreferrer" class="checklist-link">http://example.com</a>'
|
||||
);
|
||||
});
|
||||
|
||||
it('converts bare www URLs with https href', () => {
|
||||
expect(linkifyText('check www.example.com')).toBe(
|
||||
'check <a href="https://www.example.com" target="_blank" rel="noopener noreferrer" class="checklist-link">www.example.com</a>'
|
||||
);
|
||||
});
|
||||
|
||||
it('handles multiple URLs in one string', () => {
|
||||
const result = linkifyText('see https://a.com and https://b.com');
|
||||
expect(result).toContain('href="https://a.com"');
|
||||
expect(result).toContain('href="https://b.com"');
|
||||
});
|
||||
|
||||
it('returns plain text unchanged when no URLs present', () => {
|
||||
expect(linkifyText('just some text')).toBe('just some text');
|
||||
});
|
||||
|
||||
it('HTML-escapes user content to prevent XSS', () => {
|
||||
expect(linkifyText('<script>alert("xss")</script>')).toBe(
|
||||
'<script>alert("xss")</script>'
|
||||
);
|
||||
});
|
||||
|
||||
it('HTML-escapes text around URLs', () => {
|
||||
expect(linkifyText('<b>bold</b> https://example.com')).toContain('<b>bold</b>');
|
||||
expect(linkifyText('<b>bold</b> https://example.com')).toContain('href="https://example.com"');
|
||||
});
|
||||
|
||||
it('handles URL at start of string', () => {
|
||||
expect(linkifyText('https://example.com is great')).toContain('href="https://example.com"');
|
||||
});
|
||||
|
||||
it('handles URL at end of string', () => {
|
||||
expect(linkifyText('visit https://example.com')).toContain('href="https://example.com"');
|
||||
});
|
||||
|
||||
it('does not linkify partial matches without protocol or www', () => {
|
||||
expect(linkifyText('example.com is not linked')).toBe('example.com is not linked');
|
||||
});
|
||||
|
||||
it('handles URLs with paths and query strings', () => {
|
||||
const url = 'https://example.com/path?q=1&b=2';
|
||||
const result = linkifyText(url);
|
||||
expect(result).toContain('href="https://example.com/path?q=1&b=2"');
|
||||
});
|
||||
|
||||
it('strips trailing punctuation from URLs', () => {
|
||||
expect(linkifyText('see https://example.com.')).toBe(
|
||||
'see <a href="https://example.com" target="_blank" rel="noopener noreferrer" class="checklist-link">https://example.com</a>.'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns empty string for empty input', () => {
|
||||
expect(linkifyText('')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('unlinkifyHtml', () => {
|
||||
it('strips anchor tags and returns text content', () => {
|
||||
expect(unlinkifyHtml('visit <a href="https://example.com">https://example.com</a> today'))
|
||||
.toBe('visit https://example.com today');
|
||||
});
|
||||
|
||||
it('returns plain text unchanged', () => {
|
||||
expect(unlinkifyHtml('just text')).toBe('just text');
|
||||
});
|
||||
|
||||
it('handles multiple anchor tags', () => {
|
||||
expect(unlinkifyHtml('<a href="https://a.com">https://a.com</a> and <a href="https://b.com">https://b.com</a>'))
|
||||
.toBe('https://a.com and https://b.com');
|
||||
});
|
||||
|
||||
it('returns empty string for empty input', () => {
|
||||
expect(unlinkifyHtml('')).toBe('');
|
||||
});
|
||||
|
||||
it('handles div and br tags from contenteditable', () => {
|
||||
expect(unlinkifyHtml('line1<br>line2')).toBe('line1line2');
|
||||
});
|
||||
|
||||
it('decodes HTML entities after stripping tags', () => {
|
||||
expect(unlinkifyHtml('Tom & Jerry')).toBe('Tom & Jerry');
|
||||
expect(unlinkifyHtml('<b>bold</b>')).toBe('<b>bold</b>');
|
||||
expect(unlinkifyHtml('say "hello"')).toBe('say "hello"');
|
||||
});
|
||||
|
||||
it('converts from contenteditable to regular spaces', () => {
|
||||
expect(unlinkifyHtml('hello world')).toBe('hello world');
|
||||
expect(unlinkifyHtml('a b')).toBe('a b');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
export interface ChecklistItem {
|
||||
id: string;
|
||||
text: string;
|
||||
checked: boolean;
|
||||
parentId: string | null;
|
||||
}
|
||||
|
||||
export function generateId(): string {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
export function isChild(item: ChecklistItem): boolean {
|
||||
return item.parentId !== null;
|
||||
}
|
||||
|
||||
export function getChildren(parentId: string, items: ChecklistItem[]): ChecklistItem[] {
|
||||
return items.filter((i) => i.parentId === parentId);
|
||||
}
|
||||
|
||||
export function getParent(item: ChecklistItem, items: ChecklistItem[]): ChecklistItem | undefined {
|
||||
return item.parentId ? items.find((i) => i.id === item.parentId) : undefined;
|
||||
}
|
||||
|
||||
export function toggleItemWithCascade(id: string, items: ChecklistItem[]): ChecklistItem[] {
|
||||
const target = items.find((i) => i.id === id)!;
|
||||
const newChecked = !target.checked;
|
||||
|
||||
let result = items.map((item) => {
|
||||
if (item.id === id) return { ...item, checked: newChecked };
|
||||
if (item.parentId === id) return { ...item, checked: newChecked };
|
||||
return item;
|
||||
});
|
||||
|
||||
// When unchecking a child: reposition it after its parent/siblings in the array
|
||||
// (otherwise it stays at the end of the array where done items live)
|
||||
if (!newChecked && target.parentId) {
|
||||
const parentExists = result.some((i) => i.id === target.parentId);
|
||||
if (!parentExists) {
|
||||
// Parent deleted — orphan it
|
||||
result = result.map((i) => (i.id === id ? { ...i, parentId: null } : i));
|
||||
} else {
|
||||
// Remove the item from its current position
|
||||
const uncheckedItem = result.find((i) => i.id === id)!;
|
||||
result = result.filter((i) => i.id !== id);
|
||||
// Find the last sibling or the parent, insert after it
|
||||
let insertAfter = -1;
|
||||
for (let i = 0; i < result.length; i++) {
|
||||
if (result[i].id === target.parentId || result[i].parentId === target.parentId) {
|
||||
insertAfter = i;
|
||||
}
|
||||
}
|
||||
if (insertAfter >= 0) {
|
||||
result.splice(insertAfter + 1, 0, uncheckedItem);
|
||||
} else {
|
||||
result.push(uncheckedItem);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function indentItem(id: string, items: ChecklistItem[], activeItems: ChecklistItem[]): ChecklistItem[] {
|
||||
const item = items.find((i) => i.id === id);
|
||||
if (!item || item.parentId !== null) return items;
|
||||
|
||||
const activeIndex = activeItems.findIndex((i) => i.id === id);
|
||||
let newParentId: string | null = null;
|
||||
for (let i = activeIndex - 1; i >= 0; i--) {
|
||||
if (activeItems[i].parentId === null) {
|
||||
newParentId = activeItems[i].id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!newParentId) return items;
|
||||
|
||||
return items.map((i) => {
|
||||
if (i.id === id) return { ...i, parentId: newParentId };
|
||||
if (i.parentId === id) return { ...i, parentId: newParentId };
|
||||
return i;
|
||||
});
|
||||
}
|
||||
|
||||
export function outdentItem(id: string, items: ChecklistItem[]): ChecklistItem[] {
|
||||
const index = items.findIndex((i) => i.id === id);
|
||||
const item = items[index];
|
||||
if (!item || item.parentId === null) return items;
|
||||
|
||||
const originalParentId = item.parentId;
|
||||
|
||||
const adoptees: string[] = [];
|
||||
for (let i = index + 1; i < items.length; i++) {
|
||||
if (items[i].parentId === originalParentId) {
|
||||
adoptees.push(items[i].id);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return items.map((i) => {
|
||||
if (i.id === id) return { ...i, parentId: null };
|
||||
if (adoptees.includes(i.id)) return { ...i, parentId: id };
|
||||
return i;
|
||||
});
|
||||
}
|
||||
|
||||
export function parseChecklist(text: string): ChecklistItem[] {
|
||||
if (!text.trim()) return [{ id: generateId(), text: '', checked: false, parentId: null }];
|
||||
|
||||
let lastTopLevelId: string | null = null;
|
||||
return text.split('\n').map((line) => {
|
||||
if (line.startsWith(' - [x] ')) {
|
||||
return { id: generateId(), text: line.slice(8), checked: true, parentId: lastTopLevelId };
|
||||
}
|
||||
if (line.startsWith(' - [ ] ')) {
|
||||
return { id: generateId(), text: line.slice(8), checked: false, parentId: lastTopLevelId };
|
||||
}
|
||||
const item: ChecklistItem = { id: generateId(), text: '', checked: false, parentId: null };
|
||||
if (line.startsWith('- [x] ')) {
|
||||
item.text = line.slice(6);
|
||||
item.checked = true;
|
||||
} else if (line.startsWith('- [ ] ')) {
|
||||
item.text = line.slice(6);
|
||||
} else {
|
||||
item.text = line;
|
||||
}
|
||||
lastTopLevelId = item.id;
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
export function serializeChecklist(items: ChecklistItem[]): string {
|
||||
const topLevel = items.filter((i) => i.parentId === null);
|
||||
const childMap = new Map<string, ChecklistItem[]>();
|
||||
for (const item of items) {
|
||||
if (item.parentId) {
|
||||
const siblings = childMap.get(item.parentId) ?? [];
|
||||
siblings.push(item);
|
||||
childMap.set(item.parentId, siblings);
|
||||
}
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
for (const parent of topLevel) {
|
||||
lines.push(`- [${parent.checked ? 'x' : ' '}] ${parent.text}`);
|
||||
const children = childMap.get(parent.id) ?? [];
|
||||
for (const child of children) {
|
||||
lines.push(` - [${child.checked ? 'x' : ' '}] ${child.text}`);
|
||||
}
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
const URL_REGEX = /(?:https?:\/\/|www\.)[^\s<]+[^\s<.,;:!?)'"}\]]/g;
|
||||
|
||||
export function linkifyText(text: string): string {
|
||||
if (!text) return '';
|
||||
|
||||
const matches: { index: number; match: string; href: string }[] = [];
|
||||
let m: RegExpExecArray | null;
|
||||
const regex = new RegExp(URL_REGEX.source, 'g');
|
||||
|
||||
while ((m = regex.exec(text)) !== null) {
|
||||
const match = m[0];
|
||||
const href = match.startsWith('www.') ? `https://${match}` : match;
|
||||
matches.push({ index: m.index, match, href });
|
||||
}
|
||||
|
||||
if (matches.length === 0) return escapeHtml(text);
|
||||
|
||||
let result = '';
|
||||
let lastIndex = 0;
|
||||
for (const { index, match, href } of matches) {
|
||||
result += escapeHtml(text.slice(lastIndex, index));
|
||||
result += `<a href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer" class="checklist-link">${escapeHtml(match)}</a>`;
|
||||
lastIndex = index + match.length;
|
||||
}
|
||||
result += escapeHtml(text.slice(lastIndex));
|
||||
return result;
|
||||
}
|
||||
|
||||
export function unlinkifyHtml(html: string): string {
|
||||
if (!html) return '';
|
||||
return html
|
||||
.replace(/<[^>]*>/g, '')
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Custom tooltip action — drop-in replacement for title attributes.
|
||||
* Usage: <button use:tooltip={"Label"}>
|
||||
* Or with options: <button use:tooltip={{ text: "Label", position: "bottom" }}>
|
||||
*/
|
||||
|
||||
export type TooltipPosition = 'top' | 'bottom';
|
||||
|
||||
interface TooltipOptions {
|
||||
text: string;
|
||||
position?: TooltipPosition;
|
||||
}
|
||||
|
||||
function parseOptions(value: string | TooltipOptions): { text: string; position: TooltipPosition } {
|
||||
if (typeof value === 'string') return { text: value, position: 'top' };
|
||||
return { text: value.text, position: value.position ?? 'top' };
|
||||
}
|
||||
|
||||
export function tooltip(node: HTMLElement, value: string | TooltipOptions) {
|
||||
let opts = parseOptions(value);
|
||||
let el: HTMLDivElement | null = null;
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
function show() {
|
||||
timeout = setTimeout(() => {
|
||||
if (!opts.text) return;
|
||||
el = document.createElement('div');
|
||||
el.className = 'tooltip-popup';
|
||||
el.textContent = opts.text;
|
||||
document.body.appendChild(el);
|
||||
position();
|
||||
}, 400);
|
||||
}
|
||||
|
||||
function position() {
|
||||
if (!el) return;
|
||||
const rect = node.getBoundingClientRect();
|
||||
const tipRect = el.getBoundingClientRect();
|
||||
|
||||
let top: number;
|
||||
if (opts.position === 'bottom') {
|
||||
top = rect.bottom + 6;
|
||||
} else {
|
||||
top = rect.top - tipRect.height - 6;
|
||||
}
|
||||
|
||||
let left = rect.left + rect.width / 2 - tipRect.width / 2;
|
||||
// Clamp to viewport
|
||||
left = Math.max(8, Math.min(left, window.innerWidth - tipRect.width - 8));
|
||||
|
||||
el.style.top = `${top}px`;
|
||||
el.style.left = `${left}px`;
|
||||
el.classList.add('tooltip-visible');
|
||||
}
|
||||
|
||||
function hide() {
|
||||
clearTimeout(timeout);
|
||||
if (el) {
|
||||
el.remove();
|
||||
el = null;
|
||||
}
|
||||
}
|
||||
|
||||
node.addEventListener('mouseenter', show);
|
||||
node.addEventListener('mouseleave', hide);
|
||||
node.addEventListener('pointerdown', hide);
|
||||
// Remove native title to prevent double tooltip
|
||||
const nativeTitle = node.getAttribute('title');
|
||||
if (nativeTitle) node.removeAttribute('title');
|
||||
|
||||
return {
|
||||
update(newValue: string | TooltipOptions) {
|
||||
opts = parseOptions(newValue);
|
||||
},
|
||||
destroy() {
|
||||
hide();
|
||||
node.removeEventListener('mouseenter', show);
|
||||
node.removeEventListener('mouseleave', hide);
|
||||
node.removeEventListener('pointerdown', hide);
|
||||
if (nativeTitle) node.setAttribute('title', nativeTitle);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
<script lang="ts">
|
||||
import { NOTE_COLORS } from '$lib/utils/colors.js';
|
||||
import { renderMarkdown } from '$lib/utils/markdown.js';
|
||||
import { linkifyText } from '$lib/utils/checklist.js';
|
||||
import type { NoteColor, PublicAttachment } from '$lib/types/index.js';
|
||||
|
||||
interface ChecklistItem {
|
||||
text: string;
|
||||
checked: boolean;
|
||||
indented: boolean;
|
||||
}
|
||||
|
||||
const { data } = $props();
|
||||
@@ -19,10 +21,14 @@
|
||||
? data.content
|
||||
.split('\n')
|
||||
.filter((l: string) => l.trim())
|
||||
.map((line: string) => ({
|
||||
text: line.replace(/^- \[[ x]\] /, ''),
|
||||
checked: line.startsWith('- [x] ')
|
||||
}))
|
||||
.map((line: string) => {
|
||||
const indented = line.startsWith(' - [');
|
||||
return {
|
||||
text: line.replace(/^ {0,2}- \[[ x]\] /, ''),
|
||||
checked: /^ {0,2}- \[x\] /.test(line),
|
||||
indented
|
||||
};
|
||||
})
|
||||
: [];
|
||||
|
||||
const activeChecklistItems = checklistItems.filter((i) => !i.checked);
|
||||
@@ -64,9 +70,10 @@
|
||||
<ul class="space-y-2" data-testid="shared-note-checklist">
|
||||
{#each sortedChecklistItems as item}
|
||||
<li
|
||||
class="flex items-start gap-2 text-sm {item.checked
|
||||
class="flex items-start gap-2 text-sm {item.indented ? 'pl-4 ' : ''}{item.checked
|
||||
? 'text-[var(--text-muted)] line-through'
|
||||
: 'text-[var(--text)]'}"
|
||||
data-testid={item.indented ? 'shared-checklist-child' : undefined}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -74,7 +81,7 @@
|
||||
disabled
|
||||
class="mt-0.5 h-3.5 w-3.5 shrink-0 rounded border-[var(--border-subtle)]"
|
||||
/>
|
||||
<span class="break-words min-w-0">{item.text}</span>
|
||||
<span class="break-words min-w-0">{@html linkifyText(item.text)}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
+94
-1
@@ -3,7 +3,7 @@
|
||||
"info": {
|
||||
"title": "Crumbs API",
|
||||
"description": "Auto-generated API spec for crumbs",
|
||||
"version": "0.21.9",
|
||||
"version": "0.27.1",
|
||||
"contact": {
|
||||
"name": "Bretzel",
|
||||
"url": "https://bretzel.app"
|
||||
@@ -541,6 +541,47 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/notes/{id}/share": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"post": {
|
||||
"tags": [
|
||||
"Notes"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
},
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"tags": [
|
||||
"Notes"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/notes/{id}/versions": {
|
||||
"parameters": [
|
||||
{
|
||||
@@ -728,6 +769,58 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/shared/{token}": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "token",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"get": {
|
||||
"tags": [
|
||||
"Shared"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/shared/{token}/attachment/{attachmentId}": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "token",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "attachmentId",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"get": {
|
||||
"tags": [
|
||||
"Shared"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/sync": {
|
||||
"post": {
|
||||
"tags": [
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { test, expect, setupAndLogin } from './helpers/fixtures.js';
|
||||
import { readFileSync } from 'fs';
|
||||
import { TEST_CREDENTIALS_FILE } from './global-setup';
|
||||
|
||||
const { userPassword } = JSON.parse(readFileSync(TEST_CREDENTIALS_FILE, 'utf-8'));
|
||||
|
||||
test.describe.serial('Authentication', () => {
|
||||
test('Scenario: Unauthenticated user is prompted to set up or log in', async ({ page }) => {
|
||||
@@ -78,7 +82,7 @@ test.describe('Multi-user', () => {
|
||||
data: {
|
||||
email: 'user2@test.com',
|
||||
displayName: 'User Two',
|
||||
password: 'userpassword123',
|
||||
password: userPassword,
|
||||
role: 'user'
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { test, expect, noteCard, createNote } from './helpers/fixtures.js';
|
||||
|
||||
test.describe('Auto-save', () => {
|
||||
test('Scenario: New note is auto-saved without closing the editor', async ({ authenticatedPage: page }) => {
|
||||
// When the user starts a new note and types a title and content
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Auto-saved Note');
|
||||
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
|
||||
await editor.click();
|
||||
await editor.pressSequentially('This should be saved automatically');
|
||||
|
||||
// Then the note is persisted after the auto-save delay (2s debounce)
|
||||
await expect(noteCard(page, 'Auto-saved Note')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// And closing the editor preserves the note
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
await expect(noteCard(page, 'Auto-saved Note')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Edits to an existing note are auto-saved', async ({ authenticatedPage: page }) => {
|
||||
// Given a note titled "Edit Me" exists
|
||||
await createNote(page, 'Edit Me', 'Original content');
|
||||
|
||||
// When the user opens the note and changes the title
|
||||
await noteCard(page, 'Edit Me').click();
|
||||
await page.getByTestId('note-title-input').clear();
|
||||
await page.getByTestId('note-title-input').fill('Edited Title');
|
||||
|
||||
// Then the updated title is auto-saved after the debounce delay
|
||||
// Wait for the API call that confirms the save
|
||||
await page.waitForResponse(
|
||||
(res) => res.url().includes('/api/notes/') && res.request().method() === 'PATCH',
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
|
||||
// And the note card reflects the change while editor is still open
|
||||
await expect(noteCard(page, 'Edited Title')).toBeVisible();
|
||||
|
||||
// Cleanup: close the editor
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
});
|
||||
|
||||
test('Scenario: Empty note is not auto-saved', async ({ authenticatedPage: page }) => {
|
||||
// Given the current number of notes
|
||||
const noteCards = page.locator('[data-testid="note-card"]');
|
||||
const countBefore = await noteCards.count();
|
||||
|
||||
// When the user opens a new note but leaves it empty
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
|
||||
// And waits longer than the auto-save delay
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// And closes the empty editor
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
|
||||
// Then no new note was created
|
||||
const countAfter = await noteCards.count();
|
||||
expect(countAfter).toBe(countBefore);
|
||||
});
|
||||
|
||||
test('Scenario: Auto-save persists note across page reload', async ({ authenticatedPage: page }) => {
|
||||
// When the user creates a note and waits for auto-save
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Auto-save Survives Reload');
|
||||
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
|
||||
await editor.click();
|
||||
await editor.pressSequentially('Content that survives');
|
||||
|
||||
// Wait for auto-save to fire
|
||||
await page.waitForResponse(
|
||||
(res) => res.url().includes('/api/notes') && res.request().method() === 'POST',
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
|
||||
// When the page is reloaded without closing the editor
|
||||
await page.reload();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Then the note is still visible
|
||||
await expect(noteCard(page, 'Auto-save Survives Reload')).toBeVisible();
|
||||
});
|
||||
});
|
||||
+322
-45
@@ -1,13 +1,20 @@
|
||||
import { test, expect, noteCard } from './helpers/fixtures.js';
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
/** Toggle checklist mode via the overflow menu. */
|
||||
async function toggleChecklistMode(page: Page) {
|
||||
await page.getByTestId('overflow-menu-btn').click();
|
||||
await page.getByTestId('checklist-toggle').click();
|
||||
}
|
||||
|
||||
/** Create a checklist note via UI. Leaves the editor closed. */
|
||||
async function createChecklistNote(page: Page, title: string, items: string[]) {
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill(title);
|
||||
await page.getByTestId('checklist-toggle').click();
|
||||
await toggleChecklistMode(page);
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
await page.getByTestId('checklist-input').nth(i).fill(items[i]);
|
||||
await page.getByTestId('checklist-input').nth(i).focus();
|
||||
await page.keyboard.type(items[i]);
|
||||
if (i < items.length - 1) {
|
||||
await page.getByTestId('checklist-input').nth(i).press('Enter');
|
||||
}
|
||||
@@ -22,7 +29,7 @@ test.describe('Checklist', () => {
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
|
||||
// When the user enables checklist mode
|
||||
await page.getByTestId('checklist-toggle').click();
|
||||
await toggleChecklistMode(page);
|
||||
|
||||
// Then the checklist component is displayed
|
||||
await expect(page.getByTestId('checklist')).toBeVisible();
|
||||
@@ -40,7 +47,7 @@ test.describe('Checklist', () => {
|
||||
|
||||
// Then the checklist is displayed with the saved item
|
||||
await expect(page.getByTestId('checklist')).toBeVisible();
|
||||
await expect(page.getByTestId('checklist-input').first()).toHaveValue('Buy milk');
|
||||
await expect(page.getByTestId('checklist-input').first()).toHaveText('Buy milk');
|
||||
});
|
||||
|
||||
test('Scenario: Checked item moves to the done section', async ({ authenticatedPage: page }) => {
|
||||
@@ -60,11 +67,37 @@ test.describe('Checklist', () => {
|
||||
await expect(page.getByTestId('checklist-done-checkbox').first()).toBeChecked();
|
||||
});
|
||||
|
||||
test('Scenario: Sequential check and uncheck both persist correctly', async ({ authenticatedPage: page }) => {
|
||||
// Given a checklist note with an item "Buy milk" exists
|
||||
await createChecklistNote(page, 'Tasks2', ['Buy milk']);
|
||||
|
||||
// When the user checks the item and closes
|
||||
await noteCard(page, 'Tasks2').click();
|
||||
await page.getByTestId('checklist-checkbox').first().click();
|
||||
await expect(page.getByTestId('checklist-toggle-done')).toContainText('1 done');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
|
||||
// Then the checked state persists
|
||||
await noteCard(page, 'Tasks2').click();
|
||||
await expect(page.getByTestId('checklist-done-checkbox').first()).toBeChecked();
|
||||
|
||||
// When the user unchecks the item from the done section and closes
|
||||
await page.getByTestId('checklist-done-checkbox').first().click();
|
||||
await expect(page.getByTestId('checklist-checkbox')).toHaveCount(1);
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
|
||||
// Then the unchecked state also persists (not reverted by 3-way merge)
|
||||
await noteCard(page, 'Tasks2').click();
|
||||
await expect(page.getByTestId('checklist-checkbox')).toHaveCount(1);
|
||||
await expect(page.getByTestId('checklist-input').first()).toHaveText('Buy milk');
|
||||
});
|
||||
|
||||
test('Scenario: Enter key adds a new checklist item', async ({ authenticatedPage: page }) => {
|
||||
// Given a checklist with one item
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('checklist-toggle').click();
|
||||
await page.getByTestId('checklist-input').first().fill('First item');
|
||||
await toggleChecklistMode(page);
|
||||
await page.getByTestId('checklist-input').first().focus();
|
||||
await page.keyboard.type('First item');
|
||||
|
||||
// When the user presses Enter
|
||||
await page.getByTestId('checklist-input').first().press('Enter');
|
||||
@@ -96,44 +129,15 @@ test.describe('Checklist', () => {
|
||||
// Then focus moves to the newly created empty item
|
||||
await expect(page.getByTestId('checklist-input')).toHaveCount(3);
|
||||
await expect(page.getByTestId('checklist-input').nth(1)).toBeFocused();
|
||||
await expect(page.getByTestId('checklist-input').nth(1)).toHaveValue('');
|
||||
});
|
||||
|
||||
test('Scenario: Arrow keys navigate between checklist items', async ({ authenticatedPage: page }) => {
|
||||
// Given a checklist with three items
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('checklist-toggle').click();
|
||||
await page.getByTestId('checklist-input').first().fill('First');
|
||||
await page.getByTestId('checklist-input').first().press('Enter');
|
||||
await page.getByTestId('checklist-input').nth(1).fill('Second');
|
||||
await page.getByTestId('checklist-input').nth(1).press('Enter');
|
||||
await page.getByTestId('checklist-input').nth(2).fill('Third');
|
||||
|
||||
// When the user presses ArrowUp from the third item
|
||||
await page.getByTestId('checklist-input').nth(2).press('ArrowUp');
|
||||
|
||||
// Then focus moves to the second item
|
||||
await expect(page.getByTestId('checklist-input').nth(1)).toBeFocused();
|
||||
|
||||
// When the user presses ArrowDown
|
||||
await page.getByTestId('checklist-input').nth(1).press('ArrowDown');
|
||||
|
||||
// Then focus moves back to the third item
|
||||
await expect(page.getByTestId('checklist-input').nth(2)).toBeFocused();
|
||||
|
||||
// When the user presses ArrowUp from the first item
|
||||
await page.getByTestId('checklist-input').nth(0).focus();
|
||||
await page.getByTestId('checklist-input').nth(0).press('ArrowUp');
|
||||
|
||||
// Then focus stays on the first item (no wrap)
|
||||
await expect(page.getByTestId('checklist-input').nth(0)).toBeFocused();
|
||||
await expect(page.getByTestId('checklist-input').nth(1)).toHaveText('');
|
||||
});
|
||||
|
||||
test('Scenario: Backspace on empty item removes it from the list', async ({ authenticatedPage: page }) => {
|
||||
// Given a checklist with two items where the second is empty
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('checklist-toggle').click();
|
||||
await page.getByTestId('checklist-input').first().fill('First item');
|
||||
await toggleChecklistMode(page);
|
||||
await page.getByTestId('checklist-input').first().focus();
|
||||
await page.keyboard.type('First item');
|
||||
await page.getByTestId('checklist-input').first().press('Enter');
|
||||
await expect(page.getByTestId('checklist-input')).toHaveCount(2);
|
||||
|
||||
@@ -148,17 +152,19 @@ test.describe('Checklist', () => {
|
||||
// Given a checklist note with two items
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Hide Done Test');
|
||||
await page.getByTestId('checklist-toggle').click();
|
||||
await page.getByTestId('checklist-input').first().fill('Done task');
|
||||
await toggleChecklistMode(page);
|
||||
await page.getByTestId('checklist-input').first().focus();
|
||||
await page.keyboard.type('Done task');
|
||||
await page.getByTestId('checklist-input').first().press('Enter');
|
||||
await page.getByTestId('checklist-input').nth(1).fill('Pending task');
|
||||
await page.getByTestId('checklist-input').nth(1).focus();
|
||||
await page.keyboard.type('Pending task');
|
||||
|
||||
// When the user completes the first item
|
||||
await page.getByTestId('checklist-checkbox').first().click();
|
||||
|
||||
// Then only the pending item remains in the active list
|
||||
await expect(page.getByTestId('checklist-input')).toHaveCount(1);
|
||||
await expect(page.getByTestId('checklist-input').first()).toHaveValue('Pending task');
|
||||
await expect(page.getByTestId('checklist-input').first()).toHaveText('Pending task');
|
||||
|
||||
// And the done section shows the completed item
|
||||
await expect(page.getByTestId('checklist-toggle-done')).toContainText('1 done');
|
||||
@@ -166,17 +172,288 @@ test.describe('Checklist', () => {
|
||||
await expect(page.getByTestId('checklist-done-section')).toContainText('Done task');
|
||||
});
|
||||
|
||||
test('Scenario: Arrow keys navigate between checklist items', async ({ authenticatedPage: page }) => {
|
||||
// Given a checklist with three items
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await toggleChecklistMode(page);
|
||||
await page.getByTestId('checklist-input').first().focus();
|
||||
await page.keyboard.type('First');
|
||||
await page.getByTestId('checklist-input').first().press('Enter');
|
||||
await page.getByTestId('checklist-input').nth(1).focus();
|
||||
await page.keyboard.type('Second');
|
||||
await page.getByTestId('checklist-input').nth(1).press('Enter');
|
||||
await page.getByTestId('checklist-input').nth(2).focus();
|
||||
await page.keyboard.type('Third');
|
||||
|
||||
// When the user presses ArrowUp from the third item
|
||||
await page.getByTestId('checklist-input').nth(2).press('ArrowUp');
|
||||
|
||||
// Then focus moves to the second item
|
||||
await expect(page.getByTestId('checklist-input').nth(1)).toBeFocused();
|
||||
|
||||
// When the user presses ArrowDown
|
||||
await page.getByTestId('checklist-input').nth(1).press('ArrowDown');
|
||||
|
||||
// Then focus moves back to the third item
|
||||
await expect(page.getByTestId('checklist-input').nth(2)).toBeFocused();
|
||||
});
|
||||
|
||||
test('Scenario: Tab indents an item and Shift+Tab outdents it', async ({ authenticatedPage: page }) => {
|
||||
// Given a checklist with two top-level items
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await toggleChecklistMode(page);
|
||||
await page.getByTestId('checklist-input').first().focus();
|
||||
await page.keyboard.type('Parent');
|
||||
await page.getByTestId('checklist-input').first().press('Enter');
|
||||
await page.getByTestId('checklist-input').nth(1).focus();
|
||||
await page.keyboard.type('Child');
|
||||
|
||||
// When the user presses Tab on the second item
|
||||
await page.getByTestId('checklist-input').nth(1).press('Tab');
|
||||
|
||||
// Then the item becomes indented
|
||||
await expect(page.getByTestId('checklist-child-row')).toHaveCount(1);
|
||||
|
||||
// When the user presses Shift+Tab
|
||||
await page.getByTestId('checklist-input').nth(1).press('Shift+Tab');
|
||||
|
||||
// Then the item returns to top-level
|
||||
await expect(page.getByTestId('checklist-child-row')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('Scenario: Checking a parent checks all its children', async ({ authenticatedPage: page }) => {
|
||||
// Given a checklist with a parent and two children
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await toggleChecklistMode(page);
|
||||
await page.getByTestId('checklist-input').first().focus();
|
||||
await page.keyboard.type('Buy groceries');
|
||||
await page.getByTestId('checklist-input').first().press('Enter');
|
||||
await page.getByTestId('checklist-input').nth(1).focus();
|
||||
await page.keyboard.type('Milk');
|
||||
await page.getByTestId('checklist-input').nth(1).press('Tab');
|
||||
await page.getByTestId('checklist-input').nth(1).press('Enter');
|
||||
await page.getByTestId('checklist-input').nth(2).focus();
|
||||
await page.keyboard.type('Eggs');
|
||||
|
||||
// When the user checks the parent
|
||||
await page.getByTestId('checklist-checkbox').first().click();
|
||||
|
||||
// Then all items move to done section
|
||||
await expect(page.getByTestId('checklist-toggle-done')).toContainText('3 done');
|
||||
await expect(page.getByTestId('checklist-input')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('Scenario: Checking a child shows read-only parent label in done section', async ({ authenticatedPage: page }) => {
|
||||
// Given a checklist with a parent and a child
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await toggleChecklistMode(page);
|
||||
await page.getByTestId('checklist-input').first().focus();
|
||||
await page.keyboard.type('Groceries');
|
||||
await page.getByTestId('checklist-input').first().press('Enter');
|
||||
await page.getByTestId('checklist-input').nth(1).focus();
|
||||
await page.keyboard.type('Milk');
|
||||
await page.getByTestId('checklist-input').nth(1).press('Tab');
|
||||
|
||||
// When the user checks only the child
|
||||
await page.getByTestId('checklist-checkbox').nth(1).click();
|
||||
|
||||
// Then the done section shows a parent label above the checked child
|
||||
await expect(page.getByTestId('checklist-toggle-done')).toContainText('1 done');
|
||||
await expect(page.getByTestId('checklist-done-parent-label')).toBeVisible();
|
||||
await expect(page.getByTestId('checklist-done-parent-label')).toContainText('Groceries');
|
||||
});
|
||||
|
||||
test('Scenario: Unchecking a parent from done restores group to active', async ({ authenticatedPage: page }) => {
|
||||
// Given a checklist with a parent and child, all checked
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await toggleChecklistMode(page);
|
||||
await page.getByTestId('checklist-input').first().focus();
|
||||
await page.keyboard.type('Shopping');
|
||||
await page.getByTestId('checklist-input').first().press('Enter');
|
||||
await page.getByTestId('checklist-input').nth(1).focus();
|
||||
await page.keyboard.type('Milk');
|
||||
await page.getByTestId('checklist-input').nth(1).press('Tab');
|
||||
await page.getByTestId('checklist-checkbox').first().click();
|
||||
await expect(page.getByTestId('checklist-toggle-done')).toContainText('2 done');
|
||||
|
||||
// When the user unchecks the parent from the done section
|
||||
await page.getByTestId('checklist-done-checkbox').first().click();
|
||||
|
||||
// Then both parent and child return to active
|
||||
await expect(page.getByTestId('checklist-input')).toHaveCount(2);
|
||||
await expect(page.getByTestId('checklist-child-row')).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('Scenario: Nested items persist after close and reopen', async ({ authenticatedPage: page }) => {
|
||||
// Given a checklist with nested items
|
||||
await createChecklistNote(page, 'Nested Persist', ['Parent', 'Child']);
|
||||
|
||||
// Reopen and indent the second item
|
||||
await noteCard(page, 'Nested Persist').click();
|
||||
await page.getByTestId('checklist-input').nth(1).press('Tab');
|
||||
await expect(page.getByTestId('checklist-child-row')).toHaveCount(1);
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
|
||||
// When the user reopens the note
|
||||
await noteCard(page, 'Nested Persist').click();
|
||||
|
||||
// Then the nesting is preserved
|
||||
await expect(page.getByTestId('checklist-child-row')).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('Scenario: Enter on a child creates a sibling at the same level', async ({ authenticatedPage: page }) => {
|
||||
// Given a checklist with a parent and a child
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await toggleChecklistMode(page);
|
||||
await page.getByTestId('checklist-input').first().focus();
|
||||
await page.keyboard.type('Parent');
|
||||
await page.getByTestId('checklist-input').first().press('Enter');
|
||||
await page.getByTestId('checklist-input').nth(1).focus();
|
||||
await page.keyboard.type('Child 1');
|
||||
await page.getByTestId('checklist-input').nth(1).press('Tab');
|
||||
|
||||
// When the user presses Enter on the child
|
||||
await page.getByTestId('checklist-input').nth(1).press('Enter');
|
||||
|
||||
// Then a new child is created at the same level
|
||||
await expect(page.getByTestId('checklist-child-row')).toHaveCount(2);
|
||||
await expect(page.getByTestId('checklist-input').nth(2)).toBeFocused();
|
||||
});
|
||||
|
||||
test('Scenario: NoteCard preview shows indentation for nested items', async ({ authenticatedPage: page }) => {
|
||||
// Given a checklist note with nested items
|
||||
await createChecklistNote(page, 'Indent Preview Note', ['Parent', 'Child']);
|
||||
await noteCard(page, 'Indent Preview Note').click();
|
||||
await page.getByTestId('checklist-input').nth(1).press('Tab');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
|
||||
// Then the NoteCard preview shows the child indented (scoped to this card)
|
||||
const card = noteCard(page, 'Indent Preview Note');
|
||||
await expect(card.getByTestId('card-checklist-child')).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('Scenario: Disabling checklist mode restores the rich text editor', async ({ authenticatedPage: page }) => {
|
||||
// Given checklist mode is enabled on a new note
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('checklist-toggle').click();
|
||||
await toggleChecklistMode(page);
|
||||
await expect(page.getByTestId('checklist')).toBeVisible();
|
||||
|
||||
// When the user disables checklist mode
|
||||
await page.getByTestId('checklist-toggle').click();
|
||||
await toggleChecklistMode(page);
|
||||
|
||||
// Then the rich text editor is displayed again
|
||||
await expect(page.getByTestId('tiptap-editor')).toBeVisible();
|
||||
await expect(page.getByTestId('checklist')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Delete checked items removes only done items', async ({ authenticatedPage: page }) => {
|
||||
// Given a checklist note with two checked and one unchecked item
|
||||
await createChecklistNote(page, 'Cleanup', ['Keep me', 'Done 1', 'Done 2']);
|
||||
await noteCard(page, 'Cleanup').click();
|
||||
await page.getByTestId('checklist-checkbox').nth(1).click();
|
||||
await page.getByTestId('checklist-checkbox').nth(1).click();
|
||||
await expect(page.getByTestId('checklist-toggle-done')).toContainText('2 done');
|
||||
|
||||
// When the user deletes checked items via the overflow menu
|
||||
await page.getByTestId('overflow-menu-btn').click();
|
||||
await page.getByTestId('delete-checked-btn').click();
|
||||
|
||||
// Then only the unchecked item remains
|
||||
await expect(page.getByTestId('checklist-input')).toHaveCount(1);
|
||||
await expect(page.getByTestId('checklist-input').first()).toHaveText('Keep me');
|
||||
await expect(page.getByTestId('checklist-toggle-done')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Uncheck all items moves everything back to active', async ({ authenticatedPage: page }) => {
|
||||
// Given a checklist note with checked items
|
||||
await createChecklistNote(page, 'Reset', ['Item A', 'Item B']);
|
||||
await noteCard(page, 'Reset').click();
|
||||
await page.getByTestId('checklist-checkbox').first().click();
|
||||
await page.getByTestId('checklist-checkbox').first().click();
|
||||
await expect(page.getByTestId('checklist-toggle-done')).toContainText('2 done');
|
||||
|
||||
// When the user unchecks all via the overflow menu
|
||||
await page.getByTestId('overflow-menu-btn').click();
|
||||
await page.getByTestId('uncheck-all-btn').click();
|
||||
|
||||
// Then all items are back in the active section
|
||||
await expect(page.getByTestId('checklist-input')).toHaveCount(2);
|
||||
await expect(page.getByTestId('checklist-toggle-done')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: URL typed in a checklist item becomes a clickable link', async ({ authenticatedPage: page }) => {
|
||||
// Given a new checklist note
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await toggleChecklistMode(page);
|
||||
|
||||
// When the user types a URL and leaves the field
|
||||
await page.getByTestId('checklist-input').first().focus();
|
||||
await page.keyboard.type('check https://example.com today');
|
||||
await page.getByTestId('note-title-input').focus();
|
||||
|
||||
// Then the URL is rendered as a link
|
||||
const link = page.getByTestId('checklist-input').first().locator('a');
|
||||
await expect(link).toBeVisible();
|
||||
await expect(link).toHaveAttribute('href', 'https://example.com');
|
||||
});
|
||||
|
||||
test('Scenario: Clicking a link in a checklist item shows the link popover', async ({ authenticatedPage: page }) => {
|
||||
// Given a checklist item with a URL
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await toggleChecklistMode(page);
|
||||
await page.getByTestId('checklist-input').first().focus();
|
||||
await page.keyboard.type('https://example.com');
|
||||
await page.getByTestId('note-title-input').focus();
|
||||
|
||||
// When the user clicks the link
|
||||
await page.getByTestId('checklist-input').first().locator('a').click();
|
||||
|
||||
// Then the link popover appears with an Open button
|
||||
await expect(page.getByTestId('link-popover')).toBeVisible();
|
||||
await expect(page.getByTestId('link-popover-open')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Link popover dismisses on Escape', async ({ authenticatedPage: page }) => {
|
||||
// Given a link popover is open
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await toggleChecklistMode(page);
|
||||
await page.getByTestId('checklist-input').first().focus();
|
||||
await page.keyboard.type('https://example.com');
|
||||
await page.getByTestId('note-title-input').focus();
|
||||
await page.getByTestId('checklist-input').first().locator('a').click();
|
||||
await expect(page.getByTestId('link-popover')).toBeVisible();
|
||||
|
||||
// When the user presses Escape
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// Then the popover is dismissed
|
||||
await expect(page.getByTestId('link-popover')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Links in done section are clickable', async ({ authenticatedPage: page }) => {
|
||||
// Given a checklist item with a URL is checked
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await toggleChecklistMode(page);
|
||||
await page.getByTestId('checklist-input').first().focus();
|
||||
await page.keyboard.type('https://example.com');
|
||||
await page.getByTestId('checklist-checkbox').first().click();
|
||||
|
||||
// Then the done section shows the URL as a link
|
||||
await expect(page.getByTestId('checklist-done-section').locator('a.checklist-link')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Links in NoteCard preview are rendered', async ({ authenticatedPage: page }) => {
|
||||
// Given a checklist note with a URL
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Link Note');
|
||||
await toggleChecklistMode(page);
|
||||
await page.getByTestId('checklist-input').first().focus();
|
||||
await page.keyboard.type('visit https://example.com');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
|
||||
// Then the NoteCard preview shows the URL as a link
|
||||
const card = noteCard(page, 'Link Note');
|
||||
await expect(card.locator('a.checklist-link')).toBeVisible();
|
||||
await expect(card.locator('a.checklist-link')).toHaveAttribute('href', 'https://example.com');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,11 +13,17 @@ async function runTiptapCommand(page: Page, commandFn: string) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Toggle markdown mode via the overflow menu. */
|
||||
async function toggleMarkdownMode(page: Page) {
|
||||
await page.getByTestId('overflow-menu-btn').click();
|
||||
await page.getByTestId('markdown-toggle').click();
|
||||
}
|
||||
|
||||
/** Type content into the TipTap editor via markdown mode (reliable for e2e) */
|
||||
async function typeViaMarkdown(page: Page, content: string) {
|
||||
await page.getByTestId('markdown-toggle').click();
|
||||
await toggleMarkdownMode(page);
|
||||
await page.getByTestId('note-content-input').fill(content);
|
||||
await page.getByTestId('markdown-toggle').click();
|
||||
await toggleMarkdownMode(page);
|
||||
}
|
||||
|
||||
test.describe('Rich text formatting', () => {
|
||||
@@ -34,7 +40,7 @@ test.describe('Rich text formatting', () => {
|
||||
await expect(editor.locator('strong')).toHaveText('world');
|
||||
|
||||
// And the markdown content contains bold syntax
|
||||
await page.getByTestId('markdown-toggle').click();
|
||||
await toggleMarkdownMode(page);
|
||||
await expect(page.getByTestId('note-content-input')).toHaveValue('hello **world**');
|
||||
});
|
||||
|
||||
@@ -95,7 +101,7 @@ test.describe('Rich text formatting', () => {
|
||||
await expect(editor.locator('strong')).toHaveText('bold');
|
||||
|
||||
// When the user toggles markdown mode
|
||||
await page.getByTestId('markdown-toggle').click();
|
||||
await toggleMarkdownMode(page);
|
||||
|
||||
// Then the raw markdown textarea is shown with the markdown source
|
||||
await expect(page.getByTestId('note-content-input')).toBeVisible();
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { existsSync, unlinkSync } from 'fs';
|
||||
import { existsSync, unlinkSync, writeFileSync } from 'fs';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
const TEST_DB = './data/test-crumbs.db';
|
||||
export const TEST_CREDENTIALS_FILE = './data/test-credentials.json';
|
||||
|
||||
export default function globalSetup() {
|
||||
// Clean test database before E2E run so each run starts fresh
|
||||
@@ -10,4 +12,14 @@ export default function globalSetup() {
|
||||
unlinkSync(path);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate random passwords for this test run, shared across all workers
|
||||
writeFileSync(
|
||||
TEST_CREDENTIALS_FILE,
|
||||
JSON.stringify({
|
||||
testPassword: `test-${randomUUID()}`,
|
||||
collabPassword: `collab-${randomUUID()}`,
|
||||
userPassword: `user-${randomUUID()}`
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { test as base, expect, type Page, type Locator } from '@playwright/test';
|
||||
import { readFileSync } from 'fs';
|
||||
import { collectCoverage } from './coverage';
|
||||
import { TEST_CREDENTIALS_FILE } from '../global-setup';
|
||||
|
||||
const { testPassword, collabPassword } = JSON.parse(readFileSync(TEST_CREDENTIALS_FILE, 'utf-8'));
|
||||
|
||||
const TEST_EMAIL = 'admin@test.com';
|
||||
const TEST_PASSWORD = 'testpassword123';
|
||||
const TEST_PASSWORD = testPassword;
|
||||
const TEST_DISPLAY_NAME = 'Test Admin';
|
||||
|
||||
const COLLAB_EMAIL = 'collab@test.com';
|
||||
const COLLAB_PASSWORD = 'collabpass123';
|
||||
const COLLAB_PASSWORD = collabPassword;
|
||||
const COLLAB_NAME = 'Collaborator User';
|
||||
|
||||
/** Find a specific note card by its title text */
|
||||
|
||||
+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.24.0</span>
|
||||
<span class="footer-version">v0.28.2</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user