mirror of
https://github.com/wavetermdev/waveterm.git
synced 2024-12-31 18:18:02 +01:00
df2889f280
The memoizing of the tabs was causing the callbacks for handleContextMenu to become dead ends. This makes more of the callbacks into memoized callbacks and makes the handleContextMenu function itself a memoized callback to ensure it's properly updated when its upstream callbacks change.
241 lines
9.8 KiB
TypeScript
241 lines
9.8 KiB
TypeScript
// Copyright 2024, Command Line Inc.
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
import { atoms, globalStore, refocusNode } from "@/app/store/global";
|
|
import { RpcApi } from "@/app/store/wshclientapi";
|
|
import { TabRpcClient } from "@/app/store/wshrpcutil";
|
|
import { Button } from "@/element/button";
|
|
import { ContextMenuModel } from "@/store/contextmenu";
|
|
import { clsx } from "clsx";
|
|
import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react";
|
|
import { ObjectService } from "../store/services";
|
|
import { makeORef, useWaveObjectValue } from "../store/wos";
|
|
import "./tab.scss";
|
|
|
|
interface TabProps {
|
|
id: string;
|
|
active: boolean;
|
|
isFirst: boolean;
|
|
isBeforeActive: boolean;
|
|
isDragging: boolean;
|
|
tabWidth: number;
|
|
isNew: boolean;
|
|
isPinned: boolean;
|
|
onSelect: () => void;
|
|
onClose: (event: React.MouseEvent<HTMLButtonElement, MouseEvent> | null) => void;
|
|
onDragStart: (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
|
|
onLoaded: () => void;
|
|
onPinChange: () => void;
|
|
}
|
|
|
|
const Tab = memo(
|
|
forwardRef<HTMLDivElement, TabProps>(
|
|
(
|
|
{
|
|
id,
|
|
active,
|
|
isPinned,
|
|
isBeforeActive,
|
|
isDragging,
|
|
tabWidth,
|
|
isNew,
|
|
onLoaded,
|
|
onSelect,
|
|
onClose,
|
|
onDragStart,
|
|
onPinChange,
|
|
},
|
|
ref
|
|
) => {
|
|
const [tabData, _] = useWaveObjectValue<Tab>(makeORef("tab", id));
|
|
const [originalName, setOriginalName] = useState("");
|
|
const [isEditable, setIsEditable] = useState(false);
|
|
|
|
const editableRef = useRef<HTMLDivElement>(null);
|
|
const editableTimeoutRef = useRef<NodeJS.Timeout>();
|
|
const loadedRef = useRef(false);
|
|
const tabRef = useRef<HTMLDivElement>(null);
|
|
|
|
useImperativeHandle(ref, () => tabRef.current as HTMLDivElement);
|
|
|
|
useEffect(() => {
|
|
if (tabData?.name) {
|
|
setOriginalName(tabData.name);
|
|
}
|
|
}, [tabData]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (editableTimeoutRef.current) {
|
|
clearTimeout(editableTimeoutRef.current);
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
const handleRenameTab = (event) => {
|
|
event?.stopPropagation();
|
|
setIsEditable(true);
|
|
editableTimeoutRef.current = setTimeout(() => {
|
|
if (editableRef.current) {
|
|
editableRef.current.focus();
|
|
document.execCommand("selectAll", false);
|
|
}
|
|
}, 0);
|
|
};
|
|
|
|
const handleBlur = () => {
|
|
let newText = editableRef.current.innerText.trim();
|
|
newText = newText || originalName;
|
|
editableRef.current.innerText = newText;
|
|
setIsEditable(false);
|
|
ObjectService.UpdateTabName(id, newText);
|
|
setTimeout(() => refocusNode(null), 10);
|
|
};
|
|
|
|
const handleKeyDown = (event) => {
|
|
if ((event.metaKey || event.ctrlKey) && event.key === "a") {
|
|
event.preventDefault();
|
|
if (editableRef.current) {
|
|
const range = document.createRange();
|
|
const selection = window.getSelection();
|
|
range.selectNodeContents(editableRef.current);
|
|
selection.removeAllRanges();
|
|
selection.addRange(range);
|
|
}
|
|
return;
|
|
}
|
|
// this counts glyphs, not characters
|
|
const curLen = Array.from(editableRef.current.innerText).length;
|
|
if (event.key === "Enter") {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
if (editableRef.current.innerText.trim() === "") {
|
|
editableRef.current.innerText = originalName;
|
|
}
|
|
editableRef.current.blur();
|
|
} else if (event.key === "Escape") {
|
|
editableRef.current.innerText = originalName;
|
|
editableRef.current.blur();
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
} else if (curLen >= 14 && !["Backspace", "Delete", "ArrowLeft", "ArrowRight"].includes(event.key)) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!loadedRef.current) {
|
|
onLoaded();
|
|
loadedRef.current = true;
|
|
}
|
|
}, [onLoaded]);
|
|
|
|
useEffect(() => {
|
|
if (tabRef.current && isNew) {
|
|
const initialWidth = `${(tabWidth / 3) * 2}px`;
|
|
tabRef.current.style.setProperty("--initial-tab-width", initialWidth);
|
|
tabRef.current.style.setProperty("--final-tab-width", `${tabWidth}px`);
|
|
}
|
|
}, [isNew, tabWidth]);
|
|
|
|
// Prevent drag from being triggered on mousedown
|
|
const handleMouseDownOnClose = (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
|
event.stopPropagation();
|
|
};
|
|
|
|
const handleContextMenu = useCallback(
|
|
(e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
|
e.preventDefault();
|
|
let menu: ContextMenuItem[] = [
|
|
{ label: isPinned ? "Unpin Tab" : "Pin Tab", click: () => onPinChange() },
|
|
{ label: "Rename Tab", click: () => handleRenameTab(null) },
|
|
{ label: "Copy TabId", click: () => navigator.clipboard.writeText(id) },
|
|
{ type: "separator" },
|
|
];
|
|
const fullConfig = globalStore.get(atoms.fullConfigAtom);
|
|
const bgPresets: string[] = [];
|
|
for (const key in fullConfig?.presets ?? {}) {
|
|
if (key.startsWith("bg@")) {
|
|
bgPresets.push(key);
|
|
}
|
|
}
|
|
bgPresets.sort((a, b) => {
|
|
const aOrder = fullConfig.presets[a]["display:order"] ?? 0;
|
|
const bOrder = fullConfig.presets[b]["display:order"] ?? 0;
|
|
return aOrder - bOrder;
|
|
});
|
|
if (bgPresets.length > 0) {
|
|
const submenu: ContextMenuItem[] = [];
|
|
const oref = makeORef("tab", id);
|
|
for (const presetName of bgPresets) {
|
|
const preset = fullConfig.presets[presetName];
|
|
if (preset == null) {
|
|
continue;
|
|
}
|
|
submenu.push({
|
|
label: preset["display:name"] ?? presetName,
|
|
click: () => {
|
|
ObjectService.UpdateObjectMeta(oref, preset);
|
|
RpcApi.ActivityCommand(TabRpcClient, { settabtheme: 1 });
|
|
},
|
|
});
|
|
}
|
|
menu.push({ label: "Backgrounds", type: "submenu", submenu }, { type: "separator" });
|
|
}
|
|
menu.push({ label: "Close Tab", click: () => onClose(null) });
|
|
ContextMenuModel.showContextMenu(menu, e);
|
|
},
|
|
[onPinChange, handleRenameTab, id, onClose, isPinned]
|
|
);
|
|
|
|
return (
|
|
<div
|
|
ref={tabRef}
|
|
className={clsx("tab", {
|
|
active,
|
|
isDragging,
|
|
"before-active": isBeforeActive,
|
|
"new-tab": isNew,
|
|
})}
|
|
onMouseDown={onDragStart}
|
|
onClick={onSelect}
|
|
onContextMenu={handleContextMenu}
|
|
data-tab-id={id}
|
|
>
|
|
<div className="tab-inner">
|
|
<div
|
|
ref={editableRef}
|
|
className={clsx("name", { focused: isEditable })}
|
|
contentEditable={isEditable}
|
|
onDoubleClick={handleRenameTab}
|
|
onBlur={handleBlur}
|
|
onKeyDown={handleKeyDown}
|
|
suppressContentEditableWarning={true}
|
|
>
|
|
{tabData?.name}
|
|
</div>
|
|
{isPinned ? (
|
|
<Button
|
|
className="ghost grey pin"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onPinChange();
|
|
}}
|
|
>
|
|
<i className="fa fa-solid fa-thumbtack" />
|
|
</Button>
|
|
) : (
|
|
<Button className="ghost grey close" onClick={onClose} onMouseDown={handleMouseDownOnClose}>
|
|
<i className="fa fa-solid fa-xmark" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
)
|
|
);
|
|
|
|
export { Tab };
|