initial commit

This commit is contained in:
Joseph HENRY 2025-11-13 12:14:15 +01:00
commit 5e6b69af34
11 changed files with 4469 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
dist
node_modules

73
README.md Normal file
View File

@ -0,0 +1,73 @@
# tb-harmony-modern-ts
This repository is a project template that allows you to code in modern TypeScript for ToonBoom Harmony.
## Example script
```ts
function* recursiveIterNodes(n: string): Generator<string> {
yield n;
if (node.isGroup(n)) {
for (let i = 0; i < node.numberOfSubNodes(n); i++) {
yield* recursiveIterNodes(node.subNode(n, i));
}
}
}
function main() {
const nodes = Array.from(recursiveIterNodes(node.root()));
for (const node of nodes) {
console.log(node);
}
}
main();
```
## Why
You can script in two ways for ToonBoom Harmony: either with [Python](https://docs.toonboom.com/help/harmony-24/scripting/pythonmodule/index.html) or [QtScript](https://docs.toonboom.com/help/harmony-24/scripting/script/index.html) (JavaScript).
[QtScript](https://doc.qt.io/archives/qt-5.15/qtscript-index.html) (scripting engine part of the Qt framework) is an old JavaScript dialect that is based on the [ECMAScript 3.0 standard](https://www-archive.mozilla.org/js/language/e262-3.pdf), therefore it lacks features like modules, modern syntax, arrow functions, etc...
Today we have [TypeScript](https://www.typescriptlang.org/) which is a superset of JavaScript with syntax for types, ES modules, async/await and more but how can we use those?
## How
### TypeScript and type definitions
We can code in TypeScript but in order to use Harmony's QtScript API, we need type definitions (`.d.ts` files describing the types).
Luckily for us, Bryan Fordney made scripts to generate them: [tba-types](https://github.com/bryab/tba-types) (Typescript definitions for Toon Boom Harmony and Storyboard Pro)
### Modules and imports
In Harmony's QtScript you can import code from another file in two ways:
#### `include`
```js
include("module.js");
```
Which include the whole script into the current one, therefore polluting the current namespace.
#### `exports`
```js
// function.js
exports.test = function test() {
MessageLog.trace("test");
};
```
```js
// main.js
var module = require("./function.js");
module.test();
```
Which is a [CommonJS](https://nodejs.org/api/modules.html#modules-commonjs-modules) "like" custom implementation from ToonBoom without the same semantics. It's also used to define [ToonBoom packages](https://docs.toonboom.com/help/harmony-24/scripting/extended/index.html#create_package) by exporting a `configure` function from a `configure.js` file.
-> In today's modern JavaScript, the official standard package format is [ECMAScript modules](https://nodejs.org/api/esm.html#modules-ecmascript-modules) (or ESM/ES6 modules) with `import`/`export`.

42
bblloader.ts Normal file
View File

@ -0,0 +1,42 @@
import babelLoader from "babel-loader";
import { ConfigItem } from "@babel/core";
export default babelLoader.custom(() => {
// Extract the custom options in the custom plugin
function myPlugin() {
return {
visitor: {},
};
}
return {
// Passed the loader options.
// customOptions({ opt1, opt2, ...loader }) {
// return {
// // Pull out any custom options that the loader might have.
// custom: { opt1, opt2 },
//
// // Pass the options back with the two custom options removed.
// loader,
// };
// },
// Passed Babel's 'PartialConfig' object.
config(cfg: ConfigItem) {
return {
...cfg.options,
plugins: [
...((cfg.options && cfg.options["plugins"]) || []),
[myPlugin],
],
};
},
// result(result) {
// return {
// ...result,
// code: result.code + "\n// Generated by some custom loader",
// };
// },
};
});

24
bblplugin.ts Normal file
View File

@ -0,0 +1,24 @@
// Example from: https://github.com/babel/babel/blob/main/packages/babel-plugin-transform-reserved-words/src/index.ts
import { declare } from "@babel/helper-plugin-utils";
import { types as t, type NodePath } from "@babel/core";
import { isIdentifierName } from "@babel/helper-validator-identifier";
const QT_SCRIPT_RESERVED_WORDS = new Set(["for"]);
function isQtScriptReservedWord(name: string): boolean {
return isIdentifierName(name) && QT_SCRIPT_RESERVED_WORDS.has(name);
}
export default declare((_api) => {
return {
name: "test",
visitor: {
Identifier(path: NodePath<t.Identifier>) {
if (isQtScriptReservedWord(path.node.name)) {
console.log(path.node.name);
path.scope.rename(path.node.name);
}
},
},
};
});

4068
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

37
package.json Normal file
View File

@ -0,0 +1,37 @@
{
"name": "tb-harmony-modern-ts",
"version": "1.0.0",
"description": "Template for coding in modern TypeScript for ToonBoom Harmony",
"private": true,
"scripts": {
"build": "cross-env TS_NODE_PROJECT=\"tsconfig.webpack.json\" webpack",
"type-check": "tsc",
"dev": "npm run build -- --watch"
},
"keywords": [],
"author": "Joseph HENRY <joseph@autourdeminuit.com> (Autour du Volcan)",
"license": "MIT",
"devDependencies": {
"@babel/core": "^7.28.5",
"@babel/helper-validator-identifier": "^7.28.5",
"@babel/preset-env": "^7.28.5",
"@babel/preset-typescript": "^7.28.5",
"@types/babel__helper-plugin-utils": "^7.10.3",
"@types/babel__helper-validator-identifier": "^7.15.2",
"@types/node": "^24.10.0",
"@types/webpack": "^5.28.5",
"babel-loader": "^10.0.0",
"core-js": "^3.46.0",
"cross-env": "^10.1.0",
"replace-text-in-bundle-plugin": "^1.0.3",
"tba-types": "github:bryab/tba-types",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.9.3",
"webpack": "^5.102.1",
"webpack-cli": "^6.0.1"
},
"dependencies": {
"axios": "^1.13.2"
}
}

20
src/scripts/generator.ts Normal file
View File

@ -0,0 +1,20 @@
const console = { log: MessageLog.trace };
function* recursiveIterNodes(n: string): Generator<string> {
yield n;
if (node.isGroup(n)) {
for (let i = 0; i < node.numberOfSubNodes(n); i++) {
yield* recursiveIterNodes(node.subNode(n, i));
}
}
}
function main() {
const nodes = Array.from(recursiveIterNodes(node.root()));
for (const node of nodes) {
console.log(node);
}
}
main();

33
src/scripts/uniq.ts Normal file
View File

@ -0,0 +1,33 @@
class Node {
name: string;
constructor(name: string) {
this.name = name;
}
static root() {
return new Node(node.root());
}
get isGroup() {
return node.isGroup(this.name);
}
*subNodesRecursive(): Generator<Node> {
for (let i = 0; i < node.numberOfSubNodes(this.name); i++) {
const subNode = new Node(node.subNode(this.name, i));
yield subNode;
yield* subNode.subNodesRecursive();
}
}
}
function main() {
const root = Node.root();
for (const node of root.subNodesRecursive()) {
MessageLog.trace(node.name);
}
}
main();

13
tsconfig.json Normal file
View File

@ -0,0 +1,13 @@
{
"compilerOptions": {
"strict": true,
"noEmit": true,
"module": "nodenext",
"moduleResolution": "nodenext",
"target": "esnext",
"lib": ["ESNext"],
"allowSyntheticDefaultImports": true,
"types": ["tba-types/Harmony/24"]
},
"include": ["src/**/*"]
}

12
tsconfig.webpack.json Normal file
View File

@ -0,0 +1,12 @@
// TypeScript config for Webpack configuration (webpack.config.ts)
// See: https://webpack.js.org/configuration/configuration-languages/#typescript
{
"compilerOptions": {
"strict": true,
"esModuleInterop": true,
"noEmit": true,
"lib": ["esnext"],
"types": ["node"]
},
"include": ["./webpack.config.ts"]
}

145
webpack.config.ts Normal file
View File

@ -0,0 +1,145 @@
import path from "path";
import { readdirSync } from "fs";
import { Compilation, sources } from "webpack";
import type {
Configuration,
RuleSetRule,
Compiler,
EntryObject,
} from "webpack";
interface FindReplacePluginOptions {
replace: { from: RegExp; to: string }[];
}
class FindReplacePlugin {
options: FindReplacePluginOptions;
constructor(options: FindReplacePluginOptions) {
this.options = options;
}
apply(compiler: Compiler) {
const pluginName = FindReplacePlugin.name;
const logger = compiler.getInfrastructureLogger(pluginName);
compiler.hooks.compilation.tap({ name: pluginName }, (compilation) => {
compilation.hooks.processAssets.tap(
{
name: pluginName,
stage: Compilation.PROCESS_ASSETS_STAGE_ANALYSE,
},
(assets) => {
Object.entries(assets).forEach(([pathname, source]) => {
if (path.extname(pathname) !== ".js") return;
let replaced = source.source().toString();
// For every regex pattern, replace it in the source content
for (const { from, to } of this.options.replace) {
const matches = replaced.match(from);
logger.info(
from,
to,
`Found ${matches ? matches.length : 0} matches`,
matches,
);
replaced = replaced.replace(from, to);
}
const newSource = new sources.RawSource(replaced);
const previousSize = source.size();
compilation.updateAsset(pathname, newSource);
const newSize = newSource.size();
if (newSize !== previousSize) {
logger.info(
`Found and replaced in ${pathname}: ${previousSize} -> ${newSize}`,
);
}
});
},
);
});
}
}
const babelRule: RuleSetRule = {
test: /\.(ts|js)$/,
use: {
loader: "babel-loader",
options: {
targets: "defaults",
sourceType: "unambiguous",
presets: [
["@babel/preset-typescript"],
[
"@babel/preset-env",
{
// Use this to cover the lowest possible syntax requirement for QtScript
// See: https://browsersl.ist/
targets: "cover 100%",
useBuiltIns: "usage",
corejs: "3.46.0",
},
],
],
},
},
};
/**
* Dynamically returns an object of script entries from src/scripts/*.ts files
*/
function getScriptsEntryObject(): EntryObject {
const entries: EntryObject = {};
const scriptsPath = path.resolve(__dirname, "src", "scripts");
const scripts = readdirSync(scriptsPath).filter((file) =>
file.endsWith(".ts"),
);
scripts.forEach((script) => {
entries[path.parse(script).name] = path.resolve(scriptsPath, script);
});
return entries;
}
const config: Configuration = {
mode: "production",
module: {
rules: [babelRule],
},
entry: getScriptsEntryObject(),
resolve: { extensions: [".ts", ".js"] },
output: {
filename: "[name].js",
path: path.resolve(__dirname, "dist"),
environment: { arrowFunction: false },
},
plugins: [
new FindReplacePlugin({
replace: [
/**
* Replaces reserved words as property names
* For example obj.for = 0 -> obj["for"] = 0
*/
{ from: /\.(for|return)([^\w])/g, to: '["$1"]$2' },
/**
* Replaces reserved words as object literal property names
* See: https://eslint.style/rules/quote-props#quote-props
* For example { for: 0 } -> { "for": 0 }
*/
{ from: /(for|return):/g, to: '"$1":' },
{ from: /\/(.*)\[([^\]]*)\/([^\]]*)\](.*)\//g, to: "/$1[$2\/$3]$4/" },
],
}),
],
optimization: {
minimize: false,
},
};
export default config;