clickgo 6.8.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clickgo",
3
- "version": "6.8.0",
3
+ "version": "6.8.1",
4
4
  "description": "Background interface, software interface, mobile phone APP interface operation library.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -14,10 +14,15 @@
14
14
  "types": "./dist/clickgo.d.ts",
15
15
  "scripts": {
16
16
  "sass": "sass --watch dist/:dist/ --style compressed --no-source-map",
17
- "check": "tsc --noEmit --pretty false && eslint \"dist/**/*.ts\" --format stylish && npm run test:locale && npm run test:style && npm run test:desktop && npm run test:form",
18
- "test:form": "node test/form-viewport.mjs && node --experimental-vm-modules test/form-native.mjs && node test/form-launcher.mjs",
17
+ "check": "tsc --noEmit --pretty false && eslint \"dist/**/*.ts\" --format stylish && npm run test:locale && npm run test:style && npm run test:desktop && npm run test:form && npm run test:task && npm run test:module && npm run test:vflow && npm run test:scroll && npm run test:jodit",
18
+ "test:module": "node test/tool-assets.mjs && node test/core-module-loading.mjs && node test/tuieditor-locale.mjs",
19
+ "test:jodit": "node test/jodit-lifecycle.mjs",
20
+ "test:scroll": "node test/scroll.mjs",
21
+ "test:vflow": "node test/vflow.mjs",
22
+ "test:task": "node test/task-tray.mjs && node test/task-package-loading.mjs",
23
+ "test:form": "node test/form-viewport.mjs && node --experimental-vm-modules test/form-native.mjs && node test/form-launcher.mjs && node test/form-notify.mjs",
19
24
  "test:native-regions": "node test/form-native-regions.mjs",
20
- "test:desktop": "node --experimental-vm-modules test/desktop.mjs",
25
+ "test:desktop": "node --experimental-vm-modules test/desktop.mjs && node --experimental-vm-modules test/iconview.mjs",
21
26
  "test:style": "node --experimental-vm-modules test/dom-style.mjs",
22
27
  "test:archive": "node test/rtl-archive.mjs",
23
28
  "test:locale": "node --experimental-vm-modules test/locale-integrity.mjs && node --experimental-vm-modules test/rtl-direction.mjs",
@@ -0,0 +1,169 @@
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 source = await readFile(new URL('../dist/lib/core.ts', import.meta.url), 'utf8');
7
+ const tree = ts.createSourceFile('core.ts', source, ts.ScriptTarget.ES2022, true);
8
+ const names = new Set(['regModule', 'loadModule', 'getModule']);
9
+ const selected = tree.statements.filter(node =>
10
+ (ts.isVariableStatement(node) && node.declarationList.declarations.some(item => item.name.getText(tree) === 'modules')) ||
11
+ (ts.isFunctionDeclaration(node) && node.body && names.has(node.name?.text)));
12
+ const requests = [];
13
+ const globals = {};
14
+ const clickgo = { modules: {}, getCdn: () => 'https://cdn.example', getDirname: () => 'https://app.example/clickgo' };
15
+ const api = {};
16
+ function pending(urls) {
17
+ let resolve;
18
+ let reject;
19
+ const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
20
+ requests.push({ urls: Array.from(urls), resolve, reject });
21
+ return promise;
22
+ }
23
+ vm.runInNewContext(ts.transpileModule(selected.map(node => node.getText(tree)).join('\n'), {
24
+ compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, esModuleInterop: false },
25
+ }).outputText, {
26
+ exports: api, window: globals, clickgo,
27
+ lTask: { checkPermission: async () => [true] },
28
+ lTool: { loadAssets: pending, loadScript: url => pending([url]), loadStyle() {}, urlResolve: (base, path) => new URL(path, base).href },
29
+ });
30
+
31
+ const xterm = api.getModule('xterm');
32
+ const duplicate = api.getModule('xterm');
33
+ assert.equal(requests.length, 1, 'concurrent callers share one unified resource batch');
34
+ assert.equal(requests[0].urls.length, 4);
35
+ assert.ok(requests[0].urls[0].endsWith('/xterm.js'));
36
+ assert.ok(requests[0].urls[3].endsWith('/xterm.min.css'));
37
+ assert.equal(clickgo.modules.xterm, undefined, 'modules are not published until their resources complete');
38
+ globals.Terminal = class {};
39
+ globals.FitAddon = { FitAddon: class {} };
40
+ globals.WebglAddon = { WebglAddon: class {} };
41
+ requests[0].resolve(true);
42
+ const first = await xterm;
43
+ assert.equal(await duplicate, first);
44
+ assert.equal(await api.getModule('xterm'), first);
45
+ assert.equal(requests.length, 1, 'ready modules do not reload resources');
46
+
47
+ requests.length = 0;
48
+ const toast = api.getModule('@toast-ui/editor');
49
+ const toastWaiter = api.getModule('@toast-ui/editor');
50
+ assert.equal(requests.length, 1, 'concurrent Toast UI callers share one batch of the local complete library and CDN styles');
51
+ assert.equal(requests[0].urls.length, 3);
52
+ assert.equal(requests[0].urls[0], 'https://app.example/clickgo/ext/toastui-editor-all.min.js', 'the complete library resolves relative to the framework, independently of its CDN');
53
+ assert.ok(requests[0].urls.slice(1).every(url => url.startsWith(clickgo.getCdn() + '/npm/@toast-ui/editor@3.2.2/')), 'Toast UI styles keep the configured CDN');
54
+ assert.equal(requests[0].urls.filter(url => url.includes('/i18n/')).length, 0, 'the shared module and pure viewer need no locale scripts');
55
+ assert.equal(requests[0].urls.filter(url => url.endsWith('.css')).length, 2);
56
+ const Editor = class {};
57
+ globals.toastui = { Editor };
58
+ assert.equal(clickgo.modules['@toast-ui/editor'], undefined, 'a script constructor is not published before the resource batch completes');
59
+ requests[0].resolve(true);
60
+ const toastModule = await toast;
61
+ assert.equal(await toastWaiter, toastModule);
62
+ assert.equal(toastModule.Editor, Editor, 'the wrapper exposes the local library constructor used by language scripts');
63
+ assert.equal(globals.toastui.loadLanguage, undefined, 'the shared helper is separate from the native namespace');
64
+ assert.equal(await api.getModule('@toast-ui/editor'), toastModule, 'all callers receive the same wrapper');
65
+ requests.length = 0;
66
+ assert.equal(await toastModule.loadLanguage('en'), true);
67
+ assert.equal(await toastModule.loadLanguage('en-US'), true);
68
+ assert.equal(await toastModule.loadLanguage('../unknown'), false);
69
+ assert.equal(requests.length, 0, 'built-in English and unsupported names issue no requests');
70
+ const locale = toastModule.loadLanguage('zh-CN');
71
+ const localeWaiter = toastModule.loadLanguage('ZH-cn');
72
+ assert.equal(requests.length, 1, 'normalized language names share a pending script globally');
73
+ assert.ok(requests[0].urls[0].endsWith('/i18n/zh-cn.min.js'));
74
+ requests[0].resolve(true);
75
+ assert.equal(await locale, true);
76
+ assert.equal(await localeWaiter, true);
77
+ assert.equal(await toastModule.loadLanguage('zh-cn'), true);
78
+ assert.equal(requests.length, 1, 'ready languages are reused');
79
+ const badLocale = toastModule.loadLanguage('fr-FR');
80
+ const badLocaleWaiter = toastModule.loadLanguage('fr-fr');
81
+ requests.at(-1).resolve(false);
82
+ assert.equal(await badLocale, false);
83
+ assert.equal(await badLocaleWaiter, false);
84
+ const localeRetry = toastModule.loadLanguage('fr-FR');
85
+ assert.equal(requests.length, 3, 'failed languages can be retried');
86
+ requests.at(-1).resolve(true);
87
+ assert.equal(await localeRetry, true);
88
+
89
+ // Resource rejection releases all waiters even when a library global already exists.
90
+ delete clickgo.modules['@toast-ui/editor'];
91
+ requests.length = 0;
92
+ const resourceFailure = api.getModule('@toast-ui/editor');
93
+ const resourceFailureWaiter = api.getModule('@toast-ui/editor');
94
+ requests[0].reject(new Error('local library unavailable'));
95
+ assert.equal(await resourceFailure, null);
96
+ assert.equal(await resourceFailureWaiter, null);
97
+ assert.equal(clickgo.modules['@toast-ui/editor'], undefined);
98
+ requests.length = 0;
99
+ const resourceRetry = api.getModule('@toast-ui/editor');
100
+ requests[0].resolve(true);
101
+ assert.equal((await resourceRetry).Editor, Editor, 'failed initialization leaves the shared module available for retry');
102
+
103
+ delete clickgo.modules['@toast-ui/editor'];
104
+ requests.length = 0;
105
+ const missingConstructor = api.getModule('@toast-ui/editor');
106
+ delete globals.toastui.Editor;
107
+ requests[0].resolve(true);
108
+ assert.equal(await missingConstructor, null, 'a missing local constructor is not published');
109
+ requests.length = 0;
110
+ const failedCss = api.getModule('@toast-ui/editor');
111
+ globals.toastui.Editor = Editor;
112
+ requests[0].resolve(false);
113
+ assert.equal(await failedCss, null, 'a library constructor does not hide resource failure');
114
+
115
+ requests.length = 0;
116
+ const failed = api.getModule('jodit');
117
+ const alsoFailed = api.getModule('jodit');
118
+ assert.equal(requests.length, 1);
119
+ requests[0].resolve(true);
120
+ assert.equal(await failed, null, 'a missing script global is a module failure');
121
+ assert.equal(await alsoFailed, null, 'waiting callers also receive failure instead of hanging');
122
+ requests.length = 0;
123
+ const retried = api.getModule('jodit');
124
+ assert.equal(requests.length, 1, 'failure clears the in-flight state and allows retry');
125
+ globals.Jodit = { make() {} };
126
+ requests[0].resolve(true);
127
+ assert.equal(await retried, globals.Jodit);
128
+
129
+ requests.length = 0;
130
+ delete clickgo.modules.jodit;
131
+ const badAssets = api.getModule('jodit');
132
+ const badAssetsWaiter = api.getModule('jodit');
133
+ requests[0].resolve(false);
134
+ assert.equal(await badAssets, null, 'any failed resource prevents publishing the module');
135
+ assert.equal(await badAssetsWaiter, null);
136
+ assert.equal(clickgo.modules.jodit, undefined);
137
+ requests.length = 0;
138
+ const assetsRetry = api.getModule('jodit');
139
+ requests[0].resolve(true);
140
+ assert.equal(await assetsRetry, globals.Jodit);
141
+
142
+ let attempts = 0;
143
+ let finish;
144
+ assert.equal(await api.regModule('owner', 'retry-test', {
145
+ func: () => { ++attempts; return new Promise(resolve => { finish = resolve; }); },
146
+ }), true);
147
+ const empty = api.loadModule('retry-test');
148
+ const emptyWaiter = api.loadModule('retry-test');
149
+ finish(null);
150
+ assert.equal(await empty, false);
151
+ assert.equal(await emptyWaiter, false);
152
+ const retry = api.loadModule('retry-test');
153
+ finish({ ready: true });
154
+ assert.equal(await retry, true);
155
+ assert.equal(attempts, 2);
156
+
157
+ let reject;
158
+ assert.equal(await api.regModule('owner', 'reject-test', {
159
+ func: () => new Promise((resolve, no) => { reject = no; }),
160
+ }), true);
161
+ const rejected = api.loadModule('reject-test');
162
+ const rejectedWaiter = api.loadModule('reject-test');
163
+ reject(new Error('external loader failed'));
164
+ assert.equal(await rejected, false);
165
+ assert.equal(await rejectedWaiter, false, 'external rejections release every waiting caller');
166
+ const secondTry = api.loadModule('reject-test');
167
+ reject(new Error('external loader failed again'));
168
+ assert.equal(await secondTry, false);
169
+ console.log('Local complete Toast UI library/CDN styles, locale-free loading, shared requests, failures and retries passed.');
@@ -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.');
@@ -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.');