From 1767764912a27eb6cee1aa8225dc84730df23402 Mon Sep 17 00:00:00 2001 From: Sandro Date: Mon, 2 Nov 2026 22:22:17 +0100 Subject: [PATCH 1/2] feat(workspace): copy bullets as Markdown, Ctrl+A twice selects the whole note Ctrl+C over a selection of several bullets now puts Markdown on the clipboard (lib/outlineMarkdown.ts, the format of the Markdown export: tab indent, - [ ] to-dos, headings, callouts, fenced code); pasting it into a note gives the same bullets again. A second Ctrl+A in a bullet selects the whole note. Copying goes through the browser's copy event, so it works on plain HTTP too. Co-Authored-By: Claude Opus 5.5 --- frontend/src/features/help/HelpView.tsx | 3 +- .../src/features/workspace/OutlinerTree.tsx | 56 ++++++++++++-- .../src/lib/__tests__/outlineMarkdown.test.ts | 44 +++++++++++ frontend/src/lib/outlineMarkdown.ts | 74 +++++++++++++++++++ 4 files changed, 169 insertions(+), 8 deletions(-) create mode 100644 frontend/src/lib/__tests__/outlineMarkdown.test.ts create mode 100644 frontend/src/lib/outlineMarkdown.ts diff --git a/frontend/src/features/help/HelpView.tsx b/frontend/src/features/help/HelpView.tsx index 5f95790..57d1cb4 100644 --- a/frontend/src/features/help/HelpView.tsx +++ b/frontend/src/features/help/HelpView.tsx @@ -761,7 +761,8 @@ export const HelpView: React.FC = ({ onBack }) => { { key: '↑ / ↓', scope: 'Workspace Outliner', desc: 'Zum vorherigen / nächsten Block navigieren' }, { key: 'Strg + Z / Cmd + Z', scope: 'Workspace Outliner', desc: 'Letzte Notiz-Aktion rückgängig machen' }, { key: 'Strg + Y / Strg + Shift + Z', scope: 'Workspace Outliner', desc: 'Rückgängig gemachte Aktion wiederholen' }, - { key: 'Maus über mehrere Punkte ziehen', scope: 'Workspace Outliner', desc: 'Text über mehrere Punkte markieren; Entf/Backspace löscht ihn, Tippen ersetzt ihn, Strg + C kopiert' }, + { key: 'Maus über mehrere Punkte ziehen', scope: 'Workspace Outliner', desc: 'Text über mehrere Punkte markieren; Entf/Backspace löscht ihn, Tippen ersetzt ihn, Strg + C kopiert ihn als Markdown (Einrückung, To-dos, Überschriften)' }, + { key: 'Strg + A zweimal', scope: 'Workspace Outliner', desc: 'Erst den Punkt, beim zweiten Mal die ganze Notiz markieren; Strg + C kopiert sie als Markdown, eingefügt entstehen wieder dieselben Punkte' }, { key: 'Strg + Z / Cmd + Z', scope: 'Aufgaben', desc: 'Löschen, Verschieben oder Abhaken rückgängig machen' }, { key: '/', scope: 'Workspace Outliner', desc: 'Slash-Menü öffnen (/todo, /link, /h1, /code, ...)' }, { key: '[] oder [ ] am Anfang', scope: 'Workspace Outliner', desc: 'Sofort in Todo-Checkbox umwandeln' }, diff --git a/frontend/src/features/workspace/OutlinerTree.tsx b/frontend/src/features/workspace/OutlinerTree.tsx index ff0a7da..99a4bad 100644 --- a/frontend/src/features/workspace/OutlinerTree.tsx +++ b/frontend/src/features/workspace/OutlinerTree.tsx @@ -2,6 +2,7 @@ import React, { useState, useRef, useEffect, useCallback, useMemo, forwardRef, u import { DocumentBlock, BlockType } from '../../lib/types'; import { OutlinerBlock, mapRenderedOffsetToRaw } from './OutlinerBlock'; import { parsePastedLines } from '../../lib/pasteOutline'; +import { blocksToMarkdown } from '../../lib/outlineMarkdown'; import { numberedLabels } from '../../lib/outlineList'; import { flushSync } from 'react-dom'; import { MobileOutlineBar, isTouchOnly } from './MobileOutlineBar'; @@ -47,6 +48,24 @@ export interface OutlinerTreeProps { trailingArea?: boolean; } +// Puts text on the clipboard from a key press: through the browser's copy event, which works on plain +// HTTP too (navigator.clipboard needs HTTPS or localhost); the clipboard API only as a fallback +function writeClipboardText(text: string) { + const onCopy = (e: ClipboardEvent) => { + e.clipboardData?.setData('text/plain', text); + e.preventDefault(); + }; + document.addEventListener('copy', onCopy); + let copied = false; + try { + copied = document.execCommand('copy'); + } catch { + // not supported: fall back below + } + document.removeEventListener('copy', onCopy); + if (!copied) void navigator.clipboard?.writeText(text).catch(() => {}); +} + export const OutlinerTree = forwardRef(({ docId, compact = false, @@ -679,7 +698,8 @@ export const OutlinerTree = forwardRef(({ // bullet into another is tracked here instead: start (anchor) and end (focus) as bullet + position in // its raw text. The start bullet keeps the browser's own selection; the others are highlighted with the // CSS Highlight API (rows are tinted where it is missing). Delete/Backspace removes the selected text - // like a text editor in one step (Strg+Z brings it back), a typed character replaces it, Strg+C copies. + // like a text editor in one step (Strg+Z brings it back), a typed character replaces it, Strg+C copies + // it as Markdown. A second Strg+A in a bullet selects the whole note. type RangeSel = { anchorId: string; anchorOffset: number; focusId: string; focusOffset: number; focusNode?: Node; focusNodeOffset?: number }; const [rangeSel, setRangeSel] = useState(null); const rangeSelRef = useRef(null); @@ -819,13 +839,30 @@ export const OutlinerTree = forwardRef(({ return { first: blocks[fIdx], firstOffset, last: blocks[lIdx], lastOffset, fIdx, lIdx }; }; + // The selection as Markdown (lib/outlineMarkdown.ts): pasted elsewhere it is a proper list, pasted + // back into a note it becomes the same bullets again const selectedText = (sel: RangeSel) => { const r = resolveRangeSel(sel); if (!r) return ''; - const lines = [(r.first.content || '').slice(r.firstOffset)]; - for (let i = r.fIdx + 1; i < r.lIdx; i++) lines.push(blocks[i].content || ''); - lines.push((r.last.content || '').slice(0, r.lastOffset)); - return lines.join('\n'); + return blocksToMarkdown([ + { ...r.first, content: (r.first.content || '').slice(r.firstOffset) }, + ...blocks.slice(r.fIdx + 1, r.lIdx), + { ...r.last, content: (r.last.content || '').slice(0, r.lastOffset) }, + ]); + }; + + // Ctrl/Cmd+A a second time (the bullet's own text is selected already): the whole note, ready for + // Ctrl+C as Markdown + const handleTreeKeyDownCapture = (e: React.KeyboardEvent) => { + if (!(e.ctrlKey || e.metaKey) || e.shiftKey || e.altKey || e.key.toLowerCase() !== 'a') return; + const ta = e.target as HTMLTextAreaElement; + if (ta.tagName !== 'TEXTAREA' || ta.selectionStart !== 0 || ta.selectionEnd !== ta.value.length) return; + if (visibleEntries.length < 2) return; + e.preventDefault(); + const first = visibleEntries[0].block; + const last = visibleEntries[visibleEntries.length - 1].block; + focusBlockInput(first.id, 'select'); + setRangeSel({ anchorId: first.id, anchorOffset: 0, focusId: last.id, focusOffset: (last.content || '').length }); }; const deleteRangeSel = (sel: RangeSel, insert = '') => { @@ -882,7 +919,7 @@ export const OutlinerTree = forwardRef(({ }; if (mod && (e.key === 'c' || e.key === 'x')) { stop(); - void navigator.clipboard?.writeText(selectedTextRef.current(sel)).catch(() => {}); + writeClipboardText(selectedTextRef.current(sel)); if (e.key === 'x') deleteRangeSelRef.current(sel); return; } @@ -912,7 +949,12 @@ export const OutlinerTree = forwardRef(({ }, [rangeSel, visibleEntries, highlightSupported]); return ( -
+
{visibleEntries.map(({ block, index, hasChildren, maxAllowedLevel }, entryIdx) => { const isLast = entryIdx === visibleEntries.length - 1; return ( diff --git a/frontend/src/lib/__tests__/outlineMarkdown.test.ts b/frontend/src/lib/__tests__/outlineMarkdown.test.ts new file mode 100644 index 0000000..f14c7b3 --- /dev/null +++ b/frontend/src/lib/__tests__/outlineMarkdown.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest'; +import { blocksToMarkdown } from '../outlineMarkdown'; +import { parsePastedLines } from '../pasteOutline'; +import type { DocumentBlock } from '../types'; + +const b = (type: DocumentBlock['type'], content: string, level = 0, extra: Partial = {}): DocumentBlock => ({ + id: Math.random().toString(36).slice(2), + type, + content, + level, + ...extra, +}); + +describe('blocksToMarkdown', () => { + it('writes a nested list with to-dos, headings and code', () => { + const md = blocksToMarkdown([ + b('h2', 'Umzug', 0), + b('bullet', 'Server', 1), + b('todo', 'Backup prüfen', 2, { checked: true }), + b('todo', 'DNS umstellen', 2), + b('numbered', 'Erstens', 1), + b('code', 'docker compose up -d', 1, { lang: 'bash' }), + ]); + expect(md).toBe( + ['## Umzug', '', '- Server', '\t- [x] Backup prüfen', '\t- [ ] DNS umstellen', '1. Erstens', '```bash', 'docker compose up -d', '```'].join('\n') + ); + }); + + it('starts a copied branch at the left margin', () => { + expect(blocksToMarkdown([b('bullet', 'Ast', 3), b('bullet', 'Kind', 4)])).toBe('- Ast\n\t- Kind'); + }); + + it('pasted back, it gives the same bullets', () => { + // 'text' is the plain bullet of a note + const blocks = [b('text', 'Eins', 0), b('text', 'Zwei', 1), b('todo', 'Drei', 2, { checked: true }), b('text', 'Vier', 0)]; + const items = parsePastedLines(blocksToMarkdown(blocks).split('\n')); + expect(items.map((i) => [i.type, i.content, i.indent, Boolean(i.checked)])).toEqual([ + ['text', 'Eins', 0, false], + ['text', 'Zwei', 1, false], + ['todo', 'Drei', 2, true], + ['text', 'Vier', 0, false], + ]); + }); +}); diff --git a/frontend/src/lib/outlineMarkdown.ts b/frontend/src/lib/outlineMarkdown.ts new file mode 100644 index 0000000..3c1a679 --- /dev/null +++ b/frontend/src/lib/outlineMarkdown.ts @@ -0,0 +1,74 @@ +import type { DocumentBlock } from './types'; + +// Bullets as Markdown, in the format of the server's Markdown export (BlocksToVaultMarkdown): a nested list +// with one tab per level, "- [ ]" for to-dos, headings, dividers, callouts and fenced code at the top +// level as block elements. Used for copying bullets; pasting it back (pasteOutline.ts) gives the same +// structure. Levels count from the shallowest bullet, so a copied branch starts at the left margin. +export function blocksToMarkdown(blocks: DocumentBlock[]): string { + if (blocks.length === 0) return ''; + const base = Math.min(...blocks.map((b) => Math.max(0, b.level || 0))); + const out: string[] = []; + // Children of a top-level heading/callout/code/divider become a list one level up; otherwise their + // tab indent would turn them into an indented code block + let lift = 0; + for (const b of blocks) { + let lvl = Math.max(0, (b.level || 0) - base); + if (lvl === 0) lift = 0; + else lvl = Math.max(0, lvl - lift); + const indent = '\t'.repeat(lvl); + const content = (b.content || '').replace(/\r\n/g, '\n'); + const oneLine = content.replace(/\n/g, ' '); + // Soft line breaks continue the list item, aligned under its text + const cont = (prefix: string) => content.replace(/\n/g, `\n${indent}${prefix}`); + const hashes = b.type === 'h1' ? '#' : b.type === 'h2' ? '##' : b.type === 'h3' ? '###' : ''; + + if ((b.level || 0) - base <= 0) { + if (hashes) { + out.push('', `${hashes} ${oneLine}`, ''); + lift = 1; + continue; + } + if (b.type === 'divider') { + out.push('', '---', ''); + lift = 1; + continue; + } + if (b.type === 'callout') { + out.push('', `> ${content.replace(/\n/g, '\n> ')}`, ''); + lift = 1; + continue; + } + if (b.type === 'code') { + out.push('', `\`\`\`${b.lang || ''}`, content, '```', ''); + lift = 1; + continue; + } + } + switch (b.type) { + case 'code': + out.push(`${indent}\`\`\`${b.lang || ''}`, `${indent}${content.replace(/\n/g, `\n${indent}`)}`, `${indent}\`\`\``); + break; + case 'todo': + out.push(`${indent}- [${b.checked ? 'x' : ' '}] ${cont(' ')}`); + break; + case 'h1': + case 'h2': + case 'h3': + out.push(`${indent}- ${hashes} ${oneLine}`); + break; + case 'divider': + out.push(`${indent}- ---`); + break; + case 'callout': + out.push(`${indent}- > ${cont(' > ')}`); + break; + case 'numbered': + out.push(`${indent}1. ${cont(' ')}`); + break; + default: + out.push(content.trim() === '' ? `${indent}-` : `${indent}- ${cont(' ')}`); + } + } + // No blank lines at the start and end, at most one in a row + return out.join('\n').replace(/\n{3,}/g, '\n\n').replace(/^\n+|\n+$/g, ''); +} From c0dc718919ab22b903d615053e6654e996afc74d Mon Sep 17 00:00:00 2001 From: Sandro Date: Mon, 2 Nov 2026 22:22:17 +0100 Subject: [PATCH 2/2] chore: release v0.66.0 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 12 +++++++++++- README.md | 2 +- frontend/package-lock.json | 4 ++-- frontend/package.json | 2 +- 4 files changed, 15 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29f61f4..b50ca73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V ## [Unreleased] +## [0.66.0] - 2026-11-02 + +### Added +- Notizen: Strg+C über mehrere markierte Punkte kopiert sie als Markdown (Einrückung, To-dos, Überschriften, Zitate, Code). In andere Programme eingefügt ist das eine saubere Liste, in eine Notiz eingefügt entstehen wieder dieselben Punkte. +- Notizen: Ein zweites Strg+A in einem Punkt markiert die ganze Notiz, etwa um sie mit Strg+C komplett als Markdown zu kopieren. + +### Fixed +- Notizen: Kopieren über mehrere Punkte funktioniert auch auf Installationen ohne HTTPS. + ## [0.65.0] - 2026-11-02 ### Changed @@ -927,7 +936,8 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V - Geteilte Listen (Einkaufs-, Pack- und Checklisten) mit `#Kategorie`-Gruppierung, Mengenangaben und Echtzeit-Abhaken. - Benutzer-, Gruppen- und Admin-Verwaltung (Ersteinrichtungs-Assistent, Einladungscodes, Profilbilder, persönliche API-Keys & Webhook-Ingest `/api/v1/webhooks/ingest`, JSON-Datenexport) sowie CSS-Theme-Engine mit 4 Themes (`Dark`, `Light`, `High Contrast`, `Soft`) und PWA-Unterstützung. -[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.65.0...dev +[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.66.0...dev +[0.66.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.65.0...v0.66.0 [0.65.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.64.1...v0.65.0 [0.64.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.64.0...v0.64.1 [0.64.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.63.2...v0.64.0 diff --git a/README.md b/README.md index a600dd1..94098ec 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![Self-Hosted](https://img.shields.io/badge/100%25-Self--Hosted-2563eb?style=flat-square)](#) [![Docker](https://img.shields.io/badge/Deployment-Single%20Container-2496ed?style=flat-square&logo=docker&logoColor=white)](#-schnellstart) [![SQLite](https://img.shields.io/badge/Daten-SQLite-003b57?style=flat-square&logo=sqlite&logoColor=white)](#-deine-daten-gehören-dir) -[![Version](https://img.shields.io/badge/Version-v0.65.0-16a34a?style=flat-square)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/Version-v0.66.0-16a34a?style=flat-square)](CHANGELOG.md) **MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen, die mehrere Personen gleichzeitig bearbeiten können, und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 75ad226..229e497 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "mindsift-frontend", - "version": "0.65.0", + "version": "0.66.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mindsift-frontend", - "version": "0.65.0", + "version": "0.66.0", "license": "AGPL-3.0-only", "dependencies": { "@fontsource-variable/inter": "5.3.0", diff --git a/frontend/package.json b/frontend/package.json index 3a7aa72..bb2bfef 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "mindsift-frontend", "private": true, - "version": "0.65.0", + "version": "0.66.0", "license": "AGPL-3.0-only", "type": "module", "scripts": {