feat(workspace): live inline hashtag and link rendering with cursor-aware unwrapping
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s
This commit is contained in:
1 parent
4874b79439
commit
eafe75faf0
1 file changed
+311
-91
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useState, useMemo, useRef } from 'react';
|
import React, { useEffect, useLayoutEffect, useState, useMemo, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
FilePlus,
|
FilePlus,
|
||||||
FolderPlus,
|
FolderPlus,
|
||||||
@@ -154,8 +154,16 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
const [linkWebTitle, setLinkWebTitle] = useState('');
|
const [linkWebTitle, setLinkWebTitle] = useState('');
|
||||||
const [linkWebUrl, setLinkWebUrl] = useState('');
|
const [linkWebUrl, setLinkWebUrl] = useState('');
|
||||||
const [activeEditingBlockId, setActiveEditingBlockId] = useState<string | null>(null);
|
const [activeEditingBlockId, setActiveEditingBlockId] = useState<string | null>(null);
|
||||||
|
const [activeCursorOffset, setActiveCursorOffset] = useState<number>(0);
|
||||||
|
const activeCursorOffsetRef = useRef<number>(0);
|
||||||
const [editingLinkOriginalRaw, setEditingLinkOriginalRaw] = useState<string | null>(null);
|
const [editingLinkOriginalRaw, setEditingLinkOriginalRaw] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const setBlockCursor = (blockId: string | null, offset: number) => {
|
||||||
|
activeCursorOffsetRef.current = offset;
|
||||||
|
setActiveEditingBlockId(blockId);
|
||||||
|
setActiveCursorOffset(offset);
|
||||||
|
};
|
||||||
|
|
||||||
// Drag and Drop reordering state for blocks
|
// Drag and Drop reordering state for blocks
|
||||||
const [draggingBlockId, setDraggingBlockId] = useState<string | null>(null);
|
const [draggingBlockId, setDraggingBlockId] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -1045,6 +1053,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
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);
|
||||||
|
setBlockCursor(newBlock.id, 0);
|
||||||
|
|
||||||
// Focus into the new element next tick
|
// Focus into the new element next tick
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -1134,6 +1143,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
|
|
||||||
if (prevBlock) {
|
if (prevBlock) {
|
||||||
setLastFocusedBlockId(prevBlock.id);
|
setLastFocusedBlockId(prevBlock.id);
|
||||||
|
setBlockCursor(prevBlock.id, (prevBlock.content || '').length);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const el = blockInputRefs.current[prevBlock.id];
|
const el = blockInputRefs.current[prevBlock.id];
|
||||||
if (el) {
|
if (el) {
|
||||||
@@ -1172,11 +1182,34 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!activeEditingBlockId) return;
|
||||||
|
const el = blockInputRefs.current[activeEditingBlockId];
|
||||||
|
if (!el) return;
|
||||||
|
const segStartAttr = el.getAttribute('data-seg-start');
|
||||||
|
if (segStartAttr !== null && 'setSelectionRange' in el && typeof el.value === 'string') {
|
||||||
|
const segStart = parseInt(segStartAttr, 10) || 0;
|
||||||
|
const desiredOffset = activeCursorOffsetRef.current;
|
||||||
|
const localPos = Math.max(0, Math.min(el.value.length, desiredOffset - segStart));
|
||||||
|
if (document.activeElement !== el) {
|
||||||
|
el.focus();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (el.selectionStart !== localPos || el.selectionEnd !== localPos) {
|
||||||
|
el.setSelectionRange(localPos, localPos);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore selection range errors on unsupported inputs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [activeEditingBlockId, activeCursorOffset, selectedDoc]);
|
||||||
|
|
||||||
// Keyboard Navigation: Enter, Tab, Shift+Tab, Backspace, / Slash Commands
|
// Keyboard Navigation: Enter, Tab, Shift+Tab, Backspace, / Slash Commands
|
||||||
const handleKeyDown = (
|
const handleKeyDown = (
|
||||||
e: React.KeyboardEvent,
|
e: React.KeyboardEvent,
|
||||||
block: DocumentBlock,
|
block: DocumentBlock,
|
||||||
index: number
|
index: number,
|
||||||
|
segStart = 0
|
||||||
) => {
|
) => {
|
||||||
if (isReadOnly) return;
|
if (isReadOnly) return;
|
||||||
ensureLock();
|
ensureLock();
|
||||||
@@ -1220,7 +1253,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
const target = e.target as HTMLTextAreaElement | HTMLInputElement;
|
const target = e.target as HTMLTextAreaElement | HTMLInputElement;
|
||||||
let afterContent = '';
|
let afterContent = '';
|
||||||
if (target && typeof target.selectionStart === 'number') {
|
if (target && typeof target.selectionStart === 'number') {
|
||||||
const cursor = target.selectionStart;
|
const cursor = segStart + target.selectionStart;
|
||||||
const fullContent = block.content || '';
|
const fullContent = block.content || '';
|
||||||
const before = fullContent.slice(0, cursor);
|
const before = fullContent.slice(0, cursor);
|
||||||
const after = fullContent.slice(cursor);
|
const after = fullContent.slice(cursor);
|
||||||
@@ -1238,7 +1271,9 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
} else {
|
} else {
|
||||||
indentBlock(index);
|
indentBlock(index);
|
||||||
}
|
}
|
||||||
} else if (e.key === 'Backspace' && (!block.content || block.content === '')) {
|
} else if (e.key === 'Backspace') {
|
||||||
|
const target = e.target as HTMLTextAreaElement | HTMLInputElement;
|
||||||
|
if (!block.content || block.content === '') {
|
||||||
if ((block.level || 0) > 0) {
|
if ((block.level || 0) > 0) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
outdentBlock(block.id);
|
outdentBlock(block.id);
|
||||||
@@ -1247,6 +1282,25 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
setSlashBlockId(null);
|
setSlashBlockId(null);
|
||||||
deleteBlockAndFocusPrev(index);
|
deleteBlockAndFocusPrev(index);
|
||||||
}
|
}
|
||||||
|
} else if (
|
||||||
|
segStart > 0 &&
|
||||||
|
target &&
|
||||||
|
target.selectionStart === 0 &&
|
||||||
|
target.selectionEnd === 0
|
||||||
|
) {
|
||||||
|
e.preventDefault();
|
||||||
|
setBlockCursor(block.id, segStart);
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
!e.shiftKey &&
|
||||||
|
(e.key === 'ArrowLeft' || e.key === 'ArrowRight' || e.key === 'Home' || e.key === 'End')
|
||||||
|
) {
|
||||||
|
const target = e.target as HTMLTextAreaElement | HTMLInputElement;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (target && typeof target.selectionStart === 'number') {
|
||||||
|
setBlockCursor(block.id, segStart + target.selectionStart);
|
||||||
|
}
|
||||||
|
});
|
||||||
} else if (e.key === 'Escape') {
|
} else if (e.key === 'Escape') {
|
||||||
setActiveEditingBlockId(null);
|
setActiveEditingBlockId(null);
|
||||||
(e.target as HTMLElement)?.blur();
|
(e.target as HTMLElement)?.blur();
|
||||||
@@ -1330,16 +1384,22 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
}
|
}
|
||||||
}, [slashSelectedIndex, slashBlockId]);
|
}, [slashSelectedIndex, slashBlockId]);
|
||||||
|
|
||||||
const focusBlock = (blockId: string) => {
|
const focusBlock = (blockId: string, customOffset?: number) => {
|
||||||
setLastFocusedBlockId(blockId);
|
setLastFocusedBlockId(blockId);
|
||||||
setActiveEditingBlockId(blockId);
|
const blk = selectedDoc?.blocks.find((b) => b.id === blockId);
|
||||||
|
const targetOffset = customOffset !== undefined ? customOffset : (blk?.content || '').length;
|
||||||
|
setBlockCursor(blockId, targetOffset);
|
||||||
|
|
||||||
const tryFocus = () => {
|
const tryFocus = () => {
|
||||||
const el = blockInputRefs.current[blockId];
|
const el = blockInputRefs.current[blockId];
|
||||||
if (el) {
|
if (el) {
|
||||||
el.focus();
|
el.focus();
|
||||||
if ('setSelectionRange' in el && typeof (el as any).value === 'string') {
|
if ('setSelectionRange' in el && typeof (el as any).value === 'string') {
|
||||||
|
const segStartAttr = el.getAttribute('data-seg-start');
|
||||||
|
const segStart = segStartAttr !== null ? parseInt(segStartAttr, 10) || 0 : 0;
|
||||||
const len = (el as any).value.length;
|
const len = (el as any).value.length;
|
||||||
(el as any).setSelectionRange(len, len);
|
const localPos = Math.max(0, Math.min(len, targetOffset - segStart));
|
||||||
|
(el as any).setSelectionRange(localPos, localPos);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -1424,25 +1484,31 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
|
|
||||||
const handleInsertNoteLink = (docTitle: string) => {
|
const handleInsertNoteLink = (docTitle: string) => {
|
||||||
if (!linkModalBlockId || !selectedDoc) return;
|
if (!linkModalBlockId || !selectedDoc) return;
|
||||||
const block = selectedDoc.blocks.find((b) => b.id === linkModalBlockId);
|
const targetBlockId = linkModalBlockId;
|
||||||
|
const block = selectedDoc.blocks.find((b) => b.id === targetBlockId);
|
||||||
|
let nextCursor = 0;
|
||||||
if (block) {
|
if (block) {
|
||||||
let newContent: string;
|
let newContent: string;
|
||||||
if (editingLinkOriginalRaw && block.content.includes(editingLinkOriginalRaw)) {
|
if (editingLinkOriginalRaw && block.content.includes(editingLinkOriginalRaw)) {
|
||||||
newContent = block.content.replace(editingLinkOriginalRaw, `[[${docTitle}]]`);
|
newContent = block.content.replace(editingLinkOriginalRaw, `[[${docTitle}]]`);
|
||||||
|
if (!newContent.endsWith(' ')) newContent += ' ';
|
||||||
} else {
|
} else {
|
||||||
const space = block.content && !block.content.endsWith(' ') ? ' ' : '';
|
const base = block.content || '';
|
||||||
newContent = `${block.content}${space}[[${docTitle}]] `;
|
const space = base.length > 0 && !base.endsWith(' ') ? ' ' : '';
|
||||||
|
newContent = `${base}${space}[[${docTitle}]] `;
|
||||||
}
|
}
|
||||||
updateBlock(linkModalBlockId, { content: newContent });
|
nextCursor = newContent.length;
|
||||||
|
updateBlock(targetBlockId, { content: newContent });
|
||||||
}
|
}
|
||||||
setLinkModalBlockId(null);
|
setLinkModalBlockId(null);
|
||||||
setEditingLinkOriginalRaw(null);
|
setEditingLinkOriginalRaw(null);
|
||||||
setActiveEditingBlockId(null);
|
focusBlock(targetBlockId, nextCursor);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleInsertWebLink = () => {
|
const handleInsertWebLink = () => {
|
||||||
if (!linkModalBlockId || !selectedDoc) return;
|
if (!linkModalBlockId || !selectedDoc) return;
|
||||||
const block = selectedDoc.blocks.find((b) => b.id === linkModalBlockId);
|
const targetBlockId = linkModalBlockId;
|
||||||
|
const block = selectedDoc.blocks.find((b) => b.id === targetBlockId);
|
||||||
let title = linkWebTitle.trim();
|
let title = linkWebTitle.trim();
|
||||||
let url = linkWebUrl.trim();
|
let url = linkWebUrl.trim();
|
||||||
if (!url) return;
|
if (!url) return;
|
||||||
@@ -1456,21 +1522,25 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
title = url;
|
title = url;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let nextCursor = 0;
|
||||||
if (block) {
|
if (block) {
|
||||||
let newContent: string;
|
let newContent: string;
|
||||||
if (editingLinkOriginalRaw && block.content.includes(editingLinkOriginalRaw)) {
|
if (editingLinkOriginalRaw && block.content.includes(editingLinkOriginalRaw)) {
|
||||||
newContent = block.content.replace(editingLinkOriginalRaw, `[${title}](${url})`);
|
newContent = block.content.replace(editingLinkOriginalRaw, `[${title}](${url})`);
|
||||||
|
if (!newContent.endsWith(' ')) newContent += ' ';
|
||||||
} else {
|
} else {
|
||||||
const space = block.content && !block.content.endsWith(' ') ? ' ' : '';
|
const base = block.content || '';
|
||||||
newContent = `${block.content}${space}[${title}](${url}) `;
|
const space = base.length > 0 && !base.endsWith(' ') ? ' ' : '';
|
||||||
|
newContent = `${base}${space}[${title}](${url}) `;
|
||||||
}
|
}
|
||||||
updateBlock(linkModalBlockId, { content: newContent });
|
nextCursor = newContent.length;
|
||||||
|
updateBlock(targetBlockId, { content: newContent });
|
||||||
}
|
}
|
||||||
setLinkModalBlockId(null);
|
setLinkModalBlockId(null);
|
||||||
setEditingLinkOriginalRaw(null);
|
setEditingLinkOriginalRaw(null);
|
||||||
setLinkWebTitle('');
|
setLinkWebTitle('');
|
||||||
setLinkWebUrl('');
|
setLinkWebUrl('');
|
||||||
setActiveEditingBlockId(null);
|
focusBlock(targetBlockId, nextCursor);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getDirectChildTodos = (blocks: DocumentBlock[], parentIndex: number): DocumentBlock[] => {
|
const getDirectChildTodos = (blocks: DocumentBlock[], parentIndex: number): DocumentBlock[] => {
|
||||||
@@ -1629,19 +1699,28 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasRichTokens = (text: string): boolean =>
|
const findRichTokens = (text: string): { start: number; end: number; raw: string }[] => {
|
||||||
Boolean(text && (text.includes('[[') || text.includes('#') || (text.includes('[') && text.includes(']('))));
|
if (!text) return [];
|
||||||
|
const regex = /(\[\[[^\]]+\]\]|\[[^\]]*\]\(https?:\/\/[^\s)]+\)|#[a-zA-Z0-9äöüÄÖÜß_-]+)/g;
|
||||||
|
const tokens: { start: number; end: number; raw: string }[] = [];
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
while ((match = regex.exec(text)) !== null) {
|
||||||
|
tokens.push({
|
||||||
|
start: match.index,
|
||||||
|
end: match.index + match[0].length,
|
||||||
|
raw: match[0],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return tokens;
|
||||||
|
};
|
||||||
|
|
||||||
// Helper to render text with clickable Wiki-Links, Web-Links and Hashtags
|
const renderSingleRichToken = (part: string, blockId: string | undefined, key: React.Key) => {
|
||||||
const renderFormattedText = (text: string, blockId?: string) => {
|
|
||||||
const parts = text.split(/(\[\[.*?\]\]|\[.*?\]\(https?:\/\/[^\s)]+\)|#[a-zA-Z0-9äöüÄÖÜß_-]+)/g);
|
|
||||||
return parts.map((part, i) => {
|
|
||||||
if (part.startsWith('[[') && part.endsWith(']]')) {
|
if (part.startsWith('[[') && part.endsWith(']]')) {
|
||||||
const linkTitle = part.slice(2, -2).trim();
|
const linkTitle = part.slice(2, -2).trim();
|
||||||
const target = docs.find((d) => d.title.toLowerCase() === linkTitle.toLowerCase());
|
const target = docs.find((d) => d.title.toLowerCase() === linkTitle.toLowerCase());
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
key={i}
|
key={key}
|
||||||
className={`inline-flex items-center gap-1 px-1.5 py-0.5 mx-0.5 border text-xs font-medium rounded-btn transition-colors align-baseline ${
|
className={`inline-flex items-center gap-1 px-1.5 py-0.5 mx-0.5 border text-xs font-medium rounded-btn transition-colors align-baseline ${
|
||||||
target
|
target
|
||||||
? 'bg-accent/20 border-accent/40 text-accent hover:bg-accent/30'
|
? 'bg-accent/20 border-accent/40 text-accent hover:bg-accent/30'
|
||||||
@@ -1667,6 +1746,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
{!isReadOnly && blockId && (
|
{!isReadOnly && blockId && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setEditingLinkOriginalRaw(part);
|
setEditingLinkOriginalRaw(part);
|
||||||
@@ -1683,13 +1763,14 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const webMatch = part.match(/^\[(.*?)\]\((https?:\/\/[^\s)]+)\)$/);
|
const webMatch = part.match(/^\[(.*?)\]\((https?:\/\/[^\s)]+)\)$/);
|
||||||
if (webMatch) {
|
if (webMatch) {
|
||||||
const title = webMatch[1] || webMatch[2];
|
const title = webMatch[1] || webMatch[2];
|
||||||
const url = webMatch[2];
|
const url = webMatch[2];
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
key={i}
|
key={key}
|
||||||
className="inline-flex items-center gap-1 px-1.5 py-0.5 mx-0.5 bg-sky-500/10 border border-sky-500/30 text-sky-500 dark:text-sky-400 hover:bg-sky-500/20 text-xs font-medium rounded-btn transition-colors align-baseline"
|
className="inline-flex items-center gap-1 px-1.5 py-0.5 mx-0.5 bg-sky-500/10 border border-sky-500/30 text-sky-500 dark:text-sky-400 hover:bg-sky-500/20 text-xs font-medium rounded-btn transition-colors align-baseline"
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
@@ -1707,6 +1788,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
{!isReadOnly && blockId && (
|
{!isReadOnly && blockId && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setEditingLinkOriginalRaw(part);
|
setEditingLinkOriginalRaw(part);
|
||||||
@@ -1724,11 +1806,12 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (part.startsWith('#') && part.length > 1) {
|
if (part.startsWith('#') && part.length > 1) {
|
||||||
const tagWord = part.slice(1);
|
const tagWord = part.slice(1);
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
key={i}
|
key={key}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
openTagPage(tagWord);
|
openTagPage(tagWord);
|
||||||
@@ -1741,8 +1824,205 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return <span key={i} 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
|
||||||
|
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));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
const renderLiveInlineBlock = (
|
||||||
|
block: DocumentBlock,
|
||||||
|
originalIndex: number,
|
||||||
|
extraTextClass = 'text-main'
|
||||||
|
) => {
|
||||||
|
const text = block.content || '';
|
||||||
|
if (isReadOnly) {
|
||||||
|
return (
|
||||||
|
<div className={`w-full text-sm leading-relaxed break-words min-h-[22px] cursor-default ${extraTextClass}`}>
|
||||||
|
{renderFormattedText(text, block.id)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
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;
|
||||||
|
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>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- GLOBAL TO-DO ROLLUP AGGREGATOR ---
|
// --- GLOBAL TO-DO ROLLUP AGGREGATOR ---
|
||||||
@@ -2815,39 +3095,10 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
isReadOnly ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'
|
isReadOnly ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
{(!hasRichTokens(block.content) || activeEditingBlockId === block.id) && !isReadOnly ? (
|
{renderLiveInlineBlock(
|
||||||
<input
|
block,
|
||||||
ref={(el) => (blockInputRefs.current[block.id] = el)}
|
originalIndex,
|
||||||
type="text"
|
|
||||||
readOnly={isReadOnly}
|
|
||||||
value={block.content}
|
|
||||||
onFocus={() => {
|
|
||||||
setActiveEditingBlockId(block.id);
|
|
||||||
handleBlockFocus();
|
|
||||||
}}
|
|
||||||
onBlur={() => {
|
|
||||||
setActiveEditingBlockId((prev) => (prev === block.id ? null : prev));
|
|
||||||
handleBlockBlur();
|
|
||||||
}}
|
|
||||||
onChange={(e) => handleContentChange(block, e.target.value)}
|
|
||||||
onKeyDown={(e) => handleKeyDown(e, block, originalIndex)}
|
|
||||||
className={`w-full bg-transparent text-sm focus:outline-none ${
|
|
||||||
block.checked ? 'line-through text-faint' : 'text-main'
|
block.checked ? 'line-through text-faint' : 'text-main'
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
onClick={() => {
|
|
||||||
if (!isReadOnly) {
|
|
||||||
focusBlock(block.id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className={`w-full text-sm leading-relaxed break-words min-h-[20px] ${
|
|
||||||
block.checked ? 'line-through text-faint' : 'text-main'
|
|
||||||
} ${isReadOnly ? 'cursor-default' : 'cursor-text'}`}
|
|
||||||
>
|
|
||||||
{renderFormattedText(block.content, block.id)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{!isReadOnly && (
|
{!isReadOnly && (
|
||||||
<button
|
<button
|
||||||
@@ -3073,38 +3324,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
{/* TYPE: DEFAULT BULLET / PARAGRAPH */}
|
{/* TYPE: DEFAULT BULLET / PARAGRAPH */}
|
||||||
{(block.type === 'bullet' || block.type === 'paragraph') && (
|
{(block.type === 'bullet' || block.type === 'paragraph') && (
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
{(!hasRichTokens(block.content) || activeEditingBlockId === block.id) && !isReadOnly ? (
|
{renderLiveInlineBlock(block, originalIndex, 'text-main')}
|
||||||
<textarea
|
|
||||||
ref={(el) => (blockInputRefs.current[block.id] = el)}
|
|
||||||
readOnly={isReadOnly}
|
|
||||||
rows={Math.max(1, Math.ceil((block.content || '').length / 80))}
|
|
||||||
value={block.content}
|
|
||||||
onFocus={() => {
|
|
||||||
setActiveEditingBlockId(block.id);
|
|
||||||
handleBlockFocus();
|
|
||||||
}}
|
|
||||||
onBlur={() => {
|
|
||||||
setActiveEditingBlockId((prev) => (prev === block.id ? null : prev));
|
|
||||||
handleBlockBlur();
|
|
||||||
}}
|
|
||||||
onChange={(e) => handleContentChange(block, e.target.value)}
|
|
||||||
onKeyDown={(e) => handleKeyDown(e, block, originalIndex)}
|
|
||||||
className="w-full bg-transparent text-sm text-main leading-relaxed focus:outline-none resize-none placeholder:text-faint"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
onClick={() => {
|
|
||||||
if (!isReadOnly) {
|
|
||||||
focusBlock(block.id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className={`w-full text-sm text-main leading-relaxed break-words min-h-[22px] ${
|
|
||||||
isReadOnly ? 'cursor-default' : 'cursor-text'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{renderFormattedText(block.content, block.id)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user