Files
helder/design_handoff_helder_workbench/design/src/editor.jsx
2026-06-15 09:43:01 +02:00

273 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Editor: tabs + four view modes (Original / Updated / Diff / Split) + line selection */
function climbToLine(node) {
let el = node && node.nodeType === 3 ? node.parentElement : node;
while (el && !(el.dataset && el.dataset.line)) el = el.parentElement;
return el || null;
}
function EditorTabs({ tabs, active, onActivate, onClose }) {
const ref = useRef(null);
useEffect(() => {
const el = ref.current && ref.current.querySelector(".tab.active");
if (el) el.scrollIntoView({ block: "nearest", inline: "nearest" });
}, [active]);
return (
<div className="tabs" ref={ref}>
{tabs.map((t) => {
const name = t.path.split("/").pop();
return (
<div key={t.path}
className={"tab" + (active === t.path ? " active" : "")}
onClick={() => onActivate(t.path)}
onAuxClick={(e) => { if (e.button === 1) { e.preventDefault(); onClose(t.path); } }}
title={t.path}>
<FileIcon path={t.path} />
<span className="tname">{name}</span>
{t.changed && <span className="tab-mode">{t.modeLabel}</span>}
<span className="tclose" onClick={(e) => { e.stopPropagation(); onClose(t.path); }}>
{Icon.close({})}
</span>
</div>
);
})}
</div>
);
}
/* Generic pane: renders an array of line descriptors with selection + caret + context. */
function PaneView({ cacheKey, path, lines, lang, showSign, refLine, cursor, selection, setCursor, setSelection, onContext }) {
const anchorRef = useRef(null);
const html = useMemo(() => lines.map((l) => HL.hlLine(l.text, lang)), [cacheKey]);
function gutterClick(e, no) {
if (no == null) return;
e.stopPropagation();
if (e.shiftKey && anchorRef.current != null) {
const a = anchorRef.current;
setSelection({ path, start: Math.min(a, no), end: Math.max(a, no), anchor: a });
} else {
anchorRef.current = no;
setSelection({ path, start: no, end: no, anchor: no });
}
setCursor({ path, line: no, col: 1 });
}
function caretCol(sel) {
try {
const el = climbToLine(sel.focusNode);
const code = el.querySelector(".ln-code");
const r = document.createRange();
r.setStart(code, 0); r.setEnd(sel.focusNode, sel.focusOffset);
return r.toString().length + 1;
} catch (e) { return 1; }
}
function onMouseUp() {
const sel = window.getSelection();
if (sel && !sel.isCollapsed) {
const a = climbToLine(sel.anchorNode), f = climbToLine(sel.focusNode);
if (a && f) {
const an = +a.dataset.line, fn = +f.dataset.line;
const s = Math.min(an, fn), e = Math.max(an, fn);
if (s !== e) { setSelection({ path, start: s, end: e, anchor: an }); setCursor({ path, line: fn, col: caretCol(sel) }); return; }
}
}
if (sel && sel.focusNode) {
const el = climbToLine(sel.focusNode);
if (el) { setCursor({ path, line: +el.dataset.line, col: caretCol(sel) }); setSelection(null); }
}
}
function handleContext(e) {
e.preventDefault();
const sel = window.getSelection();
let info = { path, kind: "editor" };
const a = sel && sel.anchorNode && climbToLine(sel.anchorNode);
const f = sel && sel.focusNode && climbToLine(sel.focusNode);
if (sel && !sel.isCollapsed && a && f && +a.dataset.line !== +f.dataset.line) {
const s = Math.min(+a.dataset.line, +f.dataset.line), en = Math.max(+a.dataset.line, +f.dataset.line);
info.sel = { start: s, end: en }; info.line = s;
} else if (selection && selection.path === path && selection.start !== selection.end) {
info.sel = { start: selection.start, end: selection.end }; info.line = selection.start;
} else {
let no = null;
const r = document.caretRangeFromPoint ? document.caretRangeFromPoint(e.clientX, e.clientY) : null;
const el = r && climbToLine(r.startContainer);
if (el) no = +el.dataset.line;
info.line = no || (cursor && cursor.path === path ? cursor.line : 1);
}
setCursor({ path, line: info.line, col: 1 });
onContext(e, info);
}
const curLine = cursor && cursor.path === path ? cursor.line : -1;
const sel = selection && selection.path === path ? selection : null;
return (
<div className={"editor" + (showSign ? " diff" : "")} onMouseUp={onMouseUp} onContextMenu={handleContext}>
{lines.map((l, i) => {
const no = l.no;
const inSel = sel && no != null && no >= sel.start && no <= sel.end;
const cls = "ln-row"
+ (l.row === "add" ? " add" : l.row === "del" ? " del" : "")
+ (l.row === "bar-add" ? " bar-add" : l.row === "bar-del" ? " bar-del" : "")
+ (no === curLine && !inSel && !l.row ? " cursor" : "")
+ (inSel ? " selrange" : "");
return (
<div key={i} data-line={no == null ? undefined : no} className={cls}>
<span className="ln-gutter" onClick={(e) => gutterClick(e, no)}>{no == null ? "" : no}</span>
{showSign && <span className="ln-sign">{l.sign === " " || !l.sign ? "" : l.sign}</span>}
<span className="ln-code" dangerouslySetInnerHTML={{ __html: html[i] }} />
</div>
);
})}
</div>
);
}
/* Build the line descriptors for a given mode. */
function buildLines(mode, diff, fileText) {
if (mode === "original") return { lines: diff.left.map((l) => ({ no: l.no, text: l.text, row: l.mark === "del" ? "bar-del" : null })), showSign: false };
if (mode === "updated") return { lines: diff.right.map((l) => ({ no: l.no, text: l.text, row: l.mark === "add" ? "bar-add" : null })), showSign: false };
if (mode === "diff") return { lines: diff.rows.map((r) => ({ no: r.newNo || r.oldNo, text: r.text, sign: r.sign, row: r.sign === "+" ? "add" : r.sign === "-" ? "del" : null })), showSign: true };
// plain file
const arr = (fileText || "").replace(/\n$/, "").split("\n");
return { lines: arr.map((t, i) => ({ no: i + 1, text: t })), showSign: false };
}
const SEGMENTS = [
{ id: "original", label: "Original" },
{ id: "updated", label: "Updated" },
{ id: "diff", label: "Diff" },
];
function Editor({ tabs, active, mode, setMode, onActivate, onClose, onContext, onSplit, splitOpen, cursor, selection, setCursor, setSelection }) {
const tab = tabs.find((t) => t.path === active);
const change = tab ? PROJECT.changes.find((c) => c.path === tab.path) : null;
const diff = tab ? PROJECT.diffs[tab.path] : null;
const lang = tab ? HL.langFor(tab.path) : null;
const effMode = change ? mode : "code";
let built = null;
if (tab) {
if (change && diff) built = buildLines(effMode, diff, PROJECT.files[tab.path]);
else built = buildLines("code", null, PROJECT.files[tab.path]);
}
const statusWord = change ? (change.status === "A" ? "Added" : change.status === "D" ? "Deleted" : "Modified") : "";
const activeSeg = splitOpen ? "split" : effMode;
const emptyUpdated = effMode === "updated" && built && built.lines.length === 0;
const emptyOriginal = effMode === "original" && built && built.lines.length === 0;
return (
<React.Fragment>
<EditorTabs tabs={tabs} active={active} onActivate={onActivate} onClose={onClose} />
{!tab ? (
<div className="empty-ed">
<div style={{ opacity: .5 }}>{Icon.file({ width: 30, height: 30 })}</div>
<div className="big">No file open</div>
<div className="klist">
<div><span>Search files & content</span><kbd> F</kbd></div>
<div><span>Copy reference</span><kbd>right-click</kbd></div>
<div><span>Pass on to Agent</span><kbd>right-click</kbd></div>
</div>
</div>
) : (
<div className="editor-wrap">
{change && (
<div className="diff-bar">
<span className={"git-stat " + change.status} style={{ width: "auto" }}>{statusWord}</span>
{change.add > 0 && <span className="a">+{change.add}</span>}
{change.del > 0 && <span className="d">{change.del}</span>}
<div className="seg">
{SEGMENTS.map((s) => (
<button key={s.id} className={activeSeg === s.id ? "on" : ""} onClick={() => setMode(s.id)}>{s.label}</button>
))}
<button className={"split-btn" + (activeSeg === "split" ? " on" : "")} onClick={() => onSplit(tab.path)} title="Split — full screen side-by-side">
<svg width="11" height="11" viewBox="0 0 12 12" fill="none"><rect x="1" y="1.5" width="10" height="9" rx="1.5" stroke="currentColor" strokeWidth="1.2"/><line x1="6" y1="1.5" x2="6" y2="10.5" stroke="currentColor" strokeWidth="1.2"/></svg>
Split
</button>
</div>
</div>
)}
{emptyUpdated ? (
<div className="empty-ed"><div className="big" style={{ color: "var(--del)" }}>No updated version</div><div style={{ fontFamily: "var(--mono)", fontSize: 12, color: "var(--fg-3)" }}>This file was deleted in the change.</div></div>
) : emptyOriginal ? (
<div className="empty-ed"><div className="big" style={{ color: "var(--add)" }}>No original version</div><div style={{ fontFamily: "var(--mono)", fontSize: 12, color: "var(--fg-3)" }}>This file is new in the change.</div></div>
) : (
<PaneView cacheKey={tab.path + ":" + effMode} path={tab.path} lines={built.lines}
lang={lang} showSign={built.showSign} cursor={cursor} selection={selection}
setCursor={setCursor} setSelection={setSelection} onContext={onContext} />
)}
</div>
)}
</React.Fragment>
);
}
/* Full-screen side-by-side split view */
function SplitView({ path, onClose, onContext }) {
const diff = PROJECT.diffs[path];
const lang = HL.langFor(path);
const leftRef = useRef(null), rightRef = useRef(null);
const lock = useRef(false);
const change = PROJECT.changes.find((c) => c.path === path);
const leftHtml = useMemo(() => diff.split.map((r) => r.l ? HL.hlLine(r.l.text, lang) : ""), [path]);
const rightHtml = useMemo(() => diff.split.map((r) => r.r ? HL.hlLine(r.r.text, lang) : ""), [path]);
function sync(from, to) {
if (lock.current) return; lock.current = true;
to.scrollTop = from.scrollTop; to.scrollLeft = from.scrollLeft;
requestAnimationFrame(() => { lock.current = false; });
}
function ctx(e, side) {
e.preventDefault();
let no = null;
const r = document.caretRangeFromPoint ? document.caretRangeFromPoint(e.clientX, e.clientY) : null;
const el = r && climbToLine(r.startContainer);
if (el) no = +el.dataset.line;
onContext(e, { path, kind: "editor", line: no || 1 });
}
return (
<div className="split-overlay">
<div className="split-head">
<FileIcon path={path} />
<span className="sh-name">{path}</span>
{change && <span className={"git-stat " + change.status} style={{ width: "auto" }}>{change.status === "A" ? "Added" : change.status === "D" ? "Deleted" : "Modified"}</span>}
{change && change.add > 0 && <span className="a" style={{ fontFamily: "var(--mono)", color: "var(--add)" }}>+{change.add}</span>}
{change && change.del > 0 && <span className="d" style={{ fontFamily: "var(--mono)", color: "var(--del)" }}>{change.del}</span>}
<button className="split-exit" onClick={onClose}>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M7 1.5h3.5V5M5 10.5H1.5V7M10.5 1.5L7 5M1.5 10.5L5 7" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round"/></svg>
Collapse <kbd>Esc</kbd>
</button>
</div>
<div className="split-body">
<div className="split-pane left">
<div className="split-label">Original <span>before</span></div>
<div className="editor" ref={leftRef} onScroll={() => sync(leftRef.current, rightRef.current)} onContextMenu={(e) => ctx(e, "l")}>
{diff.split.map((row, i) => (
<div key={i} data-line={row.l ? row.l.no : undefined} className={"ln-row" + (row.l && row.l.mark === "del" ? " bar-del" : "") + (!row.l ? " empty" : "")}>
<span className="ln-gutter">{row.l ? row.l.no : ""}</span>
<span className="ln-code" dangerouslySetInnerHTML={{ __html: row.l ? leftHtml[i] : "" }} />
</div>
))}
</div>
</div>
<div className="split-pane right">
<div className="split-label">Updated <span>after</span></div>
<div className="editor" ref={rightRef} onScroll={() => sync(rightRef.current, leftRef.current)} onContextMenu={(e) => ctx(e, "r")}>
{diff.split.map((row, i) => (
<div key={i} data-line={row.r ? row.r.no : undefined} className={"ln-row" + (row.r && row.r.mark === "add" ? " bar-add" : "") + (!row.r ? " empty" : "")}>
<span className="ln-gutter">{row.r ? row.r.no : ""}</span>
<span className="ln-code" dangerouslySetInnerHTML={{ __html: row.r ? rightHtml[i] : "" }} />
</div>
))}
</div>
</div>
</div>
</div>
);
}
Object.assign(window, { Editor, EditorTabs, PaneView, SplitView, buildLines, climbToLine });