Skip to content

Commit 78ede8b

Browse files
authored
feat(terminal): add touch selection "More" menu API and wire select dialog (Acode-Foundation#1905)
1 parent c2eb14a commit 78ede8b

File tree

3 files changed

+293
-32
lines changed

3 files changed

+293
-32
lines changed

src/components/terminal/terminalManager.js

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import EditorFile from "lib/editorFile";
77
import TerminalComponent from "./terminal";
8+
import TerminalTouchSelection from "./terminalTouchSelection";
89
import "@xterm/xterm/css/xterm.css";
910
import quickTools from "components/quickTools";
1011
import toast from "components/toast";
@@ -729,6 +730,32 @@ class TerminalManager {
729730
return this.terminals;
730731
}
731732

733+
/**
734+
* Register a touch-selection "More" menu option.
735+
* @param {object} option
736+
* @returns {string|null}
737+
*/
738+
addTouchSelectionMoreOption(option) {
739+
return TerminalTouchSelection.addMoreOption(option);
740+
}
741+
742+
/**
743+
* Remove a touch-selection "More" menu option.
744+
* @param {string} id
745+
* @returns {boolean}
746+
*/
747+
removeTouchSelectionMoreOption(id) {
748+
return TerminalTouchSelection.removeMoreOption(id);
749+
}
750+
751+
/**
752+
* List touch-selection "More" menu options.
753+
* @returns {Array<object>}
754+
*/
755+
getTouchSelectionMoreOptions() {
756+
return TerminalTouchSelection.getMoreOptions();
757+
}
758+
732759
/**
733760
* Write to a specific terminal
734761
* @param {string} terminalId - Terminal ID

src/components/terminal/terminalTouchSelection.js

Lines changed: 256 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,139 @@
11
/**
22
* Touch Selection for Terminal
33
*/
4+
import select from "dialogs/select";
45
import "./terminalTouchSelection.css";
56

7+
const DEFAULT_MORE_OPTION_ID = "__acode_terminal_select_all__";
8+
const terminalMoreOptions = new Map();
9+
let terminalMoreOptionCounter = 0;
10+
11+
function ensureDefaultMoreOption() {
12+
if (terminalMoreOptions.has(DEFAULT_MORE_OPTION_ID)) return;
13+
14+
terminalMoreOptions.set(DEFAULT_MORE_OPTION_ID, {
15+
id: DEFAULT_MORE_OPTION_ID,
16+
label: () => strings["select all"] || "Select all",
17+
icon: "text_format",
18+
action: ({ touchSelection }) => touchSelection.selectAllText(),
19+
});
20+
}
21+
22+
function normalizeMoreOption(option) {
23+
if (!option || typeof option !== "object" || Array.isArray(option)) {
24+
console.warn(
25+
"[TerminalTouchSelection] addMoreOption expects an option object.",
26+
);
27+
return null;
28+
}
29+
30+
const id =
31+
option.id != null && option.id !== ""
32+
? String(option.id)
33+
: `terminal_more_option_${++terminalMoreOptionCounter}`;
34+
const label = option.label ?? option.text ?? option.title;
35+
const action = option.action || option.onselect || option.onclick;
36+
37+
if (!label) {
38+
console.warn(
39+
`[TerminalTouchSelection] More option '${id}' must provide a label/text/title.`,
40+
);
41+
return null;
42+
}
43+
44+
if (typeof action !== "function") {
45+
console.warn(
46+
`[TerminalTouchSelection] More option '${id}' must provide an action function.`,
47+
);
48+
return null;
49+
}
50+
51+
return {
52+
id,
53+
label,
54+
icon: option.icon || null,
55+
enabled: option.enabled,
56+
action,
57+
};
58+
}
59+
60+
function resolveMoreOptionLabel(option, context) {
61+
try {
62+
const value =
63+
typeof option.label === "function" ? option.label(context) : option.label;
64+
return value == null ? "" : String(value);
65+
} catch (error) {
66+
console.warn(
67+
`[TerminalTouchSelection] Failed to resolve label for option '${option.id}'.`,
68+
error,
69+
);
70+
return "";
71+
}
72+
}
73+
74+
function isMoreOptionEnabled(option, context) {
75+
try {
76+
if (typeof option.enabled === "function") {
77+
return option.enabled(context) !== false;
78+
}
79+
if (option.enabled === undefined) return true;
80+
return option.enabled !== false;
81+
} catch (error) {
82+
console.warn(
83+
`[TerminalTouchSelection] Failed to resolve enabled state for option '${option.id}'.`,
84+
error,
85+
);
86+
return true;
87+
}
88+
}
89+
690
export default class TerminalTouchSelection {
91+
/**
92+
* Register an option for the "More" menu in touch selection.
93+
* @param {{
94+
* id?: string,
95+
* label?: string|function(object):string,
96+
* text?: string,
97+
* title?: string,
98+
* icon?: string,
99+
* enabled?: boolean|function(object):boolean,
100+
* action?: function(object):void|Promise<void>,
101+
* onselect?: function(object):void|Promise<void>,
102+
* onclick?: function(object):void|Promise<void>
103+
* }} option
104+
* @returns {string|null}
105+
*/
106+
static addMoreOption(option) {
107+
ensureDefaultMoreOption();
108+
const normalized = normalizeMoreOption(option);
109+
if (!normalized) return null;
110+
terminalMoreOptions.set(normalized.id, normalized);
111+
return normalized.id;
112+
}
113+
114+
/**
115+
* Remove a registered "More" menu option by id.
116+
* @param {string} id
117+
* @returns {boolean}
118+
*/
119+
static removeMoreOption(id) {
120+
ensureDefaultMoreOption();
121+
if (id == null || id === "") return false;
122+
return terminalMoreOptions.delete(String(id));
123+
}
124+
125+
/**
126+
* List all registered "More" menu options.
127+
* @returns {Array<object>}
128+
*/
129+
static getMoreOptions() {
130+
ensureDefaultMoreOption();
131+
return [...terminalMoreOptions.values()].map((option) => ({ ...option }));
132+
}
133+
7134
constructor(terminal, container, options = {}) {
135+
ensureDefaultMoreOption();
136+
8137
this.terminal = terminal;
9138
this.container = container;
10139
this.options = {
@@ -783,17 +912,27 @@ export default class TerminalTouchSelection {
783912
// Mark that context menu should stay visible
784913
this.contextMenuShouldStayVisible = true;
785914

786-
// Position context menu - center it on selection with viewport bounds checking
787-
const startPos = this.terminalCoordsToPixels(this.selectionStart);
788-
const endPos = this.terminalCoordsToPixels(this.selectionEnd);
915+
// Position context menu - center it on selection (or fallback to center).
916+
const startPos = this.selectionStart
917+
? this.terminalCoordsToPixels(this.selectionStart)
918+
: null;
919+
const endPos = this.selectionEnd
920+
? this.terminalCoordsToPixels(this.selectionEnd)
921+
: null;
922+
923+
const menuWidth = this.contextMenu.offsetWidth || 200;
924+
const menuHeight = this.contextMenu.offsetHeight || 50;
925+
const containerRect = this.container.getBoundingClientRect();
926+
927+
let menuX;
928+
let menuY;
789929

790930
if (startPos || endPos) {
791-
// Use whichever position is available, or center between them
792-
let centerX, baseY;
931+
let centerX;
932+
let baseY;
793933

794934
if (startPos && endPos) {
795935
centerX = (startPos.x + endPos.x) / 2;
796-
// Position below the lower of the two positions
797936
baseY = Math.max(startPos.y, endPos.y);
798937
} else if (startPos) {
799938
centerX = startPos.x;
@@ -803,36 +942,32 @@ export default class TerminalTouchSelection {
803942
baseY = endPos.y;
804943
}
805944

806-
const menuWidth = this.contextMenu.offsetWidth || 200;
807-
const menuHeight = this.contextMenu.offsetHeight || 50;
808-
809-
const containerRect = this.container.getBoundingClientRect();
810-
811-
// Calculate initial position
812-
let menuX = centerX - menuWidth / 2;
813-
let menuY = baseY + this.cellDimensions.height + 40;
945+
menuX = centerX - menuWidth / 2;
946+
menuY = baseY + this.cellDimensions.height + 40;
814947

815-
// Ensure menu stays within terminal bounds horizontally
816-
const minX = 10; // padding from left edge
817-
const maxX = containerRect.width - menuWidth - 10; // padding from right edge
818-
menuX = Math.max(minX, Math.min(menuX, maxX));
819-
820-
// Ensure menu stays within terminal bounds vertically
821-
const maxY = containerRect.height - menuHeight - 10; // padding from bottom
948+
// If menu would overflow below, prefer placing it above selection.
949+
const maxY = containerRect.height - menuHeight - 10;
822950
if (menuY > maxY) {
823-
// If menu would go below terminal, position it above the selection
824951
const topY =
825952
startPos && endPos ? Math.min(startPos.y, endPos.y) : baseY;
826953
menuY = topY - menuHeight - 10;
827954
}
955+
} else {
956+
menuX = (containerRect.width - menuWidth) / 2;
957+
menuY = containerRect.height - menuHeight - 20;
958+
}
828959

829-
// Final bounds check
830-
menuY = Math.max(10, Math.min(menuY, maxY));
960+
const minX = 10;
961+
const maxX = containerRect.width - menuWidth - 10;
962+
menuX = Math.max(minX, Math.min(menuX, maxX));
831963

832-
this.contextMenu.style.left = `${menuX}px`;
833-
this.contextMenu.style.top = `${menuY}px`;
834-
this.contextMenu.style.display = "flex";
835-
}
964+
const minY = 10;
965+
const maxY = containerRect.height - menuHeight - 10;
966+
menuY = Math.max(minY, Math.min(menuY, maxY));
967+
968+
this.contextMenu.style.left = `${menuX}px`;
969+
this.contextMenu.style.top = `${menuY}px`;
970+
this.contextMenu.style.display = "flex";
836971
}
837972

838973
createContextMenu() {
@@ -843,7 +978,10 @@ export default class TerminalTouchSelection {
843978
const menuItems = [
844979
{ label: strings["copy"], action: this.copySelection.bind(this) },
845980
{ label: strings["paste"], action: this.pasteFromClipboard.bind(this) },
846-
{ label: "More...", action: this.showMoreOptions.bind(this) },
981+
{
982+
label: `${strings["more"] || "More"}...`,
983+
action: this.showMoreOptions.bind(this),
984+
},
847985
];
848986

849987
menuItems.forEach((item) => {
@@ -932,10 +1070,96 @@ export default class TerminalTouchSelection {
9321070
}
9331071
}
9341072

1073+
selectAllText() {
1074+
if (!this.terminal?.selectAll) return;
1075+
this.terminal.selectAll();
1076+
this.currentSelection = this.terminal.getSelection();
1077+
this.isSelecting = !!this.currentSelection;
1078+
this.selectionStart = null;
1079+
this.selectionEnd = null;
1080+
this.hideHandles();
1081+
1082+
if (this.options.showContextMenu && this.currentSelection) {
1083+
this.showContextMenu();
1084+
}
1085+
}
1086+
1087+
getMoreOptionsContext() {
1088+
return {
1089+
terminal: this.terminal,
1090+
touchSelection: this,
1091+
selection: this.currentSelection || this.terminal.getSelection(),
1092+
clearSelection: () => this.forceClearSelection(),
1093+
copySelection: () => this.copySelection(),
1094+
pasteFromClipboard: () => this.pasteFromClipboard(),
1095+
selectAll: () => this.selectAllText(),
1096+
};
1097+
}
1098+
1099+
getResolvedMoreOptions() {
1100+
ensureDefaultMoreOption();
1101+
const context = this.getMoreOptionsContext();
1102+
1103+
return [...terminalMoreOptions.values()]
1104+
.map((option) => {
1105+
const label = resolveMoreOptionLabel(option, context);
1106+
if (!label) return null;
1107+
1108+
return {
1109+
...option,
1110+
label,
1111+
disabled: !isMoreOptionEnabled(option, context),
1112+
};
1113+
})
1114+
.filter(Boolean);
1115+
}
1116+
1117+
async executeMoreOption(option) {
1118+
if (!option || typeof option.action !== "function" || option.disabled) {
1119+
if (this.isSelecting && this.options.showContextMenu) {
1120+
this.showContextMenu();
1121+
}
1122+
return;
1123+
}
1124+
1125+
try {
1126+
await option.action(this.getMoreOptionsContext());
1127+
} catch (error) {
1128+
console.error(
1129+
`[TerminalTouchSelection] Failed to execute more option '${option.id}'.`,
1130+
error,
1131+
);
1132+
window.toast?.("Failed to execute action.");
1133+
} finally {
1134+
if (this.isSelecting && this.options.showContextMenu) {
1135+
this.showContextMenu();
1136+
}
1137+
}
1138+
}
1139+
9351140
showMoreOptions() {
936-
// Implement additional options if needed
937-
window.toast("More options are not implemented yet.");
938-
this.forceClearSelection();
1141+
const moreOptions = this.getResolvedMoreOptions();
1142+
if (!moreOptions.length) return;
1143+
1144+
const items = moreOptions.map((option) => ({
1145+
value: option.id,
1146+
text: option.label,
1147+
icon: option.icon,
1148+
disabled: option.disabled,
1149+
}));
1150+
1151+
this.hideContextMenu(true);
1152+
1153+
select(strings["more"] || "More", items, true)
1154+
.then((selectedId) => {
1155+
const option = moreOptions.find((entry) => entry.id === selectedId);
1156+
return this.executeMoreOption(option);
1157+
})
1158+
.catch(() => {
1159+
if (this.isSelecting && this.options.showContextMenu) {
1160+
this.showContextMenu();
1161+
}
1162+
});
9391163
}
9401164

9411165
clearSelection() {

0 commit comments

Comments
 (0)