Skip to content

Commit e2afd0a

Browse files
author
Ajit Kumar
committed
feat: plugin quicktool adapter
- allow plugin to adapt quicktool - allow plugin to hook save, save event - fix icon size in file explorer
1 parent bcd3dd5 commit e2afd0a

22 files changed

Lines changed: 1523 additions & 30 deletions

‎CONTRIBUTING.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,3 +208,5 @@ Many font editing software and web-based tools exist for this purpose. Some of t
208208
To create plugins for Acode:
209209
- [Plugin Starter Repository](https://github.com/Acode-Foundation/acode-plugin)
210210
- [Plugin Documentation](https://docs.acode.app/)
211+
- [Custom editor quicktools API](docs/quicktools-adapter.md)
212+
- [Custom-tab Save and Save As](docs/file-save-events.md)

‎docs/file-save-events.md‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# Custom-tab Save and Save As
2+
3+
Custom tabs use Acode's standard Save controls through the `EditorFile` save event. The plugin owns serialization, destination dialogs, writing and dirty tracking.
4+
5+
```js
6+
if (typeof tab.canSave === "boolean") {
7+
const onSave = (event) => {
8+
event.respondWith(saveDocument(event.saveAs));
9+
};
10+
tab.on("save", onSave);
11+
// On plugin disposal:
12+
// tab.off("save", onSave);
13+
}
14+
```
15+
16+
`tab.canSave` is read-only: open text-editor tabs qualify automatically; custom tabs need a `save` listener or `onsave` handler. Removing the handler or closing the custom tab disables saving. The handler must implement saving and guard loading, protection and processing.
17+
18+
The event retains `target`, `preventDefault()` and `stopPropagation()`. It additionally exposes:
19+
20+
- `saveAs`: `false` for `tab.save()`, `true` for `tab.saveAs()`.
21+
- `respondWith(Promise<boolean>)`: call synchronously, once, during dispatch to suppress default saving. Resolve `true` after a successful write, `false` for cancellation or no save, and reject for failure. Calling after an `await` or responding twice throws.
22+
23+
Other observers can still see the event. Existing `preventDefault()` cancellation remains supported. Unhandled text saves use the normal text writer; unhandled custom-tab saves return `false`.
24+
25+
Concurrent requests on one custom tab share its pending promise; the first request determines Save versus Save As. Different tabs are independent. Invoke the plugin's writer directly to avoid recursive save dispatch. Remove the listener on unload and cancel pending work on tab close; closed tabs cannot publish completion notifications.
26+
27+
Before resolving success, update the destination and acknowledge only the written revision; newer edits must remain dirty. Acode then emits `save-file` and update notifications. Save All runs sequentially; save-and-close stops after cancellation, failure or remaining edits. Text encoding, format-on-save and autosave retain their existing behavior.
28+
29+
Feature-detect `tab.canSave`. Older hosts require the plugin's own fallback and commands; their built-in Save menu does not support custom tabs.

‎docs/quicktools-adapter.md‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Custom editor quicktools API
2+
3+
`acode.registerQuickToolsAdapter(tab, adapter)` connects stock quicktools to one custom `EditorFile`. Register when the editor is ready. Feature-detect the API and use `hideQuickTools: true` while loading, unsupported or on older hosts. Registration allows one adapter per tab and returns an idempotent cleanup function; call it on plugin unload. Tab closure also unregisters the adapter.
4+
5+
```js
6+
const unregister = acode.registerQuickToolsAdapter?.(tab, {
7+
getState: () => ({ enabled: editor.writable, busy: editor.saving }),
8+
canHandle: action => editor.supports(action),
9+
subscribe: listener => editor.subscribe(listener), // returns unsubscribe
10+
captureSelection: () => editor.captureSelection(),
11+
restoreSelection: selection => editor.restoreSelection(selection),
12+
execute: (action, { signal }) => editor.execute(action, signal),
13+
focus: () => editor.focus(),
14+
cancel: () => editor.cancelPendingInput(), // optional
15+
onError: error => reportError(error), // optional
16+
});
17+
```
18+
19+
The example `editor` is plugin-owned. `getState`, `canHandle`, `subscribe` and `execute` are required; the other callbacks are optional. State and support queries must be synchronous, cheap and side-effect free. Execution, selection and focus callbacks may return promises. Notify subscribers when writability, processing, history or supported operations change; report plugin-owned dialogs as busy.
20+
21+
- `enabled` controls visibility while respecting the user's quicktools preferences. `busy` blocks editing without changing the layout; the command palette stays available.
22+
- `canHandle(action)` guards execution and Undo/Redo availability. Return `false` for unsupported operations; they never fall through to another editor.
23+
- `execute` receives `{ type: 'text', text }`, `{ type: 'key', key, ctrlKey, shiftKey, altKey, metaKey }`, or `{ type: 'command', command }`. Keys use `KeyboardEvent.key` names, but editors must implement browser-default movement and deletion through their own APIs.
24+
- Command names include `undo`, `redo`, `saveFile`, `find`, `selectall`, `paste`, `movelinesup`, `movelinesdown`, `copylinesup` and `copylinesdown`.
25+
- Modifier button availability is queried as `{ type: 'modifier', key: 'ctrl' | 'shift' | 'alt' | 'meta' }`. Acode combines captured keyboard input with modifiers; adapters do not execute modifier toggles.
26+
- For supported paste operations, Acode reads native clipboard text and delivers a text action.
27+
28+
Acode captures selection before quicktools take focus and serializes input. Restore snapshots only for the current document revision. Restore focus only for explicit editing input; registration, selection restoration and dialogs must not force the keyboard open.
29+
30+
Tab changes, native overlays, disabled/busy state and disposal abort the execution signal and cancel modifiers, repeats and queued input. Check the signal and your session/revision after asynchronous work. `cancel` must not synchronously emit another state update. Leave Back handling and the action stack to Acode; keep stock quicktools items, gestures and preferences intact.

‎src/components/tooltip/index.js‎

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,78 @@
11
import "./style.scss";
22
import { animate } from "motion";
3+
import { installIconTooltips } from "./longPress";
34

45
let tooltip;
56
let rafId = null;
7+
let animation;
8+
let icons;
9+
10+
export function initIconTooltips() {
11+
icons ||= installIconTooltips(document, showTooltip, hideTooltip);
12+
return icons.dismiss;
13+
}
614

715
function createTooltip() {
816
if (tooltip) return tooltip;
917

1018
tooltip = document.createElement("div");
1119
tooltip.className = "acode-tooltip";
20+
tooltip.setAttribute("role", "tooltip");
1221
document.body.appendChild(tooltip);
1322

1423
return tooltip;
1524
}
1625

17-
export function showTooltip(target, text) {
26+
export function showTooltip(target, text, description) {
1827
if (!target || !text) return;
1928

2029
const $tooltip = createTooltip();
2130

22-
$tooltip.textContent = text;
31+
animation?.stop();
32+
$tooltip.replaceChildren();
33+
const label = document.createElement("div");
34+
label.textContent = text;
35+
$tooltip.append(label);
36+
if (description) {
37+
const detail = document.createElement("div");
38+
detail.className = "acode-tooltip-description";
39+
detail.textContent = description;
40+
$tooltip.append(detail);
41+
}
42+
$tooltip.removeAttribute("aria-hidden");
2343

2444
const rect = target.getBoundingClientRect();
2545

2646
if (rafId !== null) {
2747
cancelAnimationFrame(rafId);
2848
}
2949
rafId = requestAnimationFrame(() => {
50+
const viewport = window.visualViewport;
51+
const x = viewport?.offsetLeft || 0,
52+
y = viewport?.offsetTop || 0;
53+
const visibleWidth = viewport?.width || window.innerWidth;
54+
const visibleHeight = viewport?.height || window.innerHeight;
55+
$tooltip.style.maxWidth = `${Math.max(0, visibleWidth - 16)}px`;
56+
$tooltip.style.maxHeight = `${Math.max(0, visibleHeight - 16)}px`;
3057
const width = $tooltip.offsetWidth;
3158
const height = $tooltip.offsetHeight;
3259

3360
const left = Math.max(
34-
8,
61+
x + 8,
3562
Math.min(
36-
window.innerWidth - width - 8,
63+
x + visibleWidth - width - 8,
3764
rect.left + rect.width / 2 - width / 2,
3865
),
3966
);
4067

41-
const top = Math.max(8, rect.top - height - 10);
68+
const above = rect.top - height - 10;
69+
const top = Math.max(
70+
y + 8,
71+
Math.min(
72+
y + visibleHeight - height - 8,
73+
above >= y + 8 ? above : rect.bottom + 10,
74+
),
75+
);
4276

4377
$tooltip.style.left = `${left}px`;
4478
$tooltip.style.top = `${top}px`;
@@ -48,7 +82,7 @@ export function showTooltip(target, text) {
4882
rafId = null;
4983
return;
5084
}
51-
animate(
85+
animation = animate(
5286
$tooltip,
5387
{
5488
opacity: 1,
@@ -64,6 +98,8 @@ export function showTooltip(target, text) {
6498

6599
export function hideTooltip() {
66100
if (!tooltip) return;
101+
animation?.stop();
102+
tooltip.setAttribute("aria-hidden", "true");
67103

68104
if (rafId !== null) {
69105
cancelAnimationFrame(rafId);
@@ -74,7 +110,7 @@ export function hideTooltip() {
74110
tooltip.style.transform = "translateY(5px)";
75111
return;
76112
}
77-
animate(
113+
animation = animate(
78114
tooltip,
79115
{
80116
opacity: 0,
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
/** One delegated listener set, including icon controls inside open ShadowRoots. */
2+
export function installIconTooltips(root, show, hide) {
3+
let press, timer, suppressed, observer;
4+
let shadowRoots = [];
5+
const pointers = new Set();
6+
const labelOf = (node) =>
7+
["data-label", "aria-label", "title"]
8+
.map((name) => node.getAttribute(name)?.trim())
9+
.find(Boolean);
10+
const targetOf = (event) => {
11+
const path = event.composedPath();
12+
// Quicktools already own their tooltip, repeat and modifier gestures.
13+
if (path.some((node) => node?.id === "quick-tools")) return;
14+
return path.find(
15+
(node) =>
16+
node?.matches?.("button, [role='button'], .icon") && labelOf(node),
17+
);
18+
};
19+
function dismiss() {
20+
clearTimeout(timer);
21+
press = undefined;
22+
observer?.disconnect();
23+
observer = undefined;
24+
for (const shadow of shadowRoots)
25+
shadow.removeEventListener("scroll", dismiss, true);
26+
shadowRoots = [];
27+
hide();
28+
}
29+
function down(event) {
30+
pointers.add(event.pointerId);
31+
dismiss();
32+
suppressed = undefined;
33+
if (pointers.size !== 1 || event.button !== 0) return;
34+
const target = targetOf(event);
35+
if (!target) return;
36+
// Element scroll events do not cross a shadow boundary.
37+
shadowRoots = event
38+
.composedPath()
39+
.filter((node) => node?.nodeType === 11 && node.host);
40+
for (const shadow of shadowRoots)
41+
shadow.addEventListener("scroll", dismiss, true);
42+
press = { target, x: event.clientX, y: event.clientY, held: false };
43+
timer = setTimeout(() => {
44+
if (!press || !target.isConnected) return;
45+
press.held = true;
46+
show(target, labelOf(target), target.dataset.description);
47+
observer = new MutationObserver(() => {
48+
if (!target.isConnected) dismiss();
49+
});
50+
// Also observe removal of the tab's host, including nested shadow hosts.
51+
for (const boundary of [root, ...shadowRoots])
52+
observer.observe(boundary, { childList: true, subtree: true });
53+
}, 500);
54+
}
55+
function move(event) {
56+
if (
57+
press &&
58+
Math.hypot(event.clientX - press.x, event.clientY - press.y) > 10
59+
)
60+
dismiss();
61+
}
62+
function up(event) {
63+
pointers.delete(event.pointerId);
64+
clearTimeout(timer);
65+
if (press?.held)
66+
suppressed = { target: press.target, until: Date.now() + 750 };
67+
press = undefined;
68+
}
69+
function cancel(event) {
70+
pointers.delete(event.pointerId);
71+
dismiss();
72+
}
73+
function click(event) {
74+
if (
75+
event.detail &&
76+
suppressed?.target === targetOf(event) &&
77+
Date.now() < suppressed.until
78+
) {
79+
event.preventDefault();
80+
event.stopImmediatePropagation();
81+
}
82+
suppressed = undefined;
83+
}
84+
function contextmenu(event) {
85+
// A control's own menu wins over the shared tooltip.
86+
if (event.defaultPrevented) {
87+
dismiss();
88+
return;
89+
}
90+
if (press?.held && press.target === targetOf(event)) event.preventDefault();
91+
}
92+
function reset() {
93+
pointers.clear();
94+
suppressed = undefined;
95+
dismiss();
96+
}
97+
const events = {
98+
pointerdown: down,
99+
pointermove: move,
100+
pointerup: up,
101+
pointercancel: cancel,
102+
click,
103+
scroll: dismiss,
104+
keydown: dismiss,
105+
};
106+
for (const [name, handler] of Object.entries(events))
107+
root.addEventListener(name, handler, true);
108+
root.addEventListener("contextmenu", contextmenu);
109+
const viewport = root.defaultView?.visualViewport;
110+
root.defaultView?.addEventListener("blur", reset);
111+
viewport?.addEventListener("resize", dismiss);
112+
viewport?.addEventListener("scroll", dismiss);
113+
return {
114+
dismiss: reset,
115+
dispose() {
116+
reset();
117+
for (const [name, handler] of Object.entries(events))
118+
root.removeEventListener(name, handler, true);
119+
root.removeEventListener("contextmenu", contextmenu);
120+
root.defaultView?.removeEventListener("blur", reset);
121+
viewport?.removeEventListener("resize", dismiss);
122+
viewport?.removeEventListener("scroll", dismiss);
123+
},
124+
};
125+
}

‎src/components/tooltip/style.scss‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,11 @@
1111

1212
font-size: 12px;
1313

14-
white-space: nowrap;
14+
white-space: normal;
15+
overflow-wrap: anywhere;
16+
overflow: hidden;
17+
box-sizing: border-box;
18+
line-height: 1.4;
1519

1620
pointer-events: none;
1721

@@ -21,4 +25,7 @@
2125

2226
z-index: 999999;
2327
}
24-
28+
.acode-tooltip-description {
29+
margin-top: 3px;
30+
opacity: 0.8;
31+
}

0 commit comments

Comments
 (0)