Dev #3

Merged
sandrodach merged 30 commits from dev into main 2026-10-25 18:00:10 +01:00
2 changed files with 236 additions and 51 deletions
Showing only changes of commit 518a0903dd - Show all commits

No files matched your search

+228 -47
View File
@@ -1081,7 +1081,8 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
afterIndex: number, afterIndex: number,
level: number, level: number,
type: BlockType = 'bullet', type: BlockType = 'bullet',
initialContent: string = '' initialContent: string = '',
updatedCurrentBlock?: { id: string; content: string }
) => { ) => {
if (isReadOnly || !selectedDoc) return; if (isReadOnly || !selectedDoc) return;
const newBlock: DocumentBlock = { const newBlock: DocumentBlock = {
@@ -1092,7 +1093,11 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
checked: false, checked: false,
}; };
const nextBlocks = [...selectedDoc.blocks]; const nextBlocks = selectedDoc.blocks.map((b) =>
updatedCurrentBlock && b.id === updatedCurrentBlock.id
? { ...b, content: updatedCurrentBlock.content }
: b
);
nextBlocks.splice(afterIndex + 1, 0, newBlock); nextBlocks.splice(afterIndex + 1, 0, newBlock);
persistDocument({ ...selectedDoc, blocks: nextBlocks }); persistDocument({ ...selectedDoc, blocks: nextBlocks });
setLastFocusedBlockId(newBlock.id); setLastFocusedBlockId(newBlock.id);
@@ -1247,7 +1252,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
} }
}, [activeEditingBlockId, activeCursorOffset, selectedDoc]); }, [activeEditingBlockId, activeCursorOffset, selectedDoc]);
// Keyboard Navigation: Enter, Tab, Shift+Tab, Backspace, / Slash Commands // Keyboard Navigation: Enter, Shift+Enter, Tab, Shift+Tab, Backspace, / Slash Commands
const handleKeyDown = ( const handleKeyDown = (
e: React.KeyboardEvent, e: React.KeyboardEvent,
block: DocumentBlock, block: DocumentBlock,
@@ -1290,22 +1295,57 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
} }
} }
if (e.key === 'Enter' && e.shiftKey) {
// Shift+Enter: insert a newline within the current bullet point
if (block.type === 'bullet' || block.type === 'paragraph' || block.type === 'todo') {
e.preventDefault();
setSlashBlockId(null);
const target = e.target as HTMLTextAreaElement | HTMLInputElement;
const localPos =
target && typeof target.selectionStart === 'number'
? target.selectionStart
: (target?.value || '').length;
const cursor = segStart + localPos;
const fullContent = block.content || '';
const nextContent = fullContent.slice(0, cursor) + '\n' + fullContent.slice(cursor);
const nextCursor = cursor + 1;
setBlockCursor(block.id, nextCursor);
updateBlock(block.id, { content: nextContent });
}
return;
}
if (e.key === 'Enter' && !e.shiftKey) { if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
setSlashBlockId(null); setSlashBlockId(null);
const target = e.target as HTMLTextAreaElement | HTMLInputElement; const target = e.target as HTMLTextAreaElement | HTMLInputElement;
let afterContent = '';
if (target && typeof target.selectionStart === 'number') {
const cursor = segStart + target.selectionStart;
const fullContent = block.content || ''; const fullContent = block.content || '';
let afterContent = '';
let updatedCurrent: { id: string; content: string } | undefined = undefined;
if (!fullContent.includes('\n') && target && typeof target.selectionStart === 'number') {
const cursor = segStart + target.selectionStart;
const before = fullContent.slice(0, cursor); const before = fullContent.slice(0, cursor);
const after = fullContent.slice(cursor); const after = fullContent.slice(cursor);
if (after.length > 0) { if (after.length > 0) {
updateBlock(block.id, { content: before }); updatedCurrent = { id: block.id, content: before };
afterContent = after; afterContent = after;
} }
} else if (fullContent.endsWith('\n')) {
// Clean up trailing empty line if user pressed Shift+Enter then immediately Enter
const trimmedTrailing = fullContent.replace(/\n+$/, '');
if (trimmedTrailing !== fullContent) {
updatedCurrent = { id: block.id, content: trimmedTrailing };
} }
insertNewBulletAfter(index, block.level || 0, block.type === 'todo' ? 'todo' : 'bullet', afterContent); }
insertNewBulletAfter(
index,
block.level || 0,
block.type === 'todo' ? 'todo' : 'bullet',
afterContent,
updatedCurrent
);
return; return;
} else if (e.key === 'Tab') { } else if (e.key === 'Tab') {
e.preventDefault(); e.preventDefault();
@@ -1316,7 +1356,8 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
} }
} else if (e.key === 'Backspace') { } else if (e.key === 'Backspace') {
const target = e.target as HTMLTextAreaElement | HTMLInputElement; const target = e.target as HTMLTextAreaElement | HTMLInputElement;
if (!block.content || block.content === '') { const fullContent = block.content || '';
if (!fullContent || fullContent === '') {
if ((block.level || 0) > 0) { if ((block.level || 0) > 0) {
e.preventDefault(); e.preventDefault();
outdentBlock(block.id); outdentBlock(block.id);
@@ -1332,12 +1373,77 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
target.selectionEnd === 0 target.selectionEnd === 0
) { ) {
e.preventDefault(); e.preventDefault();
setBlockCursor(block.id, segStart); if (fullContent[segStart - 1] === '\n') {
// Join current line with previous line within the same bullet
const joined = fullContent.slice(0, segStart - 1) + fullContent.slice(segStart);
setBlockCursor(block.id, segStart - 1);
handleContentChange(block, joined);
} else {
// Move cursor into preceding inline token to unwrap it
setBlockCursor(block.id, segStart - 1);
} }
} else if ( }
!e.shiftKey && } else if (!e.shiftKey && e.key === 'ArrowLeft') {
(e.key === 'ArrowLeft' || e.key === 'ArrowRight' || e.key === 'Home' || e.key === 'End') const target = e.target as HTMLTextAreaElement | HTMLInputElement;
if (segStart > 0 && target && target.selectionStart === 0 && target.selectionEnd === 0) {
e.preventDefault();
setBlockCursor(block.id, segStart - 1);
return;
}
requestAnimationFrame(() => {
if (target && typeof target.selectionStart === 'number') {
setBlockCursor(block.id, segStart + target.selectionStart);
}
});
} else if (!e.shiftKey && e.key === 'ArrowRight') {
const target = e.target as HTMLTextAreaElement | HTMLInputElement;
const fullContent = block.content || '';
if (
target &&
typeof target.selectionStart === 'number' &&
target.selectionStart === target.value.length &&
segStart + target.value.length < fullContent.length
) { ) {
e.preventDefault();
setBlockCursor(block.id, segStart + target.value.length + 1);
return;
}
requestAnimationFrame(() => {
if (target && typeof target.selectionStart === 'number') {
setBlockCursor(block.id, segStart + target.selectionStart);
}
});
} else if (!e.shiftKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
const target = e.target as HTMLTextAreaElement | HTMLInputElement;
const fullContent = block.content || '';
if (fullContent.includes('\n') && target && typeof target.selectionStart === 'number') {
const cursor = segStart + target.selectionStart;
const rawLines = fullContent.split('\n');
let run = 0;
const lineMeta = rawLines.map((lt) => {
const start = run;
const end = start + lt.length;
run = end + 1;
return { start, end, len: lt.length };
});
const curLineIdx = lineMeta.findIndex((m) => cursor >= m.start && cursor <= m.end);
if (curLineIdx !== -1) {
const col = cursor - lineMeta[curLineIdx].start;
if (e.key === 'ArrowUp' && curLineIdx > 0) {
e.preventDefault();
const prevLine = lineMeta[curLineIdx - 1];
setBlockCursor(block.id, prevLine.start + Math.min(col, prevLine.len));
return;
}
if (e.key === 'ArrowDown' && curLineIdx < lineMeta.length - 1) {
e.preventDefault();
const nextLine = lineMeta[curLineIdx + 1];
setBlockCursor(block.id, nextLine.start + Math.min(col, nextLine.len));
return;
}
}
}
} else if (!e.shiftKey && (e.key === 'Home' || e.key === 'End')) {
const target = e.target as HTMLTextAreaElement | HTMLInputElement; const target = e.target as HTMLTextAreaElement | HTMLInputElement;
requestAnimationFrame(() => { requestAnimationFrame(() => {
if (target && typeof target.selectionStart === 'number') { if (target && typeof target.selectionStart === 'number') {
@@ -1871,14 +1977,33 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
return <span key={key} className="whitespace-pre-wrap">{part}</span>; return <span key={key} className="whitespace-pre-wrap">{part}</span>;
}; };
// Helper to render text with clickable Wiki-Links, Web-Links and Hashtags // Helper to render text with clickable Wiki-Links, Web-Links and Hashtags across multiple lines
const renderFormattedText = (text: string, blockId?: string) => { const renderFormattedText = (text: string, blockId?: string) => {
const parts = text.split(/(\[\[.*?\]\]|\[.*?\]\(https?:\/\/[^\s)]+\)|#[a-zA-Z0-9äöüÄÖÜß_-]+)/g); const rawLines = (text || '').split('\n');
if (rawLines.length <= 1) {
const parts = (text || '').split(/(\[\[.*?\]\]|\[.*?\]\(https?:\/\/[^\s)]+\)|#[a-zA-Z0-9äöüÄÖÜß_-]+)/g);
return parts.map((part, i) => renderSingleRichToken(part, blockId, i)); return parts.map((part, i) => renderSingleRichToken(part, blockId, i));
}
return (
<div className="w-full space-y-0.5">
{rawLines.map((lineText, lineIdx) => {
const parts = lineText.split(/(\[\[.*?\]\]|\[.*?\]\(https?:\/\/[^\s)]+\)|#[a-zA-Z0-9äöüÄÖÜß_-]+)/g);
return (
<div key={lineIdx} className="flex flex-wrap items-center gap-y-0.5 w-full min-h-[22px]">
{lineText.length === 0 ? (
<span>&nbsp;</span>
) : (
parts.map((part, i) => renderSingleRichToken(part, blockId, `${lineIdx}-${i}`))
)}
</div>
);
})}
</div>
);
}; };
// Live inline token-aware editor: renders completed #hashtags and links inline as pills on the // Live inline token-aware editor: splits multi-line bullets (Shift+Enter) into visual rows and renders
// exact same line while keeping any token currently touched by the cursor unwrapped as raw text. // completed #hashtags and links inline as pills on each line while unwrapping any token touched by the cursor.
const renderLiveInlineBlock = ( const renderLiveInlineBlock = (
block: DocumentBlock, block: DocumentBlock,
originalIndex: number, originalIndex: number,
@@ -1893,32 +2018,90 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
); );
} }
const allTokens = findRichTokens(text);
const isEditing = activeEditingBlockId === block.id; const isEditing = activeEditingBlockId === block.id;
const cursor = isEditing ? Math.max(0, Math.min(text.length, activeCursorOffset)) : text.length;
if (!isEditing && allTokens.length > 0) { const rawLines = text.split('\n');
const segments: ( let runningOffset = 0;
const lines = rawLines.map((lineText, lineIdx) => {
const lineStart = runningOffset;
const lineEnd = lineStart + lineText.length;
runningOffset = lineEnd + 1; // +1 for the '\n' delimiter
return { lineIdx, lineText, lineStart, lineEnd };
});
let activeLineIdx = lines.findIndex((l) => cursor >= l.lineStart && cursor <= l.lineEnd);
if (activeLineIdx === -1) {
activeLineIdx = lines.length - 1;
}
type InlineSegment =
| { kind: 'text'; start: number; end: number; text: string } | { kind: 'text'; start: number; end: number; text: string }
| { kind: 'token'; start: number; end: number; raw: string } | { kind: 'token'; start: number; end: number; raw: string };
)[] = [];
let lastIdx = 0; return (
for (const t of allTokens) { <div className={`w-full space-y-0.5 ${extraTextClass}`}>
{lines.map((line) => {
const isLineActive = isEditing && line.lineIdx === activeLineIdx;
const lineTokens = findRichTokens(line.lineText).map((t) => ({
start: line.lineStart + t.start,
end: line.lineStart + t.end,
raw: t.raw,
}));
// Non-active line (or block not currently focused)
if (!isLineActive) {
const segments: InlineSegment[] = [];
let lastIdx = line.lineStart;
for (const t of lineTokens) {
if (t.start > lastIdx) { if (t.start > lastIdx) {
segments.push({ kind: 'text', start: lastIdx, end: t.start, text: text.slice(lastIdx, t.start) }); segments.push({
kind: 'text',
start: lastIdx,
end: t.start,
text: text.slice(lastIdx, t.start),
});
} }
segments.push({ kind: 'token', start: t.start, end: t.end, raw: t.raw }); segments.push({ kind: 'token', start: t.start, end: t.end, raw: t.raw });
lastIdx = t.end; lastIdx = t.end;
} }
if (lastIdx < text.length) { if (lastIdx < line.lineEnd) {
segments.push({ kind: 'text', start: lastIdx, end: text.length, text: text.slice(lastIdx) }); segments.push({
kind: 'text',
start: lastIdx,
end: line.lineEnd,
text: text.slice(lastIdx, line.lineEnd),
});
} }
return ( return (
<div <div
onClick={() => focusBlock(block.id, text.length)} key={`line-${line.lineIdx}`}
className={`flex flex-wrap items-center gap-y-0.5 w-full text-sm leading-relaxed break-words min-h-[22px] cursor-text ${extraTextClass}`} onMouseDown={(e) => {
if (e.target === e.currentTarget) {
e.preventDefault();
focusBlock(block.id, line.lineEnd);
}
}}
onClick={(e) => {
if (e.target === e.currentTarget) {
focusBlock(block.id, line.lineEnd);
}
}}
className="flex flex-wrap items-center gap-y-0.5 w-full text-sm leading-relaxed break-words min-h-[22px] cursor-text"
> >
{segments.map((seg, i) => {segments.length === 0 ? (
<span
onMouseDown={(e) => {
e.preventDefault();
focusBlock(block.id, line.lineStart);
}}
className="w-full min-h-[22px] inline-block"
>
&nbsp;
</span>
) : (
segments.map((seg, i) =>
seg.kind === 'token' ? ( seg.kind === 'token' ? (
renderSingleRichToken(seg.raw, block.id, `tok-${seg.start}-${i}`) renderSingleRichToken(seg.raw, block.id, `tok-${seg.start}-${i}`)
) : ( ) : (
@@ -1938,22 +2121,16 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
{seg.text} {seg.text}
</span> </span>
) )
)
)} )}
</div> </div>
); );
} }
const cursor = isEditing ? Math.max(0, Math.min(text.length, activeCursorOffset)) : text.length; // Active line being edited
const renderedTokens = isEditing const renderedTokens = lineTokens.filter((t) => !(cursor >= t.start && cursor <= t.end));
? allTokens.filter((t) => !(cursor >= t.start && cursor <= t.end))
: [];
type InlineSegment =
| { kind: 'text'; start: number; end: number; text: string }
| { kind: 'token'; start: number; end: number; raw: string };
const segments: InlineSegment[] = []; const segments: InlineSegment[] = [];
let lastIdx = 0; let lastIdx = line.lineStart;
for (const t of renderedTokens) { for (const t of renderedTokens) {
segments.push({ segments.push({
kind: 'text', kind: 'text',
@@ -1972,8 +2149,8 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
segments.push({ segments.push({
kind: 'text', kind: 'text',
start: lastIdx, start: lastIdx,
end: text.length, end: line.lineEnd,
text: text.slice(lastIdx), text: text.slice(lastIdx, line.lineEnd),
}); });
let activeSegIdx = segments.findIndex( let activeSegIdx = segments.findIndex(
@@ -1985,13 +2162,14 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
return ( return (
<div <div
key={`line-${line.lineIdx}`}
onMouseDown={(e) => { onMouseDown={(e) => {
if (e.target === e.currentTarget) { if (e.target === e.currentTarget) {
e.preventDefault(); e.preventDefault();
focusBlock(block.id, text.length); focusBlock(block.id, line.lineEnd);
} }
}} }}
className={`flex flex-wrap items-center gap-y-0.5 w-full min-h-[22px] cursor-text ${extraTextClass}`} className="flex flex-wrap items-center gap-y-0.5 w-full min-h-[22px] cursor-text"
> >
{segments.map((seg, idx) => { {segments.map((seg, idx) => {
if (seg.kind === 'token') { if (seg.kind === 'token') {
@@ -2066,6 +2244,9 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
})} })}
</div> </div>
); );
})}
</div>
);
}; };
// --- GLOBAL TO-DO ROLLUP AGGREGATOR --- // --- GLOBAL TO-DO ROLLUP AGGREGATOR ---
@@ -3120,7 +3301,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
{/* TYPE: TODO */} {/* TYPE: TODO */}
{block.type === 'todo' && ( {block.type === 'todo' && (
<div className="space-y-1"> <div className="space-y-1">
<div className="flex items-center gap-2 group/todo"> <div className="flex items-start gap-2 group/todo">
<input <input
type="checkbox" type="checkbox"
disabled={isReadOnly} disabled={isReadOnly}
@@ -3134,7 +3315,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
onStatsChange(); onStatsChange();
} }
}} }}
className={`w-4 h-4 checkbox-square flex-shrink-0 ${ className={`w-4 h-4 mt-1 checkbox-square flex-shrink-0 ${
isReadOnly ? 'cursor-not-allowed opacity-60' : 'cursor-pointer' isReadOnly ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'
}`} }`}
/> />
@@ -3247,7 +3428,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
<textarea <textarea
ref={(el) => (blockInputRefs.current[block.id] = el)} ref={(el) => (blockInputRefs.current[block.id] = el)}
readOnly={isReadOnly} readOnly={isReadOnly}
rows={Math.max(1, Math.ceil((block.content || '').length / 70))} rows={Math.max(1, (block.content || '').split('\n').length, Math.ceil((block.content || '').length / 70))}
value={block.content} value={block.content}
onFocus={handleBlockFocus} onFocus={handleBlockFocus}
onBlur={handleBlockBlur} onBlur={handleBlockBlur}
+8 -4
View File
@@ -1178,13 +1178,16 @@ func (s *Store) saveDocumentToDisk(doc *domain.Document) {
if b.Checked { if b.Checked {
chk = "x" chk = "x"
} }
md.WriteString(fmt.Sprintf("%s- [%s] %s\n", indent, chk, b.Content)) formatted := strings.ReplaceAll(b.Content, "\n", "\n"+indent+" ")
md.WriteString(fmt.Sprintf("%s- [%s] %s\n", indent, chk, formatted))
case "bullet", "paragraph": case "bullet", "paragraph":
md.WriteString(fmt.Sprintf("%s- %s\n", indent, b.Content)) formatted := strings.ReplaceAll(b.Content, "\n", "\n"+indent+" ")
md.WriteString(fmt.Sprintf("%s- %s\n", indent, formatted))
case "code": case "code":
md.WriteString(fmt.Sprintf("%s- ```%s\n%s %s\n%s ```\n", indent, b.Lang, indent, b.Content, indent)) md.WriteString(fmt.Sprintf("%s- ```%s\n%s %s\n%s ```\n", indent, b.Lang, indent, b.Content, indent))
case "callout": case "callout":
md.WriteString(fmt.Sprintf("%s- > %s\n", indent, b.Content)) formatted := strings.ReplaceAll(b.Content, "\n", "\n"+indent+" > ")
md.WriteString(fmt.Sprintf("%s- > %s\n", indent, formatted))
case "divider": case "divider":
md.WriteString(fmt.Sprintf("%s- ---\n", indent)) md.WriteString(fmt.Sprintf("%s- ---\n", indent))
case "table": case "table":
@@ -1192,7 +1195,8 @@ func (s *Store) saveDocumentToDisk(doc *domain.Document) {
case "progress": case "progress":
md.WriteString(fmt.Sprintf("%s- <!-- progress: %s -->\n", indent, b.Content)) md.WriteString(fmt.Sprintf("%s- <!-- progress: %s -->\n", indent, b.Content))
default: default:
md.WriteString(fmt.Sprintf("%s- %s\n", indent, b.Content)) formatted := strings.ReplaceAll(b.Content, "\n", "\n"+indent+" ")
md.WriteString(fmt.Sprintf("%s- %s\n", indent, formatted))
} }
} }
} else if doc.Content != "" { } else if doc.Content != "" {