clickgo 6.9.0 → 6.11.0

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.
Files changed (47) hide show
  1. package/AGENTS.md +21 -0
  2. package/README.md +4 -0
  3. package/dist/app/demo.cga +0 -0
  4. package/dist/app/task.cga +0 -0
  5. package/dist/clickgo.d.ts +8 -1
  6. package/dist/index.js +1 -1
  7. package/dist/lib/command/guide.d.ts +7 -0
  8. package/dist/lib/command/native.d.ts +18 -0
  9. package/dist/lib/command/palette.d.ts +10 -0
  10. package/dist/lib/command/schema.d.ts +31 -0
  11. package/dist/lib/command.d.ts +271 -0
  12. package/dist/lib/core.d.ts +8 -1
  13. package/dist/lib/form.d.ts +7 -0
  14. package/dist/lib/native.d.ts +29 -0
  15. package/dist/lib/task.d.ts +2 -0
  16. package/doc/clickgo-rag.md +4637 -2263
  17. package/package.json +8 -3
  18. package/test/core-module-loading.mjs +0 -169
  19. package/test/desktop.mjs +0 -715
  20. package/test/dock-lifecycle.mjs +0 -280
  21. package/test/dom-style.mjs +0 -261
  22. package/test/form-launcher.mjs +0 -177
  23. package/test/form-modal-layer.mjs +0 -75
  24. package/test/form-native-regions.mjs +0 -115
  25. package/test/form-native-style.mjs +0 -80
  26. package/test/form-native.mjs +0 -68
  27. package/test/form-notify-animation.mjs +0 -102
  28. package/test/form-notify.mjs +0 -90
  29. package/test/form-viewport.mjs +0 -127
  30. package/test/iconview.mjs +0 -70
  31. package/test/jodit-lifecycle.mjs +0 -212
  32. package/test/locale-integrity.mjs +0 -189
  33. package/test/pop-max-size.mjs +0 -66
  34. package/test/rtl-archive.mjs +0 -440
  35. package/test/rtl-direction.mjs +0 -193
  36. package/test/scroll.mjs +0 -145
  37. package/test/select.mjs +0 -885
  38. package/test/sidebar-toggle.mjs +0 -75
  39. package/test/task-package-loading.mjs +0 -94
  40. package/test/task-start.mjs +0 -141
  41. package/test/task-tray.mjs +0 -331
  42. package/test/tool-assets.mjs +0 -64
  43. package/test/tool-clone.mjs +0 -42
  44. package/test/toolbox.mjs +0 -88
  45. package/test/tuieditor-locale.mjs +0 -175
  46. package/test/vflow.mjs +0 -122
  47. package/test/web-drag-mask.mjs +0 -96
package/test/desktop.mjs DELETED
@@ -1,715 +0,0 @@
1
- // Run after TypeScript compilation: npm run test:desktop
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 { computed, reactive } from 'vue';
7
- import { copyPositions, resolveLayout, samePositions, sameValues } from '../dist/sources/control/desktop/lib/layout.js';
8
-
9
- const options = {
10
- 'width': 328, 'height': 328, 'cellWidth': 96, 'cellHeight': 96, 'gap': 8, 'padding': 8,
11
- 'direction': 'column', 'snap': true, 'arrange': false, 'rtl': false
12
- };
13
-
14
- function checkLayout(ids, result, size = options) {
15
- assert.deepEqual([...result.visible, ...result.overflow].sort(), [...ids].sort());
16
- const points = result.visible.map(id => result.positions[id]);
17
- if (result.more) {
18
- points.push(result.more);
19
- }
20
- for (const point of points) {
21
- assert.ok(point.x >= size.padding && point.y >= size.padding);
22
- assert.ok(point.x + size.cellWidth <= size.width - size.padding);
23
- assert.ok(point.y + size.cellHeight <= size.height - size.padding);
24
- }
25
- for (let i = 0; i < points.length; ++i) {
26
- for (let j = i + 1; j < points.length; ++j) {
27
- const a = points[i], b = points[j];
28
- assert.ok(a.x + size.cellWidth + size.gap <= b.x || b.x + size.cellWidth + size.gap <= a.x ||
29
- a.y + size.cellHeight + size.gap <= b.y || b.y + size.cellHeight + size.gap <= a.y,
30
- 'committed icons and overflow entry must not overlap');
31
- }
32
- }
33
- }
34
-
35
- // An unpositioned new item must not take a later existing item's valid anchor.
36
- const initial = Object.freeze({
37
- 'a': Object.freeze({ 'x': 216, 'y': 216 }),
38
- 'b': Object.freeze({ 'x': 8, 'y': 8 }),
39
- 'c': Object.freeze({ 'x': 112, 'y': 8 })
40
- });
41
- const added = resolveLayout(['new', 'a', 'b', 'c'], initial, options);
42
- assert.deepEqual(added.positions.a, initial.a);
43
- assert.deepEqual(added.positions.b, initial.b);
44
- assert.deepEqual(added.positions.c, initial.c);
45
- const reversed = resolveLayout(['c', 'b', 'a', 'new'], added.positions, options);
46
- assert.equal(samePositions(added.positions, reversed.positions), true);
47
- checkLayout(['new', 'a', 'b', 'c'], added);
48
-
49
- // The pure resolver relocates invalid anchors; the controller below retains preferred positions across resizes.
50
- const narrow = { ...options, 'width': 224 };
51
- const shrunk = resolveLayout(['a', 'b', 'c'], initial, narrow);
52
- assert.deepEqual(shrunk.positions.b, initial.b);
53
- assert.deepEqual(shrunk.positions.c, initial.c);
54
- assert.notDeepEqual(shrunk.positions.a, initial.a);
55
- checkLayout(['a', 'b', 'c'], shrunk, narrow);
56
- assert.equal(samePositions(resolveLayout(['a', 'b', 'c'], shrunk.positions, options).positions, shrunk.positions), true);
57
-
58
- // Finite capacity reserves an accessible entry without losing hidden coordinates.
59
- const many = Array.from({ 'length': 12 }, (_, i) => 'item-' + i);
60
- const large = { ...options, 'width': 640, 'height': 536 };
61
- const full = resolveLayout(many, {}, large);
62
- const limited = resolveLayout(many, full.positions, options);
63
- assert.equal(limited.visible.length, 8);
64
- assert.equal(limited.overflow.length, 4);
65
- for (const id of limited.overflow) {
66
- assert.deepEqual(limited.positions[id], full.positions[id]);
67
- }
68
- checkLayout(many, limited);
69
- const expanded = resolveLayout(many, limited.positions, large);
70
- assert.deepEqual(expanded.overflow, []);
71
- checkLayout(many, expanded, large);
72
- assert.equal(resolveLayout(['a', 'b'], initial, { ...options, 'width': 120, 'height': 120 }).visible.length, 0);
73
- assert.deepEqual(resolveLayout(['a'], initial, { ...options, 'width': 20, 'height': 20 }).overflow, ['a']);
74
- assert.deepEqual(resolveLayout([], initial, options).positions, {});
75
-
76
- // Explicitly moved icons get their target; unaffected valid anchors are stable.
77
- const priority = resolveLayout(['a', 'b', 'c'], {
78
- 'a': { 'x': 8, 'y': 8 }, 'b': { 'x': 8, 'y': 8 }, 'c': { 'x': 216, 'y': 216 }
79
- }, options, ['b']);
80
- assert.deepEqual(priority.positions.b, { 'x': 8, 'y': 8 });
81
- assert.deepEqual(priority.positions.c, initial.a);
82
- checkLayout(['a', 'b', 'c'], priority);
83
- const free = resolveLayout(['a', 'b'], { 'a': { 'x': 21, 'y': 31 } }, { ...options, 'snap': false });
84
- assert.deepEqual(free.positions.a, { 'x': 21, 'y': 31 });
85
- checkLayout(['a', 'b'], free, { ...options, 'snap': false });
86
- assert.deepEqual(resolveLayout(['a'], initial, { ...options, 'arrange': true }).positions.a, { 'x': 8, 'y': 8 });
87
- assert.deepEqual(resolveLayout(['a'], {}, { ...options, 'rtl': true }).positions.a, { 'x': 216, 'y': 8 });
88
-
89
- // Malformed coordinates and arbitrary IDs cannot corrupt object prototypes.
90
- const specialIds = ['__proto__', 'constructor', 'a\0b'];
91
- const special = resolveLayout(specialIds, {}, options);
92
- assert.equal(Object.getPrototypeOf(special.positions), Object.prototype);
93
- assert.equal(Object.hasOwn(special.positions, '__proto__'), true);
94
- checkLayout(specialIds, special);
95
- const copied = copyPositions(['a', 'b', 'c'], {
96
- 'a': { 'x': NaN, 'y': 2 }, 'b': { 'x': 8, 'y': Infinity }, 'c': { 'x': 3, 'y': 4 }
97
- });
98
- assert.deepEqual(copied, { 'c': { 'x': 3, 'y': 4 } });
99
- assert.equal(samePositions({ 'a': null }, { 'a': { 'x': 8, 'y': 8 } }), false);
100
- assert.equal(sameValues(['a\0b'], ['a', 'b']), false);
101
-
102
- // Random layouts verify the invariant, rather than copying the placement algorithm.
103
- let seed = 51;
104
- function random(max) {
105
- seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0;
106
- return seed % max;
107
- }
108
- for (const snap of [true, false]) {
109
- for (let i = 0; i < 80; ++i) {
110
- const size = { ...options, 'snap': snap, 'width': 120 + random(700), 'height': 120 + random(500) };
111
- const ids = Array.from({ 'length': random(25) }, (_, j) => 'r-' + j);
112
- const positions = Object.fromEntries(ids.map(id => [id, { 'x': random(1000) - 100, 'y': random(700) - 100 }]));
113
- const before = structuredClone(positions);
114
- checkLayout(ids, resolveLayout(ids, positions, size), size);
115
- assert.deepEqual(positions, before);
116
- }
117
- }
118
-
119
- const dom = new JSDOM('<body></body>', { 'pretendToBeVisual': true });
120
- const win = dom.window;
121
- globalThis.window = win;
122
- globalThis.document = win.document;
123
- globalThis.MouseEvent = win.MouseEvent;
124
- globalThis.Element = win.Element;
125
- globalThis.HTMLElement = win.HTMLElement;
126
- globalThis.Node = win.Node;
127
- class PointerEvent extends win.MouseEvent {
128
- constructor(type, values = {}) {
129
- super(type, { 'bubbles': true, 'cancelable': true, ...values });
130
- Object.defineProperties(this, {
131
- 'pointerId': { 'value': values.pointerId ?? 1 },
132
- 'pointerType': { 'value': values.pointerType ?? 'mouse' },
133
- 'isPrimary': { 'value': values.isPrimary ?? true }
134
- });
135
- }
136
- }
137
- win.PointerEvent = PointerEvent;
138
- globalThis.PointerEvent = PointerEvent;
139
- globalThis.CustomEvent = win.CustomEvent;
140
- globalThis.getComputedStyle = win.getComputedStyle.bind(win);
141
- let hitElement = null;
142
- win.document.elementFromPoint = () => hitElement;
143
- win.document.elementsFromPoint = () => hitElement ? [hitElement] : [];
144
- // Pointer sessions are real; omit Pointer's separate global tap translator so resource counts belong to Desktop.
145
- const documentAdd = win.document.addEventListener.bind(win.document);
146
- win.document.addEventListener = (type, handler, options) => {
147
- if (type !== 'pointerdown') {
148
- documentAdd(type, handler, options);
149
- }
150
- };
151
- const pointer = await import('../node_modules/@litert/pointer/dist/index.esm.js');
152
- win.document.addEventListener = documentAdd;
153
- const sizeHandlers = new Map();
154
- const shown = [];
155
- const listeners = new Map();
156
- const addListener = win.addEventListener.bind(win);
157
- const removeListener = win.removeEventListener.bind(win);
158
- win.addEventListener = (type, handler, options) => {
159
- if (type.startsWith('pointer') || type === 'blur' || type === 'keydown') {
160
- if (!listeners.has(type)) {
161
- listeners.set(type, new Set());
162
- }
163
- listeners.get(type).add(handler);
164
- }
165
- addListener(type, handler, options);
166
- };
167
- win.removeEventListener = (type, handler, options) => {
168
- listeners.get(type)?.delete(handler);
169
- removeListener(type, handler, options);
170
- };
171
- function listenerCount() {
172
- return [...listeners.values()].reduce((count, list) => count + list.size, 0);
173
- }
174
-
175
- class AbstractControl {
176
- constructor() {
177
- this.element = win.document.createElement('div');
178
- this.element.setAttribute('data-cg-control', 'desktop');
179
- this.element.setAttribute('tabindex', '0');
180
- win.document.body.append(this.element);
181
- this.testWidth = 640;
182
- this.testHeight = 420;
183
- this.testLocale = 'ltr';
184
- for (const name of ['clientWidth', 'offsetWidth']) {
185
- Object.defineProperty(this.element, name, { 'get': () => this.testWidth });
186
- }
187
- for (const name of ['clientHeight', 'offsetHeight']) {
188
- Object.defineProperty(this.element, name, { 'get': () => this.testHeight });
189
- }
190
- this.element.getBoundingClientRect = () => ({
191
- 'x': 0, 'y': 0, 'left': 0, 'top': 0, 'width': this.testWidth, 'height': this.testHeight,
192
- 'right': this.testWidth, 'bottom': this.testHeight
193
- });
194
- this.refs = { 'pop': win.document.createElement('div'), 'itempop': win.document.createElement('div') };
195
- this.watchers = new Map();
196
- this.emitted = [];
197
- this.autoBind = true;
198
- }
199
- get localeDirection() {
200
- return this.testLocale;
201
- }
202
- propNumber(name) {
203
- return Number(this.props[name]);
204
- }
205
- propBoolean(name) {
206
- return this.props[name] === true || this.props[name] === 'true';
207
- }
208
- watch(name, handler) {
209
- if (!this.watchers.has(name)) {
210
- this.watchers.set(name, []);
211
- }
212
- this.watchers.get(name).push(handler);
213
- }
214
- trigger(name) {
215
- for (const handler of this.watchers.get(name) ?? []) {
216
- handler();
217
- }
218
- }
219
- nextTick() {
220
- return Promise.resolve();
221
- }
222
- emit(name, value) {
223
- this.emitted.push([name, value]);
224
- if (this.autoBind && name.startsWith('update:')) {
225
- const snapshot = structuredClone(value);
226
- queueMicrotask(() => {
227
- this.props[name.slice(7)] = snapshot;
228
- this.trigger(name.slice(7));
229
- });
230
- }
231
- }
232
- }
233
- const clickgo = new SyntheticModule(['control', 'dom', 'modules', 'tool', 'form'], function() {
234
- this.setExport('control', { AbstractControl });
235
- this.setExport('dom', {
236
- 'isRtl': (element) => element.dir === 'rtl',
237
- 'watchSizeMulti': (_current, element, handler, immediate) => {
238
- sizeHandlers.set(element, handler);
239
- if (immediate) {
240
- handler();
241
- }
242
- },
243
- 'unwatchSizeMulti': (_current, element, handler) => {
244
- assert.equal(sizeHandlers.get(element), handler);
245
- sizeHandlers.delete(element);
246
- }
247
- });
248
- this.setExport('modules', { pointer });
249
- this.setExport('tool', { 'random': () => Math.random().toString(36).slice(2) });
250
- this.setExport('form', {
251
- 'doFocusAndPopEvent': async () => {},
252
- 'showPop': (target, pop, point) => {
253
- pop.setAttribute('data-cg-level', '0');
254
- shown.push({ target, pop, point });
255
- },
256
- 'hidePop': pop => pop.removeAttribute('data-cg-level')
257
- });
258
- });
259
- const layoutModule = new SourceTextModule(await readFile(new URL('../dist/sources/control/desktop/lib/layout.js', import.meta.url), 'utf8'));
260
- const controlModule = new SourceTextModule(await readFile(new URL('../dist/sources/control/desktop/code.js', import.meta.url), 'utf8'));
261
- await controlModule.link(specifier => specifier === 'clickgo' ? clickgo : layoutModule);
262
- await controlModule.evaluate();
263
- const Desktop = controlModule.namespace.default;
264
-
265
- async function makeControl(extra = {}) {
266
- const control = new Desktop();
267
- Object.assign(control.props, {
268
- 'data': ['a', 'b', 'c', 'd'].map(id => ({ id, 'name': id })),
269
- 'positions': {
270
- 'a': { 'x': 8, 'y': 8 }, 'b': { 'x': 8, 'y': 112 },
271
- 'c': { 'x': 112, 'y': 8 }, 'd': { 'x': 320, 'y': 8 }
272
- }, ...extra
273
- });
274
- control.element.dataset.drop = '';
275
- control.element.addEventListener('drop', event => control.drop(event));
276
- control.onMounted();
277
- await Promise.resolve();
278
- for (const id of control.visible) {
279
- const item = win.document.createElement('div');
280
- item.dataset.desktopId = id;
281
- control.element.append(item);
282
- }
283
- control.element.addEventListener('pointerdown', event => control.down(event, event.target.dataset.desktopId ?? ''));
284
- control.emitted.length = 0;
285
- return control;
286
- }
287
- function down(control, id, x, y, extra = {}) {
288
- hitElement = control.element;
289
- const target = [...control.element.children].find(item => item.dataset.desktopId === id) ?? control.element;
290
- target.dispatchEvent(new PointerEvent('pointerdown', { 'clientX': x, 'clientY': y, ...extra }));
291
- }
292
- function move(x, y, extra = {}) {
293
- win.document.body.dispatchEvent(new PointerEvent('pointermove', { 'clientX': x, 'clientY': y, ...extra }));
294
- }
295
- function up(x, y, type = 'pointerup', extra = {}) {
296
- win.document.body.dispatchEvent(new PointerEvent(type, { 'clientX': x, 'clientY': y, ...extra }));
297
- }
298
- function key(control, name, extra = {}) {
299
- const event = new win.KeyboardEvent('keydown', { 'key': name, 'cancelable': true, ...extra });
300
- Object.defineProperty(event, 'target', { 'value': control.element });
301
- control.keydown(event);
302
- return event;
303
- }
304
-
305
- // Real Pointer sessions: two selected icons keep their relative shape; collision is resolved.
306
- const group = await makeControl({ 'modelValue': ['a', 'b'] });
307
- const beforeDrag = structuredClone(group.positionsData);
308
- const stylesBeforeDrag = group.visible.map(id => group.itemStyle(id));
309
- down(group, 'a', 30, 30);
310
- move(134, 30);
311
- assert.deepEqual(group.positionsData, beforeDrag, 'dragging never repositions source or neighboring icons');
312
- assert.deepEqual(group.visible.map(id => group.itemStyle(id)), stylesBeforeDrag, 'rendered positions stay unchanged until release');
313
- assert.ok(document.querySelector('[data-pointer-drag]'), 'only the shared drag feedback follows the pointer');
314
- assert.equal(group.emitted.filter(([name]) => name === 'update:positions').length, 0);
315
- assert.deepEqual(group.dropPositions, { 'a': { 'x': 112, 'y': 8 }, 'b': { 'x': 112, 'y': 112 } });
316
- assert.equal(group.emitted.some(([name]) => name === 'layout'), false, 'preview does not notify persistence');
317
- const groupPreview = structuredClone(group.dropPositions);
318
- up(134, 30);
319
- await Promise.resolve();
320
- assert.deepEqual(group.positionsData.a, { 'x': 112, 'y': 8 });
321
- assert.deepEqual(group.positionsData.b, { 'x': 112, 'y': 112 });
322
- assert.deepEqual(group.positionsData.c, { 'x': 8, 'y': 8 });
323
- assert.deepEqual(group.positionsData.d, { 'x': 320, 'y': 8 });
324
- assert.deepEqual(copyPositions(['a', 'b'], group.positionsData), groupPreview, 'preview matches resolved group placement');
325
- assert.deepEqual(group.dropPositions, {}, 'release clears the preview');
326
- assert.equal(group.emitted.filter(([name]) => name === 'update:positions').length, 1);
327
- assert.equal(listenerCount(), 0);
328
- assert.ok(group.emitted.some(([name, event]) => name === 'layout' && event.detail.reason === 'drag'));
329
-
330
- // Destination previews use the shared hit target and clear over folders, overlays and auto-arrange.
331
- const preview = await makeControl({ 'modelValue': ['a'], 'snap': false });
332
- const previewOriginal = structuredClone(preview.positionsData);
333
- const previewFolder = document.createElement('div');
334
- previewFolder.dataset.drop = '';
335
- preview.element.append(previewFolder);
336
- const previewOverlay = document.createElement('div');
337
- document.body.append(previewOverlay);
338
- down(preview, 'a', 30, 30);
339
- move(77, 61);
340
- assert.deepEqual(preview.dropPositions.a, { 'x': 55, 'y': 39 });
341
- assert.deepEqual(preview.positionsData, previewOriginal);
342
- hitElement = previewFolder;
343
- move(80, 61);
344
- assert.deepEqual(preview.dropPositions, {}, 'a folder receives files instead of placing icons');
345
- hitElement = previewOverlay;
346
- move(83, 61);
347
- assert.deepEqual(preview.dropPositions, {}, 'an overlay blocks the underlying desktop preview');
348
- hitElement = preview.element;
349
- move(77, 61);
350
- const freePreview = structuredClone(preview.dropPositions);
351
- up(77, 61);
352
- await Promise.resolve();
353
- assert.deepEqual(copyPositions(['a'], preview.positionsData), freePreview, 'unsnapped preview matches release');
354
- assert.deepEqual(preview.dropPositions, {});
355
- preview.props.autoArrange = true;
356
- preview.trigger('autoArrange');
357
- down(preview, 'a', 30, 30);
358
- move(134, 30);
359
- assert.deepEqual(preview.dropPositions, {}, 'auto-arrange permits export without a placement preview');
360
- preview.access.stop();
361
- preview.onBeforeUnmount();
362
- previewOverlay.remove();
363
- assert.equal(listenerCount(), 0);
364
-
365
- // A parent's selection echo on the next microtask must not abort an unselected icon's drag.
366
- const echoed = await makeControl();
367
- down(echoed, 'c', 135, 30);
368
- await Promise.resolve();
369
- assert.ok(echoed.access.stop);
370
- move(239, 30);
371
- up(239, 30);
372
- await Promise.resolve();
373
- assert.deepEqual(echoed.positionsData.c, { 'x': 216, 'y': 8 });
374
- assert.equal(listenerCount(), 0);
375
-
376
- // Pointer cancellation, disabling, resize, Escape and unmount leave icon positions unchanged.
377
- const cancelled = await makeControl({ 'modelValue': ['a'] });
378
- const original = structuredClone(cancelled.positionsData);
379
- down(cancelled, 'a', 30, 30);
380
- move(134, 30);
381
- up(134, 30, 'pointercancel');
382
- assert.deepEqual(cancelled.positionsData, original);
383
- assert.equal(cancelled.dragging, false);
384
- assert.deepEqual(cancelled.dropPositions, {});
385
- assert.equal(listenerCount(), 0);
386
- assert.equal(cancelled.emitted.some(([name]) => name === 'update:positions'), false);
387
- down(cancelled, 'a', 30, 30);
388
- move(134, 30);
389
- key(cancelled, 'Escape');
390
- assert.deepEqual(cancelled.dropPositions, {});
391
- assert.deepEqual(cancelled.positionsData, original);
392
- assert.equal(listenerCount(), 0);
393
- down(cancelled, 'a', 30, 30);
394
- move(134, 30);
395
- cancelled.props.disabled = true;
396
- cancelled.trigger('disabled');
397
- assert.equal(cancelled.dragging, false);
398
- assert.deepEqual(cancelled.dropPositions, {});
399
- assert.equal(listenerCount(), 0);
400
- cancelled.props.disabled = false;
401
- cancelled.trigger('disabled');
402
- down(cancelled, 'a', 30, 30);
403
- move(134, 30);
404
- cancelled.testWidth = 600;
405
- cancelled.access.resize();
406
- assert.equal(cancelled.dragging, false);
407
- assert.deepEqual(cancelled.dropPositions, {});
408
- assert.equal(listenerCount(), 0);
409
- down(cancelled, 'a', 30, 30);
410
- move(134, 30);
411
- cancelled.onBeforeUnmount();
412
- assert.deepEqual(cancelled.dropPositions, {});
413
- assert.equal(sizeHandlers.has(cancelled.element), false);
414
- assert.equal(listenerCount(), 0);
415
-
416
- // Framework prop echoes are quiet; removing data while hidden does not leave stale render IDs.
417
- const hidden = await makeControl();
418
- hidden.testWidth = 0;
419
- hidden.access.resize();
420
- const hiddenPosition = structuredClone(hidden.positionsData);
421
- hidden.props.data = hidden.props.data.filter(item => item.id !== 'c');
422
- hidden.trigger('data');
423
- assert.equal(hidden.visible.includes('c'), false);
424
- assert.deepEqual(hidden.positionsData.a, hiddenPosition.a);
425
- assert.equal(Object.hasOwn(hidden.positionsData, 'c'), false);
426
- await Promise.resolve();
427
- hidden.testWidth = 640;
428
- hidden.access.resize();
429
- assert.deepEqual(hidden.positionsData.a, hiddenPosition.a);
430
- const beforeEcho = hidden.emitted.length;
431
- hidden.trigger('positions');
432
- assert.equal(hidden.emitted.length, beforeEcho);
433
-
434
-
435
- // Toggle-without-Ctrl also supports dragging a selected group and clearing on a blank click.
436
- const toggle = await makeControl({ 'ctrl': false, 'modelValue': ['a', 'b'] });
437
- down(toggle, 'a', 30, 30);
438
- move(134, 30);
439
- up(134, 30);
440
- await Promise.resolve();
441
- assert.deepEqual(toggle.valueData, ['a', 'b']);
442
- assert.deepEqual(toggle.positionsData.a, { 'x': 112, 'y': 8 });
443
- down(toggle, 'a', 134, 30);
444
- up(134, 30);
445
- await Promise.resolve();
446
- assert.deepEqual(toggle.valueData, ['b']);
447
- down(toggle, '', 500, 300);
448
- up(500, 300);
449
- await Promise.resolve();
450
- assert.deepEqual(toggle.valueData, []);
451
- toggle.onBeforeUnmount();
452
-
453
- // Locked/automatic icons cannot be moved by the user, but application position props still work.
454
- const locked = await makeControl();
455
- locked.props.data[0].locked = true;
456
- down(locked, 'a', 30, 30);
457
- move(134, 30);
458
- up(134, 30);
459
- assert.deepEqual(locked.positionsData.a, { 'x': 8, 'y': 8 });
460
- locked.props.positions = { ...locked.positionsData, 'a': { 'x': 216, 'y': 112 } };
461
- locked.trigger('positions');
462
- assert.deepEqual(locked.positionsData.a, { 'x': 216, 'y': 112 });
463
- locked.props.autoArrange = true;
464
- locked.trigger('autoArrange');
465
- down(locked, 'b', 30, 134);
466
- move(134, 134);
467
- up(134, 134);
468
- assert.equal(locked.dragging, false);
469
-
470
- // Box selection syncs only on completion; Ctrl box selection toggles previous hits.
471
- const box = await makeControl();
472
- down(box, '', 2, 2);
473
- move(108, 218);
474
- assert.deepEqual(box.valueData, ['a', 'b']);
475
- assert.equal(box.emitted.some(([name]) => name === 'update:modelValue'), false);
476
- up(108, 218);
477
- await Promise.resolve();
478
- assert.deepEqual(box.props.modelValue, ['a', 'b']);
479
- assert.equal(box.emitted.filter(([name]) => name === 'select').length, 1);
480
- down(box, '', 2, 2, { 'ctrlKey': true });
481
- move(106, 106);
482
- up(106, 106);
483
- await Promise.resolve();
484
- assert.deepEqual(box.valueData, ['b']);
485
-
486
- // Keyboard follows physical positions in RTL and leaves interactive slot descendants alone.
487
- box.element.dir = 'rtl';
488
- box.active = 'a';
489
- key(box, 'ArrowRight');
490
- assert.deepEqual(box.valueData, ['c']);
491
- key(box, 'ArrowLeft');
492
- assert.deepEqual(box.valueData, ['a']);
493
- key(box, 'ArrowDown');
494
- assert.deepEqual(box.valueData, ['b']);
495
- key(box, 'a', { 'metaKey': true });
496
- assert.deepEqual(box.valueData, ['a', 'b', 'c', 'd']);
497
- key(box, 'Enter');
498
- assert.ok(box.emitted.some(([name, event]) => name === 'open' && event.detail.value.length === 4));
499
- const input = win.document.createElement('input');
500
- box.element.append(input);
501
- const childKey = new win.KeyboardEvent('keydown', { 'key': 'ArrowRight', 'cancelable': true });
502
- Object.defineProperty(childKey, 'target', { 'value': input });
503
- box.keydown(childKey);
504
- assert.equal(childKey.defaultPrevented, false);
505
- assert.equal(box.isInteractive(input), true);
506
-
507
- // Vue caches metrics: runtime locale changes must be an observable dependency.
508
- const locale = reactive(await makeControl());
509
- const metrics = computed(() => locale.metrics);
510
- assert.equal(metrics.value.rtl, false);
511
- locale.testLocale = 'rtl';
512
- assert.equal(metrics.value.rtl, true);
513
- locale.onBeforeUnmount();
514
-
515
- // Right-click retains a selected group; long press and unmount share cancellable lifetime.
516
- box.setValue(['a', 'b']);
517
- await Promise.resolve();
518
- await box.context(new win.MouseEvent('contextmenu', { 'clientX': 30, 'clientY': 30 }), 'a');
519
- assert.deepEqual(box.valueData, ['a', 'b']);
520
- assert.equal(shown.at(-1).pop, box.refs.itempop);
521
- key(box, 'Escape');
522
- assert.deepEqual(box.valueData, ['a', 'b']);
523
- assert.equal(box.refs.itempop.hasAttribute('data-cg-level'), false);
524
- await box.context(new win.MouseEvent('contextmenu', { 'clientX': 135, 'clientY': 30 }), 'c');
525
- assert.deepEqual(box.valueData, ['c']);
526
- const touch = await makeControl();
527
- down(touch, 'a', 30, 30, { 'pointerType': 'touch' });
528
- await new Promise(resolve => setTimeout(resolve, 380));
529
- assert.equal(shown.at(-1).pop, touch.refs.itempop);
530
- assert.equal(listenerCount(), 0);
531
- const shownBeforeUnmount = shown.length;
532
- down(touch, 'a', 30, 30, { 'pointerType': 'touch' });
533
- touch.onBeforeUnmount();
534
- await new Promise(resolve => setTimeout(resolve, 380));
535
- assert.equal(shown.length, shownBeforeUnmount);
536
- assert.equal(listenerCount(), 0);
537
-
538
- // Empty data, duplicate IDs and prototype-property IDs are handled at the control boundary.
539
- const odd = await makeControl({ 'data': [
540
- { 'id': '__proto__', 'name': 'special' }, { 'id': 'constructor', 'name': 'other' },
541
- { 'id': '__proto__', 'name': 'duplicate' }, { 'id': '', 'name': 'missing' }
542
- ], 'positions': {} });
543
- assert.equal(odd.items.length, 2);
544
- assert.equal(odd.itemStyle('__proto__').left, '8px');
545
- assert.equal(odd.contextItem, undefined);
546
- odd.props.data = [];
547
- odd.trigger('data');
548
- assert.deepEqual(odd.positionsData, {});
549
- assert.deepEqual(odd.valueData, []);
550
- for (const control of [group, echoed, hidden, locked, box, odd]) {
551
- control.onBeforeUnmount();
552
- }
553
- assert.equal(sizeHandlers.size, 0);
554
- assert.equal(listenerCount(), 0);
555
-
556
- // Shrinking and expanding is temporary; model echoes cannot overwrite preferred anchors.
557
- const restored = await makeControl();
558
- const anchors = structuredClone(restored.props.positions);
559
- restored.testWidth = 224;
560
- restored.access.resize();
561
- await Promise.resolve();
562
- assert.notDeepEqual(restored.positionsData.d, anchors.d);
563
- assert.deepEqual(restored.props.positions, anchors);
564
- const compactD = { ...restored.positionsData.d };
565
- restored.testWidth = 640;
566
- restored.access.resize();
567
- assert.deepEqual(restored.positionsData.d, anchors.d);
568
- restored.testWidth = 224;
569
- restored.access.resize();
570
- down(restored, 'b', 30, 134);
571
- move(134, 238);
572
- up(134, 238);
573
- await Promise.resolve();
574
- const manualB = { ...restored.positionsData.b };
575
- restored.testWidth = 640;
576
- restored.access.resize();
577
- assert.deepEqual(restored.positionsData.b, manualB);
578
- assert.deepEqual(restored.positionsData.d, anchors.d);
579
- assert.notDeepEqual(compactD, restored.positionsData.d);
580
- assert.deepEqual(restored.emitted.filter(([name]) => name === 'layout').at(-1)[1].detail.preferredPositions,
581
- restored.props.positions);
582
- restored.onBeforeUnmount();
583
-
584
- // Invalid pointer events and explicit cancellation cannot leak drag listeners.
585
- const occupied = document.createElement('div');
586
- document.body.append(occupied);
587
- pointer.drag(new PointerEvent('pointerdown'), occupied)();
588
- assert.equal(listenerCount(), 0);
589
- let releaseOccupied;
590
- occupied.addEventListener('pointerdown', event => {
591
- releaseOccupied = pointer.scale(event, () => {}, { 'target': occupied });
592
- const baseline = listenerCount();
593
- pointer.drag(event, occupied)();
594
- assert.equal(listenerCount(), baseline);
595
- });
596
- occupied.dispatchEvent(new PointerEvent('pointerdown'));
597
- releaseOccupied();
598
- assert.equal(listenerCount(), 0);
599
- occupied.remove();
600
-
601
- // Real shared drag payload interoperates with direct pointer.drag, without committing local moves on export.
602
- const receiver = await makeControl();
603
- const source = await makeControl();
604
- const sourcePositions = structuredClone(source.positionsData);
605
- const folder = win.document.createElement('div');
606
- folder.dataset.drop = '';
607
- receiver.element.append(folder);
608
- receiver.props.data[0].type = 0;
609
- folder.addEventListener('drop', event => receiver.drop(event, 'a'));
610
- down(source, 'c', 135, 30);
611
- hitElement = folder;
612
- move(700, 200);
613
- assert.equal(folder.hasAttribute('data-hover'), true);
614
- assert.equal(pointer.getDragData().type, 'fs');
615
- assert.equal(pointer.getDragData().list[0].id, 'c');
616
- up(700, 200);
617
- assert.deepEqual(source.positionsData, sourcePositions);
618
- const received = receiver.emitted.filter(([name]) => name === 'drop').at(-1)[1].detail;
619
- assert.equal(received.self, false);
620
- assert.equal(received.to.id, 'a');
621
- assert.equal(received.from[0].id, 'c');
622
- assert.equal(pointer.getDragData(), undefined);
623
- assert.equal(document.querySelector('[data-pointer-drag]'), null);
624
- assert.equal(listenerCount(), 0);
625
-
626
- // An opaque foreground surface blocks the desktop behind it, including on release.
627
- const blocker = win.document.createElement('div');
628
- document.body.append(blocker);
629
- const dropCount = receiver.emitted.filter(([name]) => name === 'drop').length;
630
- down(source, 'c', 135, 30);
631
- hitElement = folder;
632
- move(700, 200);
633
- hitElement = blocker;
634
- up(701, 200);
635
- assert.equal(receiver.emitted.filter(([name]) => name === 'drop').length, dropCount);
636
- assert.deepEqual(source.positionsData, sourcePositions);
637
-
638
- // Cancel while over a receiver never dispatches drop; Escape also removes the ghost and listeners.
639
- down(source, 'c', 135, 30);
640
- hitElement = folder;
641
- move(700, 200);
642
- win.dispatchEvent(new win.KeyboardEvent('keydown', { 'key': 'Escape', 'bubbles': true, 'cancelable': true }));
643
- assert.equal(receiver.emitted.filter(([name]) => name === 'drop').length, dropCount);
644
- assert.equal(folder.hasAttribute('data-hover'), false);
645
- assert.equal(pointer.getDragData(), undefined);
646
- assert.equal(document.querySelector('[data-pointer-drag]'), null);
647
- assert.equal(listenerCount(), 0);
648
- down(source, 'c', 135, 30);
649
- hitElement = folder;
650
- move(700, 200);
651
- up(700, 200, 'pointercancel');
652
- assert.equal(receiver.emitted.filter(([name]) => name === 'drop').length, dropCount);
653
- assert.equal(listenerCount(), 0);
654
-
655
- // Auto-arrange prevents repositioning but still permits export.
656
- source.props.autoArrange = true;
657
- source.trigger('autoArrange');
658
- down(source, 'a', 30, 30);
659
- hitElement = folder;
660
- move(700, 200);
661
- up(700, 200);
662
- assert.equal(receiver.emitted.filter(([name]) => name === 'drop').length, dropCount + 1);
663
-
664
- // An direct pointer.drag source can drop its fs payload on the new desktop background.
665
- const legacy = document.createElement('div');
666
- document.body.append(legacy);
667
- legacy.addEventListener('pointerdown', event => pointer.drag(event, legacy, {
668
- 'start': () => pointer.setDragData({ 'rand': 'legacy', 'type': 'fs', 'list': [
669
- { 'index': 7, 'type': 1, 'path': '/demo/from-other-task.txt' }
670
- ] })
671
- }));
672
- legacy.dispatchEvent(new PointerEvent('pointerdown', { 'clientX': 10, 'clientY': 10 }));
673
- hitElement = receiver.element;
674
- move(100, 100);
675
- up(100, 100);
676
- const legacyDrop = receiver.emitted.filter(([name]) => name === 'drop').at(-1)[1].detail;
677
- assert.equal(legacyDrop.to, null);
678
- assert.equal(legacyDrop.from[0].path, '/demo/from-other-task.txt');
679
- assert.equal(legacyDrop.self, false);
680
- assert.equal(listenerCount(), 0);
681
-
682
- // Invalid external payloads are ignored.
683
- const beforeInvalid = receiver.emitted.length;
684
- for (const value of [null, false, {}, { 'type': 'fs', 'list': null }, { 'type': 'fs', 'list': [null, false] }]) {
685
- receiver.drop(new win.CustomEvent('drop', { 'detail': { value } }));
686
- }
687
- assert.equal(receiver.emitted.length, beforeInvalid);
688
- source.onBeforeUnmount();
689
- receiver.onBeforeUnmount();
690
-
691
- // The workspace persists only user layout commits; Form owns viewport geometry.
692
- const savedLayouts = [];
693
- const workspaceClickgo = new SyntheticModule(['form', 'storage'], function() {
694
- this.setExport('form', { 'AbstractForm': class {} });
695
- this.setExport('storage', { 'set': (...args) => { savedLayouts.push(args); return true; } });
696
- });
697
- const workspaceData = new SourceTextModule(await readFile(
698
- new URL('../dist/app/demo/form/control/desktop/data.js', import.meta.url), 'utf8'));
699
- await workspaceData.link(() => workspaceClickgo);
700
- const iconviewStub = new SyntheticModule(['default'], function() { this.setExport('default', class {}); });
701
- const workspaceModule = new SourceTextModule(await readFile(
702
- new URL('../dist/app/demo/form/solution/desktop/desktop.js', import.meta.url), 'utf8'));
703
- await workspaceModule.link(specifier => specifier === 'clickgo' ? workspaceClickgo :
704
- specifier.endsWith('/data') ? workspaceData : iconviewStub);
705
- await workspaceModule.evaluate();
706
- const workspace = new workspaceModule.namespace.default();
707
- const preferredPositions = { 'a': { 'x': 500, 'y': 300 } };
708
- for (const reason of ['resize', 'positions', 'options', 'data', 'drag', 'arrange']) {
709
- workspace.onLayout({ 'detail': { reason, preferredPositions, 'positions': { 'a': { 'x': 8, 'y': 8 } } } });
710
- }
711
- assert.equal(savedLayouts.length, 2);
712
- assert.ok(savedLayouts.every(args => args[2] === preferredPositions), 'persist preferred anchors rather than temporary display positions');
713
-
714
- dom.window.close();
715
- console.log('Desktop layout, overflow, stable IDs, model echo, real pointer sessions, cancellation, slots and RTL keyboard checks passed.');