From 31847ffaa3db38c7ec083bbfa12e69de16b817c5 Mon Sep 17 00:00:00 2001 From: Sandro Date: Sun, 25 Oct 2026 12:20:27 +0100 Subject: [PATCH 01/30] fix(workspace): auto-create bullet below divider and allow click to create bullet at bottom --- .../src/features/workspace/WorkspaceView.tsx | 152 ++++++++++++++++-- 1 file changed, 139 insertions(+), 13 deletions(-) diff --git a/frontend/src/features/workspace/WorkspaceView.tsx b/frontend/src/features/workspace/WorkspaceView.tsx index 0cf0570..efd4747 100644 --- a/frontend/src/features/workspace/WorkspaceView.tsx +++ b/frontend/src/features/workspace/WorkspaceView.tsx @@ -996,12 +996,17 @@ export const WorkspaceView: React.FC = ({ }; // Insert a new bullet immediately after index, inheriting level - const insertNewBulletAfter = (afterIndex: number, level: number, type: BlockType = 'bullet') => { + const insertNewBulletAfter = ( + afterIndex: number, + level: number, + type: BlockType = 'bullet', + initialContent: string = '' + ) => { if (isReadOnly || !selectedDoc) return; const newBlock: DocumentBlock = { id: `blk_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`, type, - content: '', + content: initialContent, level, checked: false, }; @@ -1016,6 +1021,9 @@ export const WorkspaceView: React.FC = ({ const el = blockInputRefs.current[newBlock.id]; if (el) { el.focus(); + if ('setSelectionRange' in el && typeof (el as any).value === 'string') { + (el as any).setSelectionRange(0, 0); + } } }, 20); }; @@ -1179,7 +1187,20 @@ export const WorkspaceView: React.FC = ({ if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); setSlashBlockId(null); - insertNewBulletAfter(index, block.level || 0); + const target = e.target as HTMLTextAreaElement | HTMLInputElement; + let afterContent = ''; + if (target && typeof target.selectionStart === 'number') { + const cursor = target.selectionStart; + const fullContent = block.content || ''; + const before = fullContent.slice(0, cursor); + const after = fullContent.slice(cursor); + if (after.length > 0) { + updateBlock(block.id, { content: before }); + afterContent = after; + } + } + insertNewBulletAfter(index, block.level || 0, block.type === 'todo' ? 'todo' : 'bullet', afterContent); + return; } else if (e.key === 'Tab') { e.preventDefault(); if (e.shiftKey) { @@ -1202,6 +1223,34 @@ export const WorkspaceView: React.FC = ({ const handleContentChange = (block: DocumentBlock, newContent: string) => { if (isReadOnly) return; ensureLock(); + + // Markdown shortcut: typing '---' creates a divider and inserts a new bullet below + if (newContent.trim() === '---') { + const blkIdx = selectedDoc ? selectedDoc.blocks.findIndex((b) => b.id === block.id) : -1; + if (selectedDoc && blkIdx !== -1) { + const curLevel = block.level || 0; + const newBlockId = `blk_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`; + const newBullet: DocumentBlock = { + id: newBlockId, + type: 'bullet', + content: '', + level: curLevel, + checked: false, + }; + const nextBlocks = [...selectedDoc.blocks]; + nextBlocks[blkIdx] = { + ...block, + type: 'divider', + content: '', + }; + nextBlocks.splice(blkIdx + 1, 0, newBullet); + persistDocument({ ...selectedDoc, blocks: nextBlocks }); + setSlashBlockId(null); + focusBlock(newBlockId); + return; + } + } + updateBlock(block.id, { content: newContent }); // Check if user just typed '/' @@ -1270,6 +1319,21 @@ export const WorkspaceView: React.FC = ({ }, 20); }; + const handleCanvasBottomClick = () => { + if (isReadOnly || !selectedDoc) return; + if (selectedDoc.blocks.length === 0) { + insertNewBulletAfter(-1, 0); + return; + } + const lastIndex = selectedDoc.blocks.length - 1; + const lastBlock = selectedDoc.blocks[lastIndex]; + if (lastBlock && lastBlock.type !== 'divider' && (!lastBlock.content || lastBlock.content.trim() === '')) { + focusBlock(lastBlock.id); + } else { + insertNewBulletAfter(lastIndex, 0); + } + }; + const applySlashCommand = (blockId: string, cmd: SlashCommandItem) => { if (isReadOnly || !selectedDoc) return; const block = selectedDoc.blocks.find((b) => b.id === blockId); @@ -1290,6 +1354,29 @@ export const WorkspaceView: React.FC = ({ } if (cmd.type) { + if (cmd.type === 'divider') { + const blkIdx = selectedDoc.blocks.findIndex((b) => b.id === blockId); + const curLevel = block.level || 0; + const newBlockId = `blk_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`; + const newBullet: DocumentBlock = { + id: newBlockId, + type: 'bullet', + content: '', + level: curLevel, + checked: false, + }; + const nextBlocks = [...selectedDoc.blocks]; + nextBlocks[blkIdx] = { + ...block, + type: 'divider', + content: '', + }; + nextBlocks.splice(blkIdx + 1, 0, newBullet); + persistDocument({ ...selectedDoc, blocks: nextBlocks }); + focusBlock(newBlockId); + return; + } + updateBlock(blockId, { type: cmd.type, content: cleanContent, checked: cmd.type === 'todo' ? false : undefined }); focusBlock(blockId); } @@ -2266,7 +2353,14 @@ export const WorkspaceView: React.FC = ({ {/* Right Logseq Outliner Editor */}
{selectedDoc ? ( -
+
{ + if (e.target === e.currentTarget) { + handleCanvasBottomClick(); + } + }} + > {/* Document Header & Metadata */}
@@ -2519,15 +2613,10 @@ export const WorkspaceView: React.FC = ({ {/* LOGSEQ HIERARCHICAL BULLET LIST CANVAS */} {/* =========================================================== */}
{ if (e.target === e.currentTarget) { - if (selectedDoc.blocks.length === 0) { - insertNewBulletAfter(-1, 0); - } else { - const lastBlock = selectedDoc.blocks[selectedDoc.blocks.length - 1]; - blockInputRefs.current[lastBlock.id]?.focus(); - } + handleCanvasBottomClick(); } }} > @@ -2822,8 +2911,34 @@ export const WorkspaceView: React.FC = ({ {/* TYPE: DIVIDER */} {block.type === 'divider' && ( -
-
+
{ + if (!isReadOnly && e.key === 'Enter') { + e.preventDefault(); + insertNewBulletAfter(originalIndex, block.level || 0); + } else if (!isReadOnly && (e.key === 'Backspace' || e.key === 'Delete')) { + e.preventDefault(); + deleteBlockAndFocusPrev(originalIndex); + } + }} + onClick={() => { + if (!isReadOnly) { + insertNewBulletAfter(originalIndex, block.level || 0); + } + }} + className="py-2.5 my-1 group/divider relative cursor-pointer focus:outline-none" + title={isReadOnly ? undefined : "Klicken oder Enter drücken, um darunter einen neuen Aufzählungspunkt einzufügen"} + > +
+ {!isReadOnly && ( +
+ + + Punkt einfügen + +
+ )}
)} @@ -2899,6 +3014,17 @@ export const WorkspaceView: React.FC = ({
); })} + + {/* Explicit bottom clickable area */} + {!isReadOnly && ( +
+ + Neuen Punkt hinzufügen... +
+ )}
) : ( -- 2.54.0 From e2b20198e44bde16efce384d371a366e267590a9 Mon Sep 17 00:00:00 2001 From: Sandro Date: Sun, 25 Oct 2026 12:25:04 +0100 Subject: [PATCH 02/30] feat(workspace): add syntax highlighting and language selector to /code blocks --- frontend/package-lock.json | 17 ++ frontend/package.json | 2 + .../src/features/workspace/CodeBlockItem.tsx | 282 ++++++++++++++++++ .../src/features/workspace/WorkspaceView.tsx | 37 ++- frontend/src/index.css | 58 ++++ 5 files changed, 380 insertions(+), 16 deletions(-) create mode 100644 frontend/src/features/workspace/CodeBlockItem.tsx diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ef3a8a0..2201d64 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,8 +8,10 @@ "name": "mindsift-frontend", "version": "1.0.0", "dependencies": { + "@types/prismjs": "^1.26.6", "framer-motion": "^11.15.0", "lucide-react": "^0.468.0", + "prismjs": "^1.30.0", "react": "^18.3.1", "react-dom": "^18.3.1" }, @@ -1224,6 +1226,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/prismjs": { + "version": "1.26.6", + "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz", + "integrity": "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==", + "license": "MIT" + }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", @@ -2242,6 +2250,15 @@ "dev": true, "license": "MIT" }, + "node_modules/prismjs": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index 266a668..ee43213 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,8 +11,10 @@ "test:run": "echo 'Frontend checks passed'" }, "dependencies": { + "@types/prismjs": "^1.26.6", "framer-motion": "^11.15.0", "lucide-react": "^0.468.0", + "prismjs": "^1.30.0", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/frontend/src/features/workspace/CodeBlockItem.tsx b/frontend/src/features/workspace/CodeBlockItem.tsx new file mode 100644 index 0000000..ab200cf --- /dev/null +++ b/frontend/src/features/workspace/CodeBlockItem.tsx @@ -0,0 +1,282 @@ +import React, { useState, useMemo, useRef, useEffect } from 'react'; +import Prism from 'prismjs'; +import 'prismjs/components/prism-python'; +import 'prismjs/components/prism-typescript'; +import 'prismjs/components/prism-bash'; +import 'prismjs/components/prism-json'; +import 'prismjs/components/prism-go'; +import 'prismjs/components/prism-sql'; +import 'prismjs/components/prism-yaml'; +import 'prismjs/components/prism-rust'; +import 'prismjs/components/prism-c'; +import 'prismjs/components/prism-cpp'; +import 'prismjs/components/prism-java'; +import 'prismjs/components/prism-php'; +import 'prismjs/components/prism-docker'; +import 'prismjs/components/prism-markdown'; +import { Code, Copy, Check, Eye, Edit3 } from 'lucide-react'; +import { DocumentBlock } from '../../lib/types'; + +export interface CodeBlockItemProps { + block: DocumentBlock; + isReadOnly: boolean; + onChangeContent: (content: string) => void; + onChangeLang: (lang: string) => void; + onFocus?: () => void; + onBlur?: () => void; + inputRef?: (el: HTMLTextAreaElement | null) => void; +} + +export const CODE_LANGUAGES = [ + { id: 'javascript', label: 'JavaScript' }, + { id: 'typescript', label: 'TypeScript' }, + { id: 'python', label: 'Python' }, + { id: 'go', label: 'Go' }, + { id: 'bash', label: 'Bash / Shell' }, + { id: 'json', label: 'JSON' }, + { id: 'html', label: 'HTML / XML', alias: 'markup' }, + { id: 'css', label: 'CSS' }, + { id: 'sql', label: 'SQL' }, + { id: 'rust', label: 'Rust' }, + { id: 'cpp', label: 'C++' }, + { id: 'c', label: 'C' }, + { id: 'java', label: 'Java' }, + { id: 'php', label: 'PHP' }, + { id: 'yaml', label: 'YAML' }, + { id: 'docker', label: 'Dockerfile' }, + { id: 'markdown', label: 'Markdown' }, + { id: 'text', label: 'Plain Text' }, +]; + +export const CodeBlockItem: React.FC = ({ + block, + isReadOnly, + onChangeContent, + onChangeLang, + onFocus, + onBlur, + inputRef, +}) => { + const [isEditing, setIsEditing] = useState(false); + const [copied, setCopied] = useState(false); + const localTextareaRef = useRef(null); + + const currentLang = block.lang || 'javascript'; + + // Highlighted HTML generated by Prism + const highlightedCode = useMemo(() => { + const code = block.content || ''; + if (!code) return ''; + + const prismLang = currentLang === 'html' ? 'markup' : currentLang; + const grammar = Prism.languages[prismLang] || Prism.languages.javascript; + if (!grammar) { + return code + .replace(/&/g, '&') + .replace(//g, '>'); + } + try { + return Prism.highlight(code, grammar, prismLang); + } catch { + return code + .replace(/&/g, '&') + .replace(//g, '>'); + } + }, [block.content, currentLang]); + + // Code lines for line numbers + const lines = useMemo(() => { + return (block.content || '').split('\n'); + }, [block.content]); + + const handleCopy = async (e: React.MouseEvent) => { + e.stopPropagation(); + try { + await navigator.clipboard.writeText(block.content || ''); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + // Fallback + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + setIsEditing(false); + return; + } + // Tab key indent/outdent + if (e.key === 'Tab') { + e.preventDefault(); + const textarea = e.currentTarget; + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + const val = textarea.value; + + if (e.shiftKey) { + if (val.substring(start - 2, start) === ' ') { + const newVal = val.substring(0, start - 2) + val.substring(start); + onChangeContent(newVal); + setTimeout(() => { + textarea.selectionStart = textarea.selectionEnd = Math.max(0, start - 2); + }, 0); + } + } else { + const newVal = val.substring(0, start) + ' ' + val.substring(end); + onChangeContent(newVal); + setTimeout(() => { + textarea.selectionStart = textarea.selectionEnd = start + 2; + }, 0); + } + } + }; + + const switchToEdit = () => { + if (isReadOnly) return; + setIsEditing(true); + setTimeout(() => { + localTextareaRef.current?.focus(); + }, 20); + }; + + return ( +
+ {/* Code Block Header Toolbar */} +
+ {/* Left: Language Selection */} +
+ + +
+ + {/* Right: Mode Toggle & Copy */} +
+ {!isReadOnly && ( + + )} + + +
+
+ + {/* Code Block Content */} + {isEditing && !isReadOnly ? ( +
+