fix(workspace): support multi-line bullets via Shift+Enter and preserve lines on Enter
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m0s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m0s
This commit is contained in:
1 parent
b0403e09bc
commit
518a0903dd
2 files changed
+361
-176
No files matched your search
@@ -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> </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"
|
||||
>
|
||||
|
||||
</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}
|
||||
|
||||
Reference in new issue
Block a user