Merge pull request 'Dev' (#9) from dev into main
Reviewed-on: #9
This commit was merged in pull request #9.
This commit is contained in:
commit
8a4b4820df
14 files changed
+1638
-112
No files matched your search
+1
-1
@@ -6,7 +6,7 @@ WORKDIR /app/frontend
|
|||||||
COPY frontend/package*.json ./
|
COPY frontend/package*.json ./
|
||||||
RUN npm install --no-audit --no-fund
|
RUN npm install --no-audit --no-fund
|
||||||
COPY frontend/ ./
|
COPY frontend/ ./
|
||||||
RUN npx tsc --noEmit && npm run build
|
RUN npx tsc --noEmit && npm run test:run && npm run build
|
||||||
|
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
# Stage 2: Go Backend Lint, Unit Tests & Binary Build (Cached Go module & sqlite layer)
|
# Stage 2: Go Backend Lint, Unit Tests & Binary Build (Cached Go module & sqlite layer)
|
||||||
|
|||||||
+1
-1
@@ -34,7 +34,7 @@ Referenz für Entwickler und Administratoren. Alle Angaben wurden gegen den Code
|
|||||||
go vet ./... && go test ./...
|
go vet ./... && go test ./...
|
||||||
|
|
||||||
# Frontend (Node 22)
|
# Frontend (Node 22)
|
||||||
cd frontend && npm ci && npx tsc --noEmit && npm run build
|
cd frontend && npm ci && npx tsc --noEmit && npm run test:run && npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
Das Frontend wird für das Binary nach `cmd/server/dist` gebaut (siehe `Dockerfile`). Die CI läuft unter `.gitea/workflows/build-and-publish.yaml`.
|
Das Frontend wird für das Binary nach `cmd/server/dist` gebaut (siehe `Dockerfile`). Die CI läuft unter `.gitea/workflows/build-and-publish.yaml`.
|
||||||
@@ -79,9 +79,9 @@ Hinweis: `DATABASE_URL` mit `postgres…` führt dazu, dass der Standardpfad in
|
|||||||
## Build
|
## Build
|
||||||
|
|
||||||
```
|
```
|
||||||
Stage 1 node:22-alpine npm ci && npm run build → frontend/dist
|
Stage 1 node:22-alpine tsc, vitest, vite build → frontend/dist
|
||||||
Stage 2 golang:1.23 go vet, go test, go build → /app/mindsift (dist wird eingebettet)
|
Stage 2 golang:1.23 go vet, go test, go build → /app/mindsift (dist wird eingebettet)
|
||||||
Stage 3 alpine:3.21 nur Binary, ca-certificates, tzdata
|
Stage 3 alpine:3.21 nur Binary, ca-certificates, tzdata
|
||||||
```
|
```
|
||||||
|
|
||||||
Die Tests laufen beim Image-Build (`go test -v ./...`); ein Fehler bricht den Build ab. Frontend-Tests existieren nicht ([FLAW-19](flaws.md)).
|
Die Tests laufen beim Image-Build (Frontend: `npm run test:run`, Backend: `go test -v ./...`); ein Fehler bricht den Build ab. Frontend-Tests liegen unter `frontend/src/**/__tests__/` (Vitest + jsdom + Testing Library, Setup in `frontend/src/test/setup.ts`).
|
||||||
+5
-5
@@ -28,7 +28,7 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** (
|
|||||||
| FLAW-16 | Setup | `/auth/setup` ist bis zum ersten Konto offen; Demo-Modus legt `admin/admin` an | Mittel |
|
| FLAW-16 | Setup | `/auth/setup` ist bis zum ersten Konto offen; Demo-Modus legt `admin/admin` an | Mittel |
|
||||||
| FLAW-17 | Workspace | Dokument- und Ordnerfreigaben sind per Migration erzwungen schreibgeschützt; tote Schreibpfade | Niedrig |
|
| FLAW-17 | Workspace | Dokument- und Ordnerfreigaben sind per Migration erzwungen schreibgeschützt; tote Schreibpfade | Niedrig |
|
||||||
| FLAW-18 | Suche | Nur eigene Gedanken und Dokumente, `LIKE` ohne Escaping, keine FTS | Niedrig |
|
| FLAW-18 | Suche | Nur eigene Gedanken und Dokumente, `LIKE` ohne Escaping, keine FTS | Niedrig |
|
||||||
| FLAW-19 | Qualität | Keine Frontend-Tests, `lint` und `test:run` sind Platzhalter | Niedrig |
|
| FLAW-19 | Qualität | Frontend-Tests nur für den Outliner, `lint` ist ein Platzhalter | Niedrig |
|
||||||
| FLAW-20 | Qualität | Sehr große Dateien, duplizierte Zugriffslogik | Niedrig |
|
| FLAW-20 | Qualität | Sehr große Dateien, duplizierte Zugriffslogik | Niedrig |
|
||||||
| FLAW-21 | Repo | `LICENSE` fehlt, obwohl README MIT angibt | Niedrig |
|
| FLAW-21 | Repo | `LICENSE` fehlt, obwohl README MIT angibt | Niedrig |
|
||||||
| FLAW-22 | Netzwerk | Token in der URL (WS/SSE), CORS `*`, `CheckOrigin` immer `true` | Niedrig |
|
| FLAW-22 | Netzwerk | Token in der URL (WS/SSE), CORS `*`, `CheckOrigin` immer `true` | Niedrig |
|
||||||
@@ -139,10 +139,10 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** (
|
|||||||
- **Probleme:** Durchsucht nur eigene Gedanken (30) und eigene Dokumente (20); geteilte Dokumente, Aufgaben aus geteilten Listen, Einkaufslisten und Blöcke bleiben außen vor. `LIKE '%…%'` ohne Escaping von `%`/`_`, keine Indizes, keine Relevanz.
|
- **Probleme:** Durchsucht nur eigene Gedanken (30) und eigene Dokumente (20); geteilte Dokumente, Aufgaben aus geteilten Listen, Einkaufslisten und Blöcke bleiben außen vor. `LIKE '%…%'` ohne Escaping von `%`/`_`, keine Indizes, keine Relevanz.
|
||||||
- **Maßnahme:** FTS5-Index über Blöcke/Gedanken; Berechtigungen mitberücksichtigen.
|
- **Maßnahme:** FTS5-Index über Blöcke/Gedanken; Berechtigungen mitberücksichtigen.
|
||||||
|
|
||||||
### FLAW-19: Keine automatisierten Frontend-Prüfungen
|
### FLAW-19: Geringe Frontend-Testabdeckung
|
||||||
- **Fundstelle:** [`frontend/package.json`](../frontend/package.json): `"lint": "echo 'Linting passed'"`, `"test:run": "echo 'Frontend checks passed'"`.
|
- **Stand:** Vitest mit jsdom und Testing Library ist eingerichtet (`npm run test:run`) und läuft beim Image-Build. Getestet sind bisher nur Outliner-Funktionen (Backspace-Zusammenführen, Cursor-Position beim Klicken).
|
||||||
- **Problem:** Die Skripte melden immer Erfolg. Die CI (`.gitea/workflows/build-and-publish.yaml`) prüft daher nur Go-Tests und den Vite-Build. Regressionen im UI (z. B. Berechtigungsanzeigen) fallen nicht auf.
|
- **Problem:** Aufgaben, Listen, Freigabe-Dialoge und Berechtigungsanzeigen sind ungetestet. `"lint"` in [`frontend/package.json`](../frontend/package.json) ist weiterhin ein Platzhalter (`echo`).
|
||||||
- **Maßnahme:** `tsc --noEmit` und ESLint als echte Skripte, Komponententests für kritische Pfade.
|
- **Maßnahme:** Tests für kritische Pfade ergänzen, ESLint als echtes `lint`-Skript.
|
||||||
|
|
||||||
### FLAW-20: Dateigröße und Duplikate
|
### FLAW-20: Dateigröße und Duplikate
|
||||||
- `internal/storage/sqlite.go` (≈ 5.600 Zeilen), `internal/api/server.go` (≈ 3.000), `WorkspaceView.tsx` (≈ 2.300), `TasksView.tsx` (≈ 2.500), `api.ts` (≈ 1.500).
|
- `internal/storage/sqlite.go` (≈ 5.600 Zeilen), `internal/api/server.go` (≈ 3.000), `WorkspaceView.tsx` (≈ 2.300), `TasksView.tsx` (≈ 2.500), `api.ts` (≈ 1.500).
|
||||||
|
|||||||
@@ -167,10 +167,11 @@ cd frontend
|
|||||||
npm ci
|
npm ci
|
||||||
npm run dev # Vite-Entwicklungsserver
|
npm run dev # Vite-Entwicklungsserver
|
||||||
npx tsc --noEmit # Typprüfung
|
npx tsc --noEmit # Typprüfung
|
||||||
|
npm run test:run # Komponententests (Vitest, jsdom)
|
||||||
npm run build # Produktions-Build
|
npm run build # Produktions-Build
|
||||||
```
|
```
|
||||||
|
|
||||||
Für das Binary wird das gebaute Frontend nach `cmd/server/dist` kopiert (`go:embed`); das mehrstufige `Dockerfile` erledigt das und führt Vet und Tests beim Image-Build aus.
|
Für das Binary wird das gebaute Frontend nach `cmd/server/dist` kopiert (`go:embed`); das mehrstufige `Dockerfile` erledigt das und führt Typprüfung, Frontend-Tests, Go-Vet und Go-Tests beim Image-Build aus.
|
||||||
|
|
||||||
**Stack:** Go (chi, gorilla/websocket, JWT, modernc SQLite) · React 18, TypeScript, Vite, Tailwind.
|
**Stack:** Go (chi, gorilla/websocket, JWT, modernc SQLite) · React 18, TypeScript, Vite, Tailwind.
|
||||||
|
|
||||||
|
|||||||
Generated
+1286
-61
File diff suppressed because it is too large.
Load diff
@@ -8,7 +8,8 @@
|
|||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"lint": "echo 'Linting passed'",
|
"lint": "echo 'Linting passed'",
|
||||||
"test:run": "echo 'Frontend checks passed'"
|
"test": "vitest",
|
||||||
|
"test:run": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/prismjs": "^1.26.6",
|
"@types/prismjs": "^1.26.6",
|
||||||
@@ -19,13 +20,17 @@
|
|||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@testing-library/dom": "^10.4.2",
|
||||||
|
"@testing-library/react": "^16.3.3",
|
||||||
"@types/react": "^18.3.12",
|
"@types/react": "^18.3.12",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.1",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
|
"jsdom": "^25.0.1",
|
||||||
"postcss": "^8.4.49",
|
"postcss": "^8.4.49",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"typescript": "^5.6.3",
|
"typescript": "^5.6.3",
|
||||||
"vite": "^5.4.11"
|
"vite": "^5.4.11",
|
||||||
|
"vitest": "^2.1.9"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
ListTodo,
|
ListTodo,
|
||||||
Minus,
|
Minus,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
Hash,
|
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
Maximize2,
|
Maximize2,
|
||||||
Link as LinkIcon,
|
Link as LinkIcon,
|
||||||
@@ -57,6 +56,7 @@ export interface OutlinerBlockProps {
|
|||||||
onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void;
|
onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void;
|
||||||
onCreateBlockBelow: (afterBlockId: string, newContent?: string) => void;
|
onCreateBlockBelow: (afterBlockId: string, newContent?: string) => void;
|
||||||
onDeleteBlock: (blockId: string, deleteSubtree?: boolean) => void;
|
onDeleteBlock: (blockId: string, deleteSubtree?: boolean) => void;
|
||||||
|
onMergeWithPrevious?: (blockId: string, content: string) => boolean;
|
||||||
onIndentBlock: (blockId: string, delta: 1 | -1) => void;
|
onIndentBlock: (blockId: string, delta: 1 | -1) => void;
|
||||||
onNavigateFocus: (blockId: string, direction: 'up' | 'down') => void;
|
onNavigateFocus: (blockId: string, direction: 'up' | 'down') => void;
|
||||||
onFocusBlock?: (blockId: string) => void;
|
onFocusBlock?: (blockId: string) => void;
|
||||||
@@ -72,6 +72,18 @@ export interface OutlinerBlockProps {
|
|||||||
onPasteMultiLines?: (blockId: string, textBefore: string, textAfter: string, rawLines: string[]) => void;
|
onPasteMultiLines?: (blockId: string, textBefore: string, textAfter: string, rawLines: string[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rendered markdown only drops syntax characters (**, `, [..](url) …), so its text is a subsequence of the raw
|
||||||
|
// text. Walking both strings in parallel maps a caret offset in the rendered text back to the raw text.
|
||||||
|
export function mapRenderedOffsetToRaw(raw: string, rendered: string, renderedOffset: number): number {
|
||||||
|
let j = 0;
|
||||||
|
for (let i = 0; i < renderedOffset && i < rendered.length; i++) {
|
||||||
|
const idx = raw.indexOf(rendered[i], j);
|
||||||
|
if (idx === -1) break;
|
||||||
|
j = idx + 1;
|
||||||
|
}
|
||||||
|
return Math.min(j, raw.length);
|
||||||
|
}
|
||||||
|
|
||||||
export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||||
docId,
|
docId,
|
||||||
block,
|
block,
|
||||||
@@ -86,6 +98,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
onUpdateBlock,
|
onUpdateBlock,
|
||||||
onCreateBlockBelow,
|
onCreateBlockBelow,
|
||||||
onDeleteBlock,
|
onDeleteBlock,
|
||||||
|
onMergeWithPrevious,
|
||||||
onIndentBlock,
|
onIndentBlock,
|
||||||
onNavigateFocus,
|
onNavigateFocus,
|
||||||
onFocusBlock,
|
onFocusBlock,
|
||||||
@@ -124,6 +137,8 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
onPasteMultiLinesRef.current = onPasteMultiLines;
|
onPasteMultiLinesRef.current = onPasteMultiLines;
|
||||||
const onDeleteBlockRef = useRef(onDeleteBlock);
|
const onDeleteBlockRef = useRef(onDeleteBlock);
|
||||||
onDeleteBlockRef.current = onDeleteBlock;
|
onDeleteBlockRef.current = onDeleteBlock;
|
||||||
|
const onMergeWithPreviousRef = useRef(onMergeWithPrevious);
|
||||||
|
onMergeWithPreviousRef.current = onMergeWithPrevious;
|
||||||
const onIndentBlockRef = useRef(onIndentBlock);
|
const onIndentBlockRef = useRef(onIndentBlock);
|
||||||
onIndentBlockRef.current = onIndentBlock;
|
onIndentBlockRef.current = onIndentBlock;
|
||||||
const onNavigateFocusRef = useRef(onNavigateFocus);
|
const onNavigateFocusRef = useRef(onNavigateFocus);
|
||||||
@@ -203,6 +218,41 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const renderedTextRef = useRef<HTMLSpanElement | null>(null);
|
||||||
|
|
||||||
|
// Focus the textarea and place the caret where the user clicked in the rendered (markdown) text layer
|
||||||
|
const focusAtPoint = (clientX: number, clientY: number) => {
|
||||||
|
const ta = textareaRef.current;
|
||||||
|
if (!ta) return;
|
||||||
|
const raw = ta.value;
|
||||||
|
let pos = raw.length;
|
||||||
|
const span = renderedTextRef.current;
|
||||||
|
const doc = document as any;
|
||||||
|
let node: Node | null = null;
|
||||||
|
let offset = 0;
|
||||||
|
if (typeof doc.caretPositionFromPoint === 'function') {
|
||||||
|
const p = doc.caretPositionFromPoint(clientX, clientY);
|
||||||
|
if (p) {
|
||||||
|
node = p.offsetNode;
|
||||||
|
offset = p.offset;
|
||||||
|
}
|
||||||
|
} else if (typeof doc.caretRangeFromPoint === 'function') {
|
||||||
|
const r = doc.caretRangeFromPoint(clientX, clientY);
|
||||||
|
if (r) {
|
||||||
|
node = r.startContainer;
|
||||||
|
offset = r.startOffset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (span && node && span.contains(node)) {
|
||||||
|
const range = document.createRange();
|
||||||
|
range.setStart(span, 0);
|
||||||
|
range.setEnd(node, offset);
|
||||||
|
pos = mapRenderedOffsetToRaw(raw, span.textContent || '', range.toString().length);
|
||||||
|
}
|
||||||
|
ta.focus();
|
||||||
|
ta.setSelectionRange(pos, pos);
|
||||||
|
};
|
||||||
|
|
||||||
const adjustHeight = useCallback(() => {
|
const adjustHeight = useCallback(() => {
|
||||||
const el = textareaRef.current;
|
const el = textareaRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
@@ -455,6 +505,28 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3b. Backspace at the very start of a non-empty block -> append its text to the previous block
|
||||||
|
if (
|
||||||
|
e.key === 'Backspace' &&
|
||||||
|
localContent !== '' &&
|
||||||
|
block.type !== 'code' &&
|
||||||
|
onMergeWithPreviousRef.current &&
|
||||||
|
e.currentTarget.selectionStart === 0 &&
|
||||||
|
e.currentTarget.selectionEnd === 0
|
||||||
|
) {
|
||||||
|
if (onMergeWithPreviousRef.current(block.id, localContent)) {
|
||||||
|
// Text lives in the previous block afterwards, so drop this block's pending save
|
||||||
|
if (debounceTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(debounceTimerRef.current);
|
||||||
|
debounceTimerRef.current = null;
|
||||||
|
}
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setSlashSearch(null);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 4. Arrow navigation between blocks
|
// 4. Arrow navigation between blocks
|
||||||
if (e.key === 'ArrowUp' && !e.shiftKey && !e.altKey) {
|
if (e.key === 'ArrowUp' && !e.shiftKey && !e.altKey) {
|
||||||
const el = textareaRef.current;
|
const el = textareaRef.current;
|
||||||
@@ -489,9 +561,9 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
return parts.filter(Boolean);
|
return parts.filter(Boolean);
|
||||||
}, [localContent]);
|
}, [localContent]);
|
||||||
|
|
||||||
|
// #Hashtags are rendered inline inside the text, so only links need the badge bar below
|
||||||
const hasSpecialTokens = tokens.some(
|
const hasSpecialTokens = tokens.some(
|
||||||
(t) =>
|
(t) =>
|
||||||
t.startsWith('#') ||
|
|
||||||
(t.startsWith('[[') && t.endsWith(']]')) ||
|
(t.startsWith('[[') && t.endsWith(']]')) ||
|
||||||
/^\[(.*?)\]\((https?:\/\/.*?)\)$/.test(t) ||
|
/^\[(.*?)\]\((https?:\/\/.*?)\)$/.test(t) ||
|
||||||
/^https?:\/\//.test(t)
|
/^https?:\/\//.test(t)
|
||||||
@@ -503,10 +575,8 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
isDragTarget
|
isDragTarget
|
||||||
? 'border-t-2 border-accent bg-accent/10'
|
? 'border-t-2 border-accent bg-accent/10'
|
||||||
: isTargeted
|
: isTargeted
|
||||||
? 'bg-accent/15 ring-2 ring-accent'
|
|
||||||
: activeFocus
|
|
||||||
? 'bg-panel-elevated'
|
? 'bg-panel-elevated'
|
||||||
: 'hover:bg-panel'
|
: ''
|
||||||
}`}
|
}`}
|
||||||
style={{
|
style={{
|
||||||
paddingLeft: `${visualLevel * 22 + 4}px`,
|
paddingLeft: `${visualLevel * 22 + 4}px`,
|
||||||
@@ -540,7 +610,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
onClick={() =>
|
onClick={() =>
|
||||||
flushDebounce(localContent, { collapsed: !block.collapsed })
|
flushDebounce(localContent, { collapsed: !block.collapsed })
|
||||||
}
|
}
|
||||||
className="p-0.5 text-muted hover:text-accent transition-colors"
|
className="p-0.5 text-muted hover:text-accent transition-[color,opacity] opacity-0 group-hover:opacity-100 focus-visible:opacity-100"
|
||||||
title={block.collapsed ? 'Unterpunkte ausklappen' : 'Unterpunkte einklappen'}
|
title={block.collapsed ? 'Unterpunkte ausklappen' : 'Unterpunkte einklappen'}
|
||||||
>
|
>
|
||||||
{block.collapsed ? (
|
{block.collapsed ? (
|
||||||
@@ -559,7 +629,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
draggable={canEdit}
|
draggable={canEdit}
|
||||||
onDragStart={() => canEdit && onDragStartBlock?.(index)}
|
onDragStart={() => canEdit && onDragStartBlock?.(index)}
|
||||||
onClick={() => onZoomBlock?.(block.id)}
|
onClick={() => onZoomBlock?.(block.id)}
|
||||||
className={`w-4 h-4 flex items-center justify-center rounded-btn hover:bg-panel-hover group/bullet transition-transform ${
|
className={`w-4 h-4 flex items-center justify-center rounded-btn group/bullet transition-transform ${
|
||||||
canEdit ? 'cursor-grab active:cursor-grabbing' : 'cursor-pointer'
|
canEdit ? 'cursor-grab active:cursor-grabbing' : 'cursor-pointer'
|
||||||
}`}
|
}`}
|
||||||
title={
|
title={
|
||||||
@@ -573,7 +643,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
block.collapsed && hasChildren
|
block.collapsed && hasChildren
|
||||||
? 'bg-accent ring-2 ring-accent/30'
|
? 'bg-accent ring-2 ring-accent/30'
|
||||||
: activeFocus
|
: activeFocus
|
||||||
? 'bg-accent scale-125'
|
? 'bg-main scale-125'
|
||||||
: 'bg-faint group-hover/bullet:bg-accent group-hover/bullet:scale-125'
|
: 'bg-faint group-hover/bullet:bg-accent group-hover/bullet:scale-125'
|
||||||
} ${
|
} ${
|
||||||
isEmpty
|
isEmpty
|
||||||
@@ -657,11 +727,11 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
<div className="relative w-full min-h-[1.5rem]">
|
<div className="relative w-full min-h-[1.5rem]">
|
||||||
{/* View Layer with Inline Markdown & Actions (3-dots or Erweitert Badge) directly behind text */}
|
{/* View Layer with Inline Markdown & Actions (3-dots or Erweitert Badge) directly behind text */}
|
||||||
<div
|
<div
|
||||||
onClick={() => {
|
onClick={(e) => {
|
||||||
if (!activeFocus && canEdit && textareaRef.current) {
|
if (!activeFocus && canEdit && textareaRef.current) {
|
||||||
textareaRef.current.focus();
|
// Keep the row's click handler from moving the caret back to the end
|
||||||
const len = textareaRef.current.value.length;
|
e.stopPropagation();
|
||||||
textareaRef.current.setSelectionRange(len, len);
|
focusAtPoint(e.clientX, e.clientY);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={`w-full min-h-[1.5rem] leading-relaxed whitespace-pre-wrap break-words [overflow-wrap:anywhere] ${
|
className={`w-full min-h-[1.5rem] leading-relaxed whitespace-pre-wrap break-words [overflow-wrap:anywhere] ${
|
||||||
@@ -679,8 +749,8 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* When actively focused, ghost text is invisible so textarea renders text/caret directly */}
|
{/* When actively focused, ghost text is invisible so textarea renders text/caret directly */}
|
||||||
<span className={activeFocus ? 'invisible' : ''}>
|
<span ref={renderedTextRef} className={activeFocus ? 'invisible' : ''}>
|
||||||
{localContent ? renderInlineMarkdown(localContent) : '\u200b'}
|
{localContent ? renderInlineMarkdown(localContent, { onTagClick }) : '\u200b'}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Inline Badge and 3-Dots directly behind the text */}
|
{/* Inline Badge and 3-Dots directly behind the text */}
|
||||||
@@ -809,20 +879,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
{hasSpecialTokens && !isFocused && (
|
{hasSpecialTokens && !isFocused && (
|
||||||
<div className="flex flex-wrap items-center gap-1 mt-0.5">
|
<div className="flex flex-wrap items-center gap-1 mt-0.5">
|
||||||
{tokens.map((part, idx) => {
|
{tokens.map((part, idx) => {
|
||||||
if (part.startsWith('#') && part.length > 1) {
|
|
||||||
const tag = part.slice(1);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={idx}
|
|
||||||
type="button"
|
|
||||||
onClick={() => onTagClick?.(tag)}
|
|
||||||
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] font-medium text-accent bg-accent-dim hover:bg-accent/20 rounded-btn transition-colors"
|
|
||||||
>
|
|
||||||
<Hash className="w-2.5 h-2.5 opacity-80" />
|
|
||||||
<span>{tag}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (part.startsWith('[[') && part.endsWith(']]')) {
|
if (part.startsWith('[[') && part.endsWith(']]')) {
|
||||||
const page = part.slice(2, -2).trim();
|
const page = part.slice(2, -2).trim();
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -50,27 +50,34 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
|||||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
const textareaMapRef = useRef<Map<string, HTMLTextAreaElement>>(new Map());
|
const textareaMapRef = useRef<Map<string, HTMLTextAreaElement>>(new Map());
|
||||||
const pendingFocusRef = useRef<{ blockId: string; cursor: 'start' | 'end' | 'select' } | null>(null);
|
const pendingFocusRef = useRef<{ blockId: string; cursor: 'start' | 'end' | 'select' | number } | null>(null);
|
||||||
|
|
||||||
const focusBlockInput = useCallback(
|
const focusBlockInput = useCallback(
|
||||||
(blockId: string, cursor: 'start' | 'end' | 'select' = 'end') => {
|
(blockId: string, cursor: 'start' | 'end' | 'select' | number = 'end') => {
|
||||||
setFocusedBlockId(blockId);
|
setFocusedBlockId(blockId);
|
||||||
const el = textareaMapRef.current.get(blockId);
|
const el = textareaMapRef.current.get(blockId);
|
||||||
if (el) {
|
if (el) {
|
||||||
requestAnimationFrame(() => {
|
const apply = () => {
|
||||||
el.focus();
|
el.focus();
|
||||||
try {
|
try {
|
||||||
if (cursor === 'select') {
|
if (cursor === 'select') {
|
||||||
el.select();
|
el.select();
|
||||||
} else {
|
} else {
|
||||||
const pos = cursor === 'end' ? el.value.length : 0;
|
const pos =
|
||||||
|
typeof cursor === 'number'
|
||||||
|
? Math.min(cursor, el.value.length)
|
||||||
|
: cursor === 'end'
|
||||||
|
? el.value.length
|
||||||
|
: 0;
|
||||||
el.setSelectionRange(pos, pos);
|
el.setSelectionRange(pos, pos);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
});
|
};
|
||||||
|
// A numeric position refers to content that was just changed, so wait one extra frame for it to render
|
||||||
|
requestAnimationFrame(() => (typeof cursor === 'number' ? requestAnimationFrame(apply) : apply()));
|
||||||
} else {
|
} else {
|
||||||
pendingFocusRef.current = { blockId, cursor };
|
pendingFocusRef.current = { blockId, cursor };
|
||||||
}
|
}
|
||||||
@@ -514,6 +521,43 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
|||||||
[blocks, canEdit, focusBlockInput, levelOffset, onDeleteBlock, onUpdateBlock]
|
[blocks, canEdit, focusBlockInput, levelOffset, onDeleteBlock, onUpdateBlock]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Backspace at the start of a non-empty block: append its text to the previous visible block
|
||||||
|
const handleMergeWithPrevious = useCallback(
|
||||||
|
(blockId: string, content: string) => {
|
||||||
|
if (!canEdit) return false;
|
||||||
|
const notMergeable = ['code', 'divider', 'table', 'progress'];
|
||||||
|
const visIdx = visibleEntries.findIndex((e) => e.block.id === blockId);
|
||||||
|
if (visIdx <= 0) return false;
|
||||||
|
const prev = visibleEntries[visIdx - 1].block;
|
||||||
|
const idx = blocks.findIndex((b) => b.id === blockId);
|
||||||
|
const cur = blocks[idx];
|
||||||
|
if (!cur || notMergeable.includes(prev.type) || notMergeable.includes(cur.type)) return false;
|
||||||
|
|
||||||
|
// Children of the merged block move up so they become children of the previous block
|
||||||
|
const curLevel = Math.max(0, cur.level || 0);
|
||||||
|
const delta = Math.max(0, prev.level || 0) - curLevel;
|
||||||
|
const movedChildren: DocumentBlock[] = [];
|
||||||
|
for (let j = idx + 1; j < blocks.length; j++) {
|
||||||
|
const lvl = Math.max(0, blocks[j].level || 0);
|
||||||
|
if (lvl <= curLevel) break;
|
||||||
|
movedChildren.push({ ...blocks[j], level: Math.max(levelOffset || 0, lvl + delta) });
|
||||||
|
}
|
||||||
|
|
||||||
|
const joinPos = (prev.content || '').length;
|
||||||
|
const mergedPrev: DocumentBlock = { ...prev, content: (prev.content || '') + content };
|
||||||
|
if (movedChildren.length > 0 && onUpdateBlocks) {
|
||||||
|
onUpdateBlocks([mergedPrev, ...movedChildren]);
|
||||||
|
} else {
|
||||||
|
onUpdateBlock(mergedPrev, true);
|
||||||
|
movedChildren.forEach((c) => onUpdateBlock(c));
|
||||||
|
}
|
||||||
|
onDeleteBlock(cur.id);
|
||||||
|
focusBlockInput(prev.id, joinPos);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
[blocks, canEdit, focusBlockInput, levelOffset, onDeleteBlock, onUpdateBlock, onUpdateBlocks, visibleEntries]
|
||||||
|
);
|
||||||
|
|
||||||
const handleIndentBlock = useCallback(
|
const handleIndentBlock = useCallback(
|
||||||
(blockId: string, delta: 1 | -1) => {
|
(blockId: string, delta: 1 | -1) => {
|
||||||
if (!canEdit) return;
|
if (!canEdit) return;
|
||||||
@@ -672,6 +716,7 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
|||||||
onCreateBlockBelow={handleCreateBlockBelow}
|
onCreateBlockBelow={handleCreateBlockBelow}
|
||||||
onPasteMultiLines={handlePasteMultiLines}
|
onPasteMultiLines={handlePasteMultiLines}
|
||||||
onDeleteBlock={handleDeleteBlock}
|
onDeleteBlock={handleDeleteBlock}
|
||||||
|
onMergeWithPrevious={handleMergeWithPrevious}
|
||||||
onIndentBlock={handleIndentBlock}
|
onIndentBlock={handleIndentBlock}
|
||||||
onNavigateFocus={handleNavigateFocus}
|
onNavigateFocus={handleNavigateFocus}
|
||||||
onFocusBlock={setFocusedBlockId}
|
onFocusBlock={setFocusedBlockId}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import { render, fireEvent } from '@testing-library/react';
|
||||||
|
import { OutlinerBlock, mapRenderedOffsetToRaw } from '../OutlinerBlock';
|
||||||
|
import { DocumentBlock } from '../../../lib/types';
|
||||||
|
|
||||||
|
describe('mapRenderedOffsetToRaw', () => {
|
||||||
|
it('is the identity for plain text', () => {
|
||||||
|
expect(mapRenderedOffsetToRaw('Hallo Welt', 'Hallo Welt', 4)).toBe(4);
|
||||||
|
});
|
||||||
|
it('skips bold markers', () => {
|
||||||
|
expect(mapRenderedOffsetToRaw('Hallo **Welt** du', 'Hallo Welt du', 8)).toBe(10);
|
||||||
|
});
|
||||||
|
it('maps behind closing markers', () => {
|
||||||
|
expect(mapRenderedOffsetToRaw('a **b** c', 'a b c', 4)).toBe(8);
|
||||||
|
});
|
||||||
|
it('maps into a link label', () => {
|
||||||
|
expect(mapRenderedOffsetToRaw('x [Seite](https://a.de) y', 'x Seite y', 4)).toBe(5);
|
||||||
|
});
|
||||||
|
it('handles the empty placeholder', () => {
|
||||||
|
expect(mapRenderedOffsetToRaw('', '', 1)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('clicking into an unfocused bullet', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
delete (document as any).caretRangeFromPoint;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('places the caret at the click position instead of the end', () => {
|
||||||
|
const block = { id: 'b1', type: 'bullet', content: 'Hallo **Welt** du', level: 0, position: 1 } as DocumentBlock;
|
||||||
|
const { container } = render(
|
||||||
|
<OutlinerBlock
|
||||||
|
docId="d"
|
||||||
|
block={block}
|
||||||
|
index={0}
|
||||||
|
isLast
|
||||||
|
maxAllowedLevel={1}
|
||||||
|
hasChildren={false}
|
||||||
|
isFocused={false}
|
||||||
|
canEdit
|
||||||
|
onUpdateBlock={() => {}}
|
||||||
|
onCreateBlockBelow={() => {}}
|
||||||
|
onDeleteBlock={() => {}}
|
||||||
|
onIndentBlock={() => {}}
|
||||||
|
onNavigateFocus={() => {}}
|
||||||
|
registerTextareaRef={() => {}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
|
||||||
|
const strong = container.querySelector('strong') as HTMLElement; // rendered "Welt"
|
||||||
|
// jsdom has no layout: pretend the click hit between "We" and "lt"
|
||||||
|
(document as any).caretRangeFromPoint = () => {
|
||||||
|
const r = document.createRange();
|
||||||
|
r.setStart(strong.firstChild!, 2);
|
||||||
|
return r;
|
||||||
|
};
|
||||||
|
|
||||||
|
fireEvent.click(strong);
|
||||||
|
|
||||||
|
expect(document.activeElement).toBe(ta);
|
||||||
|
expect(ta.selectionStart).toBe(10); // "Hallo **We|lt** du"
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render, fireEvent, screen, act } from '@testing-library/react';
|
||||||
|
import { OutlinerTree } from '../OutlinerTree';
|
||||||
|
import { DocumentBlock } from '../../../lib/types';
|
||||||
|
|
||||||
|
// Holds the latest block state rendered by the harness
|
||||||
|
let live: DocumentBlock[] = [];
|
||||||
|
|
||||||
|
const Harness: React.FC<{ initial: DocumentBlock[] }> = ({ initial }) => {
|
||||||
|
const [blocks, setBlocks] = useState(initial);
|
||||||
|
live = blocks;
|
||||||
|
return (
|
||||||
|
<OutlinerTree
|
||||||
|
docId="d1"
|
||||||
|
blocks={blocks}
|
||||||
|
canEdit
|
||||||
|
onUpdateBlock={(b) => setBlocks((p) => p.map((x) => (x.id === b.id ? b : x)))}
|
||||||
|
onUpdateBlocks={(u) => setBlocks((p) => p.map((x) => u.find((y) => y.id === x.id) || x))}
|
||||||
|
onCreateBlock={() => {}}
|
||||||
|
onDeleteBlock={(id) => setBlocks((p) => p.filter((x) => x.id !== id))}
|
||||||
|
onMoveBlock={() => {}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const mk = (id: string, content: string, level = 0, type: any = 'bullet'): DocumentBlock =>
|
||||||
|
({ id, type, content, level, position: 1 }) as DocumentBlock;
|
||||||
|
|
||||||
|
const area = (text: string) => screen.getAllByDisplayValue(text)[0] as HTMLTextAreaElement;
|
||||||
|
|
||||||
|
const backspaceAt = async (ta: HTMLTextAreaElement, pos = 0) => {
|
||||||
|
ta.focus();
|
||||||
|
ta.setSelectionRange(pos, pos);
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.keyDown(ta, { key: 'Backspace' });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Backspace at the start of a bullet', () => {
|
||||||
|
it('appends its text to the previous bullet', async () => {
|
||||||
|
render(<Harness initial={[mk('a', 'Hallo '), mk('b', 'Welt')]} />);
|
||||||
|
await backspaceAt(area('Welt'));
|
||||||
|
expect(live.map((b) => b.content)).toEqual(['Hallo Welt']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('works for legacy blocks without a type', async () => {
|
||||||
|
render(<Harness initial={[mk('a', 'A', 0, undefined), mk('b', 'B', 0, '')]} />);
|
||||||
|
await backspaceAt(area('B'));
|
||||||
|
expect(live.map((b) => b.content)).toEqual(['AB']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves children up under the previous bullet', async () => {
|
||||||
|
render(<Harness initial={[mk('a', 'A'), mk('b', 'B'), mk('c', 'C', 1)]} />);
|
||||||
|
await backspaceAt(area('B'));
|
||||||
|
expect(live.map((b) => [b.content, b.level])).toEqual([
|
||||||
|
['AB', 0],
|
||||||
|
['C', 1],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing when the caret is not at the start', async () => {
|
||||||
|
render(<Harness initial={[mk('a', 'A'), mk('b', 'BB')]} />);
|
||||||
|
await backspaceAt(area('BB'), 1);
|
||||||
|
expect(live).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not merge the first bullet', async () => {
|
||||||
|
render(<Harness initial={[mk('a', 'A'), mk('b', 'B')]} />);
|
||||||
|
await backspaceAt(area('A'));
|
||||||
|
expect(live).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not merge into a code block', async () => {
|
||||||
|
render(<Harness initial={[mk('a', 'x = 1', 0, 'code'), mk('b', 'B')]} />);
|
||||||
|
await backspaceAt(area('B'));
|
||||||
|
expect(live).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,11 +7,20 @@ import React from 'react';
|
|||||||
* - ~~strikethrough~~
|
* - ~~strikethrough~~
|
||||||
* - `code`
|
* - `code`
|
||||||
* - [label](url)
|
* - [label](url)
|
||||||
|
* - #hashtag (only when `options.onTagClick` is given), rendered inline as a clickable chip
|
||||||
*/
|
*/
|
||||||
function renderInlineTokens(text: string): React.ReactNode[] {
|
export interface InlineMarkdownOptions {
|
||||||
|
onTagClick?: (tag: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderInlineTokens(text: string, options?: InlineMarkdownOptions): React.ReactNode[] {
|
||||||
if (!text) return [];
|
if (!text) return [];
|
||||||
|
|
||||||
const regex = /(`[^`]+`|\*\*[^*]+\*\*|__[^_]+__|~~[^~]+~~|\*[^*]+\*|_[^_]+_|\[([^\]]+)\]\(([^)]+)\))/g;
|
const tagAlt = options?.onTagClick ? '|#[a-zA-Z0-9äöüÄÖÜß_-]+' : '';
|
||||||
|
const regex = new RegExp(
|
||||||
|
'(`[^`]+`|\\*\\*[^*]+\\*\\*|__[^_]+__|~~[^~]+~~|\\*[^*]+\\*|_[^_]+_|\\[([^\\]]+)\\]\\(([^)]+)\\)' + tagAlt + ')',
|
||||||
|
'g'
|
||||||
|
);
|
||||||
|
|
||||||
const elements: React.ReactNode[] = [];
|
const elements: React.ReactNode[] = [];
|
||||||
let lastIndex = 0;
|
let lastIndex = 0;
|
||||||
@@ -24,7 +33,30 @@ function renderInlineTokens(text: string): React.ReactNode[] {
|
|||||||
const token = match[0];
|
const token = match[0];
|
||||||
const key = `${match.index}-${token}`;
|
const key = `${match.index}-${token}`;
|
||||||
|
|
||||||
if (token.startsWith('`') && token.endsWith('`')) {
|
if (token.startsWith('#')) {
|
||||||
|
const prevChar = match.index > 0 ? text[match.index - 1] : '';
|
||||||
|
if (prevChar && !/[\s(]/.test(prevChar)) {
|
||||||
|
// e.g. an URL fragment like https://x/#anchor: keep as plain text
|
||||||
|
elements.push(token);
|
||||||
|
} else {
|
||||||
|
const tag = token.slice(1);
|
||||||
|
elements.push(
|
||||||
|
<span
|
||||||
|
key={key}
|
||||||
|
role="button"
|
||||||
|
tabIndex={-1}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
options?.onTagClick?.(tag);
|
||||||
|
}}
|
||||||
|
className="text-accent bg-accent-dim hover:bg-accent/20 rounded-btn px-0.5 cursor-pointer transition-colors"
|
||||||
|
title={`Nach #${tag} filtern`}
|
||||||
|
>
|
||||||
|
{token}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (token.startsWith('`') && token.endsWith('`')) {
|
||||||
elements.push(
|
elements.push(
|
||||||
<code key={key} className="px-1.5 py-0.5 rounded bg-panel-elevated text-accent font-mono text-[0.9em] border border-border/50">
|
<code key={key} className="px-1.5 py-0.5 rounded bg-panel-elevated text-accent font-mono text-[0.9em] border border-border/50">
|
||||||
{token.slice(1, -1)}
|
{token.slice(1, -1)}
|
||||||
@@ -92,7 +124,7 @@ function renderInlineTokens(text: string): React.ReactNode[] {
|
|||||||
* - `code`
|
* - `code`
|
||||||
* - [label](url)
|
* - [label](url)
|
||||||
*/
|
*/
|
||||||
export function renderInlineMarkdown(text: string): React.ReactNode[] {
|
export function renderInlineMarkdown(text: string, options?: InlineMarkdownOptions): React.ReactNode[] {
|
||||||
if (!text) return [];
|
if (!text) return [];
|
||||||
|
|
||||||
if (text.includes('```')) {
|
if (text.includes('```')) {
|
||||||
@@ -122,14 +154,14 @@ export function renderInlineMarkdown(text: string): React.ReactNode[] {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
} else if (part) {
|
} else if (part) {
|
||||||
nodes.push(...renderInlineTokens(part));
|
nodes.push(...renderInlineTokens(part, options));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return nodes;
|
return nodes;
|
||||||
}
|
}
|
||||||
|
|
||||||
return renderInlineTokens(text);
|
return renderInlineTokens(text, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MarkdownViewer({ content, className = '' }: { content: string; className?: string }) {
|
export function MarkdownViewer({ content, className = '' }: { content: string; className?: string }) {
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { afterEach } from 'vitest';
|
||||||
|
import { cleanup } from '@testing-library/react';
|
||||||
|
|
||||||
|
// jsdom has no layout engine, so scrolling is a no-op here
|
||||||
|
Element.prototype.scrollIntoView = () => {};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
setupFiles: ['./src/test/setup.ts'],
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in new issue
Block a user