Dev #9
@@ -72,6 +72,18 @@ export interface OutlinerBlockProps {
|
|||||||
onPasteMultiLines?: (blockId: string, textBefore: string, textAfter: string, rawLines: string[]) => void;
|
onPasteMultiLines?: (blockId: string, textBefore: string, textAfter: string, rawLines: string[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rendered markdown only drops syntax characters (**, `, [..](url) …), so its text is a subsequence of the raw
|
||||||
|
// text. Walking both strings in parallel maps a caret offset in the rendered text back to the raw text.
|
||||||
|
export function mapRenderedOffsetToRaw(raw: string, rendered: string, renderedOffset: number): number {
|
||||||
|
let j = 0;
|
||||||
|
for (let i = 0; i < renderedOffset && i < rendered.length; i++) {
|
||||||
|
const idx = raw.indexOf(rendered[i], j);
|
||||||
|
if (idx === -1) break;
|
||||||
|
j = idx + 1;
|
||||||
|
}
|
||||||
|
return Math.min(j, raw.length);
|
||||||
|
}
|
||||||
|
|
||||||
export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||||
docId,
|
docId,
|
||||||
block,
|
block,
|
||||||
@@ -206,6 +218,41 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const renderedTextRef = useRef<HTMLSpanElement | null>(null);
|
||||||
|
|
||||||
|
// Focus the textarea and place the caret where the user clicked in the rendered (markdown) text layer
|
||||||
|
const focusAtPoint = (clientX: number, clientY: number) => {
|
||||||
|
const ta = textareaRef.current;
|
||||||
|
if (!ta) return;
|
||||||
|
const raw = ta.value;
|
||||||
|
let pos = raw.length;
|
||||||
|
const span = renderedTextRef.current;
|
||||||
|
const doc = document as any;
|
||||||
|
let node: Node | null = null;
|
||||||
|
let offset = 0;
|
||||||
|
if (typeof doc.caretPositionFromPoint === 'function') {
|
||||||
|
const p = doc.caretPositionFromPoint(clientX, clientY);
|
||||||
|
if (p) {
|
||||||
|
node = p.offsetNode;
|
||||||
|
offset = p.offset;
|
||||||
|
}
|
||||||
|
} else if (typeof doc.caretRangeFromPoint === 'function') {
|
||||||
|
const r = doc.caretRangeFromPoint(clientX, clientY);
|
||||||
|
if (r) {
|
||||||
|
node = r.startContainer;
|
||||||
|
offset = r.startOffset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (span && node && span.contains(node)) {
|
||||||
|
const range = document.createRange();
|
||||||
|
range.setStart(span, 0);
|
||||||
|
range.setEnd(node, offset);
|
||||||
|
pos = mapRenderedOffsetToRaw(raw, span.textContent || '', range.toString().length);
|
||||||
|
}
|
||||||
|
ta.focus();
|
||||||
|
ta.setSelectionRange(pos, pos);
|
||||||
|
};
|
||||||
|
|
||||||
const adjustHeight = useCallback(() => {
|
const adjustHeight = useCallback(() => {
|
||||||
const el = textareaRef.current;
|
const el = textareaRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
@@ -529,9 +576,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
? 'border-t-2 border-accent bg-accent/10'
|
? 'border-t-2 border-accent bg-accent/10'
|
||||||
: isTargeted
|
: isTargeted
|
||||||
? 'bg-panel-elevated'
|
? 'bg-panel-elevated'
|
||||||
: activeFocus
|
: ''
|
||||||
? 'bg-panel-elevated'
|
|
||||||
: 'hover:bg-panel'
|
|
||||||
}`}
|
}`}
|
||||||
style={{
|
style={{
|
||||||
paddingLeft: `${visualLevel * 22 + 4}px`,
|
paddingLeft: `${visualLevel * 22 + 4}px`,
|
||||||
@@ -565,7 +610,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
onClick={() =>
|
onClick={() =>
|
||||||
flushDebounce(localContent, { collapsed: !block.collapsed })
|
flushDebounce(localContent, { collapsed: !block.collapsed })
|
||||||
}
|
}
|
||||||
className="p-0.5 text-muted hover:text-accent transition-colors"
|
className="p-0.5 text-muted hover:text-accent transition-[color,opacity] opacity-0 group-hover:opacity-100 focus-visible:opacity-100"
|
||||||
title={block.collapsed ? 'Unterpunkte ausklappen' : 'Unterpunkte einklappen'}
|
title={block.collapsed ? 'Unterpunkte ausklappen' : 'Unterpunkte einklappen'}
|
||||||
>
|
>
|
||||||
{block.collapsed ? (
|
{block.collapsed ? (
|
||||||
@@ -584,7 +629,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
draggable={canEdit}
|
draggable={canEdit}
|
||||||
onDragStart={() => canEdit && onDragStartBlock?.(index)}
|
onDragStart={() => canEdit && onDragStartBlock?.(index)}
|
||||||
onClick={() => onZoomBlock?.(block.id)}
|
onClick={() => onZoomBlock?.(block.id)}
|
||||||
className={`w-4 h-4 flex items-center justify-center rounded-btn hover:bg-panel-hover group/bullet transition-transform ${
|
className={`w-4 h-4 flex items-center justify-center rounded-btn group/bullet transition-transform ${
|
||||||
canEdit ? 'cursor-grab active:cursor-grabbing' : 'cursor-pointer'
|
canEdit ? 'cursor-grab active:cursor-grabbing' : 'cursor-pointer'
|
||||||
}`}
|
}`}
|
||||||
title={
|
title={
|
||||||
@@ -682,11 +727,11 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
<div className="relative w-full min-h-[1.5rem]">
|
<div className="relative w-full min-h-[1.5rem]">
|
||||||
{/* View Layer with Inline Markdown & Actions (3-dots or Erweitert Badge) directly behind text */}
|
{/* View Layer with Inline Markdown & Actions (3-dots or Erweitert Badge) directly behind text */}
|
||||||
<div
|
<div
|
||||||
onClick={() => {
|
onClick={(e) => {
|
||||||
if (!activeFocus && canEdit && textareaRef.current) {
|
if (!activeFocus && canEdit && textareaRef.current) {
|
||||||
textareaRef.current.focus();
|
// Keep the row's click handler from moving the caret back to the end
|
||||||
const len = textareaRef.current.value.length;
|
e.stopPropagation();
|
||||||
textareaRef.current.setSelectionRange(len, len);
|
focusAtPoint(e.clientX, e.clientY);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={`w-full min-h-[1.5rem] leading-relaxed whitespace-pre-wrap break-words [overflow-wrap:anywhere] ${
|
className={`w-full min-h-[1.5rem] leading-relaxed whitespace-pre-wrap break-words [overflow-wrap:anywhere] ${
|
||||||
@@ -704,7 +749,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* When actively focused, ghost text is invisible so textarea renders text/caret directly */}
|
{/* When actively focused, ghost text is invisible so textarea renders text/caret directly */}
|
||||||
<span className={activeFocus ? 'invisible' : ''}>
|
<span ref={renderedTextRef} className={activeFocus ? 'invisible' : ''}>
|
||||||
{localContent ? renderInlineMarkdown(localContent, { onTagClick }) : '\u200b'}
|
{localContent ? renderInlineMarkdown(localContent, { onTagClick }) : '\u200b'}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user