diff --git a/projects/cetz-editor/index.html b/projects/cetz-editor/index.html
index 2e9952ea..3741527e 100644
--- a/projects/cetz-editor/index.html
+++ b/projects/cetz-editor/index.html
@@ -64,16 +64,15 @@
language: 'yaml',
theme: 'vs-dark',
});
- window.updateMainContent = v => mainEditor.setValue(v);
- $preview.bindElement(document.getElementById('preview-panel'));
+ $preview.bindElement(document.getElementById('preview-panel'), v => mainEditor.setValue(v));
document.getElementById('export-svg').addEventListener('click', () => {
- $preview.exportSvg();
+ $preview.doExport('svg');
});
document.getElementById('export-pdf').addEventListener('click', () => {
- $preview.exportPdf();
+ $preview.doExport('pdf');
});
document.getElementById('export-cetz').addEventListener('click', () => {
- $preview.exportCetz();
+ $preview.doExport('cetz');
});
document.getElementById('insert-elem').addEventListener('click', () => {
let insertNameValue = insertName.value;
@@ -81,14 +80,14 @@
insertNameValue = 'node-' + Math.random().toString(36).substring(7).replace('0.', '');
}
console.log('insertSelector.value', insertSelector.value, insertNameValue);
- $preview.insertElem(insertSelector.value, insertNameValue);
+ $preview.doInsertElem(insertSelector.value, insertNameValue);
});
const triggerSyncDefinition = () => {
const yml = definitionEditor.getValue();
script$jsYamlLoad.then(() => {
try {
- $preview.flushDefinitions(jsYaml.load(yml));
+ $preview.doSetDefinitions(jsYaml.load(yml));
previewSelector.innerHTML = '';
insertSelector.innerHTML = '';
for (const def of ['main', ...$preview.getDefinitionNames()]) {
@@ -113,7 +112,7 @@
const yml = mainEditor.getValue();
script$jsYamlLoad.then(() => {
try {
- $preview.flushMain(jsYaml.load(yml));
+ $preview.doSetMainContent(jsYaml.load(yml));
} catch (e) {
console.log('error', e);
}
@@ -197,10 +196,10 @@
previewSelector.onchange = () => {
console.log('previewSelector.value', previewSelector.value);
- $preview.previewDefinition(previewSelector.value);
+ $preview.doSelectDef(previewSelector.value);
};
- $preview.previewDefinition('');
+ $preview.doSelectDef('');
});
};
diff --git a/projects/cetz-editor/src/global.d.mts b/projects/cetz-editor/src/global.d.mts
new file mode 100644
index 00000000..d0c22097
--- /dev/null
+++ b/projects/cetz-editor/src/global.d.mts
@@ -0,0 +1,6 @@
+interface Window {
+ $typst$moduleSource: 'local' | 'jsdelivr';
+ $wasm$typst_compiler: any;
+ $wasm$typst_renderer: any;
+ $preview: any;
+}
diff --git a/projects/cetz-editor/src/index.mts b/projects/cetz-editor/src/index.mts
index 7cc0326b..5a539a99 100644
--- a/projects/cetz-editor/src/index.mts
+++ b/projects/cetz-editor/src/index.mts
@@ -1,20 +1,12 @@
import { $typst } from '@myriaddreamin/typst.ts/dist/esm/contrib/snippet.mjs';
+import type { WebAssemblyModuleRef } from '@myriaddreamin/typst.ts/dist/esm/wasm.mjs';
-// Use CDN
-let compiler = fetch(
- 'https://cdn.jsdelivr.net/npm/@myriaddreamin/typst-ts-web-compiler/pkg/typst_ts_web_compiler_bg.wasm',
-);
-let renderer = fetch(
- 'https://cdn.jsdelivr.net/npm/@myriaddreamin/typst-ts-renderer/pkg/typst_ts_renderer_bg.wasm',
-);
-
-// Use local server
-// let compiler = fetch(
-// 'http://127.0.0.1:20810/base/node_modules/@myriaddreamin/typst-ts-web-compiler/pkg/typst_ts_web_compiler_bg.wasm',
-// );
-// let renderer = fetch(
-// 'http://127.0.0.1:20810/base/node_modules/@myriaddreamin/typst-ts-renderer/pkg/typst_ts_renderer_bg.wasm',
-// );
+type ModuleSource = 'local' | 'jsdelivr';
+
+/// Begin of Retrieve Wasm Modules from somewhere
+/// We need a compiler module and a renderer module
+/// - `@myriaddreamin/typst-ts-web-compiler`
+/// - `@myriaddreamin/typst-ts-renderer`
// Bundle
// @ts-ignore
@@ -22,261 +14,282 @@ let renderer = fetch(
// @ts-ignore
// import renderer from '@myriaddreamin/typst-ts-renderer/pkg/typst_ts_renderer_bg.wasm?url';
+// window.$typst$moduleSource = 'local';
+
+let moduleSource: ModuleSource = (window.$typst$moduleSource || 'jsdelivr') as any;
+
+let compiler: WebAssemblyModuleRef;
+let renderer: WebAssemblyModuleRef;
+
+switch (moduleSource) {
+ case 'jsdelivr':
+ compiler = fetch(
+ 'https://cdn.jsdelivr.net/npm/@myriaddreamin/typst-ts-web-compiler/pkg/typst_ts_web_compiler_bg.wasm',
+ );
+ renderer = fetch(
+ 'https://cdn.jsdelivr.net/npm/@myriaddreamin/typst-ts-renderer/pkg/typst_ts_renderer_bg.wasm',
+ );
+ break;
+ case 'local':
+ compiler = fetch(
+ 'http://127.0.0.1:20810/base/node_modules/@myriaddreamin/typst-ts-web-compiler/pkg/typst_ts_web_compiler_bg.wasm',
+ );
+ renderer = fetch(
+ 'http://127.0.0.1:20810/base/node_modules/@myriaddreamin/typst-ts-renderer/pkg/typst_ts_renderer_bg.wasm',
+ );
+ break;
+ default:
+ console.warn('unknown module source for importing typst module', moduleSource);
+}
+
$typst.setCompilerInitOptions({
- getModule: () => compiler,
+ getModule: () => compiler || window.$wasm$typst_compiler,
});
$typst.setRendererInitOptions({
- getModule: () => renderer,
+ getModule: () => renderer || window.$wasm$typst_renderer,
});
+/// End of Retrieve Wasm Modules from somewhere
+
+/**
+ * Newline with 2 spaces indent
+ */
const INEW_LINE = '\n ';
interface ElementDefinition {
id: string;
interactive?: boolean;
+ props?: Record;
draw: string;
+
sig: string;
interactiveSig: string;
- props?: Record;
}
-interface EditorState {
+interface DefinitionEditorState {
definitions?: ElementDefinition[];
width?: string;
height?: string;
- main?: any;
}
-class PreviewState {
+type ExportKind = 'svg' | 'pdf' | 'cetz';
+
+/**
+ * Global singleton state for preview
+ */
+export class PreviewState {
+ /// Editor states
+ /**
+ * Map from definition id to definition
+ */
definitions: Map = new Map();
+ /**
+ * Map from instance id to instance
+ */
+ instances: Record = {};
+ /**
+ * Selected definition id for preview
+ * @default "main"
+ */
previewingDef: string = '';
- isMain = false;
+ /**
+ * Preview panel element
+ */
+ panelElem: HTMLElement | null = null;
+ /**
+ * svg width of the preview panel
+ */
width: number = 500;
+ /**
+ * svg height of the preview panel
+ */
height: number = 500;
- main: Record = {};
- element: HTMLElement | null = null;
- selectedElement: SVGElement | null = null;
- svg: SVGSVGElement | null = null;
- offset: any;
- transform: any;
+ /**
+ * Update main content of the editor
+ */
updateMainContent: (content: string) => void = undefined!;
- constructor() {}
-
- bindElement(element: HTMLElement) {
- this.element = element;
- this.element.addEventListener('click', e => {});
- this.element.addEventListener('mousedown', e => this.startDrag(e));
- this.element.addEventListener('mousemove', e => this.drag(e));
- this.element.addEventListener('mouseup', e => this.endDrag(e));
- this.element.addEventListener('mouseleave', e => this.endDrag(e));
- element.addEventListener('contextmenu', e => {
- e.preventDefault();
- this.checkContextMenuAction(e);
- });
- this.flushPreview();
-
- this.updateMainContent = (window as any).updateMainContent;
- }
-
- // exportSvg();
- async exportSvg() {
- const d = await this.exportAs('svg');
- var b = new Blob([d], { type: 'image/svg' });
- this.exportBlobTo(b);
- }
- async exportPdf() {
- const d = await this.exportAs('pdf');
- var b = new Blob([d], { type: 'application/pdf' });
- this.exportBlobTo(b);
- }
-
- async exportCetz() {
- const d = await this.exportAs('cetz');
- var b = new Blob([d], { type: 'text/plain' });
- this.exportBlobTo(b);
- }
-
- private exportBlobTo(blob: Blob) {
- // Create element with tag
- const link = document.createElement('a');
-
- // Add file content in the object URL
- link.href = URL.createObjectURL(blob);
+ /// Rendering states
+ /**
+ * Whether the preview is rendering main content
+ */
+ isMain = false;
+ /**
+ * Current svg element
+ */
+ svgElem: SVGSVGElement | null = null;
+ /**
+ * Selected element for drag
+ */
+ selectedElem: SVGElement | null = null;
+ /**
+ * Offset for drag
+ */
+ offset: any;
+ /**
+ * Transform for drag
+ */
+ transform: any;
- // Add file name
- link.target = '_blank';
+ constructor() {}
- // Add click event to tag to save file.
- link.click();
- URL.revokeObjectURL(link.href);
+ /**
+ * Bind preview panel element
+ * @param panel preview panel element
+ */
+ bindElement(panel: HTMLElement, updateMainContent: (content: string) => void) {
+ this.panelElem = panel;
+ this.updateMainContent = updateMainContent;
+
+ // element.addEventListener('click', e => {});
+ panel.addEventListener('mousedown', e => this.startDrag(e));
+ panel.addEventListener('mousemove', e => this.drag(e));
+ panel.addEventListener('mouseup', e => this.endDrag(e));
+ panel.addEventListener('mouseleave', e => this.endDrag(e));
+ panel.addEventListener('contextmenu', e => this.doToggleContextMenu(e));
+
+ this.renderPreview();
}
- previewPromise: Promise | null = null;
- flushPreview() {
- if (this.previewPromise) {
- this.previewPromise = this.previewPromise
- .then(() => this.workPreview())
- .catch(e => console.log(e));
- } else {
- this.previewPromise = this.workPreview();
- }
+ /**
+ * Get stored definition names of the preview
+ * @returns definition names
+ */
+ getDefinitionNames() {
+ return Array.from(this.definitions.keys());
}
- async workPreview() {
- if (!this.element) {
- return;
- }
+ getSignatures(interactive?: boolean) {
const sigs: string[] = [];
this.definitions.forEach(def => {
- sigs.push(def.sig);
- });
-
- let content: string | undefined = undefined;
-
- let isMain = () => false;
- if (this.previewingDef === '' || this.previewingDef == 'main') {
- // console.log('previewing main', $typst, this);
- content = await this.drawInteractive();
- isMain = () => true;
- } else {
- const def = this.definitions.get(this.previewingDef);
- if (def) {
- // console.log('previewing definition', def, $typst, this);
- content = await this.drawDefinition(def);
+ if (interactive) {
+ sigs.push(def.interactiveSig);
+ } else {
+ sigs.push(def.sig);
}
- }
+ });
+ // console.log(sigs);
+ return sigs;
+ }
- if (content !== undefined) {
- // console.log({ content });
- this.element.innerHTML = content;
+ /// Begin of Preview Actions
- const svgElem = this.element.firstElementChild;
- this.svg = svgElem as any;
- if (!svgElem) {
- return;
- }
- const width = Number.parseFloat(svgElem.getAttribute('width')!);
- const height = Number.parseFloat(svgElem.getAttribute('height')!);
- const cw = document.body.clientWidth / 2 - 40;
- svgElem.setAttribute('width', cw.toString());
- svgElem.setAttribute('height', ((height * cw) / width).toString());
- this.isMain = isMain();
- }
+ /**
+ * Select definition for preview
+ * @param id definition id
+ */
+ doSelectDef(id: string) {
+ this.previewingDef = id;
+ this.renderPreview();
}
- find(target: Element) {
- while (target) {
- if (target.classList.contains('typst-cetz-elem')) {
- return target;
+ /**
+ * Set definitions for preview
+ * @param editor editor state
+ */
+ doSetDefinitions(editor: DefinitionEditorState) {
+ // console.log('doSetDefinitions', editor);
+ if (editor.definitions) {
+ this.definitions = new Map();
+ for (const def of editor.definitions) {
+ const props = Object.keys(def.props || {})
+ .map(k => `${k}: ${def.props![k]}`)
+ .join(', ');
+ let draw = def.draw.trim();
+ const sig = `let ${def.id}(${props}, tag: black, node-label: none) = ${draw}`;
+ const interactiveSig =
+ def.interactive !== false
+ ? `let ${def.id}(${props}, tag: black, node-label: none) = {
+ rect((0, 0), (1, 1), stroke: 0.00012345pt + tag)
+ let debug-label(pos) = content(pos, box(fill: color.linear-rgb(153, 199, 240, 70%), inset: 5pt, [
+ #set text(fill: color.linear-rgb(0, 0, 0, 70%))
+ #node-label
+ ]))
+ ${draw}
+ rect((0, 0), (1, 1), stroke: 0.00012345pt + tag)
+}`
+ : sig;
+ // console.log({ sig, draw });
+ this.definitions.set(def.id, { ...def, sig, interactiveSig });
}
- target = target.parentElement!;
}
- return undefined;
- }
-
- getMousePosition(evt: MouseEvent) {
- var CTM = this.svg!.getScreenCTM()!;
- return {
- x: (evt.clientX - CTM.e) / CTM.a,
- y: (evt.clientY - CTM.f) / CTM.d,
- };
- }
-
- startDrag(evt: MouseEvent) {
- if (!this.isMain) {
- return;
+ if (editor.width) {
+ this.width = Number.parseFloat(editor.width.replace('pt', ''));
}
-
- let target = this.find(evt.target as Element);
- if (target) {
- const elem = (this.selectedElement = target as any);
-
- this.offset = this.getMousePosition(evt);
- // Get all the transforms currently on this element
- let transforms = elem.transform.baseVal;
- // Ensure the first transform is a translate transform
- if (
- transforms.length === 0 ||
- transforms.getItem(0).type !== SVGTransform.SVG_TRANSFORM_TRANSLATE
- ) {
- // Create an transform that translates by (0, 0)
- var translate = this.svg!.createSVGTransform();
- translate.setTranslate(0, 0);
- // Add the translation to the front of the transforms list
- elem.transform.baseVal.insertItemBefore(translate, 0);
- }
- // Get initial translation amount
- this.transform = transforms.getItem(0);
- this.offset.x -= this.transform.matrix.e;
- this.offset.y -= this.transform.matrix.f;
-
- const typstId = target.id.replace('cetz-app-', '');
- if (!this.main[typstId].initPos) {
- this.main[typstId].initPos = [...this.main[typstId].pos];
- }
- } else {
- this.selectedElement = null;
+ if (editor.height) {
+ this.height = Number.parseFloat(editor.height.replace('pt', ''));
}
+ this.renderPreview();
}
- syncMainContent() {
- const data: string[] = [];
- for (const [_, ins] of Object.entries(this.main).sort((x, y) => {
- return x[1].idx - y[1].idx;
- })) {
- let args = '';
- const argEntries = Object.entries(ins.args ?? {});
- if (argEntries.length) {
- args = ' args:';
- for (const [k, v] of argEntries) {
- args += `\n ${k}: ${v}`;
- }
- }
- let [x, y] = ins.pos;
- if (ins.deltaPos) {
- x = ins.initPos[0] + ins.deltaPos[0];
- y = ins.initPos[1] - ins.deltaPos[1];
- }
- data.push(`- name: ${ins.name}\n type: ${ins.type}${args}\n pos: [${x}, ${y}]`);
+ /**
+ * Set content for preview
+ * @param editor editor state
+ */
+ doSetMainContent(editor: any[]) {
+ const main: any = {};
+ let idx = 0;
+ for (let i = 0; i < editor.length; i++) {
+ const ins = editor[i];
+ idx += 1;
+ ins.idx = idx;
+ main[ins.name] = ins;
}
- this.updateMainContent(data.join('\n'));
+ this.instances = main;
}
- drag(evt: MouseEvent) {
- const selectedElement = this.selectedElement;
- if (selectedElement) {
- evt.preventDefault();
- var coord = this.getMousePosition(evt);
- const x = coord.x - this.offset.x;
- const y = coord.y - this.offset.y;
- this.transform.setTranslate(x, y);
- const typstId = selectedElement.id.replace('cetz-app-', '');
- this.main[typstId].deltaPos = [x, y];
- this.syncMainContent();
+ /**
+ * Insert element to main content
+ * @param ty definition id
+ * @param id instance id
+ * @returns
+ */
+ doInsertElem(ty: string, id: string) {
+ const def = this.definitions.get(ty);
+ if (!def) {
+ return;
}
+ this.instances[id] = {
+ type: ty,
+ pos: [0, 0],
+ name: id,
+ idx: Object.keys(this.instances).length,
+ };
+ this.renderPreview();
+ this.syncMainContent();
}
- endDrag(e: MouseEvent) {
- if (this.selectedElement) {
- const typstId = this.selectedElement.id.replace('cetz-app-', '');
- const ins = this.main[typstId];
- if (ins.deltaPos) {
- ins.pos[0] = ins.initPos[0] + ins.deltaPos[0];
- ins.pos[1] = ins.initPos[1] - ins.deltaPos[1];
- ins.deltaPos = undefined;
- ins.initPos = undefined;
- console.log(JSON.stringify(ins));
- setTimeout(() => {
- this.flushPreview();
- }, 16);
- }
- this.selectedElement = null;
+ /**
+ * Export as kind
+ * See {@link ExportKind}
+ * @param kind export kind
+ */
+ async doExport(kind: ExportKind) {
+ let blobType: string;
+ switch (kind) {
+ case 'svg':
+ blobType = 'image/svg+xml';
+ break;
+ case 'pdf':
+ blobType = 'application/pdf';
+ break;
+ case 'cetz':
+ blobType = 'text/plain';
+ break;
+ default:
+ throw new Error(`unknown export kind ${kind}`);
}
+
+ const d = await this.exportAs(kind);
+ var b = new Blob([d], { type: blobType });
+ this.exportBlobTo(b);
}
- checkContextMenuAction(e: MouseEvent) {
- let target = this.find(e.target as Element);
+ /**
+ * Toggle context menu for interactive elements
+ */
+ doToggleContextMenu(e: MouseEvent) {
+ let target = this.findTaggedTypstElement(e.target as Element);
if (!target) {
return;
}
@@ -286,7 +299,7 @@ class PreviewState {
console.log('checkClickAction', target.id);
const typstId = target.id.replace('cetz-app-', '');
- const ins = this.main[typstId];
+ const ins = this.instances[typstId];
const def = this.definitions.get(ins.type)!;
if (!def || def.interactive === false) {
return;
@@ -321,7 +334,7 @@ class PreviewState {
console.log('change', inputValue);
ins.args = ins.args ?? {};
ins.args[k] = inputValue;
- this.flushPreview();
+ this.renderPreview();
});
div.appendChild(input);
@@ -335,36 +348,71 @@ class PreviewState {
menu.classList.toggle('hidden');
}
- async drawInteractive() {
- // const instances: string[] = [];
- // for (const k of Object.keys(this.main)) {
- // const ins = this.main[k];
- // // const pos = `(${ins.pos[0]}, ${ins.pos[1]})`;
- // const def = await this.drawDefinition(this.definitions.get(ins.type)!, ins.args?.trim());
- // instances.push(
- // `${def}`,
- // );
- // }
-
- // return ``;
-
- return (await this.exportAs('svg', true)) as string;
+ /// End of Preview Actions
+
+ /// Begin of Export Actions
+
+ /**
+ * Export blob to file
+ * @param blob blob to export
+ */
+ private exportBlobTo(blob: Blob) {
+ // Create element with tag
+ const link = document.createElement('a');
+
+ // Add file content in the object URL
+ link.href = URL.createObjectURL(blob);
+
+ // Add file name
+ link.target = '_blank';
+
+ // Add click event to tag to save file.
+ link.click();
+ URL.revokeObjectURL(link.href);
}
- getDefinitionNames() {
- return Array.from(this.definitions.keys());
+ /**
+ * Export definition as cetz code
+ * @param def definition
+ * @param extraElemArgs element arguments
+ * @returns
+ */
+ async exportDefinition(def: ElementDefinition, extraElemArgs?: string) {
+ let elemArgs = `node-label: "t"`;
+ if (extraElemArgs) {
+ elemArgs += `, ${extraElemArgs}`;
+ }
+ const mainContent = `
+#import "@preview/cetz:0.1.2"
+#set page(margin: 3pt, width: auto, height: auto)
+#let debug-label(_) = ()
+#cetz.canvas({
+ import cetz.draw: *
+ ${this.getSignatures().join(INEW_LINE)}
+ ${def.id}(${elemArgs})
+}, length: 1pt)
+`;
+ console.log({ mainContent });
+ const content = await $typst.svg({ mainContent });
+ return content;
}
- async exportAs(kind?: string, interactive?: boolean) {
+ /**
+ * Export as kind
+ * @param kind kind of export
+ * @param interactive whether to export as interactive cetz code
+ */
+ async exportAs(kind: 'cetz', interactive?: boolean): Promise;
+ async exportAs(kind: 'svg', interactive?: boolean): Promise;
+ async exportAs(kind: 'pdf', interactive?: boolean): Promise;
+ async exportAs(kind: ExportKind, interactive?: boolean): Promise;
+ async exportAs(kind: ExportKind, interactive?: boolean): Promise {
const instances: string[] = [];
let prevPos = [0, 0];
let t = 1;
const tagMapping: string[] = [];
- for (const [k, ins] of Object.entries(this.main).sort((x, y) => {
+
+ for (const [k, ins] of Object.entries(this.instances).sort((x, y) => {
return x[1].idx - y[1].idx;
})) {
let args = `node-label: "${k}"`;
@@ -391,7 +439,7 @@ class PreviewState {
#let debug-label(_) = ()
#cetz.canvas({
import cetz.draw: *
- ${this.sigs(interactive).join(INEW_LINE)}
+ ${this.getSignatures(interactive).join(INEW_LINE)}
${instances.join(INEW_LINE)}
}, length: 1pt)
`;
@@ -399,214 +447,375 @@ import cetz.draw: *
switch (kind) {
case 'svg':
- return this.postProcess(await $typst.svg({ mainContent }), tagMapping);
+ return postProcess(await $typst.svg({ mainContent }));
case 'pdf':
return await $typst.pdf({ mainContent });
case 'cetz':
default:
return mainContent;
}
- }
-
- postProcess(svg: string, tagMapping: string[]) {
- if (!tagMapping.length) {
- return svg;
- }
- const div = document.createElement('div');
- div.innerHTML = svg;
- const svgElem = div.firstElementChild;
- if (!svgElem) {
- return svg;
- }
-
- this.postProcessElement(svgElem, tagMapping);
- return div.innerHTML;
- }
- postProcessElement(elem: Element, tagMapping: string[]) {
- if (elem.tagName === 'path') {
- // console.log('found path', elem);
- // if (elem.)
- // path data starts with M 0 0 M
- const pathData = elem.getAttribute('d');
- if (!pathData) {
- return;
+ /**
+ * Post process svg with instrumented tags
+ * @param svgContent svg content
+ * @returns post processed svg content
+ */
+ function postProcess(svgContent: string): string {
+ if (!tagMapping.length) {
+ /// No tag mapping, return original svg content
+ return svgContent;
}
- if (pathData.startsWith('M 0 0 M')) {
- elem.setAttribute('d', pathData.replace('M 0 0', '').trim());
+
+ /// Parse svg content
+ const svgDiv = document.createElement('div');
+ svgDiv.innerHTML = svgContent;
+ const svgElem = svgDiv.firstElementChild;
+ if (!svgElem) {
+ return svgContent;
}
+
+ /// Post process svg element
+ postProcessElement(svgElem);
+
+ /// Return post processed svg content
+ return svgDiv.innerHTML;
}
- if (elem.tagName === 'g') {
- let pathChildren = elem;
- while (pathChildren && pathChildren.children.length === 1) {
- pathChildren = pathChildren.children[0];
- }
- const strokeWith = Number.parseFloat(pathChildren.getAttribute('stroke-width') || '0');
- if (Math.abs(strokeWith - 0.00012345) < 1e-6) {
- const color = pathChildren.getAttribute('stroke');
- // console.log('found color', color);
- if (!color) {
+ /**
+ * Post process svg element
+ * @returns if the element is an instrumented tag, return the tag name,
+ * otherwise return undefined
+ */
+ function postProcessElement(elem: Element) {
+ /// Post process path element
+ if (elem.tagName === 'path') {
+ // console.log('found path', elem);
+
+ /// Process `` to ``
+ /// This would fix the bounding box of the path
+ const pathData = elem.getAttribute('d');
+ if (!pathData) {
return;
}
- const tagIdx = Number.parseInt(color.replace('#', ''), 16);
- return tagMapping[tagIdx - 1];
+ /// path data starts with M 0 0 M
+ if (pathData.startsWith('M 0 0 M')) {
+ elem.setAttribute('d', pathData.slice('M 0 0 '.length));
+ }
+
+ return undefined;
}
- }
- let elements = [];
- const nestElements = [];
- let tagStart: string | undefined = undefined;
- for (const child of elem.children) {
- let tag = this.postProcessElement(child, tagMapping);
- if (!tag) {
- elements.push(child);
- continue;
+ /// Post process other elements
+
+ /// Detect an instrumented tag
+ if (elem.tagName === 'g') {
+ /// Cast a group element to a single inner path element
+ let pathChildren = elem;
+ while (pathChildren && pathChildren.children.length === 1) {
+ pathChildren = pathChildren.children[0];
+ }
+
+ const strokeWith = Number.parseFloat(pathChildren.getAttribute('stroke-width') || '0');
+ if (Math.abs(strokeWith - 0.00012345) < 1e-8) {
+ const color = pathChildren.getAttribute('stroke');
+ // console.log('found color', color);
+ if (!color) {
+ return;
+ }
+ const tagIdx = Number.parseInt(color.replace('#', ''), 16);
+
+ /// Return the tag name
+ return tagMapping[tagIdx - 1];
+ }
}
- // console.log('found tagIdx', tag, tagStart);
+ /// Post process children
+
+ /// Check tags in children
+ /**
+ * Nested identified elements with tags
+ * @example
+ * ```typescript
+ * [['c1', [elem1,elem2]], [undefined, [elem3]]]
+ * ```
+ */
+ const nestElements: [string | undefined, Element[]][] = [];
+ /**
+ * Scanned elements to be appended to `nestElements`
+ */
+ let elements: Element[] = [];
+ /**
+ * Scanned tag start
+ */
+ let tagStart: string | undefined = undefined;
+ for (const child of elem.children) {
+ let tag = postProcessElement(child);
+ if (!tag) {
+ /// Not an instrumented tag, append to `elements`
+ elements.push(child);
+ continue;
+ }
+
+ // console.log('found tagIdx', tag, tagStart);
- if (!tagStart) {
- tagStart = tag;
- if (elements.length) {
- nestElements.push([undefined, elements]);
+ /// Found an instrumented tag
+
+ if (!tagStart) {
+ /// No tag start, set tag start
+ tagStart = tag;
+
+ /// Account for untagged elements
+ if (elements.length) {
+ nestElements.push([undefined, elements]);
+ elements = [];
+ }
+ } else {
+ // console.log('found', tagStart, tag);
+
+ /// Broken tag, reset tag start
+ if (tag !== tagStart) {
+ console.warn('broken tag', tagStart, tag);
+ return;
+ }
+
+ /// Account for tagged elements
+ nestElements.push([tag, elements]);
elements = [];
+
+ /// Reset tag start
+ tagStart = undefined;
}
- } else {
- // console.log('found', tagStart, tag);
- if (tag !== tagStart) {
- return;
- }
- nestElements.push([tag, elements]);
- elements = [];
- tagStart = undefined;
}
- }
- if (elements.length === elem.children.length) {
- return;
- }
+ /// No instrumented tag found, return directly
+ if (elements.length === elem.children.length) {
+ return;
+ }
- // remove all children
- while (elem.firstChild) {
- elem.removeChild(elem.firstChild);
- }
- for (const [tag, elements] of nestElements) {
- if (!tag) {
+ // remove all children
+ while (elem.firstChild) {
+ elem.removeChild(elem.firstChild);
+ }
+ for (const [tag, elements] of nestElements) {
+ if (!tag) {
+ elem.append(...elements!);
+ continue;
+ }
+
+ /// Create a group element for the tag
+ const g = document.createElement('g');
+ g.setAttribute('id', `cetz-app-${tag}`);
+ g.setAttribute('class', 'typst-cetz-elem');
+ g.append(...elements!);
+ elem.appendChild(g);
+ }
+
+ /// Append tail elements
+ if (elements.length) {
elem.append(...elements!);
- continue;
}
- const g = document.createElement('g');
- g.setAttribute('id', `cetz-app-${tag}`);
- g.setAttribute('class', 'typst-cetz-elem');
- g.append(...elements!);
- elem.appendChild(g);
+ return undefined;
}
+ }
- if (elements.length) {
- elem.append(...elements!);
+ /// End of Export Actions
+ /// Begin of DOM State Fetch/Push Actions
+
+ /**
+ * A Fetch Action from DOM
+ *
+ * Find the tagged typst element from the target element
+ * @param target
+ * @returns
+ */
+ findTaggedTypstElement(target: Element) {
+ while (target) {
+ if (target.classList.contains('typst-cetz-elem')) {
+ return target;
+ }
+ target = target.parentElement!;
}
-
return undefined;
}
- async drawDefinition(def: ElementDefinition, extraArgs?: string) {
- let arg = extraArgs ?? '';
- const mainContent = `
-#import "@preview/cetz:0.1.2"
-#set page(margin: 0pt, width: auto, height: auto)
-#let debug-label(_) = ()
-#cetz.canvas({
- import cetz.draw: *
- ${this.sigs().join(INEW_LINE)}
- ${def.id}(${arg})
-}, length: 1pt)
-`;
- console.log({ mainContent });
- const content = await $typst.svg({ mainContent });
- return content;
+ /**
+ * A Fetch Action from DOM
+ *
+ */
+ getMousePosition(evt: MouseEvent) {
+ var CTM = this.svgElem!.getScreenCTM()!;
+ return {
+ x: (evt.clientX - CTM.e) / CTM.a,
+ y: (evt.clientY - CTM.f) / CTM.d,
+ };
+ }
+
+ /**
+ * A Push Action to DOM
+ *
+ */
+ syncMainContent() {
+ const data: string[] = [];
+ for (const [_, ins] of Object.entries(this.instances).sort((x, y) => {
+ return x[1].idx - y[1].idx;
+ })) {
+ let args = '';
+ const argEntries = Object.entries(ins.args ?? {});
+ if (argEntries.length) {
+ args = ' args:';
+ for (const [k, v] of argEntries) {
+ args += `\n ${k}: ${v}`;
+ }
+ }
+ let [x, y] = ins.pos;
+ if (ins.deltaPos) {
+ x = ins.initPos[0] + ins.deltaPos[0];
+ y = ins.initPos[1] - ins.deltaPos[1];
+ }
+ data.push(`- name: ${ins.name}\n type: ${ins.type}${args}\n pos: [${x}, ${y}]`);
+ }
+ this.updateMainContent(data.join('\n'));
}
- sigs(interactive?: boolean) {
+ /// End of Render State Fetch/Push Actions
+
+ /// Begin of Rendering Actions
+
+ previewPromise: Promise | null = null;
+
+ /**
+ * Flush Rendering preview panel
+ */
+ renderPreview() {
+ if (this.previewPromise) {
+ this.previewPromise = this.previewPromise
+ .then(() => this.workPreview())
+ .catch(e => console.log(e));
+ } else {
+ this.previewPromise = this.workPreview();
+ }
+ }
+
+ /**
+ * Work for rendering preview panel
+ */
+ async workPreview() {
+ if (!this.panelElem) {
+ return;
+ }
const sigs: string[] = [];
this.definitions.forEach(def => {
- if (interactive) {
- sigs.push(def.interactiveSig);
- } else {
- sigs.push(def.sig);
- }
+ sigs.push(def.sig);
});
- // console.log(sigs);
- return sigs;
- }
- flushDefinitions(editor: EditorState) {
- // console.log('flushDefinitions', editor);
- if (editor.definitions) {
- this.definitions = new Map();
- for (const def of editor.definitions) {
- const props = Object.keys(def.props || {})
- .map(k => `${k}: ${def.props![k]}`)
- .join(', ');
- let draw = def.draw.trim();
- const sig = `let ${def.id}(${props}, tag: black, node-label: none) = ${draw}`;
- const interactiveSig =
- def.interactive !== false
- ? `let ${def.id}(${props}, tag: black, node-label: none) = {
- rect((0, 0), (1, 1), stroke: 0.00012345pt + tag)
- let debug-label(pos) = content(pos, box(fill: color.linear-rgb(153, 199, 240, 70%), inset: 5pt, [
- #set text(fill: color.linear-rgb(0, 0, 0, 70%))
- #node-label
- ]))
- ${draw}
- rect((0, 0), (1, 1), stroke: 0.00012345pt + tag)
-}`
- : sig;
- // console.log({ sig, draw });
- this.definitions.set(def.id, { ...def, sig, interactiveSig });
+ let content: string | undefined = undefined;
+
+ let isMain = () => false;
+ if (this.previewingDef === '' || this.previewingDef == 'main') {
+ // console.log('previewing main', $typst, this);
+ content = await this.exportAs('svg', true);
+ isMain = () => true;
+ } else {
+ const def = this.definitions.get(this.previewingDef);
+ if (def) {
+ // console.log('previewing definition', def, $typst, this);
+ content = await this.exportDefinition(def);
}
}
- if (editor.width) {
- this.width = Number.parseFloat(editor.width.replace('pt', ''));
- }
- if (editor.height) {
- this.height = Number.parseFloat(editor.height.replace('pt', ''));
+
+ if (content !== undefined) {
+ // console.log({ content });
+ this.panelElem.innerHTML = content;
+
+ const svgElem = this.panelElem.firstElementChild;
+ this.svgElem = svgElem as any;
+ if (!svgElem) {
+ return;
+ }
+ const width = Number.parseFloat(svgElem.getAttribute('width')!);
+ const height = Number.parseFloat(svgElem.getAttribute('height')!);
+ const cw = document.body.clientWidth / 2 - 40;
+ svgElem.setAttribute('width', cw.toString());
+ svgElem.setAttribute('height', ((height * cw) / width).toString());
+ this.isMain = isMain();
}
- this.flushPreview();
}
- flushMain(editor: any[]) {
- const main: any = {};
- let idx = 0;
- for (let i = 0; i < editor.length; i++) {
- const ins = editor[i];
- idx += 1;
- ins.idx = idx;
- main[ins.name] = ins;
+ /// End of Rendering Actions
+
+ /// Begin of Rendering Drag Actions
+
+ startDrag(evt: MouseEvent) {
+ if (!this.isMain) {
+ return;
+ }
+
+ let target = this.findTaggedTypstElement(evt.target as Element);
+ if (target) {
+ const elem = (this.selectedElem = target as any);
+
+ this.offset = this.getMousePosition(evt);
+ // Get all the transforms currently on this element
+ let transforms = elem.transform.baseVal;
+ // Ensure the first transform is a translate transform
+ if (
+ transforms.length === 0 ||
+ transforms.getItem(0).type !== SVGTransform.SVG_TRANSFORM_TRANSLATE
+ ) {
+ // Create an transform that translates by (0, 0)
+ var translate = this.svgElem!.createSVGTransform();
+ translate.setTranslate(0, 0);
+ // Add the translation to the front of the transforms list
+ elem.transform.baseVal.insertItemBefore(translate, 0);
+ }
+ // Get initial translation amount
+ this.transform = transforms.getItem(0);
+ this.offset.x -= this.transform.matrix.e;
+ this.offset.y -= this.transform.matrix.f;
+
+ const typstId = target.id.replace('cetz-app-', '');
+ if (!this.instances[typstId].initPos) {
+ this.instances[typstId].initPos = [...this.instances[typstId].pos];
+ }
+ } else {
+ this.selectedElem = null;
}
- this.main = main;
}
- previewDefinition(id: string) {
- this.previewingDef = id;
- this.flushPreview();
+ drag(evt: MouseEvent) {
+ const selectedElement = this.selectedElem;
+ if (selectedElement) {
+ evt.preventDefault();
+ var coord = this.getMousePosition(evt);
+ const x = coord.x - this.offset.x;
+ const y = coord.y - this.offset.y;
+ this.transform.setTranslate(x, y);
+ const typstId = selectedElement.id.replace('cetz-app-', '');
+ this.instances[typstId].deltaPos = [x, y];
+ this.syncMainContent();
+ }
}
- insertElem(ty: string, id: string) {
- const def = this.definitions.get(ty);
- if (!def) {
- return;
+ endDrag(_evt: MouseEvent) {
+ if (this.selectedElem) {
+ const typstId = this.selectedElem.id.replace('cetz-app-', '');
+ const ins = this.instances[typstId];
+ if (ins.deltaPos) {
+ ins.pos[0] = ins.initPos[0] + ins.deltaPos[0];
+ ins.pos[1] = ins.initPos[1] - ins.deltaPos[1];
+ ins.deltaPos = undefined;
+ ins.initPos = undefined;
+ console.log(JSON.stringify(ins));
+ setTimeout(() => {
+ this.renderPreview();
+ }, 16);
+ }
+ this.selectedElem = null;
}
- this.main[id] = {
- type: ty,
- pos: [0, 0],
- name: id,
- idx: Object.keys(this.main).length,
- };
- this.flushPreview();
- this.syncMainContent();
}
+
+ /// End of Rendering Drag Actions
}
-(window as any).$preview = new PreviewState();
+window.$preview = new PreviewState();
diff --git a/projects/cetz-editor/tsconfig.json b/projects/cetz-editor/tsconfig.json
index abb22a01..6a9cac54 100644
--- a/projects/cetz-editor/tsconfig.json
+++ b/projects/cetz-editor/tsconfig.json
@@ -1,6 +1,6 @@
{
"compilerOptions": {
- "types": ["web"],
+ "types": ["web", "./src/global.d.mts"],
"lib": ["ES5", "ES6", "ES7", "ES2018"],
"target": "ES2020",
"module": "NodeNext",