Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@
"dev": "rollup -c -w",
"validate": "svelte-check",
"lint": "eslint src/main/**/*.{ts,svelte}",
"test": "echo 'No tests hooked up yet'",
"test": "bedrock-auto --bundler rspack -b chrome-headless -f src/test/ts/**/*Test.ts -c tsconfig.test.json",
"test-manual": "bedrock --bundler rspack -f src/test/ts/**/*Test.ts -c tsconfig.test.json",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"deploy-storybook": "storybook build && gh-pages -d ./storybook-static -u 'tiny-bot <no-reply@tiny.cloud>' --nojekyll",
Expand Down Expand Up @@ -55,6 +56,12 @@
}
},
"devDependencies": {
"@ephox/agar": "^8.0.1",
"@ephox/bedrock-client": "^16.0.0",
"@ephox/bedrock-server": "^16.2.0",
"@ephox/katamari": "^9.1.6",
"@ephox/mcagar": "^9.0.1",
"@ephox/sugar": "^9.3.1",
"@rollup/plugin-commonjs": "^17.1.0",
"@rollup/plugin-node-resolve": "^11.2.1",
"@rollup/plugin-typescript": "^8.5.0",
Expand All @@ -65,6 +72,7 @@
"@sveltejs/vite-plugin-svelte": "^4.0.0",
"@tinymce/beehive-flow": "^0.19.0",
"@tinymce/eslint-plugin": "3.0.0",
"@tinymce/miniature": "^6.0.0",
"@tsconfig/svelte": "^5.0.8",
"@typescript-eslint/eslint-plugin": "^8.57.0",
"@typescript-eslint/parser": "^8.46.2",
Expand All @@ -85,6 +93,11 @@
"svelte-loader": "^3.2.4",
"svelte-preprocess": "^6.0.0",
"tinymce": "^8.1.2",
"tinymce-5": "npm:tinymce@^5",
"tinymce-6": "npm:tinymce@^6",
"tinymce-7": "npm:tinymce@^7",
"tinymce-7.5": "npm:tinymce@7.5",
"tinymce-8": "npm:tinymce@^8.0.0",
"tslib": "^2.8.1",
"typescript": "^5.9.3",
"vite": "^5.4.21"
Expand Down
16 changes: 16 additions & 0 deletions scripts/svelte-loader.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Minimal Svelte loader for webpack/rspack.
// Used via inline loader syntax in test files:
// import Editor from '!!<path>/svelte-loader.js!./Editor.svelte';
const { compile } = require('svelte/compiler');

module.exports = function svelteLoader(source) {
const result = compile(source, {
filename: this.resourcePath,
generate: 'client',
dev: false
});

result.warnings.forEach((w) => this.emitWarning(new Error(w.message)));

return result.js.code;
};
122 changes: 122 additions & 0 deletions src/test/ts/alien/Loader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { after, before, context } from '@ephox/bedrock-client';
import { Remove, SugarElement } from '@ephox/sugar';
import { VersionLoader } from '@tinymce/miniature';
import { flushSync, mount, unmount } from 'svelte';
import type { Editor as TinyMCEEditor } from 'tinymce';
import type { Version } from './TestHelpers';

// eslint-disable-next-line @typescript-eslint/no-require-imports
const Editor = (require('!!../../../../scripts/svelte-loader.js!../../../main/component/Editor.svelte') as any).default;
// proxy() is the runtime equivalent of $state({}) for objects — mutations trigger reactive updates
// in the mounted component exactly as $state would inside a .svelte file.
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { proxy } = require('svelte/internal/client') as { proxy: <T extends object>(val: T) => T };


export interface EditorProps {
id?: string;
inline?: boolean;
disabled?: boolean;
readonly?: boolean;
apiKey?: string;
licenseKey?: string;
channel?: string;
scriptSrc?: string;
conf?: Record<string, unknown>;
modelEvents?: string;
value?: string;
cssClass?: string;
[key: string]: unknown;
}

export interface SvelteEditorContext extends Disposable {
editor: TinyMCEEditor;
DOMNode: HTMLElement;
componentInstance: Record<string, any>;
/** Update any props on the live component instance and flush Svelte reactivity synchronously. */
setProps(patch: Partial<EditorProps>): void;
remove(): void;
}

export type RenderFn = (props?: EditorProps) => Promise<SvelteEditorContext>;


export const render = async (props: EditorProps = {}): Promise<SvelteEditorContext> => {
const container = document.createElement('div');
document.body.appendChild(container);

const userConf = (props.conf as Record<string, unknown>) ?? {};
const userSetup = typeof userConf.setup === 'function' ? userConf.setup as (editor: TinyMCEEditor) => void : undefined;

// Reactive proxy — mutations via setProps() propagate into the mounted component.
const reactiveProps = proxy({
...props,
licenseKey: props.licenseKey ?? 'gpl',
}) as Record<string, unknown>;

let componentInstance!: Record<string, any>;

const { editor, DOMNode } = await new Promise<{ editor: TinyMCEEditor; DOMNode: HTMLElement }>((resolve, reject) => {
reactiveProps.conf = {
...userConf,
setup: (editor: TinyMCEEditor) => {
if (userSetup) userSetup(editor);
editor.on('SkinLoaded', () => {
setTimeout(() => {
const DOMNode = editor.targetElm as HTMLElement;
if (DOMNode) {
resolve({ editor, DOMNode });
} else {
reject(new Error('Could not find DOMNode after SkinLoaded'));
}
}, 0);
});
}
};

componentInstance = mount(Editor, { target: container, props: reactiveProps });
});

const setProps = (patch: Partial<EditorProps>) => {
Object.assign(reactiveProps, patch);
flushSync();
};

const remove = () => {
unmount(componentInstance);
Remove.remove(SugarElement.fromDom(container));
};

return {
editor,
DOMNode,
componentInstance,
setProps,
remove,
[Symbol.dispose]: remove
};
};

const unloadTinymce = () => {
const win = window as Window & { tinymce?: unknown };
if (win.tinymce && typeof (win.tinymce as any).remove === 'function') {
(win.tinymce as any).remove();
}
document.querySelectorAll('script[src*="/node_modules/tinymce"]').forEach((el) => el.remove());
document.querySelectorAll('link[href*="/node_modules/tinymce"]').forEach((el) => el.remove());
delete win.tinymce;
};

export const withVersion = (version: Version, fn: (render: RenderFn) => void): void => {
context(`TinyMCE (${version})`, () => {
before(async () => {
await VersionLoader.pLoadVersion(version);
});

after(() => {
unloadTinymce();
});

fn(render);
});
};
7 changes: 7 additions & 0 deletions src/test/ts/alien/TestHelpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export type Version = '5' | '6' | '7' | '7.5' | '8';

export const VERSIONS: Version[] = [ '5', '6', '7', '8' ];

export const VALID_API_KEY = 'qagffr3pkuv17a8on1afax661irst1hbr4e6tbv888sz91jc';


65 changes: 65 additions & 0 deletions src/test/ts/browser/EditorDisabledTest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { context, describe, it } from "@ephox/bedrock-client";
import * as Loader from "../alien/Loader";
import { Assertions, Waiter } from "@ephox/agar";

describe('EditorDisabledTest', () => {
context('with TinyMCE < 7.6', () => {
Loader.withVersion('7.5', (render) => {
it('updating disabled prop should toggle the editor\'s mode', async () => {
using ctx = await render({
disabled: true
});
Assertions.assertEq('mode is readonly', 'readonly', ctx.editor.mode.get());

ctx.setProps({ disabled: false });
await Waiter.pTryUntil('mode is changed to design', () => {
Assertions.assertEq('mode is design', 'design', ctx.editor.mode.get());
});
});

it('updating readonly prop should toggle the editor\'s mode', async () => {
using ctx = await render({
readonly: true
});
Assertions.assertEq('mode is readonly', 'readonly', ctx.editor.mode.get());

ctx.setProps({ readonly: false });
await Waiter.pTryUntil('mode is changed to design', () => {
Assertions.assertEq('mode is design', 'design', ctx.editor.mode.get());
});
});
});
});

context('with TinyMCE >= 7.6', () => {
Loader.withVersion('7', (render) => {
it('updating disabled prop should only change the editor\'s state', async () => {
using ctx = await render({
disabled: true
});
Assertions.assertEq('mode is design', 'design', ctx.editor.mode.get());
Assertions.assertEq('editor is disabled', true, ctx.editor.options.get('disabled'));

ctx.setProps({ disabled: false });
await Waiter.pTryUntil('editor\'s state should be updated', () => {
Assertions.assertEq('mode is design', 'design', ctx.editor.mode.get());
Assertions.assertEq('editor is not disabled', false, ctx.editor.options.get('disabled'));
});
});

it('updating readonly prop should only change the editor\'s mode', async () => {
using ctx = await render({
readonly: true
});
Assertions.assertEq('mode is readonly', 'readonly', ctx.editor.mode.get());
Assertions.assertEq('editor is not disabled', false, ctx.editor.options.get('disabled'));

ctx.setProps({ readonly: false });
await Waiter.pTryUntil('editor\'s mode should be updated', () => {
Assertions.assertEq('mode is design', 'design', ctx.editor.mode.get());
Assertions.assertEq('editor is not disabled', false, ctx.editor.options.get('disabled'));
});
});
});
});
});
56 changes: 56 additions & 0 deletions src/test/ts/browser/EditorInitTest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { Assertions } from '@ephox/agar';
import { context, describe, it } from '@ephox/bedrock-client';
import { TinyAssertions } from '@ephox/mcagar';

import * as Loader from '../alien/Loader';
import { VALID_API_KEY, VERSIONS } from '../alien/TestHelpers';

const assertProperty = (obj: object, propName: string, expected: unknown) => {
Assertions.assertEq(`${propName} should be ${expected}`, expected, (obj as Record<string, unknown>)[propName]);
};

describe('EditorInitTest', () => {
VERSIONS.forEach((version) =>
Loader.withVersion(version, (render) => {
const defaultProps: Loader.EditorProps = { apiKey: VALID_API_KEY };

context('inline prop controls element tag', () => {
it('uses textarea by default (iframe mode)', async () => {
using ctx = await render(defaultProps);
assertProperty(ctx.DOMNode, 'tagName', 'TEXTAREA');
});

it('uses div for inline mode', async () => {
using ctx = await render({ ...defaultProps, inline: true });
assertProperty(ctx.DOMNode, 'tagName', 'DIV');
});
});

context('id prop', () => {
it('is set when provided', async () => {
using ctx = await render({ ...defaultProps, id: 'test-editor' });
assertProperty(ctx.DOMNode, 'id', 'test-editor');
});

it('is auto-generated as uuid when not provided', async () => {
using ctx = await render(defaultProps);
Assertions.assertEq(
'id should be a uuid starting with tinymce-svelte',
true,
typeof ctx.DOMNode.id === 'string' && ctx.DOMNode.id.startsWith('tinymce-svelte_')
);
});
});

it('value prop sets initial content', async () => {
using ctx = await render({ ...defaultProps, value: '<p>Hello World</p>' });
TinyAssertions.assertContent(ctx.editor, '<p>Hello World</p>');
});

it('empty value prop results in empty editor', async () => {
using ctx = await render({ ...defaultProps, value: '' });
TinyAssertions.assertContent(ctx.editor, '');
});
})
);
});
21 changes: 21 additions & 0 deletions tsconfig.test.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"baseUrl": ".",
"module": "es2015",
"moduleResolution": "node",
"target": "es2017",
"strict": false,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"allowJs": true,
"skipLibCheck": true,
"noUnusedLocals": false,
"declaration": false,
"sourceMap": true
},
"include": [
"src/test/ts/**/*.ts",
"src/main/component/Utils.ts",
"src/main/index.ts"
]
}
Loading
Loading