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
package/test/form-viewport.mjs
CHANGED
|
@@ -5,20 +5,27 @@ import vm from 'node:vm';
|
|
|
5
5
|
import ts from 'typescript';
|
|
6
6
|
|
|
7
7
|
let nativeMode = false;
|
|
8
|
+
let area = { left: 0, top: 0, width: 640, height: 400, owidth: 640, oheight: 440 };
|
|
9
|
+
const bool = value => value === true || value === '' || value === 'true';
|
|
8
10
|
const clickgo = {
|
|
9
11
|
isNative: () => nativeMode,
|
|
10
12
|
control: {
|
|
11
13
|
AbstractControl: class {
|
|
12
|
-
|
|
14
|
+
constructor() { this.rootForm = { bottomMost: false }; this.parent = { controlName: 'root' }; this.watches = {}; this.events = []; }
|
|
15
|
+
emit(...event) { this.events.push(event); }
|
|
16
|
+
propBoolean(name) { return bool(this.props[name]); }
|
|
17
|
+
watch(name, callback, options) { this.watches[name] = callback; if(options?.immediate) callback(); }
|
|
13
18
|
propInt(name) { return Number(this.props[name]); }
|
|
14
19
|
trigger() { return Promise.resolve(); }
|
|
15
20
|
},
|
|
16
21
|
},
|
|
22
|
+
core: { getAvailArea: () => area },
|
|
23
|
+
dom: { watchSize() {} },
|
|
17
24
|
native: {
|
|
18
25
|
restore: async () => {},
|
|
19
26
|
size: async () => {},
|
|
20
27
|
},
|
|
21
|
-
tool: { sleep: () => Promise.resolve() },
|
|
28
|
+
tool: { sleep: () => Promise.resolve(), getBoolean: bool },
|
|
22
29
|
};
|
|
23
30
|
const source = await readFile(new URL('../dist/sources/control/form/code.ts', import.meta.url), 'utf8');
|
|
24
31
|
const compiled = ts.transpileModule(source, {
|
|
@@ -48,3 +55,73 @@ assert.deepEqual(restore({ width: 600, height: 40, left: 40, top: 400 }), [600,
|
|
|
48
55
|
assert.deepEqual(restore({ width: 900, height: 700, left: 500, top: 300 }, 'native-framed'), [900, 700, 500, 300]);
|
|
49
56
|
assert.deepEqual(restore({ width: 900, height: 700, left: 500, top: 300 }, 'native-frameless'), [900, 700, 500, 300]);
|
|
50
57
|
console.log('Form restore stays visible, edge-positioned Forms stay anchored, and Native geometry is unchanged.');
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
nativeMode = false;
|
|
61
|
+
function create(viewport = false, initialMax = false) {
|
|
62
|
+
const form = new Form();
|
|
63
|
+
form.props.viewport = viewport;
|
|
64
|
+
form.props.stateMax = initialMax;
|
|
65
|
+
form.props.padding = 'safe';
|
|
66
|
+
form.element = { style: {}, dataset: {}, offsetWidth: 300, offsetHeight: 200, removeAttribute() {} };
|
|
67
|
+
form.onMounted();
|
|
68
|
+
if (!initialMax) form.maxMethod();
|
|
69
|
+
return form;
|
|
70
|
+
}
|
|
71
|
+
function rect(form) { return [form.leftData, form.topData, form.widthData, form.heightData]; }
|
|
72
|
+
const full = create(true, true);
|
|
73
|
+
assert.deepEqual(rect(full), [0, 0, 640, 440], 'initial state-max uses the full viewport');
|
|
74
|
+
const history = JSON.stringify(full.historyLocation);
|
|
75
|
+
for (const a of [
|
|
76
|
+
{ left: 0, top: 0, width: 640, height: 400, owidth: 640, oheight: 440, padding: '0px 0px 40px 0px' },
|
|
77
|
+
{ left: 60, top: 0, width: 580, height: 440, owidth: 640, oheight: 440, padding: '0px 0px 0px 60px' },
|
|
78
|
+
{ left: 0, top: 40, width: 640, height: 400, owidth: 640, oheight: 440, padding: '40px 0px 0px 0px' },
|
|
79
|
+
{ left: 0, top: 0, width: 580, height: 440, owidth: 640, oheight: 440, padding: '0px 60px 0px 0px' },
|
|
80
|
+
{ left: 0, top: 0, width: 800, height: 520, owidth: 800, oheight: 560, padding: '0px 0px 40px 0px' },
|
|
81
|
+
{ left: 0, top: 0, width: 800, height: 560, owidth: 800, oheight: 560, padding: '0px 0px 0px 0px' }
|
|
82
|
+
]) {
|
|
83
|
+
area = a;
|
|
84
|
+
full.refreshMaxPosition();
|
|
85
|
+
assert.deepEqual(rect(full), [0, 0, a.owidth, a.oheight]);
|
|
86
|
+
assert.equal(full.contentPadding, a.padding);
|
|
87
|
+
assert.equal(JSON.stringify(full.historyLocation), history, 'refresh never overwrites restore geometry');
|
|
88
|
+
}
|
|
89
|
+
area = { left: 60, top: 0, width: 580, height: 440, owidth: 640, oheight: 440 };
|
|
90
|
+
full.props.viewport = false;
|
|
91
|
+
full.watches.viewport();
|
|
92
|
+
assert.deepEqual(rect(full), [60, 0, 580, 440]);
|
|
93
|
+
assert.equal(full.contentPadding, '0px 0px 0px 0px', 'available-area maximization does not reserve the taskbar twice');
|
|
94
|
+
full.props.viewport = 'true';
|
|
95
|
+
full.watches.viewport();
|
|
96
|
+
assert.deepEqual(rect(full), [0, 0, 640, 440]);
|
|
97
|
+
full.maxMethod();
|
|
98
|
+
assert.equal(full.contentPadding, undefined, 'safe padding is removed when restoring');
|
|
99
|
+
full.props.padding = '4 8';
|
|
100
|
+
assert.equal(full.contentPadding, '4px 8px', 'numeric padding is unchanged');
|
|
101
|
+
const normal = create();
|
|
102
|
+
assert.deepEqual(rect(normal), [60, 0, 580, 440], 'default maximization still avoids the taskbar');
|
|
103
|
+
const bottom = create();
|
|
104
|
+
bottom.rootForm.bottomMost = true;
|
|
105
|
+
bottom.refreshMaxPosition();
|
|
106
|
+
assert.deepEqual(rect(bottom), [60, 0, 580, 440], 'bottomMost alone does not change the layout boundary');
|
|
107
|
+
assert.equal(bottom.contentPadding, '0px 0px 0px 0px');
|
|
108
|
+
bottom.props.viewport = true;
|
|
109
|
+
bottom.watches.viewport();
|
|
110
|
+
assert.deepEqual(rect(bottom), [0, 0, 640, 440], 'bottomMost uses the full viewport only when explicitly enabled');
|
|
111
|
+
assert.equal(bottom.contentPadding, '0px 0px 0px 60px');
|
|
112
|
+
bottom.props.viewport = false;
|
|
113
|
+
bottom.watches.viewport();
|
|
114
|
+
assert.deepEqual(rect(bottom), [60, 0, 580, 440], 'disabling viewport also restores taskbar exclusion for bottomMost');
|
|
115
|
+
const inline = create();
|
|
116
|
+
inline.isInside = true;
|
|
117
|
+
const previous = rect(inline);
|
|
118
|
+
inline.props.viewport = true;
|
|
119
|
+
inline.refreshMaxPosition();
|
|
120
|
+
assert.deepEqual(rect(inline), previous, 'inline Forms do not resize to the viewport');
|
|
121
|
+
const automatic = create();
|
|
122
|
+
automatic.props.width = 0;
|
|
123
|
+
automatic.props.height = 0;
|
|
124
|
+
automatic.events = [];
|
|
125
|
+
automatic.refreshMaxPosition();
|
|
126
|
+
assert.ok(automatic.events.every(([name]) => !['update:width', 'update:height'].includes(name)));
|
|
127
|
+
console.log('Form viewport bounds, four taskbar edges, safe padding, live switching, restore and bottomMost layout independence passed.');
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { SourceTextModule, SyntheticModule } from 'node:vm';
|
|
4
|
+
|
|
5
|
+
class AbstractControl {
|
|
6
|
+
element = { dir: 'ltr' };
|
|
7
|
+
emitted = [];
|
|
8
|
+
propInt(name) { return Number(this.props[name]); }
|
|
9
|
+
propBoolean(name) { return Boolean(this.props[name]); }
|
|
10
|
+
emit(name, value) { this.emitted.push([name, value]); }
|
|
11
|
+
}
|
|
12
|
+
const clickgo = new SyntheticModule(['control', 'dom', 'tool'], function() {
|
|
13
|
+
this.setExport('control', { AbstractControl });
|
|
14
|
+
this.setExport('dom', { isRtl: element => element.dir === 'rtl' });
|
|
15
|
+
this.setExport('tool', { clone: structuredClone });
|
|
16
|
+
});
|
|
17
|
+
const module = new SourceTextModule(await readFile('dist/sources/control/iconview/code.js', 'utf8'));
|
|
18
|
+
await module.link(() => clickgo);
|
|
19
|
+
await module.evaluate();
|
|
20
|
+
const view = new module.namespace.default();
|
|
21
|
+
view.props.data = Array.from({ length: 8 }, (_, i) => ({ name: 'item-' + i }));
|
|
22
|
+
|
|
23
|
+
// Resizing crosses column thresholds without stretching existing cells.
|
|
24
|
+
for (const [width, columns] of [[360, 2], [420, 2], [539, 2], [540, 3], [620, 3]]) {
|
|
25
|
+
view.clientwidth(width);
|
|
26
|
+
assert.equal(view.rowCount, columns);
|
|
27
|
+
assert.equal(view.cellWidth, 180);
|
|
28
|
+
assert.equal(view.dataComp[0].length, columns);
|
|
29
|
+
}
|
|
30
|
+
view.props.size = 64;
|
|
31
|
+
assert.equal(view.cellWidth, 144);
|
|
32
|
+
assert.equal(view.rowCount, 4);
|
|
33
|
+
view.clientwidth(100);
|
|
34
|
+
assert.equal(view.cellWidth, 100);
|
|
35
|
+
assert.equal(view.rowCount, 1);
|
|
36
|
+
view.clientwidth(0);
|
|
37
|
+
assert.ok(Number.isFinite(view.rowCount));
|
|
38
|
+
assert.equal(view.rowCount, 1);
|
|
39
|
+
view.props.size = 100;
|
|
40
|
+
view.clientwidth(500);
|
|
41
|
+
|
|
42
|
+
function box(x, width, modifiers = {}) {
|
|
43
|
+
view.onBeforeSelect();
|
|
44
|
+
view.onSelect({ x, width, y: 0, height: 100, start: 0, end: 0, empty: false, ...modifiers });
|
|
45
|
+
return [...view.valueData];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Column hit testing uses actual fixed cells; trailing whitespace selects nothing.
|
|
49
|
+
assert.deepEqual(box(190, 10), [1]);
|
|
50
|
+
assert.deepEqual(box(370, 80), []);
|
|
51
|
+
assert.equal(view.emitted.at(-1)[1].detail.area.empty, true);
|
|
52
|
+
assert.deepEqual(box(170, 200), [0, 1]);
|
|
53
|
+
assert.deepEqual(box(0, 20), [0]);
|
|
54
|
+
assert.deepEqual(box(370, 50, { ctrl: true }), [0]);
|
|
55
|
+
assert.deepEqual(box(190, 20, { shift: true }), [0, 1]);
|
|
56
|
+
view.arrowDown();
|
|
57
|
+
assert.ok(view.valueData.every(index => index >= 0 && index < 8));
|
|
58
|
+
|
|
59
|
+
// RTL starts at the right edge, leaving unused space at the left edge.
|
|
60
|
+
view.element.dir = 'rtl';
|
|
61
|
+
assert.deepEqual(box(10, 50), []);
|
|
62
|
+
assert.deepEqual(box(450, 20), [0]);
|
|
63
|
+
assert.deepEqual(box(280, 20), [1]);
|
|
64
|
+
assert.deepEqual(box(280, 200), [0, 1]);
|
|
65
|
+
view.props.multi = false;
|
|
66
|
+
assert.deepEqual(box(280, 20), [1]);
|
|
67
|
+
assert.deepEqual(box(10, 50), [1]);
|
|
68
|
+
view.props.data = [];
|
|
69
|
+
assert.deepEqual(view.dataComp, []);
|
|
70
|
+
console.log('Iconview fixed cells, column thresholds, size changes, narrow widths, LTR/RTL selection and modifiers passed.');
|
|
@@ -0,0 +1,212 @@
|
|
|
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
|
+
import { clone, getClassPrototype } from '../dist/lib/tool.js';
|
|
7
|
+
|
|
8
|
+
const dom = new JSDOM('<!doctype html><html><body></body></html>');
|
|
9
|
+
for (const name of ['window', 'document', 'Element', 'HTMLElement', 'SVGElement', 'Node']) {
|
|
10
|
+
Object.defineProperty(globalThis, name, { configurable: true, value: dom.window[name] });
|
|
11
|
+
}
|
|
12
|
+
const { createApp, h, nextTick, reactive } = await import('vue');
|
|
13
|
+
const errors = [];
|
|
14
|
+
const editors = [];
|
|
15
|
+
const menus = [];
|
|
16
|
+
let popCount = 0;
|
|
17
|
+
let moduleCount = 0;
|
|
18
|
+
let loadModule;
|
|
19
|
+
|
|
20
|
+
class AbstractControl {
|
|
21
|
+
get refs() { return this.$refs; }
|
|
22
|
+
get locale() { return 'en'; }
|
|
23
|
+
watch(name, callback, options) { return this.$watch(() => this.props[name], callback, options); }
|
|
24
|
+
emit(...args) { this.$emit(...args); }
|
|
25
|
+
propBoolean(name) { return this.props[name] === true; }
|
|
26
|
+
}
|
|
27
|
+
const module = {
|
|
28
|
+
make(element, options) {
|
|
29
|
+
assert.ok(element instanceof HTMLElement && element.isConnected, 'Jodit requires a live host');
|
|
30
|
+
const callbacks = {};
|
|
31
|
+
const editor = {
|
|
32
|
+
value: '', text: 'plain text', container: element.parentElement,
|
|
33
|
+
options, callbacks, commands: [], images: [], destructCount: 0,
|
|
34
|
+
events: { on(name, callback) { callbacks[name] = callback; } },
|
|
35
|
+
setReadOnly(value) { this.readonly = value; },
|
|
36
|
+
execCommand(value) { this.commands.push(value); },
|
|
37
|
+
selection: { insertImage(...args) { editor.images.push(args); } },
|
|
38
|
+
destruct() {
|
|
39
|
+
assert.equal(element.isConnected, true, 'release the editor before Vue removes its DOM');
|
|
40
|
+
++this.destructCount;
|
|
41
|
+
callbacks.change?.();
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
editors.push(editor);
|
|
45
|
+
return editor;
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
const clickgo = {
|
|
49
|
+
control: { AbstractControl },
|
|
50
|
+
core: { getModule() { ++moduleCount; return loadModule(); } },
|
|
51
|
+
dom: { findParentByClass() { return null; } },
|
|
52
|
+
form: { hidePop() {}, showPop() { ++popCount; } },
|
|
53
|
+
modules: { pointer: { menu(event, callback) { menus.push(callback); } } },
|
|
54
|
+
};
|
|
55
|
+
const source = await readFile(new URL('../dist/sources/control/jodit/code.ts', import.meta.url), 'utf8');
|
|
56
|
+
const exports = {};
|
|
57
|
+
vm.runInNewContext(ts.transpileModule(source, {
|
|
58
|
+
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
|
|
59
|
+
}).outputText, { exports, require: () => clickgo, HTMLElement });
|
|
60
|
+
const instance = new exports.default();
|
|
61
|
+
const prototype = getClassPrototype(instance);
|
|
62
|
+
const controls = [];
|
|
63
|
+
const Control = {
|
|
64
|
+
template: '<div><div ref="content"><textarea ref="editor"></textarea><div class="jodit-workplace"></div></div><div ref="pop"></div></div>',
|
|
65
|
+
props: Object.fromEntries(Object.entries(instance.props).map(([name, value]) => [name, {
|
|
66
|
+
default: () => clone(value),
|
|
67
|
+
}])),
|
|
68
|
+
emits: instance.emits,
|
|
69
|
+
data: () => Object.fromEntries(Object.entries(new exports.default()).filter(([name]) =>
|
|
70
|
+
!['props', 'emits', 'access'].includes(name))),
|
|
71
|
+
computed: prototype.access,
|
|
72
|
+
methods: prototype.method,
|
|
73
|
+
created() {
|
|
74
|
+
this.props = this.$props;
|
|
75
|
+
this.access = clone(instance.access);
|
|
76
|
+
controls.push(this);
|
|
77
|
+
},
|
|
78
|
+
// Mirror buildComponents: its mounted hook defers the control hook by one Vue tick.
|
|
79
|
+
async mounted() { await this.$nextTick(); await this.onMounted(); },
|
|
80
|
+
beforeUnmount: prototype.method.onBeforeUnmount,
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/** --- Drain both Vue mounting and asynchronous module continuations. --- */
|
|
84
|
+
async function flush() {
|
|
85
|
+
for (let i = 0; i < 8; ++i) await nextTick();
|
|
86
|
+
assert.deepEqual(errors, [], 'Jodit lifecycle must not produce runtime errors');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** --- Mount independent instances with the framework's per-instance access storage. --- */
|
|
90
|
+
function mount(items) {
|
|
91
|
+
const state = reactive({ items });
|
|
92
|
+
const events = [];
|
|
93
|
+
const host = document.createElement('div');
|
|
94
|
+
document.body.append(host);
|
|
95
|
+
const app = createApp({
|
|
96
|
+
render: () => h('div', state.items.map(item => h(Control, {
|
|
97
|
+
key: item.id,
|
|
98
|
+
modelValue: item.value ?? '', readonly: item.readonly ?? false,
|
|
99
|
+
'onUpdate:modelValue': value => events.push(['value', value]),
|
|
100
|
+
onText: value => events.push(['text', value]),
|
|
101
|
+
onInit: editor => events.push(['init', editor]),
|
|
102
|
+
onImgselect: callback => events.push(['image', callback]),
|
|
103
|
+
}))),
|
|
104
|
+
});
|
|
105
|
+
app.config.errorHandler = error => errors.push(error);
|
|
106
|
+
app.mount(host);
|
|
107
|
+
return { state, events, host, unmount() { app.unmount(); host.remove(); } };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// A parent can replace the temporary editor before the deferred control hook even starts.
|
|
111
|
+
loadModule = () => Promise.resolve(module);
|
|
112
|
+
let view = mount([{ id: 'temporary' }]);
|
|
113
|
+
view.state.items = [];
|
|
114
|
+
await flush();
|
|
115
|
+
assert.equal(moduleCount, 0);
|
|
116
|
+
assert.equal(editors.length, 0);
|
|
117
|
+
view.unmount();
|
|
118
|
+
|
|
119
|
+
// Module resolution after unmount must neither initialize Jodit nor change fallback state.
|
|
120
|
+
for (const result of [module, null]) {
|
|
121
|
+
let release;
|
|
122
|
+
loadModule = () => new Promise(resolve => { release = resolve; });
|
|
123
|
+
view = mount([{ id: 'pending' }]);
|
|
124
|
+
await flush();
|
|
125
|
+
const control = controls.at(-1);
|
|
126
|
+
view.state.items = [];
|
|
127
|
+
await flush();
|
|
128
|
+
release(result);
|
|
129
|
+
await flush();
|
|
130
|
+
assert.equal(editors.length, 0);
|
|
131
|
+
assert.equal(control.notInit, false);
|
|
132
|
+
assert.deepEqual(view.events, []);
|
|
133
|
+
view.unmount();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Re-rendering one editor while a shared module loads must preserve its surviving sibling.
|
|
137
|
+
let release;
|
|
138
|
+
const pending = new Promise(resolve => { release = resolve; });
|
|
139
|
+
loadModule = () => pending;
|
|
140
|
+
view = mount([{ id: 'temporary' }, { id: 'live', value: 'before' }]);
|
|
141
|
+
await flush();
|
|
142
|
+
const removed = controls.at(-2);
|
|
143
|
+
const live = controls.at(-1);
|
|
144
|
+
assert.notEqual(removed.access, live.access);
|
|
145
|
+
view.state.items.splice(0, 1);
|
|
146
|
+
view.state.items[0].value = 'after';
|
|
147
|
+
view.state.items[0].readonly = true;
|
|
148
|
+
await flush();
|
|
149
|
+
release(module);
|
|
150
|
+
await flush();
|
|
151
|
+
assert.equal(editors.length, 1);
|
|
152
|
+
const editor = editors[0];
|
|
153
|
+
assert.equal(live.access.editor, editor);
|
|
154
|
+
assert.equal(editor.value, 'after');
|
|
155
|
+
assert.equal(editor.readonly, true);
|
|
156
|
+
view.state.items[0].value = 'updated';
|
|
157
|
+
view.state.items[0].readonly = false;
|
|
158
|
+
await flush();
|
|
159
|
+
assert.equal(editor.value, 'updated');
|
|
160
|
+
assert.equal(editor.readonly, false);
|
|
161
|
+
editor.callbacks.change();
|
|
162
|
+
assert.ok(view.events.some(event => event[0] === 'value' && event[1] === 'updated'));
|
|
163
|
+
editor.callbacks.focus();
|
|
164
|
+
assert.equal(live.isFocus, true);
|
|
165
|
+
editor.callbacks.blur();
|
|
166
|
+
assert.equal(live.isFocus, false);
|
|
167
|
+
live.execCmd('copy');
|
|
168
|
+
assert.deepEqual(editor.commands, ['copy']);
|
|
169
|
+
editor.options.extraButtons[0].exec();
|
|
170
|
+
const insertImage = view.events.find(event => event[0] === 'image')[1];
|
|
171
|
+
const workplace = view.host.querySelector('.jodit-workplace');
|
|
172
|
+
workplace.dispatchEvent(new dom.window.Event('pointerdown', { bubbles: true }));
|
|
173
|
+
assert.equal(menus.length, 1);
|
|
174
|
+
const eventCount = view.events.length;
|
|
175
|
+
view.unmount();
|
|
176
|
+
await flush();
|
|
177
|
+
assert.equal(editor.destructCount, 1);
|
|
178
|
+
assert.equal(live.access.editor, undefined);
|
|
179
|
+
assert.equal(view.events.length, eventCount, 'destruction must not emit a model update');
|
|
180
|
+
insertImage('image.png');
|
|
181
|
+
menus[0]();
|
|
182
|
+
workplace.dispatchEvent(new dom.window.Event('pointerdown', { bubbles: true }));
|
|
183
|
+
live.execCmd('paste');
|
|
184
|
+
live.onBeforeUnmount();
|
|
185
|
+
assert.equal(editor.destructCount, 1, 'cleanup is idempotent');
|
|
186
|
+
assert.deepEqual(editor.images, []);
|
|
187
|
+
assert.equal(popCount, 0);
|
|
188
|
+
assert.equal(menus.length, 1, 'remove the menu listener at unmount');
|
|
189
|
+
|
|
190
|
+
// A cached module still resolves asynchronously on every later entry.
|
|
191
|
+
loadModule = async () => { await Promise.resolve(); return module; };
|
|
192
|
+
for (let i = 0; i < 20; ++i) {
|
|
193
|
+
view = mount([{ id: 'entry', value: `entry ${i}` }]);
|
|
194
|
+
await flush();
|
|
195
|
+
view.state.items = [{ id: 'replacement', value: `replacement ${i}` }];
|
|
196
|
+
await flush();
|
|
197
|
+
view.unmount();
|
|
198
|
+
await flush();
|
|
199
|
+
}
|
|
200
|
+
assert.equal(editors.length, 41);
|
|
201
|
+
assert.ok(editors.every(item => item.destructCount === 1));
|
|
202
|
+
|
|
203
|
+
// A genuine module load failure still displays the existing fallback.
|
|
204
|
+
loadModule = () => Promise.resolve(null);
|
|
205
|
+
view = mount([{ id: 'failure' }]);
|
|
206
|
+
await flush();
|
|
207
|
+
assert.equal(controls.at(-1).notInit, true);
|
|
208
|
+
assert.equal(controls.at(-1).isLoading, false);
|
|
209
|
+
view.unmount();
|
|
210
|
+
await flush();
|
|
211
|
+
dom.window.close();
|
|
212
|
+
console.log('Jodit deferred mounting, late module resolution, instance isolation, repeated entry and editor cleanup passed.');
|
package/test/rtl-archive.mjs
CHANGED
|
@@ -22,7 +22,7 @@ const commonControls = new Set([
|
|
|
22
22
|
'select', 'sidebar-toggle', 'text', 'toolbox',
|
|
23
23
|
]);
|
|
24
24
|
/** --- 全部内置主题 --- */
|
|
25
|
-
const themes = ['admin', 'cyber', 'dark', 'light', 'modern', 'modern-light', '
|
|
25
|
+
const themes = ['admin', 'cyber', 'dark', 'light', 'modern', 'modern-light', 'software-dark', 'software-light'];
|
|
26
26
|
/** --- CGA 格式常量,与编译器的公开包格式保持一致 --- */
|
|
27
27
|
const cgaHeaderLength = 106;
|
|
28
28
|
const cgaKeyContext = Buffer.from('ClickGo/Application/Package');
|
package/test/scroll.mjs
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
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
|
+
import { getClassPrototype, lang } from '../dist/lib/tool.js';
|
|
7
|
+
|
|
8
|
+
const dom = new JSDOM('<!doctype html><html><body></body></html>');
|
|
9
|
+
for (const name of ['window', 'document', 'Element', 'HTMLElement', 'SVGElement', 'Node']) {
|
|
10
|
+
Object.defineProperty(globalThis, name, { configurable: true, value: dom.window[name] });
|
|
11
|
+
}
|
|
12
|
+
const { createApp, reactive, nextTick, h } = await import('vue');
|
|
13
|
+
const style = document.createElement('style');
|
|
14
|
+
style.textContent = await readFile(new URL('../dist/sources/control/scroll/style.css', import.meta.url), 'utf8');
|
|
15
|
+
document.head.append(style);
|
|
16
|
+
const state = reactive({ locale: 'en', direction: 'h', length: 1000, client: 200, offset: 250 });
|
|
17
|
+
const errors = [];
|
|
18
|
+
const watchers = [];
|
|
19
|
+
const rolls = [];
|
|
20
|
+
const frames = new Set();
|
|
21
|
+
process.on('unhandledRejection', error => { errors.push(error); });
|
|
22
|
+
HTMLElement.prototype.getBoundingClientRect = function() {
|
|
23
|
+
const length = this.classList.contains('bar') ? 400 : 460;
|
|
24
|
+
return { width: state.direction === 'h' ? length : 30, height: state.direction === 'v' ? length : 30, left: 0, top: 0 };
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// Retain ClickGo's computed $el getter to exercise the real first-render order.
|
|
28
|
+
class AbstractControl {
|
|
29
|
+
get element() { return this.$el; }
|
|
30
|
+
get refs() { return this.$refs; }
|
|
31
|
+
get locale() { return state.locale; }
|
|
32
|
+
get localeDirection() { return lang.getDirection(this.locale); }
|
|
33
|
+
watch(...args) { return this.$watch(...args); }
|
|
34
|
+
emit(...args) { this.$emit(...args); }
|
|
35
|
+
propInt(name) { return parseInt(this.props[name]); }
|
|
36
|
+
propBoolean(name) { return this.props[name] === true || this.props[name] === 'true'; }
|
|
37
|
+
}
|
|
38
|
+
const clickgo = {
|
|
39
|
+
control: { AbstractControl },
|
|
40
|
+
dom: {
|
|
41
|
+
isRtl: el => el instanceof Element && dom.window.getComputedStyle(el).direction === 'rtl',
|
|
42
|
+
watchSize: (control, el, callback, immediate) => {
|
|
43
|
+
assert.ok(el instanceof HTMLElement && el.isConnected);
|
|
44
|
+
watchers.push(callback);
|
|
45
|
+
if (immediate) callback();
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
modules: { pointer: {
|
|
49
|
+
hover() {},
|
|
50
|
+
down: (event, options) => { options.down(); options.up(); },
|
|
51
|
+
} },
|
|
52
|
+
task: {
|
|
53
|
+
onFrame: (control, callback) => { callback(); frames.add(1); return 1; },
|
|
54
|
+
offFrame: (control, id) => { frames.delete(id); },
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
const source = await readFile(new URL('../dist/sources/control/scroll/code.ts', import.meta.url), 'utf8');
|
|
58
|
+
const exports = {};
|
|
59
|
+
vm.runInNewContext(ts.transpileModule(source, {
|
|
60
|
+
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
|
|
61
|
+
}).outputText, { exports, require: () => clickgo });
|
|
62
|
+
const instance = new exports.default();
|
|
63
|
+
const prototype = getClassPrototype(instance);
|
|
64
|
+
const Scroll = {
|
|
65
|
+
template: await readFile(new URL('../dist/sources/control/scroll/layout.html', import.meta.url), 'utf8'),
|
|
66
|
+
props: Object.fromEntries(Object.entries(instance.props).map(([name, value]) => [name, { default: value }])),
|
|
67
|
+
emits: instance.emits,
|
|
68
|
+
data: () => Object.fromEntries(Object.entries(instance).filter(([name]) => !['props', 'emits'].includes(name))),
|
|
69
|
+
computed: prototype.access,
|
|
70
|
+
methods: prototype.method,
|
|
71
|
+
created() { this.props = this.$props; },
|
|
72
|
+
async mounted() { await this.$nextTick(); this.onMounted(); },
|
|
73
|
+
};
|
|
74
|
+
const host = document.createElement('div');
|
|
75
|
+
document.body.append(host);
|
|
76
|
+
const app = createApp({
|
|
77
|
+
render() {
|
|
78
|
+
return h('div', { dir: lang.getDirection(state.locale) }, [h(Scroll, {
|
|
79
|
+
ref: 'scroll', direction: state.direction, length: state.length, client: state.client, offset: state.offset,
|
|
80
|
+
'onUpdate:offset': offset => { state.offset = offset; },
|
|
81
|
+
onRoll: () => { rolls.push(state.offset); },
|
|
82
|
+
})]);
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
app.config.errorHandler = error => { errors.push(error); };
|
|
86
|
+
const root = app.mount(host);
|
|
87
|
+
|
|
88
|
+
/** --- Drain render, mounted watchers and reactive updates. --- */
|
|
89
|
+
async function flush() {
|
|
90
|
+
for (let i = 0; i < 10; ++i) await nextTick();
|
|
91
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
92
|
+
assert.deepEqual(errors, [], 'Scroll must mount/update without runtime errors');
|
|
93
|
+
}
|
|
94
|
+
await flush();
|
|
95
|
+
const scroll = root.$refs.scroll;
|
|
96
|
+
assert.equal(scroll.element, host.querySelector('.wrap'));
|
|
97
|
+
assert.equal(scroll.barPx, 400);
|
|
98
|
+
assert.equal(scroll.blockPx, 80);
|
|
99
|
+
assert.equal(host.querySelector('.start').style.width, '30px');
|
|
100
|
+
assert.equal(host.querySelector('.end').style.width, '30px');
|
|
101
|
+
assert.equal(scroll.offsetPx, 100);
|
|
102
|
+
assert.match(host.querySelector('.block').style.transform, /translate3d\(100px, 0, 0\)/);
|
|
103
|
+
host.querySelector('.end').dispatchEvent(new dom.window.Event('pointerdown', { bubbles: true }));
|
|
104
|
+
await flush();
|
|
105
|
+
assert.equal(state.offset, 260);
|
|
106
|
+
assert.equal(rolls.length, 1);
|
|
107
|
+
assert.equal(frames.size, 0, 'pointer release stops the frame timer');
|
|
108
|
+
state.locale = 'ar';
|
|
109
|
+
await flush();
|
|
110
|
+
assert.equal(scroll.isRtl, true);
|
|
111
|
+
assert.equal(scroll.offsetPx, 216);
|
|
112
|
+
assert.equal(dom.window.getComputedStyle(host.querySelector('.bar')).direction, 'ltr',
|
|
113
|
+
'the track must use a left origin for the physical translateX coordinate');
|
|
114
|
+
host.querySelector('.start').dispatchEvent(new dom.window.Event('pointerdown', { bubbles: true }));
|
|
115
|
+
await flush();
|
|
116
|
+
assert.equal(state.offset, 250);
|
|
117
|
+
assert.equal(scroll.offsetPx, 220);
|
|
118
|
+
state.direction = 'v';
|
|
119
|
+
await flush();
|
|
120
|
+
watchers[0]();
|
|
121
|
+
await flush();
|
|
122
|
+
assert.equal(scroll.isRtl, false);
|
|
123
|
+
assert.equal(host.querySelector('.start').style.height, '30px');
|
|
124
|
+
assert.equal(host.querySelector('.start').style.width, '');
|
|
125
|
+
assert.match(host.querySelector('.block').style.transform, /translate3d\(0, 100px, 0\)/);
|
|
126
|
+
state.direction = 'h';
|
|
127
|
+
state.locale = 'en';
|
|
128
|
+
await flush();
|
|
129
|
+
watchers[0]();
|
|
130
|
+
await flush();
|
|
131
|
+
assert.equal(host.querySelector('.end').style.width, '30px');
|
|
132
|
+
assert.equal(scroll.offsetPx, 100);
|
|
133
|
+
state.length = 300;
|
|
134
|
+
await flush();
|
|
135
|
+
assert.equal(state.offset, 100, 'shrinking the range clamps the offset');
|
|
136
|
+
state.client = 400;
|
|
137
|
+
await flush();
|
|
138
|
+
assert.equal(state.offset, 0);
|
|
139
|
+
assert.equal(host.querySelector('.wrap').getAttribute('data-cg-disabled'), '');
|
|
140
|
+
host.querySelector('.end').dispatchEvent(new dom.window.Event('pointerdown', { bubbles: true }));
|
|
141
|
+
await flush();
|
|
142
|
+
assert.equal(state.offset, 0);
|
|
143
|
+
app.unmount();
|
|
144
|
+
await flush();
|
|
145
|
+
console.log('Scroll real Vue mount, arrow sizes/events, range changes and live RTL/direction switching passed.');
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import vm from 'node:vm';
|
|
4
|
+
import ts from 'typescript';
|
|
5
|
+
|
|
6
|
+
const requests = new Map();
|
|
7
|
+
const styles = [];
|
|
8
|
+
const progress = [];
|
|
9
|
+
const events = [];
|
|
10
|
+
let id = 0;
|
|
11
|
+
let taskApi;
|
|
12
|
+
function getContent(taskId, path) {
|
|
13
|
+
return new Promise((resolve, reject) => requests.set(path, { resolve, reject }));
|
|
14
|
+
}
|
|
15
|
+
const tool = {
|
|
16
|
+
urlResolve: (base, path) => path,
|
|
17
|
+
random: () => `id${++id}`,
|
|
18
|
+
stateMachine() {},
|
|
19
|
+
runIife: () => class { async main() { events.push('main'); } },
|
|
20
|
+
stylePrepend: style => ({ prep: '', style }),
|
|
21
|
+
styleUrl2DataUrl: async (base, style) => style,
|
|
22
|
+
layoutAddTagClassAndReTagName: layout => layout,
|
|
23
|
+
layoutClassPrepend: layout => layout,
|
|
24
|
+
eventsAttrWrap: layout => layout,
|
|
25
|
+
getClassPrototype: () => ({ method: {}, access: {} }),
|
|
26
|
+
};
|
|
27
|
+
const core = { config: { locale: 'en' }, trigger: async (...args) => { events.push(args); } };
|
|
28
|
+
const form = { notify() {}, notifyContent() {} };
|
|
29
|
+
const dom = { createToStyleList() {}, removeFromStyleList() {}, pushStyle: (taskId, style) => styles.push(style) };
|
|
30
|
+
const zip = { get: async blob => {
|
|
31
|
+
const label = await blob.text();
|
|
32
|
+
return {
|
|
33
|
+
readDir: path => path === '/' ? [{ name: 'same', isFile: false }] : [
|
|
34
|
+
{ path: 'same/', name: 'layout.html' }, { path: 'same/', name: 'style.css' },
|
|
35
|
+
],
|
|
36
|
+
getContent: async path => path.endsWith('config.json') ?
|
|
37
|
+
JSON.stringify({ name: 'same', layout: 'layout', style: 'style' }) :
|
|
38
|
+
path.endsWith('html') ? `<div>${label}</div>` : label,
|
|
39
|
+
};
|
|
40
|
+
} };
|
|
41
|
+
tool.getMimeByPath = name => ({ ext: name.split('.').pop() });
|
|
42
|
+
async function load(file, modules) {
|
|
43
|
+
const source = await readFile(new URL(`../dist/lib/${file}.ts`, import.meta.url), 'utf8');
|
|
44
|
+
const exports = {};
|
|
45
|
+
vm.runInNewContext(ts.transpileModule(source, {
|
|
46
|
+
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
|
|
47
|
+
}).outputText, { exports, require: name => modules[name] ?? {}, Blob, setTimeout, clearTimeout,
|
|
48
|
+
window: { innerWidth: 800, innerHeight: 600 } });
|
|
49
|
+
return exports;
|
|
50
|
+
}
|
|
51
|
+
const clickgo = { getDirname: () => '/clickgo/', modules: { vue: { reactive: value => value, watch() {} } } };
|
|
52
|
+
const common = { '../clickgo': clickgo, './tool': tool, './core': core, './fs': { getContent }, './dom': dom, './form': form };
|
|
53
|
+
const controls = await load('control', { ...common, './zip': zip, './task': { getOrigin: taskId => taskApi.getOrigin(taskId) } });
|
|
54
|
+
const themeOrder = [];
|
|
55
|
+
taskApi = await load('task', { ...common, './control': controls, './theme': {
|
|
56
|
+
read: async blob => ({ name: await blob.text() }),
|
|
57
|
+
load: async (taskId, theme) => { themeOrder.push(theme?.name ?? 'global'); },
|
|
58
|
+
} });
|
|
59
|
+
taskApi.initSysId('sys');
|
|
60
|
+
taskApi.init();
|
|
61
|
+
const app = {
|
|
62
|
+
type: 'app', config: { name: 'Concurrent packages', controls: ['/a', '/b.cgc'], themes: ['/light', '/dark'] },
|
|
63
|
+
package: { getContent: async path => path === '/app.js' ? 'app fixture' : null },
|
|
64
|
+
};
|
|
65
|
+
const running = taskApi.run('sys', app, { notify: false, progress: (loaded, total, type, path) => progress.push(path) });
|
|
66
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
67
|
+
assert.deepEqual([...requests.keys()], ['/light.cgt', '/dark.cgt', '/a.cgc', '/b.cgc'], 'all themes and controls request before the first package completes');
|
|
68
|
+
requests.get('/b.cgc').resolve(new Blob(['second']));
|
|
69
|
+
requests.get('/dark.cgt').resolve(new Blob(['dark']));
|
|
70
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
71
|
+
assert.deepEqual(styles, [], 'later downloads cannot change registration order');
|
|
72
|
+
assert.deepEqual(themeOrder, [], 'themes wait for controls and preceding themes');
|
|
73
|
+
requests.get('/a.cgc').resolve(new Blob(['first']));
|
|
74
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
75
|
+
assert.deepEqual(styles, ['first', 'second'], 'CSS registers in configured order');
|
|
76
|
+
assert.deepEqual(progress, ['/a.cgc', '/b.cgc']);
|
|
77
|
+
requests.get('/light.cgt').resolve(new Blob(['light']));
|
|
78
|
+
const taskId = await running;
|
|
79
|
+
assert.equal(typeof taskId, 'string');
|
|
80
|
+
assert.ok(taskApi.getOrigin(taskId).controls.same.layout.includes('second'), 'last configured duplicate control wins');
|
|
81
|
+
assert.deepEqual(themeOrder, ['light', 'dark'], 'theme application preserves configured order');
|
|
82
|
+
assert.equal(events.at(-1), 'main');
|
|
83
|
+
|
|
84
|
+
requests.clear();
|
|
85
|
+
const failed = taskApi.run('sys', { ...app, config: { ...app.config, controls: ['/missing', '/later'], themes: ['/unused'] } }, { notify: false });
|
|
86
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
87
|
+
requests.get('/later.cgc').reject(new Error('later request failure'));
|
|
88
|
+
requests.get('/unused.cgt').reject(new Error('unused theme failure'));
|
|
89
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
90
|
+
requests.get('/missing.cgc').resolve(null);
|
|
91
|
+
assert.equal(await failed, -905, 'missing control retains the task failure code');
|
|
92
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
93
|
+
assert.deepEqual(themeOrder, ['light', 'dark'], 'failure does not apply prefetched themes');
|
|
94
|
+
console.log('Concurrent CGC/CGT downloads, ordered registration/themes, duplicate controls and failure handling passed.');
|