Dev #3

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

No files matched your search

+353 -172
View File
@@ -1081,7 +1081,8 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
afterIndex: number,
level: number,
type: BlockType = 'bullet',
initialContent: string = ''
initialContent: string = '',
updatedCurrentBlock?: { id: string; content: string }
) => {
if (isReadOnly || !selectedDoc) return;
const newBlock: DocumentBlock = {
@@ -1092,7 +1093,11 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
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);
persistDocument({ ...selectedDoc, blocks: nextBlocks });
setLastFocusedBlockId(newBlock.id);
@@ -1247,7 +1252,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
}
}, [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 = (
e: React.KeyboardEvent,
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) {
e.preventDefault();
setSlashBlockId(null);
const target = e.target as HTMLTextAreaElement | HTMLInputElement;
const fullContent = block.content || '';
let afterContent = '';
if (target && typeof target.selectionStart === 'number') {
let updatedCurrent: { id: string; content: string } | undefined = undefined;
if (!fullContent.includes('\n') && target && typeof target.selectionStart === 'number') {
const cursor = segStart + 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 });
updatedCurrent = { id: block.id, content: before };
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;
} else if (e.key === 'Tab') {
e.preventDefault();
@@ -1316,7 +1356,8 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
}
} else if (e.key === 'Backspace') {
const target = e.target as HTMLTextAreaElement | HTMLInputElement;
if (!block.content || block.content === '') {
const fullContent = block.content || '';
if (!fullContent || fullContent === '') {
if ((block.level || 0) > 0) {
e.preventDefault();
outdentBlock(block.id);
@@ -1332,12 +1373,77 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
target.selectionEnd === 0
) {
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 &&
(e.key === 'ArrowLeft' || e.key === 'ArrowRight' || e.key === 'Home' || e.key === 'End')
) {
} else if (!e.shiftKey && e.key === 'ArrowLeft') {
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;
requestAnimationFrame(() => {
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>;
};
// 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 parts = text.split(/(\[\[.*?\]\]|\[.*?\]\(https?:\/\/[^\s)]+\)|#[a-zA-Z0-9äöüÄÖÜß_-]+)/g);
return parts.map((part, i) => renderSingleRichToken(part, blockId, i));
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 (
<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
// exact same line while keeping any token currently touched by the cursor unwrapped as raw text.
// Live inline token-aware editor: splits multi-line bullets (Shift+Enter) into visual rows and renders
// completed #hashtags and links inline as pills on each line while unwrapping any token touched by the cursor.
const renderLiveInlineBlock = (
block: DocumentBlock,
originalIndex: number,
@@ -1893,175 +2018,231 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
);
}
const allTokens = findRichTokens(text);
const isEditing = activeEditingBlockId === block.id;
if (!isEditing && allTokens.length > 0) {
const segments: (
| { kind: 'text'; start: number; end: number; text: string }
| { kind: 'token'; start: number; end: number; raw: string }
)[] = [];
let lastIdx = 0;
for (const t of allTokens) {
if (t.start > lastIdx) {
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 });
lastIdx = t.end;
}
if (lastIdx < text.length) {
segments.push({ kind: 'text', start: lastIdx, end: text.length, text: text.slice(lastIdx) });
}
return (
<div
onClick={() => focusBlock(block.id, text.length)}
className={`flex flex-wrap items-center gap-y-0.5 w-full text-sm leading-relaxed break-words min-h-[22px] cursor-text ${extraTextClass}`}
>
{segments.map((seg, i) =>
seg.kind === 'token' ? (
renderSingleRichToken(seg.raw, block.id, `tok-${seg.start}-${i}`)
) : (
<span
key={`txt-${seg.start}-${i}`}
onClick={(e) => {
e.stopPropagation();
const sel = window.getSelection();
const localOffset =
sel && sel.anchorNode && e.currentTarget.contains(sel.anchorNode)
? sel.anchorOffset
: seg.text.length;
focusBlock(block.id, seg.start + localOffset);
}}
className="whitespace-pre-wrap"
>
{seg.text}
</span>
)
)}
</div>
);
}
const cursor = isEditing ? Math.max(0, Math.min(text.length, activeCursorOffset)) : text.length;
const renderedTokens = isEditing
? allTokens.filter((t) => !(cursor >= t.start && cursor <= t.end))
: [];
const rawLines = text.split('\n');
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: 'token'; start: number; end: number; raw: string };
const segments: InlineSegment[] = [];
let lastIdx = 0;
for (const t of renderedTokens) {
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,
});
lastIdx = t.end;
}
segments.push({
kind: 'text',
start: lastIdx,
end: text.length,
text: text.slice(lastIdx),
});
let activeSegIdx = segments.findIndex(
(s) => s.kind === 'text' && cursor >= s.start && cursor <= s.end
);
if (activeSegIdx === -1) {
activeSegIdx = segments.length - 1;
}
return (
<div
onMouseDown={(e) => {
if (e.target === e.currentTarget) {
e.preventDefault();
focusBlock(block.id, text.length);
}
}}
className={`flex flex-wrap items-center gap-y-0.5 w-full min-h-[22px] cursor-text ${extraTextClass}`}
>
{segments.map((seg, idx) => {
if (seg.kind === 'token') {
return renderSingleRichToken(seg.raw, block.id, `tok-${seg.start}-${idx}`);
}
if (idx === activeSegIdx) {
const isLastSeg = idx === segments.length - 1;
<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) {
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 });
lastIdx = t.end;
}
if (lastIdx < line.lineEnd) {
segments.push({
kind: 'text',
start: lastIdx,
end: line.lineEnd,
text: text.slice(lastIdx, line.lineEnd),
});
}
return (
<input
key="live-input"
ref={(el) => (blockInputRefs.current[block.id] = el)}
data-seg-start={seg.start}
type="text"
readOnly={isReadOnly}
value={seg.text}
style={
!isLastSeg
? { width: `${Math.max(1, seg.text.length + 0.5)}ch` }
: undefined
}
onFocus={(e) => {
const localPos =
typeof e.currentTarget.selectionStart === 'number'
? e.currentTarget.selectionStart
: seg.text.length;
setBlockCursor(block.id, seg.start + localPos);
handleBlockFocus();
}}
onClick={(e) => {
if (
e.currentTarget.selectionStart === e.currentTarget.selectionEnd &&
typeof e.currentTarget.selectionStart === 'number'
) {
setBlockCursor(block.id, seg.start + e.currentTarget.selectionStart);
<div
key={`line-${line.lineIdx}`}
onMouseDown={(e) => {
if (e.target === e.currentTarget) {
e.preventDefault();
focusBlock(block.id, line.lineEnd);
}
}}
onBlur={() => {
setActiveEditingBlockId((prev) => (prev === block.id ? null : prev));
handleBlockBlur();
onClick={(e) => {
if (e.target === e.currentTarget) {
focusBlock(block.id, line.lineEnd);
}
}}
onChange={(e) => {
const newSegVal = e.target.value;
const localCursor =
typeof e.target.selectionStart === 'number'
? e.target.selectionStart
: newSegVal.length;
const newFull = text.slice(0, seg.start) + newSegVal + text.slice(seg.end);
const newGlobalCursor = seg.start + localCursor;
setBlockCursor(block.id, newGlobalCursor);
handleContentChange(block, newFull);
}}
onKeyDown={(e) => handleKeyDown(e, block, originalIndex, seg.start)}
className={`${
isLastSeg ? 'flex-1 min-w-[60px]' : 'flex-shrink-0'
} bg-transparent text-sm leading-relaxed focus:outline-none placeholder:text-faint ${extraTextClass}`}
/>
className="flex flex-wrap items-center gap-y-0.5 w-full text-sm leading-relaxed break-words min-h-[22px] cursor-text"
>
{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' ? (
renderSingleRichToken(seg.raw, block.id, `tok-${seg.start}-${i}`)
) : (
<span
key={`txt-${seg.start}-${i}`}
onClick={(e) => {
e.stopPropagation();
const sel = window.getSelection();
const localOffset =
sel && sel.anchorNode && e.currentTarget.contains(sel.anchorNode)
? sel.anchorOffset
: seg.text.length;
focusBlock(block.id, seg.start + localOffset);
}}
className="whitespace-pre-wrap"
>
{seg.text}
</span>
)
)
)}
</div>
);
}
if (!seg.text) return null;
// Active line being edited
const renderedTokens = lineTokens.filter((t) => !(cursor >= t.start && cursor <= t.end));
const segments: InlineSegment[] = [];
let lastIdx = line.lineStart;
for (const t of renderedTokens) {
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,
});
lastIdx = t.end;
}
segments.push({
kind: 'text',
start: lastIdx,
end: line.lineEnd,
text: text.slice(lastIdx, line.lineEnd),
});
let activeSegIdx = segments.findIndex(
(s) => s.kind === 'text' && cursor >= s.start && cursor <= s.end
);
if (activeSegIdx === -1) {
activeSegIdx = segments.length - 1;
}
return (
<span
key={`txt-${seg.start}-${idx}`}
<div
key={`line-${line.lineIdx}`}
onMouseDown={(e) => {
e.preventDefault();
focusBlock(block.id, seg.end);
if (e.target === e.currentTarget) {
e.preventDefault();
focusBlock(block.id, line.lineEnd);
}
}}
className="whitespace-pre-wrap text-sm leading-relaxed cursor-text"
className="flex flex-wrap items-center gap-y-0.5 w-full min-h-[22px] cursor-text"
>
{seg.text}
</span>
{segments.map((seg, idx) => {
if (seg.kind === 'token') {
return renderSingleRichToken(seg.raw, block.id, `tok-${seg.start}-${idx}`);
}
if (idx === activeSegIdx) {
const isLastSeg = idx === segments.length - 1;
return (
<input
key="live-input"
ref={(el) => (blockInputRefs.current[block.id] = el)}
data-seg-start={seg.start}
type="text"
readOnly={isReadOnly}
value={seg.text}
style={
!isLastSeg
? { width: `${Math.max(1, seg.text.length + 0.5)}ch` }
: undefined
}
onFocus={(e) => {
const localPos =
typeof e.currentTarget.selectionStart === 'number'
? e.currentTarget.selectionStart
: seg.text.length;
setBlockCursor(block.id, seg.start + localPos);
handleBlockFocus();
}}
onClick={(e) => {
if (
e.currentTarget.selectionStart === e.currentTarget.selectionEnd &&
typeof e.currentTarget.selectionStart === 'number'
) {
setBlockCursor(block.id, seg.start + e.currentTarget.selectionStart);
}
}}
onBlur={() => {
setActiveEditingBlockId((prev) => (prev === block.id ? null : prev));
handleBlockBlur();
}}
onChange={(e) => {
const newSegVal = e.target.value;
const localCursor =
typeof e.target.selectionStart === 'number'
? e.target.selectionStart
: newSegVal.length;
const newFull = text.slice(0, seg.start) + newSegVal + text.slice(seg.end);
const newGlobalCursor = seg.start + localCursor;
setBlockCursor(block.id, newGlobalCursor);
handleContentChange(block, newFull);
}}
onKeyDown={(e) => handleKeyDown(e, block, originalIndex, seg.start)}
className={`${
isLastSeg ? 'flex-1 min-w-[60px]' : 'flex-shrink-0'
} bg-transparent text-sm leading-relaxed focus:outline-none placeholder:text-faint ${extraTextClass}`}
/>
);
}
if (!seg.text) return null;
return (
<span
key={`txt-${seg.start}-${idx}`}
onMouseDown={(e) => {
e.preventDefault();
focusBlock(block.id, seg.end);
}}
className="whitespace-pre-wrap text-sm leading-relaxed cursor-text"
>
{seg.text}
</span>
);
})}
</div>
);
})}
</div>
@@ -3120,7 +3301,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
{/* TYPE: TODO */}
{block.type === 'todo' && (
<div className="space-y-1">
<div className="flex items-center gap-2 group/todo">
<div className="flex items-start gap-2 group/todo">
<input
type="checkbox"
disabled={isReadOnly}
@@ -3134,7 +3315,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
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'
}`}
/>
@@ -3247,7 +3428,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
<textarea
ref={(el) => (blockInputRefs.current[block.id] = el)}
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}
onFocus={handleBlockFocus}
onBlur={handleBlockBlur}
+8 -4
View File
@@ -1178,13 +1178,16 @@ func (s *Store) saveDocumentToDisk(doc *domain.Document) {
if b.Checked {
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":
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":
md.WriteString(fmt.Sprintf("%s- ```%s\n%s %s\n%s ```\n", indent, b.Lang, indent, b.Content, indent))
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":
md.WriteString(fmt.Sprintf("%s- ---\n", indent))
case "table":
@@ -1192,7 +1195,8 @@ func (s *Store) saveDocumentToDisk(doc *domain.Document) {
case "progress":
md.WriteString(fmt.Sprintf("%s- <!-- progress: %s -->\n", indent, b.Content))
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 != "" {