From 90e8527556f5040f5e98769130dadca94fc1324e Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Thu, 16 Apr 2020 00:39:21 +0900
Subject: [PATCH] Resolve #6256

---
 locales/ja-JP.yml                             |  6 ++
 package.json                                  |  2 +-
 src/client/components/page/page.block.vue     |  3 +-
 src/client/components/page/page.canvas.vue    | 29 +++++++
 src/client/components/page/page.vue           | 75 ++++++++++---------
 .../page-editor/els/page-editor.el.canvas.vue | 45 +++++++++++
 .../pages/page-editor/page-editor.blocks.vue  |  3 +-
 src/client/pages/page-editor/page-editor.vue  |  6 +-
 src/client/scripts/aoiscript/evaluator.ts     | 34 ++++++++-
 src/client/scripts/aoiscript/index.ts         |  1 +
 yarn.lock                                     |  8 +-
 11 files changed, 163 insertions(+), 49 deletions(-)
 create mode 100644 src/client/components/page/page.canvas.vue
 create mode 100644 src/client/pages/page-editor/els/page-editor.el.canvas.vue

diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index 1812a2660..90b5a0d8c 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -797,6 +797,12 @@ _pages:
       text: "タイトル"
       default: "デフォルト値"
 
+    canvas: "キャンバス"
+    _canvas:
+      id: "キャンバスID"
+      width: "幅"
+      height: "高さ"
+
     switch: "スイッチ"
     _switch:
       name: "変数名"
diff --git a/package.json b/package.json
index f34a0d3aa..491f937fc 100644
--- a/package.json
+++ b/package.json
@@ -42,7 +42,7 @@
 		"@koa/cors": "3.0.0",
 		"@koa/multer": "2.0.2",
 		"@koa/router": "8.0.8",
-		"@syuilo/aiscript": "0.2.0",
+		"@syuilo/aiscript": "0.3.0",
 		"@types/bcryptjs": "2.4.2",
 		"@types/bull": "3.12.1",
 		"@types/cbor": "5.0.0",
diff --git a/src/client/components/page/page.block.vue b/src/client/components/page/page.block.vue
index c1d046fa2..04bbb0b85 100644
--- a/src/client/components/page/page.block.vue
+++ b/src/client/components/page/page.block.vue
@@ -17,10 +17,11 @@ import XTextarea from './page.textarea.vue';
 import XPost from './page.post.vue';
 import XCounter from './page.counter.vue';
 import XRadioButton from './page.radio-button.vue';
+import XCanvas from './page.canvas.vue';
 
 export default Vue.extend({
 	components: {
-		XText, XSection, XImage, XButton, XNumberInput, XTextInput, XTextareaInput, XTextarea, XPost, XSwitch, XIf, XCounter, XRadioButton
+		XText, XSection, XImage, XButton, XNumberInput, XTextInput, XTextareaInput, XTextarea, XPost, XSwitch, XIf, XCounter, XRadioButton, XCanvas
 	},
 	props: {
 		value: {
diff --git a/src/client/components/page/page.canvas.vue b/src/client/components/page/page.canvas.vue
new file mode 100644
index 000000000..edcb9cba3
--- /dev/null
+++ b/src/client/components/page/page.canvas.vue
@@ -0,0 +1,29 @@
+<template>
+<div>
+	<canvas ref="canvas" class="ysrxegms" :width="value.width" :height="value.height"/>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+
+export default Vue.extend({
+	props: {
+		value: {
+			required: true
+		},
+		script: {
+			required: true
+		}
+	},
+	mounted() {
+		this.script.aoiScript.registerCanvas(this.value.name, this.$refs.canvas);
+	}
+});
+</script>
+
+<style lang="scss" scoped>
+.ysrxegms {
+	display: block;
+}
+</style>
diff --git a/src/client/components/page/page.vue b/src/client/components/page/page.vue
index 3723fcd3c..99cc6e67e 100644
--- a/src/client/components/page/page.vue
+++ b/src/client/components/page/page.vue
@@ -21,39 +21,11 @@ class Script {
 	public vars: Record<string, any>;
 	public page: Record<string, any>;
 
-	constructor(page, aoiScript, onError, cb) {
+	constructor(page, aoiScript, onError) {
 		this.page = page;
 		this.aoiScript = aoiScript;
 		this.onError = onError;
-
-		if (this.page.script && this.aoiScript.aiscript) {
-			let ast;
-			try {
-				ast = parse(this.page.script);
-			} catch (e) {
-				console.error(e);
-				/*this.$root.dialog({
-					type: 'error',
-					text: 'Syntax error :('
-				});*/
-				return;
-			}
-			this.aoiScript.aiscript.exec(ast).then(() => {
-				this.eval();
-				cb();
-			}).catch(e => {
-				console.error(e);
-				/*this.$root.dialog({
-					type: 'error',
-					text: e
-				});*/
-			});
-		} else {
-			setTimeout(() => {
-				this.eval();
-				cb();
-			}, 1);
-		}
+		this.eval();
 	}
 
 	public eval() {
@@ -67,13 +39,15 @@ class Script {
 	public interpolate(str: string) {
 		if (str == null) return null;
 		return str.replace(/{(.+?)}/g, match => {
-			const v = this.vars[match.slice(1, -1).trim()];
+			const v = this.vars ? this.vars[match.slice(1, -1).trim()] : null;
 			return v == null ? 'NULL' : v.toString();
 		});
 	}
 
 	public callAiScript(fn: string) {
-		if (this.aoiScript.aiscript) this.aoiScript.aiscript.execFn(this.aoiScript.aiscript.scope.get(fn), []);
+		try {
+			if (this.aoiScript.aiscript) this.aoiScript.aiscript.execFn(this.aoiScript.aiscript.scope.get(fn), []);
+		} catch (e) {}
 	}
 }
 
@@ -101,7 +75,7 @@ export default Vue.extend({
 	created() {
 		const pageVars = this.getPageVars();
 		
-		const s = new Script(this.page, new ASEvaluator(this, this.page.variables, pageVars, {
+		this.script = new Script(this.page, new ASEvaluator(this, this.page.variables, pageVars, {
 			randomSeed: Math.random(),
 			visitor: this.$store.state.i,
 			page: this.page,
@@ -109,15 +83,42 @@ export default Vue.extend({
 			enableAiScript: !this.$store.state.device.disablePagesScript
 		}), e => {
 			console.dir(e);
-		}, () => {
-			this.script = s;
 		});
 
-		if (s.aoiScript.aiscript) s.aoiScript.aiscript.scope.opts.onUpdated = (name, value) => {
-			s.eval();
+		if (this.script.aoiScript.aiscript) this.script.aoiScript.aiscript.scope.opts.onUpdated = (name, value) => {
+			this.script.eval();
 		};
 	},
 
+	mounted() {
+		this.$nextTick(() => {
+			if (this.script.page.script && this.script.aoiScript.aiscript) {
+				let ast;
+				try {
+					ast = parse(this.script.page.script);
+				} catch (e) {
+					console.error(e);
+					/*this.$root.dialog({
+						type: 'error',
+						text: 'Syntax error :('
+					});*/
+					return;
+				}
+				this.script.aoiScript.aiscript.exec(ast).then(() => {
+					this.script.eval();
+				}).catch(e => {
+					console.error(e);
+					/*this.$root.dialog({
+						type: 'error',
+						text: e
+					});*/
+				});
+			} else {
+				this.script.eval();
+			}
+		});
+	},
+
 	beforeDestroy() {
 		if (this.script.aoiScript.aiscript) this.script.aoiScript.aiscript.abort();
 	},
diff --git a/src/client/pages/page-editor/els/page-editor.el.canvas.vue b/src/client/pages/page-editor/els/page-editor.el.canvas.vue
new file mode 100644
index 000000000..497731891
--- /dev/null
+++ b/src/client/pages/page-editor/els/page-editor.el.canvas.vue
@@ -0,0 +1,45 @@
+<template>
+<x-container @remove="() => $emit('remove')" :draggable="true">
+	<template #header><fa :icon="faPaintBrush"/> {{ $t('_pages.blocks.canvas') }}</template>
+
+	<section style="padding: 0 16px 0 16px;">
+		<mk-input v-model="value.name"><template #prefix><fa :icon="faMagic"/></template><span>{{ $t('_pages.blocks._canvas.id') }}</span></mk-input>
+		<mk-input v-model="value.width" type="number"><span>{{ $t('_pages.blocks._canvas.width') }}</span><template #suffix>px</template></mk-input>
+		<mk-input v-model="value.height" type="number"><span>{{ $t('_pages.blocks._canvas.height') }}</span><template #suffix>px</template></mk-input>
+	</section>
+</x-container>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faPaintBrush, faMagic } from '@fortawesome/free-solid-svg-icons';
+import i18n from '../../../i18n';
+import XContainer from '../page-editor.container.vue';
+import MkInput from '../../../components/ui/input.vue';
+
+export default Vue.extend({
+	i18n,
+
+	components: {
+		XContainer, MkInput
+	},
+
+	props: {
+		value: {
+			required: true
+		},
+	},
+
+	data() {
+		return {
+			faPaintBrush, faMagic
+		};
+	},
+
+	created() {
+		if (this.value.name == null) Vue.set(this.value, 'name', '');
+		if (this.value.width == null) Vue.set(this.value, 'width', 300);
+		if (this.value.height == null) Vue.set(this.value, 'height', 200);
+	},
+});
+</script>
diff --git a/src/client/pages/page-editor/page-editor.blocks.vue b/src/client/pages/page-editor/page-editor.blocks.vue
index bfc75cada..c6ec42b8d 100644
--- a/src/client/pages/page-editor/page-editor.blocks.vue
+++ b/src/client/pages/page-editor/page-editor.blocks.vue
@@ -20,10 +20,11 @@ import XIf from './els/page-editor.el.if.vue';
 import XPost from './els/page-editor.el.post.vue';
 import XCounter from './els/page-editor.el.counter.vue';
 import XRadioButton from './els/page-editor.el.radio-button.vue';
+import XCanvas from './els/page-editor.el.canvas.vue';
 
 export default Vue.extend({
 	components: {
-		XDraggable, XSection, XText, XImage, XButton, XTextarea, XTextInput, XTextareaInput, XNumberInput, XSwitch, XIf, XPost, XCounter, XRadioButton
+		XDraggable, XSection, XText, XImage, XButton, XTextarea, XTextInput, XTextareaInput, XNumberInput, XSwitch, XIf, XPost, XCounter, XRadioButton, XCanvas
 	},
 
 	props: {
diff --git a/src/client/pages/page-editor/page-editor.vue b/src/client/pages/page-editor/page-editor.vue
index 6177663b7..1af8689de 100644
--- a/src/client/pages/page-editor/page-editor.vue
+++ b/src/client/pages/page-editor/page-editor.vue
@@ -351,6 +351,7 @@ export default Vue.extend({
 					{ value: 'text', text: this.$t('_pages.blocks.text') },
 					{ value: 'image', text: this.$t('_pages.blocks.image') },
 					{ value: 'textarea', text: this.$t('_pages.blocks.textarea') },
+					{ value: 'canvas', text: this.$t('_pages.blocks.canvas') },
 				]
 			}, {
 				label: this.$t('_pages.inputBlocks'),
@@ -428,8 +429,6 @@ export default Vue.extend({
 	margin-bottom: var(--margin);
 
 	> header {
-		background: var(--faceHeader);
-
 		> .title {
 			z-index: 1;
 			margin: 0;
@@ -437,8 +436,7 @@ export default Vue.extend({
 			line-height: 42px;
 			font-size: 0.9em;
 			font-weight: bold;
-			color: var(--faceHeaderText);
-			box-shadow: 0 var(--lineWidth) rgba(#000, 0.07);
+			box-shadow: 0 1px rgba(#000, 0.07);
 
 			> [data-icon] {
 				margin-right: 6px;
diff --git a/src/client/scripts/aoiscript/evaluator.ts b/src/client/scripts/aoiscript/evaluator.ts
index cd488aeda..e911be2ca 100644
--- a/src/client/scripts/aoiscript/evaluator.ts
+++ b/src/client/scripts/aoiscript/evaluator.ts
@@ -19,6 +19,7 @@ export class ASEvaluator {
 	private envVars: Record<keyof typeof envVarsDef, any>;
 	public aiscript?: AiScript;
 	private pageVarUpdatedCallback;
+	private canvases: Record<string, HTMLCanvasElement> = {};
 
 	private opts: {
 		randomSeed: string; visitor?: any; page?: any; url?: string;
@@ -36,6 +37,28 @@ export class ASEvaluator {
 			}), ...{
 				'MkPages:updated': values.FN_NATIVE(([callback]) => {
 					this.pageVarUpdatedCallback = callback;
+				}),
+				'MkPages:get_canvas': values.FN_NATIVE(([id]) => {
+					utils.assertString(id);
+					const canvas = this.canvases[id.value];
+					const ctx = canvas.getContext('2d');
+					return values.OBJ(new Map([
+						['clear_rect', values.FN_NATIVE(([x, y, width, height]) => { ctx.clearRect(x.value, y.value, width.value, height.value) })],
+						['fill_rect', values.FN_NATIVE(([x, y, width, height]) => { ctx.fillRect(x.value, y.value, width.value, height.value) })],
+						['stroke_rect', values.FN_NATIVE(([x, y, width, height]) => { ctx.strokeRect(x.value, y.value, width.value, height.value) })],
+						['fill_text', values.FN_NATIVE(([text, x, y, width]) => { ctx.fillText(text.value, x.value, y.value, width ? width.value : undefined) })],
+						['stroke_text', values.FN_NATIVE(([text, x, y, width]) => { ctx.strokeText(text.value, x.value, y.value, width ? width.value : undefined) })],
+						['set_line_width', values.FN_NATIVE(([width]) => { ctx.lineWidth = width.value })],
+						['set_font', values.FN_NATIVE(([font]) => { ctx.font = font.value })],
+						['set_fill_style', values.FN_NATIVE(([style]) => { ctx.fillStyle = style.value })],
+						['set_stroke_style', values.FN_NATIVE(([style]) => { ctx.strokeStyle = style.value })],
+						['begin_path', values.FN_NATIVE(() => { ctx.beginPath() })],
+						['close_path', values.FN_NATIVE(() => { ctx.closePath() })],
+						['move_to', values.FN_NATIVE(([x, y]) => { ctx.moveTo(x.value, y.value) })],
+						['line_to', values.FN_NATIVE(([x, y]) => { ctx.lineTo(x.value, y.value) })],
+						['fill', values.FN_NATIVE(() => { ctx.fill() })],
+						['stroke', values.FN_NATIVE(() => { ctx.stroke() })],
+					]));
 				})
 			}}, {
 				in: (q) => {
@@ -73,10 +96,15 @@ export class ASEvaluator {
 			IS_CAT: opts.visitor ? opts.visitor.isCat : false,
 			SEED: opts.randomSeed ? opts.randomSeed : '',
 			YMD: `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`,
+			AISCRIPT_DISABLED: !this.opts.enableAiScript,
 			NULL: null
 		};
 	}
 
+	public registerCanvas(id: string, canvas: any) {
+		this.canvases[id] = canvas;
+	}
+
 	@autobind
 	public updatePageVar(name: string, value: any) {
 		const pageVar = this.pageVars.find(v => v.name === name);
@@ -147,7 +175,11 @@ export class ASEvaluator {
 
 		if (block.type === 'aiScriptVar') {
 			if (this.aiscript) {
-				return utils.valToJs(this.aiscript.scope.get(block.value));
+				try {
+					return utils.valToJs(this.aiscript.scope.get(block.value));
+				} catch (e) {
+					return null;
+				}
 			} else {
 				return null;
 			}
diff --git a/src/client/scripts/aoiscript/index.ts b/src/client/scripts/aoiscript/index.ts
index e6de5faaa..7f3496406 100644
--- a/src/client/scripts/aoiscript/index.ts
+++ b/src/client/scripts/aoiscript/index.ts
@@ -128,6 +128,7 @@ export const envVarsDef: Record<string, Type> = {
 	IS_CAT: 'boolean',
 	SEED: null,
 	YMD: 'string',
+	AISCRIPT_DISABLED: 'boolean',
 	NULL: null,
 };
 
diff --git a/yarn.lock b/yarn.lock
index 724488a69..027894030 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -144,10 +144,10 @@
   dependencies:
     type-detect "4.0.8"
 
-"@syuilo/aiscript@0.2.0":
-  version "0.2.0"
-  resolved "https://registry.yarnpkg.com/@syuilo/aiscript/-/aiscript-0.2.0.tgz#dcb489bca13f6d965ac86034a45fd46514b1487a"
-  integrity sha512-N9fYchn3zjtniG9fNmZ81PwYZFdulk+RSBcjDZWBgHsFJQc1wxOCr9hZux/vSXrZ/ZWEzK0loNz1dorl2jJLeA==
+"@syuilo/aiscript@0.3.0":
+  version "0.3.0"
+  resolved "https://registry.yarnpkg.com/@syuilo/aiscript/-/aiscript-0.3.0.tgz#cb0645df40ae97a54eb7e318abef2ccb8045aa14"
+  integrity sha512-jjtcFqnp5ryzAU3mxP25YJEJH/FmIrMycnFwSer/q1BVsAIqHOIhnRTWjxjVI3n1YHIO5DSD4yG/Em6I3bxJow==
   dependencies:
     "@types/seedrandom" "2.4.28"
     autobind-decorator "2.4.0"