更新内容
This commit is contained in:
+180
-18
@@ -17,6 +17,7 @@ interface LineItemProps {
|
||||
fontSize: number;
|
||||
lineSpacing: number;
|
||||
readOnly?: boolean;
|
||||
canEdit: boolean;
|
||||
onUpdate: (lineId: string, newRawText: string, newFuriganaItems?: FuriganaItem[]) => void;
|
||||
onDelete: (lineId: string) => void;
|
||||
onActivate: (lineId: string) => void;
|
||||
@@ -41,6 +42,7 @@ export const LineItem: React.FC<LineItemProps> = ({
|
||||
fontSize,
|
||||
lineSpacing,
|
||||
readOnly = false,
|
||||
canEdit,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
onActivate,
|
||||
@@ -58,9 +60,25 @@ export const LineItem: React.FC<LineItemProps> = ({
|
||||
const furiganaLayerRef = useRef<FuriganaLayerHandle>(null);
|
||||
const mainInputRef = useRef<HTMLInputElement>(null);
|
||||
const displayRef = useRef<HTMLDivElement>(null);
|
||||
const mainTextRef = useRef<HTMLDivElement>(null);
|
||||
const mobileSelectionRef = useRef<{
|
||||
pointerId: number;
|
||||
startX: number;
|
||||
startY: number;
|
||||
currentX: number;
|
||||
currentY: number;
|
||||
selecting: boolean;
|
||||
startPoint: TextHitPoint | null;
|
||||
} | null>(null);
|
||||
|
||||
const furiganaItems = line.furiganaItems;
|
||||
|
||||
type TextHitPoint = {
|
||||
node: Text;
|
||||
offset: number;
|
||||
globalOffset: number;
|
||||
};
|
||||
|
||||
// Expose focus methods dynamically
|
||||
useEffect(() => {
|
||||
const handleCustomFocus = (e: CustomEvent<{ field: 'main' | 'furigana' }>) => {
|
||||
@@ -121,23 +139,152 @@ export const LineItem: React.FC<LineItemProps> = ({
|
||||
onUpdate(line.id, line.rawText, nextItems);
|
||||
};
|
||||
|
||||
const getCaretRangeFromPoint = (clientX: number, clientY: number) => {
|
||||
const doc = document as Document & {
|
||||
caretRangeFromPoint?: (x: number, y: number) => Range | null;
|
||||
caretPositionFromPoint?: (x: number, y: number) => { offsetNode: Node; offset: number } | null;
|
||||
};
|
||||
|
||||
if (doc.caretRangeFromPoint) {
|
||||
return doc.caretRangeFromPoint(clientX, clientY);
|
||||
}
|
||||
|
||||
const position = doc.caretPositionFromPoint?.(clientX, clientY);
|
||||
if (!position) return null;
|
||||
|
||||
const range = document.createRange();
|
||||
range.setStart(position.offsetNode, position.offset);
|
||||
range.collapse(true);
|
||||
return range;
|
||||
};
|
||||
|
||||
const getTextHitPoint = (clientX: number, clientY: number): TextHitPoint | null => {
|
||||
const root = mainTextRef.current;
|
||||
const range = getCaretRangeFromPoint(clientX, clientY);
|
||||
if (!root || !range || !root.contains(range.startContainer)) return null;
|
||||
|
||||
if (range.startContainer.nodeType !== Node.TEXT_NODE) return null;
|
||||
|
||||
const targetNode = range.startContainer as Text;
|
||||
const treeWalker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
||||
let globalOffset = 0;
|
||||
let current = treeWalker.nextNode() as Text | null;
|
||||
|
||||
while (current) {
|
||||
if (current === targetNode) {
|
||||
return {
|
||||
node: targetNode,
|
||||
offset: Math.min(range.startOffset, targetNode.data.length),
|
||||
globalOffset: globalOffset + Math.min(range.startOffset, targetNode.data.length),
|
||||
};
|
||||
}
|
||||
|
||||
globalOffset += current.data.length;
|
||||
current = treeWalker.nextNode() as Text | null;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const setVisualSelection = (startPoint: TextHitPoint, endPoint: TextHitPoint) => {
|
||||
const range = document.createRange();
|
||||
if (startPoint.globalOffset <= endPoint.globalOffset) {
|
||||
range.setStart(startPoint.node, startPoint.offset);
|
||||
range.setEnd(endPoint.node, endPoint.offset);
|
||||
} else {
|
||||
range.setStart(endPoint.node, endPoint.offset);
|
||||
range.setEnd(startPoint.node, startPoint.offset);
|
||||
}
|
||||
|
||||
const selection = window.getSelection();
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
return selection?.toString().trim() || '';
|
||||
};
|
||||
|
||||
const handleMobileSelectionStart = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (canEdit || e.pointerType !== 'touch' || !line.rawText) return;
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('[data-note-highlight="true"]') || target.closest('[data-furigana-layer="true"]')) return;
|
||||
|
||||
const startPoint = getTextHitPoint(e.clientX, e.clientY);
|
||||
mobileSelectionRef.current = {
|
||||
pointerId: e.pointerId,
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
currentX: e.clientX,
|
||||
currentY: e.clientY,
|
||||
selecting: false,
|
||||
startPoint,
|
||||
};
|
||||
};
|
||||
|
||||
const handleMobileSelectionMove = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
const gesture = mobileSelectionRef.current;
|
||||
if (!gesture || gesture.pointerId !== e.pointerId) return;
|
||||
|
||||
gesture.currentX = e.clientX;
|
||||
gesture.currentY = e.clientY;
|
||||
const dx = Math.abs(e.clientX - gesture.startX);
|
||||
const dy = Math.abs(e.clientY - gesture.startY);
|
||||
|
||||
if (!gesture.selecting && dx > 10 && dx > dy * 1.15) {
|
||||
gesture.selecting = true;
|
||||
}
|
||||
|
||||
if (gesture.selecting) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const endPoint = getTextHitPoint(e.clientX, e.clientY);
|
||||
if (gesture.startPoint && endPoint) {
|
||||
setVisualSelection(gesture.startPoint, endPoint);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleMobileSelectionEnd = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
const gesture = mobileSelectionRef.current;
|
||||
if (!gesture || gesture.pointerId !== e.pointerId) return;
|
||||
mobileSelectionRef.current = null;
|
||||
|
||||
if (!gesture.selecting) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const endPoint = getTextHitPoint(gesture.currentX, gesture.currentY);
|
||||
const selectedText =
|
||||
gesture.startPoint && endPoint
|
||||
? setVisualSelection(gesture.startPoint, endPoint)
|
||||
: window.getSelection()?.toString().trim() || '';
|
||||
|
||||
if (selectedText) {
|
||||
onTextSelect(selectedText, line.id);
|
||||
}
|
||||
};
|
||||
|
||||
// Capture selection in reading mode to add annotations
|
||||
const handleSelection = () => {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.isCollapsed) return;
|
||||
|
||||
const isNodeInsideFurigana = (node: Node | null) => {
|
||||
const element = node instanceof Element ? node : node?.parentElement;
|
||||
return !!element?.closest('[data-furigana-layer="true"]');
|
||||
};
|
||||
|
||||
const selectedText = selection.toString().trim();
|
||||
if (selectedText.length > 0) {
|
||||
const lowerSelected = selectedText.toLowerCase();
|
||||
const mainTextLower = (line.rawText || '').toLowerCase();
|
||||
const furiTextLower = furiganaItems.map((item) => item.text).join(' ').toLowerCase();
|
||||
|
||||
const isInside = displayRef.current && (
|
||||
displayRef.current.contains(selection.anchorNode) ||
|
||||
displayRef.current.contains(selection.focusNode)
|
||||
);
|
||||
|
||||
if (isInside || mainTextLower.includes(lowerSelected) || furiTextLower.includes(lowerSelected)) {
|
||||
const isInsideFurigana = isNodeInsideFurigana(selection.anchorNode) || isNodeInsideFurigana(selection.focusNode);
|
||||
|
||||
if (!isInsideFurigana && (isInside || mainTextLower.includes(lowerSelected))) {
|
||||
onTextSelect(selectedText, line.id);
|
||||
}
|
||||
}
|
||||
@@ -193,6 +340,12 @@ export const LineItem: React.FC<LineItemProps> = ({
|
||||
<span
|
||||
key={`highlight-${earliestNote.id}-${earliestIndex}`}
|
||||
data-note-highlight="true"
|
||||
onPointerUp={(e) => {
|
||||
if (e.pointerType !== 'touch') return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onNoteClick(earliestNote!);
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onNoteClick(earliestNote!);
|
||||
@@ -220,23 +373,23 @@ export const LineItem: React.FC<LineItemProps> = ({
|
||||
return (
|
||||
<div
|
||||
id={`line-${line.id}`}
|
||||
className={`relative group flex gap-4 pr-3 transition-colors ${isActive ? 'bg-[#FAF9F6]/40' : ''} hover:bg-[#FAF9F6]/40 min-w-[800px] md:min-w-[1200px] xl:min-w-[1600px]`}
|
||||
className={`relative group flex w-full min-w-0 gap-1 pr-1 transition-colors ${isActive ? 'bg-[#FAF9F6]/40' : ''} hover:bg-[#FAF9F6]/40 md:min-w-[900px] md:gap-4 md:pr-3 xl:min-w-[1200px]`}
|
||||
style={{ minHeight: `${calculatedLineHeight}px` }}
|
||||
>
|
||||
{/* 1. Left gutter with number */}
|
||||
<div className="relative flex flex-col items-center justify-start py-1 select-none text-[#A69F92]/60 w-12 border-r border-[#E8E4DE]/50 font-mono">
|
||||
{showLineNumber && (
|
||||
{showLineNumber && (
|
||||
<div className="relative flex w-8 shrink-0 flex-col items-center justify-start py-1 select-none text-[#A69F92]/60 border-r border-[#E8E4DE]/50 font-mono sm:w-10 md:w-12">
|
||||
<span className="text-[11px] font-bold select-none opacity-60">
|
||||
{lineNumber.toString().padStart(2, '0')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 2. Interactive sheet writing lines (Direct live editing) */}
|
||||
<div className="flex-1 py-1 flex flex-col justify-center">
|
||||
<div className="min-w-0 flex-1 py-1 flex flex-col justify-center">
|
||||
{!readOnly ? (
|
||||
/* ================= EDIT MODE: FREE-POSITIONED FURIGANA + MAIN TEXT ================= */
|
||||
<div className="flex flex-col w-full px-1 -mx-1 pr-1 font-mono">
|
||||
<div className="flex flex-col w-full min-w-0 overflow-hidden px-0.5 pr-0.5 font-mono md:-mx-1 md:px-1 md:pr-1 md:overflow-visible">
|
||||
{/* Top Row: Free-positioned Furigana / 假名 layer */}
|
||||
<FuriganaLayer
|
||||
ref={furiganaLayerRef}
|
||||
@@ -247,7 +400,6 @@ export const LineItem: React.FC<LineItemProps> = ({
|
||||
fontFamily={isSerif ? 'var(--font-serif), monospace' : 'var(--font-sans), monospace'}
|
||||
onChange={updateFuriganaItems}
|
||||
onItemSelect={(itemId) => onFuriganaSelect(line.id, itemId)}
|
||||
onTextSelect={(selectedText) => onTextSelect(selectedText, line.id)}
|
||||
onFocusMain={() => mainInputRef.current?.focus({ preventScroll: true })}
|
||||
onFocusPreviousLine={() => moveFocusUp(line.id, 'main')}
|
||||
/>
|
||||
@@ -286,40 +438,50 @@ export const LineItem: React.FC<LineItemProps> = ({
|
||||
<div
|
||||
ref={displayRef}
|
||||
onMouseUp={handleSelection}
|
||||
onPointerDown={handleMobileSelectionStart}
|
||||
onPointerMove={handleMobileSelectionMove}
|
||||
onPointerUp={handleMobileSelectionEnd}
|
||||
onPointerCancel={() => {
|
||||
mobileSelectionRef.current = null;
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest('[data-furigana-layer="true"]')) return;
|
||||
if (!canEdit) return;
|
||||
const selection = window.getSelection();
|
||||
if (selection && !selection.isCollapsed) return;
|
||||
onActivate(line.id);
|
||||
}}
|
||||
onDoubleClick={(e) => {
|
||||
if (onDoubleClick) {
|
||||
if (canEdit && onDoubleClick) {
|
||||
onDoubleClick(line.id);
|
||||
}
|
||||
}}
|
||||
className="flex flex-col w-full cursor-text select-text pr-1 font-mono hover:bg-[#FAF9F6]/80 rounded px-1 -mx-1"
|
||||
className={`flex flex-col w-full min-w-0 select-text overflow-hidden pr-0.5 font-mono hover:bg-[#FAF9F6]/80 rounded px-0.5 md:-mx-1 md:px-1 md:pr-1 md:overflow-visible ${
|
||||
canEdit ? 'cursor-text' : 'cursor-default'
|
||||
}`}
|
||||
>
|
||||
{/* Furigana line */}
|
||||
<FuriganaLayer
|
||||
items={furiganaItems}
|
||||
editable
|
||||
editable={canEdit}
|
||||
selectedItemId={selectedFuriganaItemId}
|
||||
fontSize={fontSize}
|
||||
fontFamily={isSerif ? 'var(--font-serif), monospace' : 'var(--font-sans), monospace'}
|
||||
onChange={updateFuriganaItems}
|
||||
onItemSelect={(itemId) => onFuriganaSelect(line.id, itemId)}
|
||||
onTextSelect={(selectedText) => onTextSelect(selectedText, line.id)}
|
||||
onItemSelect={canEdit ? (itemId) => onFuriganaSelect(line.id, itemId) : undefined}
|
||||
/>
|
||||
|
||||
{/* Main Kanji text line with word highlighter/comment select matches */}
|
||||
<div
|
||||
className="text-[#2D2926] m-0 p-0"
|
||||
ref={mainTextRef}
|
||||
className="text-[#2D2926] m-0 p-0 whitespace-pre-wrap break-words [overflow-wrap:anywhere]"
|
||||
style={{
|
||||
fontSize: `${fontSize}px`,
|
||||
fontFamily: isSerif ? 'var(--font-serif), monospace' : 'var(--font-sans), monospace',
|
||||
marginTop: '1px',
|
||||
height: `${fontSize * 1.2}px`,
|
||||
minHeight: `${fontSize * 1.2}px`,
|
||||
lineHeight: '1.2',
|
||||
touchAction: canEdit ? 'auto' : 'pan-y',
|
||||
}}
|
||||
>
|
||||
{line.rawText ? (
|
||||
|
||||
Reference in New Issue
Block a user