Spaces:
Running
Running
| // SelectionApp.tsx | |
| import React, { useState, useRef } from "react"; | |
| interface Span { | |
| span_text: string; | |
| start: number; | |
| end: number; | |
| } | |
| const SelectionApp: React.FC = () => { | |
| const [spans, setSpans] = useState<Span[]>([]); | |
| const [keySpan, setKeySpan] = useState<string>("span_text"); | |
| const [keyStart, setKeyStart] = useState<string>("start"); | |
| const [keyEnd, setKeyEnd] = useState<string>("end"); | |
| const [lastText, setLastText] = useState<string>(""); | |
| const inputRef = useRef<HTMLDivElement>(null); | |
| const getNormalizedText = (container: HTMLElement): string => { | |
| let html = container.innerHTML; | |
| html = html.replace(/<div><br><\/div>/gi, '\n'); | |
| html = html.replace(/<div>/gi, '\n').replace(/<\/div>/gi, ''); | |
| html = html.replace(/<br\s*\/?>/gi, '\n'); | |
| html = html.replace(/ /g, ' '); | |
| html = html.replace(/<[^>]+>/g, ''); | |
| return html; | |
| }; | |
| const getSelectionIndices = () => { | |
| if (!inputRef.current) return; | |
| const container = inputRef.current; | |
| const selection = window.getSelection(); | |
| if (!selection || !selection.rangeCount) return; | |
| const range = selection.getRangeAt(0); | |
| if (!container.contains(range.startContainer) || !container.contains(range.endContainer)) | |
| return; | |
| const selectedText = selection.toString(); | |
| if (!selectedText) return; | |
| const preRange = range.cloneRange(); | |
| preRange.selectNodeContents(container); | |
| preRange.setEnd(range.startContainer, range.startOffset); | |
| const tempDiv = document.createElement("div"); | |
| tempDiv.appendChild(preRange.cloneContents()); | |
| const preText = getNormalizedText(tempDiv); | |
| const start = preText.length; | |
| const end = start + selectedText.length; | |
| const newSpan: Span = { span_text: selectedText, start, end }; | |
| setSpans((prev) => [newSpan, ...prev]); | |
| }; | |
| const renderSpans = () => { | |
| return spans.map((s) => ({ | |
| [keySpan]: s.span_text, | |
| [keyStart]: s.start, | |
| [keyEnd]: s.end, | |
| })); | |
| }; | |
| const handleInput = (e: React.FormEvent<HTMLDivElement>) => { | |
| const currentText = getNormalizedText(e.currentTarget); | |
| if (currentText !== lastText) { | |
| setSpans([]); | |
| setLastText(currentText); | |
| } | |
| }; | |
| return ( | |
| <div | |
| style={{ | |
| maxWidth: "900px", | |
| margin: "40px auto", | |
| fontFamily: "'Segoe UI', Tahoma, Geneva, Verdana, sans-serif", | |
| color: "#333", | |
| }} | |
| > | |
| <h2 style={{ textAlign: "center", marginBottom: "25px", color: "#1a73e8" }}> | |
| Span Annotation App | |
| </h2> | |
| {/* Settings Panel */} | |
| <div | |
| style={{ | |
| marginBottom: "20px", | |
| padding: "15px 20px", | |
| borderRadius: "10px", | |
| background: "#f9f9f9", | |
| boxShadow: "0 2px 6px rgba(0,0,0,0.1)", | |
| display: "flex", | |
| flexWrap: "wrap", | |
| gap: "10px", | |
| alignItems: "center", | |
| }} | |
| > | |
| <strong>Key Names:</strong> | |
| <label> | |
| Span Text: | |
| <input | |
| value={keySpan} | |
| onChange={(e) => setKeySpan(e.target.value)} | |
| style={{ marginLeft: "5px", padding: "4px 8px", borderRadius: "4px", border: "1px solid #ccc" }} | |
| /> | |
| </label> | |
| <label> | |
| Start: | |
| <input | |
| value={keyStart} | |
| onChange={(e) => setKeyStart(e.target.value)} | |
| style={{ marginLeft: "5px", padding: "4px 8px", borderRadius: "4px", border: "1px solid #ccc" }} | |
| /> | |
| </label> | |
| <label> | |
| End: | |
| <input | |
| value={keyEnd} | |
| onChange={(e) => setKeyEnd(e.target.value)} | |
| style={{ marginLeft: "5px", padding: "4px 8px", borderRadius: "4px", border: "1px solid #ccc" }} | |
| /> | |
| </label> | |
| </div> | |
| {/* Editable Div */} | |
| <div | |
| ref={inputRef} | |
| contentEditable | |
| onInput={handleInput} | |
| style={{ | |
| width: "100%", | |
| minHeight: "180px", | |
| maxHeight: "450px", | |
| border: "1px solid #ccc", | |
| borderRadius: "10px", | |
| padding: "12px", | |
| whiteSpace: "pre-wrap", | |
| overflowWrap: "break-word", | |
| overflowY: "auto", | |
| resize: "vertical", | |
| boxShadow: "inset 0 2px 6px rgba(0,0,0,0.05)", | |
| fontSize: "16px", | |
| lineHeight: "1.5", | |
| }} | |
| ></div> | |
| <button | |
| onClick={getSelectionIndices} | |
| style={{ | |
| marginTop: "15px", | |
| padding: "10px 20px", | |
| backgroundColor: "#1a73e8", | |
| color: "#fff", | |
| border: "none", | |
| borderRadius: "6px", | |
| cursor: "pointer", | |
| boxShadow: "0 2px 6px rgba(0,0,0,0.2)", | |
| }} | |
| > | |
| Get Selection JSON | |
| </button> | |
| {/* Display JSON */} | |
| <pre | |
| style={{ | |
| marginTop: "20px", | |
| fontFamily: "monospace", | |
| background: "#f4f4f4", | |
| padding: "15px", | |
| borderRadius: "10px", | |
| border: "1px solid #ddd", | |
| whiteSpace: "pre-wrap", | |
| userSelect: "text", | |
| boxShadow: "inset 0 2px 4px rgba(0,0,0,0.05)", | |
| }} | |
| > | |
| {spans.length ? JSON.stringify(renderSpans(), null, 2) : ""} | |
| </pre> | |
| </div> | |
| ); | |
| }; | |
| export default SelectionApp; | |