feat(workspace): bar above the phone keyboard to indent, outdent and move bullets
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m33s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m33s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
32b79b5d62
commit
c6a2cfb85c
9 files changed
+183
-7
No files matched your search
@@ -32,6 +32,7 @@ import { DocSyncEngine, EngineState } from '../../lib/docSync/engine';
|
||||
import { Presence } from '../../lib/docSync/transport';
|
||||
import { OutlinerTree, OutlinerTreeHandle } from './OutlinerTree';
|
||||
import { extractHashtags } from '../../lib/hashtags';
|
||||
import { moveSibling } from '../../lib/outlineList';
|
||||
|
||||
export type DocSaveStatus = 'saved' | 'saving' | 'unsaved' | 'error';
|
||||
|
||||
@@ -704,6 +705,16 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
[docId, canEdit, applyLocalBlocks]
|
||||
);
|
||||
|
||||
// Swaps a bullet (with its children) with the previous or next one on the same level, within the same
|
||||
// parent. By id, not by index, so it also works while zoomed into a branch (the tree then sees a slice).
|
||||
const handleMoveSibling = useCallback(
|
||||
(blockId: string, direction: -1 | 1) => {
|
||||
if (!canEdit) return;
|
||||
applyLocalBlocks((prev) => moveSibling(prev, blockId, direction));
|
||||
},
|
||||
[canEdit, applyLocalBlocks]
|
||||
);
|
||||
|
||||
const handleMoveBlock = useCallback(
|
||||
(blockId: string, targetIndex: number, newLevel?: number) => {
|
||||
if (!canEdit) return;
|
||||
@@ -1241,6 +1252,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
onInsertBlocks={handleInsertBlocks}
|
||||
onDeleteBlock={handleDeleteBlock}
|
||||
onMoveBlock={handleMoveBlock}
|
||||
onMoveSibling={handleMoveSibling}
|
||||
onOpenTask={onOpenTask}
|
||||
onTagClick={onSelectTag}
|
||||
onWikiLinkClick={onWikiLinkClick}
|
||||
@@ -1338,6 +1350,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
onInsertBlocks={handleInsertBlocks}
|
||||
onDeleteBlock={handleDeleteBlock}
|
||||
onMoveBlock={handleMoveBlock}
|
||||
onMoveSibling={handleMoveSibling}
|
||||
onOpenTask={onOpenTask}
|
||||
onTagClick={onSelectTag}
|
||||
onWikiLinkClick={onWikiLinkClick}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { ArrowDown, ArrowUp, IndentDecrease, IndentIncrease } from 'lucide-react';
|
||||
import { useKeyboardInset } from '../../lib/useKeyboardInset';
|
||||
|
||||
// Phones and tablets have no Tab key: while a bullet is being edited, a slim bar sits right above the
|
||||
// on-screen keyboard with outdent / indent and move up / down (the bullet takes its children along).
|
||||
export const isTouchOnly =
|
||||
typeof window !== 'undefined' &&
|
||||
!!window.matchMedia?.('(hover: none) and (pointer: coarse)').matches &&
|
||||
!window.matchMedia?.('(any-pointer: fine)').matches;
|
||||
|
||||
interface MobileOutlineBarProps {
|
||||
onOutdent: () => void;
|
||||
onIndent: () => void;
|
||||
onMoveUp: () => void;
|
||||
onMoveDown: () => void;
|
||||
}
|
||||
|
||||
export const MobileOutlineBar: React.FC<MobileOutlineBarProps> = ({ onOutdent, onIndent, onMoveUp, onMoveDown }) => {
|
||||
// iOS keeps fixed elements behind the keyboard: lift the bar by the keyboard's height
|
||||
const { inset } = useKeyboardInset();
|
||||
|
||||
const button = (label: string, icon: React.ReactNode, action: () => void) => (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
// Keep the focus (and the keyboard) in the bullet: no focus change on press, act on click
|
||||
onPointerDown={(e) => e.preventDefault()}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={action}
|
||||
className="h-10 flex-1 max-w-[4.5rem] flex items-center justify-center rounded-btn border border-border bg-canvas text-main active:bg-panel-elevated touch-manipulation"
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed left-0 right-0 z-[70] flex items-center justify-center gap-2 px-3 py-1.5 bg-panel/95 backdrop-blur-md border-t border-border"
|
||||
style={{ bottom: inset }}
|
||||
role="toolbar"
|
||||
aria-label="Punkt bearbeiten"
|
||||
>
|
||||
{button('Ausrücken', <IndentDecrease className="w-5 h-5" />, onOutdent)}
|
||||
{button('Einrücken', <IndentIncrease className="w-5 h-5" />, onIndent)}
|
||||
<span className="w-px h-6 bg-border mx-1" aria-hidden />
|
||||
{button('Nach oben', <ArrowUp className="w-5 h-5" />, onMoveUp)}
|
||||
{button('Nach unten', <ArrowDown className="w-5 h-5" />, onMoveDown)}
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
@@ -3,6 +3,8 @@ import { DocumentBlock, BlockType } from '../../lib/types';
|
||||
import { OutlinerBlock } from './OutlinerBlock';
|
||||
import { parsePastedLines } from '../../lib/pasteOutline';
|
||||
import { numberedLabels } from '../../lib/outlineList';
|
||||
import { flushSync } from 'react-dom';
|
||||
import { MobileOutlineBar, isTouchOnly } from './MobileOutlineBar';
|
||||
|
||||
export interface OutlinerTreeHandle {
|
||||
focusFirstBlock: () => void;
|
||||
@@ -24,6 +26,8 @@ export interface OutlinerTreeProps {
|
||||
onInsertBlocks?: (afterBlockId: string, updatedCurrent: DocumentBlock, newBlocks: DocumentBlock[]) => void;
|
||||
onDeleteBlock: (blockId: string) => void;
|
||||
onMoveBlock: (blockId: string, newIndex: number, level?: number) => void;
|
||||
/** Swap a bullet (with children) with its previous / next sibling (phone bar ↑ ↓) */
|
||||
onMoveSibling?: (blockId: string, direction: -1 | 1) => void;
|
||||
onOpenTask?: (taskId: string) => void;
|
||||
onTagClick?: (tag: string) => void;
|
||||
onWikiLinkClick?: (pageTitle: string, blockId?: string) => void;
|
||||
@@ -51,6 +55,7 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
||||
onInsertBlocks,
|
||||
onDeleteBlock,
|
||||
onMoveBlock,
|
||||
onMoveSibling,
|
||||
onOpenTask,
|
||||
onTagClick,
|
||||
onWikiLinkClick,
|
||||
@@ -612,6 +617,39 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
||||
[blocks, draggedIndex, onMoveBlock, lockHolder, onLockedAction]
|
||||
);
|
||||
|
||||
// Phone bar: runs the action at once and puts the focus back into the same bullet within the same tap
|
||||
// (React may move the field in the DOM, which drops the focus; iOS reopens the keyboard only inside a tap)
|
||||
const runOnFocused = (action: (blockId: string) => void) => {
|
||||
const id = focusedBlockId;
|
||||
if (!id) return;
|
||||
const holder = lockHolder(id);
|
||||
if (holder) {
|
||||
onLockedAction?.(holder);
|
||||
return;
|
||||
}
|
||||
const before = textareaMapRef.current.get(id);
|
||||
const caret = before ? before.selectionStart : null;
|
||||
flushSync(() => action(id));
|
||||
const el = textareaMapRef.current.get(id);
|
||||
if (!el) return;
|
||||
if (document.activeElement !== el) {
|
||||
el.focus({ preventScroll: true });
|
||||
if (caret !== null) {
|
||||
try {
|
||||
el.setSelectionRange(caret, caret);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
// Keep the bullet visible above the bar (about 56 px) and the keyboard
|
||||
const vv = window.visualViewport;
|
||||
const visibleBottom = (vv ? vv.height + vv.offsetTop : window.innerHeight) - 64;
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.bottom > visibleBottom || rect.top < 0) el.scrollIntoView({ block: 'center' });
|
||||
};
|
||||
const showMobileBar = isTouchOnly && canEdit && !!focusedBlockId && blocks.some((b) => b.id === focusedBlockId);
|
||||
|
||||
useEffect(() => {
|
||||
const clearDrag = () => {
|
||||
setDraggedIndex(null);
|
||||
@@ -707,6 +745,14 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
||||
onDrop={() => handleDropBlock(blocks.length)}
|
||||
/>
|
||||
)}
|
||||
{showMobileBar && (
|
||||
<MobileOutlineBar
|
||||
onOutdent={() => runOnFocused((id) => handleIndentBlock(id, -1))}
|
||||
onIndent={() => runOnFocused((id) => handleIndentBlock(id, 1))}
|
||||
onMoveUp={() => runOnFocused((id) => onMoveSibling?.(id, -1))}
|
||||
onMoveDown={() => runOnFocused((id) => onMoveSibling?.(id, 1))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user