fix(workspace): caret at click position, no grey row highlight, hover-only collapse toggles
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
- Clicking into a bullet places the caret where the mouse was, mapped from the rendered markdown layer back to the raw text, instead of always jumping to the end. - Remove the grey background on focused and hovered rows and on the bullet handle. - Collapse/expand chevrons are only shown while hovering the row. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b4c8577a61
commit
2b5291dc7b
1 file changed
+55
-10
@@ -72,6 +72,18 @@ export interface OutlinerBlockProps {
|
||||
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> = ({
|
||||
docId,
|
||||
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 el = textareaRef.current;
|
||||
if (!el) return;
|
||||
@@ -529,9 +576,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
? 'border-t-2 border-accent bg-accent/10'
|
||||
: isTargeted
|
||||
? 'bg-panel-elevated'
|
||||
: activeFocus
|
||||
? 'bg-panel-elevated'
|
||||
: 'hover:bg-panel'
|
||||
: ''
|
||||
}`}
|
||||
style={{
|
||||
paddingLeft: `${visualLevel * 22 + 4}px`,
|
||||
@@ -565,7 +610,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
onClick={() =>
|
||||
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'}
|
||||
>
|
||||
{block.collapsed ? (
|
||||
@@ -584,7 +629,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
draggable={canEdit}
|
||||
onDragStart={() => canEdit && onDragStartBlock?.(index)}
|
||||
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'
|
||||
}`}
|
||||
title={
|
||||
@@ -682,11 +727,11 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
<div className="relative w-full min-h-[1.5rem]">
|
||||
{/* View Layer with Inline Markdown & Actions (3-dots or Erweitert Badge) directly behind text */}
|
||||
<div
|
||||
onClick={() => {
|
||||
onClick={(e) => {
|
||||
if (!activeFocus && canEdit && textareaRef.current) {
|
||||
textareaRef.current.focus();
|
||||
const len = textareaRef.current.value.length;
|
||||
textareaRef.current.setSelectionRange(len, len);
|
||||
// Keep the row's click handler from moving the caret back to the end
|
||||
e.stopPropagation();
|
||||
focusAtPoint(e.clientX, e.clientY);
|
||||
}
|
||||
}}
|
||||
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 */}
|
||||
<span className={activeFocus ? 'invisible' : ''}>
|
||||
<span ref={renderedTextRef} className={activeFocus ? 'invisible' : ''}>
|
||||
{localContent ? renderInlineMarkdown(localContent, { onTagClick }) : '\u200b'}
|
||||
</span>
|
||||
|
||||
|
||||
Reference in new issue
Block a user