clickgo 6.7.2 → 6.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +14 -0
- package/dist/app/demo.cga +0 -0
- package/dist/app/task.cga +0 -0
- package/dist/clickgo.d.ts +21 -0
- package/dist/control/common.cgc +0 -0
- package/dist/control/desktop.cgc +0 -0
- package/dist/control/form.cgc +0 -0
- package/dist/control/iconview.cgc +0 -0
- package/dist/control/jodit.cgc +0 -0
- package/dist/control/property.cgc +0 -0
- package/dist/control/task.cgc +0 -0
- package/dist/control/tuieditor.cgc +0 -0
- package/dist/global.css +1 -1
- package/dist/index.js +1 -1
- package/dist/lib/control.d.ts +69 -0
- package/dist/lib/core.d.ts +37 -1
- package/dist/lib/dom.d.ts +15 -1
- package/dist/lib/form.d.ts +43 -1
- package/dist/lib/task.d.ts +59 -2
- package/dist/lib/tool.d.ts +14 -1
- package/dist/theme/modern-light.cgt +0 -0
- package/dist/theme/modern.cgt +0 -0
- package/dist/theme/software-dark.cgt +0 -0
- package/dist/theme/software-light.cgt +0 -0
- package/doc/clickgo-rag.md +2399 -934
- package/package.json +12 -3
- package/test/core-module-loading.mjs +169 -0
- package/test/desktop.mjs +715 -0
- package/test/dom-style.mjs +261 -0
- package/test/form-launcher.mjs +102 -0
- package/test/form-native-regions.mjs +115 -0
- package/test/form-notify.mjs +90 -0
- package/test/form-viewport.mjs +79 -2
- package/test/iconview.mjs +70 -0
- package/test/jodit-lifecycle.mjs +212 -0
- package/test/rtl-archive.mjs +1 -1
- package/test/scroll.mjs +145 -0
- package/test/task-package-loading.mjs +94 -0
- package/test/task-tray.mjs +331 -0
- package/test/tool-assets.mjs +64 -0
- package/test/tuieditor-locale.mjs +175 -0
- package/test/vflow.mjs +122 -0
- package/dist/theme/compact-dark.cgt +0 -0
- package/dist/theme/compact-light.cgt +0 -0
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
// Run: node --experimental-vm-modules test/dom-style.mjs
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { readFile } from 'node:fs/promises';
|
|
4
|
+
import { SourceTextModule, SyntheticModule } from 'node:vm';
|
|
5
|
+
import { JSDOM } from 'jsdom';
|
|
6
|
+
import ts from 'typescript';
|
|
7
|
+
|
|
8
|
+
const documentDom = new JSDOM('<!doctype html><html><body></body></html>');
|
|
9
|
+
for (const name of ['window', 'document', 'HTMLElement', 'Element', 'Node']) {
|
|
10
|
+
Object.defineProperty(globalThis, name, { 'configurable': true, 'value': documentDom.window[name] });
|
|
11
|
+
}
|
|
12
|
+
window.setTimeout = () => 0;
|
|
13
|
+
globalThis.ResizeObserver = class { observe() {} unobserve() {} };
|
|
14
|
+
globalThis.requestAnimationFrame = () => 0;
|
|
15
|
+
const values = new WeakMap();
|
|
16
|
+
const reads = [];
|
|
17
|
+
let computedCalls = 0;
|
|
18
|
+
globalThis.getComputedStyle = (el) => {
|
|
19
|
+
++computedCalls;
|
|
20
|
+
return new Proxy({}, {
|
|
21
|
+
get(_, name) {
|
|
22
|
+
reads.push([el, name]);
|
|
23
|
+
return values.get(el)?.[name] ?? '';
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
};
|
|
27
|
+
let focus = 'form-a';
|
|
28
|
+
let activePanels = [];
|
|
29
|
+
const formModule = new SyntheticModule(['getFocus', 'getActivePanel'], function() {
|
|
30
|
+
this.setExport('getFocus', () => focus);
|
|
31
|
+
this.setExport('getActivePanel', () => activePanels);
|
|
32
|
+
});
|
|
33
|
+
const emptyModule = new SyntheticModule([], function() {});
|
|
34
|
+
const source = await readFile(new URL('../dist/lib/dom.ts', import.meta.url), 'utf8');
|
|
35
|
+
const domModule = new SourceTextModule(ts.transpileModule(`${source}\nexport { watchTimerHandler };`, {
|
|
36
|
+
'compilerOptions': { 'target': ts.ScriptTarget.ES2022, 'module': ts.ModuleKind.ESNext },
|
|
37
|
+
}).outputText);
|
|
38
|
+
await domModule.link((specifier) => specifier === './form' ? formModule : emptyModule);
|
|
39
|
+
await domModule.evaluate();
|
|
40
|
+
const api = domModule.namespace;
|
|
41
|
+
const tick = api.watchTimerHandler;
|
|
42
|
+
const formA = document.createElement('div');
|
|
43
|
+
formA.dataset.formId = 'form-a';
|
|
44
|
+
document.body.append(formA);
|
|
45
|
+
const formB = document.createElement('div');
|
|
46
|
+
formB.dataset.formId = 'form-b';
|
|
47
|
+
document.body.append(formB);
|
|
48
|
+
|
|
49
|
+
/** --- 创建有独立计算样式值的测试元素 --- */
|
|
50
|
+
function element(parent = formA, style = { 'font-size': '13px' }) {
|
|
51
|
+
const el = document.createElement('div');
|
|
52
|
+
parent.append(el);
|
|
53
|
+
values.set(el, style);
|
|
54
|
+
return el;
|
|
55
|
+
}
|
|
56
|
+
const events = [];
|
|
57
|
+
const first = element();
|
|
58
|
+
const second = element();
|
|
59
|
+
const firstHandler = (name, value, old) => {
|
|
60
|
+
events.push(['first', name, value, old, reads.length]);
|
|
61
|
+
values.get(second)['font-size'] = '30px';
|
|
62
|
+
};
|
|
63
|
+
api.watchStyle(first, 'font-size', firstHandler, true);
|
|
64
|
+
assert.deepEqual(events[0].slice(0, 4), ['first', 'font-size', '13px', '']);
|
|
65
|
+
values.get(second)['font-size'] = '13px';
|
|
66
|
+
api.watchStyle(second, 'font-size', (name, value, old) => {
|
|
67
|
+
events.push(['second', name, value, old, reads.length]);
|
|
68
|
+
});
|
|
69
|
+
const extraEvents = [];
|
|
70
|
+
const extraHandler = (...args) => extraEvents.push(args);
|
|
71
|
+
const beforeCalls = computedCalls;
|
|
72
|
+
api.watchStyle(first, 'font-size', extraHandler);
|
|
73
|
+
assert.equal(computedCalls, beforeCalls, 'repeat registration reuses the live style object');
|
|
74
|
+
values.get(first)['font-size'] = '14px';
|
|
75
|
+
values.get(second)['font-size'] = '15px';
|
|
76
|
+
reads.length = 0;
|
|
77
|
+
events.length = 0;
|
|
78
|
+
tick();
|
|
79
|
+
assert.equal(reads.length, 2, 'each changed property is read once, including multiple subscribers');
|
|
80
|
+
assert.deepEqual(events.map(event => event.slice(0, 4)), [
|
|
81
|
+
['first', 'font-size', '14px', '13px'],
|
|
82
|
+
['second', 'font-size', '15px', '13px'],
|
|
83
|
+
]);
|
|
84
|
+
assert.ok(events.every(event => event[4] === 2), 'all style reads finish before any callback');
|
|
85
|
+
assert.deepEqual(extraEvents, [['font-size', '14px', '13px']]);
|
|
86
|
+
events.length = 0;
|
|
87
|
+
tick();
|
|
88
|
+
assert.deepEqual(events[0].slice(0, 4), ['second', 'font-size', '30px', '15px'], 'callback writes are detected in the following poll');
|
|
89
|
+
events.length = 0;
|
|
90
|
+
tick();
|
|
91
|
+
assert.equal(events.length, 0, 'unchanged values do not notify');
|
|
92
|
+
api.unwatchStyle(first, undefined, firstHandler);
|
|
93
|
+
values.get(first)['font-size'] = '16px';
|
|
94
|
+
tick();
|
|
95
|
+
assert.equal(events.length, 0, 'removing one callback preserves the other subscriber');
|
|
96
|
+
assert.deepEqual(extraEvents.at(-1), ['font-size', '16px', '14px']);
|
|
97
|
+
api.unwatchStyle(first);
|
|
98
|
+
assert.equal(api.isWatchStyle(first), false);
|
|
99
|
+
assert.equal(first.dataset.cgStyleindex, undefined);
|
|
100
|
+
api.unwatchStyle(second);
|
|
101
|
+
|
|
102
|
+
// Copied DOM attributes must never reuse another element's subscription.
|
|
103
|
+
const original = element();
|
|
104
|
+
api.watchStyle(original, 'font-size', () => {});
|
|
105
|
+
const copy = original.cloneNode();
|
|
106
|
+
formA.append(copy);
|
|
107
|
+
values.set(copy, { 'font-size': '20px' });
|
|
108
|
+
assert.equal(api.isWatchStyle(copy), false);
|
|
109
|
+
api.watchStyle(copy, 'font-size', () => {});
|
|
110
|
+
assert.notEqual(copy.dataset.cgStyleindex, original.dataset.cgStyleindex);
|
|
111
|
+
original.remove();
|
|
112
|
+
activePanels = ['nonexistent'];
|
|
113
|
+
tick();
|
|
114
|
+
assert.equal(api.isWatchStyle(original), false);
|
|
115
|
+
assert.equal(original.dataset.cgStyleindex, undefined);
|
|
116
|
+
formA.append(original);
|
|
117
|
+
api.watchStyle(original, 'font-size', () => {});
|
|
118
|
+
assert.equal(api.isWatchStyle(original), true, 'a detached and reinserted element can be registered again');
|
|
119
|
+
api.clearWatchStyle('form-a');
|
|
120
|
+
assert.equal(api.isWatchStyle(copy), false);
|
|
121
|
+
assert.equal(api.isWatchStyle(original), false);
|
|
122
|
+
|
|
123
|
+
// Keep focused Form and active Panel filtering, including delayed changes on activation.
|
|
124
|
+
const panel = document.createElement('div');
|
|
125
|
+
panel.dataset.panelId = 'panel-a';
|
|
126
|
+
formA.append(panel);
|
|
127
|
+
const main = element();
|
|
128
|
+
const panelItem = element(panel);
|
|
129
|
+
const otherFormItem = element(formB);
|
|
130
|
+
const scopedEvents = [];
|
|
131
|
+
for (const [label, el] of [['main', main], ['panel', panelItem], ['other', otherFormItem]]) {
|
|
132
|
+
api.watchStyle(el, 'font-size', () => scopedEvents.push(label));
|
|
133
|
+
values.get(el)['font-size'] = '18px';
|
|
134
|
+
}
|
|
135
|
+
activePanels = [];
|
|
136
|
+
tick();
|
|
137
|
+
assert.deepEqual(scopedEvents, ['main']);
|
|
138
|
+
activePanels = ['panel-a'];
|
|
139
|
+
tick();
|
|
140
|
+
assert.deepEqual(scopedEvents, ['main', 'panel']);
|
|
141
|
+
focus = 'form-b';
|
|
142
|
+
tick();
|
|
143
|
+
assert.deepEqual(scopedEvents, ['main', 'panel', 'other']);
|
|
144
|
+
api.clearWatchStyle('form-a', 'panel-a');
|
|
145
|
+
assert.equal(api.isWatchStyle(main), true);
|
|
146
|
+
assert.equal(api.isWatchStyle(panelItem), false);
|
|
147
|
+
api.clearWatchStyle('form-a');
|
|
148
|
+
api.clearWatchStyle('form-b');
|
|
149
|
+
focus = 'form-a';
|
|
150
|
+
activePanels = ['panel-a'];
|
|
151
|
+
|
|
152
|
+
// An earlier notification may cancel records already collected for this poll.
|
|
153
|
+
const cancelFirst = element();
|
|
154
|
+
const cancelSecond = element();
|
|
155
|
+
let cancelledCalls = 0;
|
|
156
|
+
api.watchStyle(cancelFirst, 'font-size', () => api.clearWatchStyle('form-a'));
|
|
157
|
+
api.watchStyle(cancelSecond, 'font-size', () => ++cancelledCalls);
|
|
158
|
+
values.get(cancelFirst)['font-size'] = '22px';
|
|
159
|
+
values.get(cancelSecond)['font-size'] = '22px';
|
|
160
|
+
tick();
|
|
161
|
+
assert.equal(cancelledCalls, 0, 'clearing a Form cancels queued notifications');
|
|
162
|
+
assert.equal(api.isWatchStyle(cancelSecond), false);
|
|
163
|
+
|
|
164
|
+
const named = element(formA, { 'font-size': '13px', 'font-family': 'sans-serif' });
|
|
165
|
+
api.watchStyle(named, ['font-size', 'font-family'], () => {});
|
|
166
|
+
api.unwatchStyle(named, 'font-size');
|
|
167
|
+
assert.equal(api.isWatchStyle(named), true);
|
|
168
|
+
assert.deepEqual(api.getWatchInfo().default.unknown.style.list, ['font-family']);
|
|
169
|
+
api.unwatchStyle(named, 'font-family');
|
|
170
|
+
assert.equal(api.isWatchStyle(named), false);
|
|
171
|
+
|
|
172
|
+
// Removing a later queued element also suppresses its notification and releases its index.
|
|
173
|
+
const removeFirst = element();
|
|
174
|
+
const removeSecond = element();
|
|
175
|
+
api.watchStyle(removeFirst, 'font-size', () => removeSecond.remove());
|
|
176
|
+
api.watchStyle(removeSecond, 'font-size', () => ++cancelledCalls);
|
|
177
|
+
values.get(removeFirst)['font-size'] = '19px';
|
|
178
|
+
values.get(removeSecond)['font-size'] = '19px';
|
|
179
|
+
tick();
|
|
180
|
+
assert.equal(cancelledCalls, 0);
|
|
181
|
+
assert.equal(api.isWatchStyle(removeSecond), false);
|
|
182
|
+
api.unwatchStyle(removeFirst);
|
|
183
|
+
const immediateCancel = element(formA, { 'font-size': '13px', 'font-family': 'sans-serif' });
|
|
184
|
+
const immediateNames = [];
|
|
185
|
+
api.watchStyle(immediateCancel, ['font-size', 'font-family'], name => {
|
|
186
|
+
immediateNames.push(name);
|
|
187
|
+
api.unwatchStyle(immediateCancel);
|
|
188
|
+
}, true);
|
|
189
|
+
assert.deepEqual(immediateNames, ['font-size']);
|
|
190
|
+
assert.equal(api.isWatchStyle(immediateCancel), false);
|
|
191
|
+
|
|
192
|
+
// Reentrant registration retains per-property subscription order and cancellation.
|
|
193
|
+
const reentrant = element();
|
|
194
|
+
let registeredCalls = 0;
|
|
195
|
+
const registered = () => ++registeredCalls;
|
|
196
|
+
api.watchStyle(reentrant, 'font-size', () => api.watchStyle(reentrant, 'font-size', registered));
|
|
197
|
+
values.get(reentrant)['font-size'] = '14px';
|
|
198
|
+
tick();
|
|
199
|
+
assert.equal(registeredCalls, 0, 'a callback registered during dispatch does not receive an old queued change');
|
|
200
|
+
values.get(reentrant)['font-size'] = '15px';
|
|
201
|
+
tick();
|
|
202
|
+
assert.equal(registeredCalls, 1);
|
|
203
|
+
api.unwatchStyle(reentrant);
|
|
204
|
+
|
|
205
|
+
// A failed subscriber must not discard collected changes or stop subsequent polls.
|
|
206
|
+
const failing = element();
|
|
207
|
+
const following = element();
|
|
208
|
+
const followingValues = [];
|
|
209
|
+
api.watchStyle(failing, 'font-size', () => { throw new Error('test subscriber failure'); });
|
|
210
|
+
api.watchStyle(following, 'font-size', (name, value) => followingValues.push(value));
|
|
211
|
+
values.get(failing)['font-size'] = '17px';
|
|
212
|
+
values.get(following)['font-size'] = '17px';
|
|
213
|
+
tick();
|
|
214
|
+
values.get(following)['font-size'] = '18px';
|
|
215
|
+
tick();
|
|
216
|
+
assert.deepEqual(followingValues, ['17px', '18px']);
|
|
217
|
+
api.clearWatchStyle('form-a');
|
|
218
|
+
|
|
219
|
+
// Stab coalesces repeated requests and discards pending measurements on unmount.
|
|
220
|
+
class AbstractControl {
|
|
221
|
+
constructor() {
|
|
222
|
+
this.element = element();
|
|
223
|
+
}
|
|
224
|
+
nextTick() { return Promise.resolve(); }
|
|
225
|
+
watch(name, callback, options) { if (options?.immediate) callback(); }
|
|
226
|
+
}
|
|
227
|
+
const stopped = [];
|
|
228
|
+
const clickgoModule = new SyntheticModule(['control', 'dom'], function() {
|
|
229
|
+
this.setExport('control', { AbstractControl });
|
|
230
|
+
this.setExport('dom', {
|
|
231
|
+
'watchSizeMulti': () => true,
|
|
232
|
+
'unwatchSizeMulti': (...args) => stopped.push(args),
|
|
233
|
+
'watchStyle': api.watchStyle,
|
|
234
|
+
'unwatchStyle': api.unwatchStyle,
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
const stabSource = await readFile(new URL('../dist/sources/control/stab/code.ts', import.meta.url), 'utf8');
|
|
238
|
+
const stabModule = new SourceTextModule(ts.transpileModule(stabSource, {
|
|
239
|
+
'compilerOptions': { 'target': ts.ScriptTarget.ES2022, 'module': ts.ModuleKind.ESNext },
|
|
240
|
+
}).outputText);
|
|
241
|
+
await stabModule.link(() => clickgoModule);
|
|
242
|
+
await stabModule.evaluate();
|
|
243
|
+
const stab = new stabModule.namespace.default();
|
|
244
|
+
stab.props.type = 'rect';
|
|
245
|
+
let measurements = 0;
|
|
246
|
+
stab.resize = () => ++measurements;
|
|
247
|
+
stab.onMounted();
|
|
248
|
+
for (let i = 0; i < 9; ++i) stab.requestResize();
|
|
249
|
+
await Promise.resolve();
|
|
250
|
+
assert.equal(measurements, 1);
|
|
251
|
+
const unrelated = () => {};
|
|
252
|
+
api.watchStyle(stab.element, 'font-size', unrelated);
|
|
253
|
+
stab.requestResize();
|
|
254
|
+
stab.onBeforeUnmount();
|
|
255
|
+
await Promise.resolve();
|
|
256
|
+
assert.equal(measurements, 1, 'unmount cancels pending geometry measurement');
|
|
257
|
+
assert.equal(stopped.length, 1);
|
|
258
|
+
assert.equal(api.isWatchStyle(stab.element), true, 'unmount preserves unrelated style subscribers');
|
|
259
|
+
assert.deepEqual(api.getWatchInfo().default.unknown.style.list, ['font-size']);
|
|
260
|
+
api.unwatchStyle(stab.element);
|
|
261
|
+
console.log('Style read batching, callback order/cancellation, DOM identity, Form/Panel isolation and stab resize coalescing passed.');
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Run: node test/form-launcher.mjs
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { readFile } from 'node:fs/promises';
|
|
4
|
+
import vm from 'node:vm';
|
|
5
|
+
import { JSDOM } from 'jsdom';
|
|
6
|
+
import ts from 'typescript';
|
|
7
|
+
|
|
8
|
+
const dom = new JSDOM('<!doctype html><html><body><div id="cg-launcher"></div></body></html>');
|
|
9
|
+
for (const name of ['window', 'document', 'Element', 'HTMLElement', 'SVGElement', 'Node', 'Event', 'CustomEvent']) {
|
|
10
|
+
Object.defineProperty(globalThis, name, { configurable: true, value: dom.window[name] });
|
|
11
|
+
}
|
|
12
|
+
const errors = [];
|
|
13
|
+
window.addEventListener('error', e => errors.push(e.error));
|
|
14
|
+
const vue = await import('vue');
|
|
15
|
+
const pointer = await import('../node_modules/@litert/pointer/dist/index.esm.js');
|
|
16
|
+
const source = await readFile(process.argv[2] ?? new URL('../dist/lib/form.ts', import.meta.url), 'utf8');
|
|
17
|
+
const tree = ts.createSourceFile('form.ts', source, ts.ScriptTarget.ES2022, true);
|
|
18
|
+
let config;
|
|
19
|
+
function find(node) {
|
|
20
|
+
if (ts.isVariableDeclaration(node) && node.name.getText(tree) === 'launcherApp') {
|
|
21
|
+
config = node.initializer.arguments[0].getText(tree);
|
|
22
|
+
}
|
|
23
|
+
ts.forEachChild(node, find);
|
|
24
|
+
}
|
|
25
|
+
find(tree);
|
|
26
|
+
assert.ok(config, 'load the actual launcher template and handlers');
|
|
27
|
+
let hidden = 0;
|
|
28
|
+
const runs = [];
|
|
29
|
+
const exports = {};
|
|
30
|
+
const scope = {
|
|
31
|
+
exports, document,
|
|
32
|
+
clickgo: { modules: { pointer } },
|
|
33
|
+
info: { locale: { en: { search: 'Search' } } },
|
|
34
|
+
lCore: { config: { locale: 'en', 'launcher.list': [
|
|
35
|
+
{ name: 'App', path: '/app', icon: '' },
|
|
36
|
+
{ name: 'Folder', list: [{ name: 'Child', path: '/child', icon: '' }] },
|
|
37
|
+
] } },
|
|
38
|
+
lTask: { async run(...args) { runs.push(args); } },
|
|
39
|
+
sysId: 'system',
|
|
40
|
+
hideLauncher() { ++hidden; },
|
|
41
|
+
requestAnimationFrame(callback) { callback(); },
|
|
42
|
+
setTimeout() { return 0; },
|
|
43
|
+
launcherRoot: null,
|
|
44
|
+
};
|
|
45
|
+
vm.runInNewContext(ts.transpileModule(`export default ${config};`, {
|
|
46
|
+
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
|
|
47
|
+
}).outputText, scope);
|
|
48
|
+
const app = vue.createApp(exports.default);
|
|
49
|
+
const root = app.mount('#cg-launcher');
|
|
50
|
+
const list = document.querySelector('.cg-launcher-list');
|
|
51
|
+
const search = document.querySelector('.cg-launcher-search');
|
|
52
|
+
|
|
53
|
+
function event(target, type, x, y, button = 0) {
|
|
54
|
+
const e = new window.MouseEvent(type, { bubbles: true, clientX: x, clientY: y, button, view: window });
|
|
55
|
+
Object.defineProperties(e, { pointerId: { value: 1 }, pointerType: { value: 'mouse' } });
|
|
56
|
+
target.dispatchEvent(e);
|
|
57
|
+
}
|
|
58
|
+
async function click(target, { start = [100, 200], end = start, cancel = false, button = 0 } = {}) {
|
|
59
|
+
event(target, 'pointerdown', ...start, button);
|
|
60
|
+
event(target, cancel ? 'pointercancel' : 'pointerup', ...end, button);
|
|
61
|
+
if (!cancel) { event(target, 'click', ...end, button); }
|
|
62
|
+
await vue.nextTick();
|
|
63
|
+
}
|
|
64
|
+
await click(list);
|
|
65
|
+
assert.equal(hidden, 1, 'stationary list blank click closes');
|
|
66
|
+
await click(list, { end: [101, 201] });
|
|
67
|
+
assert.equal(hidden, 2, 'minor pointer movement still counts as a click');
|
|
68
|
+
await click(list, { start: [100.4, 200.4], end: [100, 200] });
|
|
69
|
+
assert.equal(hidden, 3, 'fractional pointer coordinates do not reject a click');
|
|
70
|
+
await click(list, { end: [120, 180] });
|
|
71
|
+
await click(list, { cancel: true });
|
|
72
|
+
await click(list, { button: 2 });
|
|
73
|
+
assert.equal(hidden, 3, 'diagonal drag, cancellation and right click do not close');
|
|
74
|
+
await click(search);
|
|
75
|
+
await click(document.querySelector('.cg-launcher-space'));
|
|
76
|
+
assert.equal(hidden, 5, 'search padding and item spacers close');
|
|
77
|
+
await click(document.querySelector('input'));
|
|
78
|
+
assert.equal(hidden, 5, 'search input stays interactive');
|
|
79
|
+
await click(document.querySelector('.cg-launcher-icon'), { end: [101, 201] });
|
|
80
|
+
assert.equal(hidden, 6);
|
|
81
|
+
assert.deepEqual(runs.map(args => args[1]), ['/app'], 'app runs once without a bubbled blank click');
|
|
82
|
+
const folder = document.querySelector('.cg-launcher-folder > div');
|
|
83
|
+
for (const target of folder.querySelectorAll('.cg-launcher-icon, .cg-launcher-inner, .cg-launcher-item, .cg-launcher-space')) {
|
|
84
|
+
await click(target);
|
|
85
|
+
assert.equal(root.folderName, 'Folder', 'every collapsed preview target opens its folder');
|
|
86
|
+
assert.equal(hidden, 6, 'collapsed previews do not dismiss the launcher');
|
|
87
|
+
assert.deepEqual(runs.map(args => args[1]), ['/app'], 'collapsed previews never start a child app');
|
|
88
|
+
await click(search);
|
|
89
|
+
}
|
|
90
|
+
await click(folder, { end: [101, 201] });
|
|
91
|
+
assert.equal(root.folderName, 'Folder');
|
|
92
|
+
await click(search);
|
|
93
|
+
assert.equal(root.folderName, '', 'blank click closes the folder before the launcher');
|
|
94
|
+
assert.equal(hidden, 6);
|
|
95
|
+
await click(folder);
|
|
96
|
+
await click(folder.querySelector('.cg-launcher-icon'), { end: [101, 201] });
|
|
97
|
+
assert.equal(hidden, 7);
|
|
98
|
+
assert.deepEqual(runs.map(args => args[1]), ['/app', '/child'], 'folder child runs once');
|
|
99
|
+
assert.deepEqual(errors, [], 'event handlers do not throw');
|
|
100
|
+
app.unmount();
|
|
101
|
+
dom.window.close();
|
|
102
|
+
console.log('Launcher blank areas, pointer tolerance, drag/cancel, input, folders and single app launch passed.');
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// Run after Sass compilation: node test/form-native-regions.mjs (requires an Electron display).
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { readFile } from 'node:fs/promises';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import electron from 'electron';
|
|
7
|
+
import { createSSRApp } from 'vue';
|
|
8
|
+
import { renderToString } from '@vue/server-renderer';
|
|
9
|
+
|
|
10
|
+
if (!process.versions.electron) {
|
|
11
|
+
const child = spawn(electron, [fileURLToPath(import.meta.url), '--ozone-platform=x11'], {
|
|
12
|
+
'env': { ...process.env, 'ELECTRON_DEBUG_DRAGGABLE_REGIONS': '1' },
|
|
13
|
+
'stdio': ['ignore', 'ignore', 'pipe'],
|
|
14
|
+
});
|
|
15
|
+
let output = '';
|
|
16
|
+
child.stderr.setEncoding('utf8');
|
|
17
|
+
child.stderr.on('data', chunk => { output += chunk; });
|
|
18
|
+
const timeout = setTimeout(() => child.kill(), 20000);
|
|
19
|
+
const code = await new Promise((resolve, reject) => {
|
|
20
|
+
child.on('error', reject);
|
|
21
|
+
child.on('exit', resolve);
|
|
22
|
+
});
|
|
23
|
+
clearTimeout(timeout);
|
|
24
|
+
assert.equal(code, 0, output);
|
|
25
|
+
let region;
|
|
26
|
+
const cases = new Map();
|
|
27
|
+
for (const line of output.split('\n')) {
|
|
28
|
+
const update = /hit-test region computed in .*?: (\d+) rect\(s\), bounds (\S+ \S+)/.exec(line);
|
|
29
|
+
if (update) {
|
|
30
|
+
region = { 'rects': Number(update[1]), 'bounds': update[2] };
|
|
31
|
+
}
|
|
32
|
+
const completed = /CASE-END (\S+)/.exec(line);
|
|
33
|
+
if (completed) {
|
|
34
|
+
assert.ok(region, 'Electron must report an actual native drag region');
|
|
35
|
+
cases.set(completed[1], region);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
assert.equal(cases.size, 8, output);
|
|
39
|
+
assert.ok(cases.get('baseline').rects > 0);
|
|
40
|
+
for (const name of ['bottom', 'hidden', 'minimized', 'bottom-restored', 'nested', 'closed']) {
|
|
41
|
+
assert.deepEqual(cases.get(name), cases.get('baseline'), `${name} must preserve the native title drag region`);
|
|
42
|
+
}
|
|
43
|
+
assert.equal(cases.get('foreground').rects, 0, `an overlapping foreground Form must still block native dragging\n${output}`);
|
|
44
|
+
console.log('Electron native drag regions passed: bottomMost, hidden, minimized, nested, foreground and close.');
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
const { app, BrowserWindow } = electron;
|
|
48
|
+
app.commandLine.appendSwitch('enable-logging');
|
|
49
|
+
/** --- 等待 Chromium 将布局更新发送给 Native 命中区域计算器 --- */
|
|
50
|
+
const settle = () => new Promise(resolve => setTimeout(resolve, 350));
|
|
51
|
+
app.whenReady().then(async () => {
|
|
52
|
+
const win = new BrowserWindow({
|
|
53
|
+
'show': false, 'frame': false, 'width': 400, 'height': 300,
|
|
54
|
+
'webPreferences': { 'backgroundThrottling': false },
|
|
55
|
+
});
|
|
56
|
+
try {
|
|
57
|
+
const template = await readFile(new URL('../dist/sources/control/form/layout.html', import.meta.url), 'utf8');
|
|
58
|
+
const style = await readFile(new URL('../dist/sources/control/form/style.css', import.meta.url), 'utf8');
|
|
59
|
+
/**
|
|
60
|
+
* --- 用实际 Form 模板生成 Native 主窗体与后创建的背景窗体 ---
|
|
61
|
+
* @param nativeFirst 是否为 Native 主窗体
|
|
62
|
+
* @returns 已渲染的 Form HTML
|
|
63
|
+
*/
|
|
64
|
+
async function render(nativeFirst) {
|
|
65
|
+
const form = createSSRApp({
|
|
66
|
+
template,
|
|
67
|
+
data: () => ({
|
|
68
|
+
'border': nativeFirst ? 'normal' : 'none',
|
|
69
|
+
'isNative': true, 'isNativeNoFrameFirst': nativeFirst,
|
|
70
|
+
'flashTimer': undefined, 'stateMinData': false, 'stateMaxData': false,
|
|
71
|
+
'taskPosition': 'bottom', 'isShow': true, 'isInside': false,
|
|
72
|
+
'formFocus': nativeFirst, 'widthData': 400, 'heightData': 300,
|
|
73
|
+
'leftData': 0, 'topData': 0, 'zIndex': nativeFirst ? 10 : 1,
|
|
74
|
+
'isMask': false, 'isResize': false, 'iconDataUrl': '', 'title': 'Native',
|
|
75
|
+
'isMin': true, 'isMax': true, 'isClose': true, 'direction': 'v',
|
|
76
|
+
'background': '', 'contentPadding': '', 'stepShowData': false,
|
|
77
|
+
'stepData': [], 'stepValue': '', 'isLoading': false,
|
|
78
|
+
}),
|
|
79
|
+
});
|
|
80
|
+
form.component('cg-step', { 'render': () => null });
|
|
81
|
+
form.component('cg-loading', { 'render': () => null });
|
|
82
|
+
return await renderToString(form);
|
|
83
|
+
}
|
|
84
|
+
const native = await render(true);
|
|
85
|
+
const background = await render(false);
|
|
86
|
+
await win.loadURL(`data:text/html,${encodeURIComponent(`<style>body{margin:0}${style}</style><div id="native">${native}</div><div id="background"></div>`)}`);
|
|
87
|
+
await settle();
|
|
88
|
+
console.error('CASE-END baseline');
|
|
89
|
+
const scripts = [
|
|
90
|
+
['bottom', `document.querySelector('#background').innerHTML=${JSON.stringify(background)};const bg=document.querySelector('#background>.wrap');bg.dataset.cgBottomMost='';`],
|
|
91
|
+
['hidden', `const bg=document.querySelector('#background>.wrap');delete bg.dataset.cgBottomMost;bg.classList.remove('show');`],
|
|
92
|
+
['minimized', `const bg=document.querySelector('#background>.wrap');bg.classList.add('show');bg.dataset.cgMin='';`],
|
|
93
|
+
['foreground', `const bg=document.querySelector('#background>.wrap');delete bg.dataset.cgMin;bg.style.zIndex='20';`],
|
|
94
|
+
['bottom-restored', `const bg=document.querySelector('#background>.wrap');bg.style.zIndex='1';bg.dataset.cgBottomMost='';`],
|
|
95
|
+
['nested', `document.querySelector('#background .content').innerHTML=${JSON.stringify(background)};`],
|
|
96
|
+
['closed', `document.querySelector('#background').replaceChildren();`],
|
|
97
|
+
];
|
|
98
|
+
for (const [name, script] of scripts) {
|
|
99
|
+
await win.webContents.executeJavaScript(`{${script}}`);
|
|
100
|
+
// --- 隐藏测试窗口也必须完成布局和绘制,不能读取上一帧的命中区域 ---
|
|
101
|
+
await win.webContents.capturePage();
|
|
102
|
+
await settle();
|
|
103
|
+
console.error(`CASE-END ${name}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
console.error(error);
|
|
108
|
+
app.exit(1);
|
|
109
|
+
}
|
|
110
|
+
finally {
|
|
111
|
+
win.destroy();
|
|
112
|
+
app.quit();
|
|
113
|
+
}
|
|
114
|
+
}).catch(error => { console.error(error); app.exit(1); });
|
|
115
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import vm from 'node:vm';
|
|
4
|
+
import { JSDOM } from 'jsdom';
|
|
5
|
+
import ts from 'typescript';
|
|
6
|
+
|
|
7
|
+
const dom = new JSDOM('<!doctype html><div id="notify"></div>');
|
|
8
|
+
const { document } = dom.window;
|
|
9
|
+
const notify = document.getElementById('notify');
|
|
10
|
+
Object.defineProperty(dom.window.HTMLElement.prototype, 'offsetHeight', {
|
|
11
|
+
get() { return Number(this.dataset.height ?? 96); },
|
|
12
|
+
});
|
|
13
|
+
const frames = [];
|
|
14
|
+
const timers = new Map();
|
|
15
|
+
const observed = new Set();
|
|
16
|
+
let timerId = 0;
|
|
17
|
+
let observerCallback;
|
|
18
|
+
let area = { left: 0, top: 0, width: 800, height: 600 };
|
|
19
|
+
const core = { config: { locale: 'en' }, getAvailArea: () => area };
|
|
20
|
+
const source = await readFile(new URL('../dist/lib/form.ts', import.meta.url), 'utf8');
|
|
21
|
+
const start = source.indexOf('let notifyId:');
|
|
22
|
+
const end = source.indexOf('export function appendToPop', start);
|
|
23
|
+
const api = {};
|
|
24
|
+
vm.runInNewContext(ts.transpileModule(source.slice(start, end), {
|
|
25
|
+
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
|
|
26
|
+
}).outputText, {
|
|
27
|
+
exports: api, document, elements: { notify }, lCore: core,
|
|
28
|
+
lTool: { lang: { getDirection: locale => locale === 'ar' ? 'rtl' : 'ltr' }, escapeHTML: value => value },
|
|
29
|
+
window: { innerWidth: 800, innerHeight: 600, setTimeout: schedule },
|
|
30
|
+
setTimeout: schedule, clearTimeout: id => timers.delete(id),
|
|
31
|
+
requestAnimationFrame: cb => frames.push(cb),
|
|
32
|
+
ResizeObserver: class {
|
|
33
|
+
constructor(callback) { observerCallback = callback; }
|
|
34
|
+
observe(el) { observed.add(el); }
|
|
35
|
+
unobserve(el) { observed.delete(el); }
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
function schedule(callback, delay) { const id = ++timerId; timers.set(id, { callback, delay }); return id; }
|
|
39
|
+
function frame() { frames.splice(0).forEach(callback => callback()); }
|
|
40
|
+
function remove() {
|
|
41
|
+
for (const [id, timer] of [...timers]) {
|
|
42
|
+
if (timer.delay === 100) { timers.delete(id); timer.callback(); }
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function position(el) { return [...el.style.transform.matchAll(/\((-?[\d.]+)px\)/g)].map(match => Number(match[1])); }
|
|
46
|
+
const firstId = api.notify({ title: 'Loading', content: 'Task', note: 'Start', timeout: 0 });
|
|
47
|
+
const first = notify.children[0];
|
|
48
|
+
area.height = 552; // Task bar appears before the enter frame.
|
|
49
|
+
api.refreshNotifyPosition();
|
|
50
|
+
frame();
|
|
51
|
+
assert.deepEqual(position(first), [-58, -10]);
|
|
52
|
+
const secondId = api.notify({ title: 'Loading', content: 'Demo', note: 'Start', timeout: 0 });
|
|
53
|
+
const second = notify.children[1];
|
|
54
|
+
frame();
|
|
55
|
+
assert.deepEqual(position(second), [-164, -10], 'notifications share the current task bar offset');
|
|
56
|
+
first.dataset.height = '128.5';
|
|
57
|
+
api.notifyContent(firstId, { note: 'Wrapped content' });
|
|
58
|
+
assert.deepEqual(position(second), [-196.5, -10], 'content growth uses actual fractional height');
|
|
59
|
+
first.dataset.height = '146';
|
|
60
|
+
observerCallback();
|
|
61
|
+
assert.deepEqual(position(second), [-214, -10], 'size observer also restacks notifications');
|
|
62
|
+
area = { left: 0, top: 0, width: 750, height: 600 };
|
|
63
|
+
api.refreshNotifyPosition();
|
|
64
|
+
assert.deepEqual(position(first), [-10, -60], 'right bar moves the existing stack left');
|
|
65
|
+
core.config.locale = 'ar';
|
|
66
|
+
area = { left: 50, top: 0, width: 750, height: 600 };
|
|
67
|
+
api.refreshNotifyPosition();
|
|
68
|
+
assert.deepEqual(position(first), [-10, 60], 'RTL avoids the left task bar');
|
|
69
|
+
area = { left: 0, top: 50, width: 800, height: 550 };
|
|
70
|
+
api.refreshNotifyPosition();
|
|
71
|
+
assert.deepEqual(position(first), [-10, 10], 'top bar does not push the bottom anchor');
|
|
72
|
+
api.hideNotify(firstId);
|
|
73
|
+
api.hideNotify(firstId);
|
|
74
|
+
assert.equal([...timers.values()].filter(timer => timer.delay === 100).length, 1, 'duplicate hides are idempotent');
|
|
75
|
+
remove();
|
|
76
|
+
assert.deepEqual(position(second), [-10, 10], 'removing a notification closes its space');
|
|
77
|
+
assert.equal(observed.has(first), false, 'removed notifications release size subscriptions');
|
|
78
|
+
api.hideNotify(secondId); remove();
|
|
79
|
+
const earlyId = api.notify({ content: 'Close before entering' });
|
|
80
|
+
api.hideNotify(earlyId); frame();
|
|
81
|
+
assert.equal(notify.children[0].style.opacity, '0', 'pending enter cannot resurrect a closing notification');
|
|
82
|
+
remove();
|
|
83
|
+
assert.equal(notify.children.length, 0);
|
|
84
|
+
assert.equal(observed.size, 0);
|
|
85
|
+
assert.equal(timers.size, 0, 'no auto-hide timer survives an early hide');
|
|
86
|
+
const next = api.notify({ content: 'Fresh stack' }); frame();
|
|
87
|
+
assert.deepEqual(position(notify.children[0]), [-10, 10], 'empty stack leaves no stale offsets');
|
|
88
|
+
api.hideNotify(next); remove();
|
|
89
|
+
dom.window.close();
|
|
90
|
+
console.log('Notify task bar appearance/movement, RTL, height changes, stacking and hide races passed.');
|