WIP WYSIWYG ~3D SVG editor.
1class Tool {
2 constructor(editor) {
3 this.editor = editor;
4 }
5 start(ptr, target, x, y) {}
6 move(ptr, target, x, y) {}
7 end(ptr, target, x, y) {}
8 drawWidget(targets) {}
9}
10
11// this tool performs the actions of another tool without hiding the widgets of the original
12class TemporaryTool extends Tool {
13 constructor(editor, style, substance, autoRestore = true) {
14 super(editor);
15 this.style = style;
16 this.substance = substance;
17 this.autoRestore = autoRestore;
18 }
19 start(ptr, target, x, y) {
20 this.substance.start(ptr, target, x, y);
21 }
22 move(ptr, target, x, y) {
23 this.substance.move(ptr, target, x, y);
24 }
25 end(ptr, target, x, y) {
26 this.substance.end(ptr, target, x, y);
27 if (this.autoRestore) {
28 this.editor.tool = this.style;
29 }
30 }
31 drawWidget(targets) {
32 this.style.drawWidget(targets);
33 }
34}
35
36class OrbitTool extends Tool {
37 constructor(editor) {
38 super(editor);
39 this.rotateStart = null;
40 }
41 start(ptr, target, x, y) {
42 this.rotateStart = this.editor.scene.rotate.copy();
43 }
44 move(ptr, target, x, y) {
45 let displaySize = Math.min( this.editor.scene.width, this.editor.scene.height );
46 let moveRY = x / displaySize * Math.PI * Zdog.TAU;
47 let moveRX = y / displaySize * Math.PI * Zdog.TAU;
48 this.editor.scene.rotate.x = this.rotateStart.x - moveRX;
49 this.editor.scene.rotate.y = this.rotateStart.y - moveRY;
50
51 this.editor.syncLayers();
52 }
53}
54
55class TranslateTool extends Tool {
56 constructor(editor) {
57 super(editor);
58 this.targets = null;
59 this.startTranslate = null;
60 this.mode = TranslateTool.MODE_NONE;
61 this.widget = null;
62 }
63 start(ptr, target, x, y) {
64 this.widget = target;
65 this.targets = this.editor.selection.slice(0);
66 this.mode = TranslateTool.MODE_NONE;
67 if (!this.targets.length || target.layer !== Zoodle.LAYER_UI || !target.color) {
68 return;
69 }
70 // Ensure our widget target is the base and not the tip.
71 if (this.widget.diameter)
72 this.widget = this.widget.addTo;
73 this.startTranslate = this.targets.map((t) => t.translate.copy());
74 switch (target.color) {
75 case rose:
76 this.mode = TranslateTool.MODE_X;
77 break;
78 case lime:
79 this.mode = TranslateTool.MODE_Y;
80 break;
81 case blueberry:
82 this.mode = TranslateTool.MODE_Z;
83 break;
84 default:
85 this.mode = TranslateTool.MODE_NONE;
86 break;
87 }
88 }
89 move(ptr, target, x, y) {
90 if (!this.mode) { return; }
91 let direction = this.widget.renderNormal; // TODO: Break out into a function.
92 let delta = this.editor.getAxisDistance(x, y, Math.atan2(direction.y, direction.x));
93 delta /= -this.editor.scene.zoom; // TODO: Include pixel ratio as well.
94 delta *= this.widget.addTo.addTo.scale.x;
95 delta *= Math.abs(this.widget.renderNormal.magnitude2d());
96 // TODO: Oh I know where this is going wrong, we're not including how the view rotation is going to shorten the distance.
97 // We need some sines or cosines or something or both in here.
98 this.targets.forEach((t, i) => {
99 t.translate[this.mode] = this.startTranslate[i][this.mode] + delta;
100 });
101 this.editor.updateHighlights();
102 this.editor.updateUI();
103 this.editor.props.updatePanel();
104 }
105 end(ptr, target, x, y) {
106 if (!this.mode) { return; }
107 // ensure any final adjustments are applied.
108 this.move(ptr, target, x, y);
109 let direction = this.widget.renderNormal; // TODO: Break out into a function.
110 let delta = this.editor.getAxisDistance(x, y, Math.atan2(direction.y, direction.x));
111 delta /= -this.editor.scene.zoom;
112 delta *= this.widget.addTo.addTo.scale.x;
113 delta /= Math.abs(this.widget.renderNormal.magnitude2d());
114 let command = new TranslateCommand(this.editor, this.targets, new Zdog.Vector({[this.mode]: delta}), this.startTranslate);
115 this.editor.did(command);
116 }
117 drawWidget(targets) {
118 // Create anchors matching selected objects.
119 targets = targets.map((target) => {
120 // TODO: Double check this is correct.
121 let parentTransforms = this.editor.getWorldTransforms(target.addTo);
122 let childTranslate = target.translate.copy().rotate(parentTransforms.rotate);
123 parentTransforms.translate.add(childTranslate);
124 parentTransforms.translate.multiply(parentTransforms.scale);
125 return new Zdog.Anchor({
126 addTo: this.editor.ui,
127 ...parentTransforms,
128 });
129 });
130
131 let origin = new Zdog.Shape({
132 stroke: .5,
133 color: lace,
134 });
135 let base = new Zdog.Shape({
136 path: [ { z: -1.5 }, { z: 1.5 } ],
137 stroke: 1,
138 translate: { z: 3 },
139 });
140 new Zdog.Cone({
141 addTo: base,
142 diameter: 2,
143 length: 1.5,
144 stroke: .5,
145 translate: { z: 1.5 },
146 });
147 let z = base.copyGraph({
148 color: blueberry,
149 });
150 z.children[0].color = blueberry;
151 let y = base.copyGraph({
152 color: lime,
153 rotate: { x: -TAU/4 },
154 translate: { y: 3 },
155 });
156 y.children[0].color = lime;
157 let x = base.copyGraph({
158 color: rose,
159 rotate: { y: -TAU/4 },
160 translate: { x: 3 },
161 });
162 x.children[0].color = rose;
163 targets.forEach(t => {
164 origin.copyGraph({ addTo: t, scale: 1/t.scale.x });
165 z.copyGraph({
166 addTo: t,
167 scale: 1/t.scale.x,
168 translate: { z: 3/t.scale.x },
169 });
170 y.copyGraph({
171 addTo: t,
172 scale: 1/t.scale.x,
173 translate: { y: 3/t.scale.x },
174 });
175 x.copyGraph({
176 addTo: t,
177 scale: 1/t.scale.x,
178 translate: { x: 3/t.scale.x },
179 });
180 });
181 }
182
183 static get MODE_NONE() { return ''; }
184 static get MODE_X() { return 'x'; }
185 static get MODE_Y() { return 'y'; }
186 static get MODE_Z() { return 'z'; }
187 static get MODE_VIEW() { return 'v'; }
188}
189
190// TODO: I think this is going to need to run on basis vectors like getWorldTransforms.
191class RotateTool extends Tool {
192 constructor(editor) {
193 super(editor);
194 this.targets = null;
195 this.startRotate = null;
196 this.mode = RotateTool.MODE_NONE;
197 this.widget = null;
198 }
199
200 start(ptr, target, x, y) {
201 this.widget = target;
202 this.targets = this.editor.selection.slice(0);
203 this.mode = RotateTool.MODE_NONE;
204 if (!this.targets.length || target.layer !== Zoodle.LAYER_UI || !target.color) {
205 return;
206 }
207
208 this.startRotate = this.targets.map( t => t.rotate.copy() );
209
210 switch (target.color) {
211 case rose:
212 this.mode = RotateTool.MODE_X;
213 break;
214 case lime:
215 this.mode = RotateTool.MODE_Y;
216 break;
217 case blueberry:
218 this.mode = RotateTool.MODE_Z;
219 break;
220 }
221 }
222 move( ptr, target, x, y ) {
223 if (!this.mode) { return; }
224
225 let displaySize = Math.min( this.editor.scene.width, this.editor.scene.height );
226 x /= displaySize / Math.PI * Zdog.TAU;
227 y /= displaySize / Math.PI * Zdog.TAU;
228 let direction = this.widget.renderNormal;
229 let delta = this.editor.getAxisDistance(x, y, Math.atan2(direction.y, direction.x) + TAU/4 );
230 this.targets.forEach((t, i) => {
231 t.rotate[this.mode] = this.startRotate[i][this.mode] + delta;
232 });
233 this.editor.updateHighlights();
234 this.editor.updateUI();
235 this.editor.props.updatePanel();
236 }
237 // TODO: Let's stash `delta` somewhere so we don't have to recalculate it in end()
238 end( ptr, target, x, y ) {
239 if (!this.mode) { return; }
240 // ensure any final adjustments are applied.
241 this.move( ptr, target, x, y );
242 let displaySize = Math.min( this.editor.scene.width, this.editor.scene.height );
243 x /= displaySize * Math.PI * Zdog.TAU;
244 y /= displaySize * Math.PI * Zdog.TAU;
245 let direction = this.widget.renderNormal;
246 let delta = this.editor.getAxisDistance( x, y, Math.atan2(direction.y, direction.x) + TAU/4 );
247 let command = new RotateCommand(this.editor, this.targets, new Zdog.Vector({[this.mode]: delta}));
248 this.editor.did(command);
249 }
250 drawWidget(targets) {
251 // Create anchors matching selected objects.
252 targets = targets.map((target) => {
253 return new Zdog.Anchor({
254 addTo: this.editor.ui,
255 ...this.editor.getWorldTransforms(target),
256 });
257 });
258
259 const widgetDiameter = 10;
260 const widgetStroke = 0.75;
261
262 let origin = new Zdog.Shape({
263 stroke: .5,
264 color: lace,
265 });
266 let zRing = new Zdog.Ellipse({
267 diameter: widgetDiameter,
268 stroke: widgetStroke,
269 color: blueberry,
270 });
271 let yRing = zRing.copyGraph({
272 rotate: { x: TAU/4 },
273 color: lime,
274 });
275 let xRing = zRing.copyGraph({
276 rotate: { y: TAU/4 },
277 color: rose,
278 });
279 targets.forEach(t => {
280 origin.copyGraph({ addTo: t, scale: 1/t.scale.x });
281 zRing.copyGraph({ addTo: t, scale: 1/t.scale.x });
282 yRing.copyGraph({ addTo: t, scale: 1/t.scale.x });
283 xRing.copyGraph({ addTo: t, scale: 1/t.scale.x });
284 });
285 }
286
287 static get MODE_NONE() { return ''; }
288 static get MODE_X() { return 'x'; }
289 static get MODE_Y() { return 'y'; }
290 static get MODE_Z() { return 'z'; }
291}
292
293class Command {
294 constructor(editor) {
295 this.editor = editor;
296 }
297 do() {}
298 undo() {}
299}
300
301class SelectCommand extends Command {
302 constructor(editor, target, replace = false) {
303 super(editor);
304 this.replace = replace;
305 this.oldSelection = null;
306
307 // If this is a compositeChild, find its parent
308 while (target && target.compositeChild) {
309 target = target.addTo;
310 }
311 // Don't select root elements.
312 if (target && !target.addTo) {
313 target = null;
314 }
315 this.target = target;
316 }
317 do() {
318 this.oldSelection = this.editor.selection.slice( 0 );
319 if (!this.target) {
320 this.editor.clearSelection();
321 } else if (this.replace) {
322 this.editor.setSelection(this.target);
323 } else {
324 this.editor.toggleSelection(this.target);
325 }
326 this.refresh();
327 }
328 undo() {
329 this.editor.selection = this.oldSelection;
330 this.refresh();
331 }
332 refresh() {
333 this.editor.updateHighlights();
334 this.editor.updateUI();
335 this.editor.props.updatePanel();
336 }
337}
338
339class TranslateCommand extends Command {
340 constructor(editor, target, delta, oldTranslate = null) {
341 super(editor);
342 if (!Array.isArray(target)) {
343 target = [target];
344 }
345 this.target = target;
346 this.delta = delta;
347 this.oldTranslate = oldTranslate || target.map((t) => t.translate.copy());
348 }
349
350 do() {
351 if (!this.target) return console.error("Doing TranslateCommand with no target.");
352
353 this.target.forEach((t, i) => {
354 t.translate.set(this.oldTranslate[i]).add(this.delta);
355 });
356 this.refresh();
357 }
358 undo() {
359 if (!this.target) return console.error("Undoing TranslateCommand with no target.");
360
361 this.target.forEach((t, i) => {
362 t.translate.set(this.oldTranslate[i]);
363 });
364 this.refresh();
365 }
366 refresh() {
367 this.editor.updateHighlights();
368 this.editor.updateUI();
369 this.editor.props.updatePanel();
370 }
371}
372
373class RotateCommand extends Command {
374 // TODO: Add oldTranslate and oldRotate to the constructor, or maybe a flag to tell it if it's getting new or old transforms.
375 constructor(editor, target, delta) {
376 super(editor);
377 if (!Array.isArray(target)) {
378 target = [target];
379 }
380 this.target = target;
381 this.delta = delta;
382 this.oldRotate = target.map((t) => t.rotate.copy());
383 }
384
385 do() {
386 // TODO: Probably better to just throw in the constructor.
387 if (!this.target) return console.error("Doing RotateCommand with no target.");
388
389 this.target.forEach((t, i) => {
390 t.rotate.set(this.oldRotate[i]).add(this.delta);
391 });
392 }
393 undo() {
394 if (!this.target) return console.error("Undoing RotateCommand with no target.");
395
396 this.target.forEach((t, i) => {
397 t.rotate.set(this.oldRotate[i]);
398 });
399 }
400}
401
402class EditCommand extends Command {
403 constructor(editor, target, propId, value, oldValue = null) {
404 super(editor);
405 if (!target) {
406 throw new Error("No target specified for EditCommand");
407 }
408
409 if (!Array.isArray(target)) {
410 target = [target];
411 }
412 this.target = target;
413 this.propId = propId;
414 this.value = value;
415 this.oldValue = oldValue || target.map( (t) => t[propId]);
416 }
417 do() {
418 this.target.forEach( (t) => {
419 t[this.propId] = this.value;
420 if (t.updatePath) t.updatePath();
421 });
422 }
423 undo() {
424 this.target.forEach( (t, i) => {
425 t[this.propId] = this.oldValue[i];
426 if (t.updatePath) t.updatePath();
427 });
428 }
429}