Socket
Socket
Sign inDemoInstall

js-draw

Package Overview
Dependencies
Maintainers
1
Versions
117
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

js-draw - npm Package Compare versions

Comparing version 0.1.6 to 0.1.7

dist/src/commands/Duplicate.d.ts

1

.eslintrc.js

@@ -56,3 +56,4 @@ module.exports = {

'**/*.bundle.js',
'dist/',
],
};

@@ -0,1 +1,8 @@

# 0.1.7
* Show the six most recent color selections in the color palette.
* Switch from checkboxes to togglebuttons in the dropdown for the hand tool.
* Adds a "duplicate selection" button.
* Add a pipette (select color from screen) tool.
* Make `Erase`, `Duplicate`, `AddElement`, `TransformElement` commands serializable.
# 0.1.6

@@ -2,0 +9,0 @@ * Fix loading text in SVG images in Chrome.

8

dist/src/Color4.js

@@ -11,5 +11,9 @@ export default class Color4 {

static ofRGB(red, green, blue) {
return new Color4(red, green, blue, 1.0);
return Color4.ofRGBA(red, green, blue, 1.0);
}
static ofRGBA(red, green, blue, alpha) {
red = Math.max(0, Math.min(red, 1));
green = Math.max(0, Math.min(green, 1));
blue = Math.max(0, Math.min(blue, 1));
alpha = Math.max(0, Math.min(alpha, 1));
return new Color4(red, green, blue, alpha);

@@ -44,3 +48,3 @@ }

}
return new Color4(components[0], components[1], components[2], components[3]);
return Color4.ofRGBA(components[0], components[1], components[2], components[3]);
}

@@ -47,0 +51,0 @@ // Like fromHex, but can handle additional colors if an HTML5Canvas is available.

import Editor from '../Editor';
import { EditorLocalization } from '../localization';
interface Command {
apply(editor: Editor): void;
unapply(editor: Editor): void;
description(localizationTable: EditorLocalization): string;
}
declare namespace Command {
const empty: {
export declare abstract class Command {
abstract apply(editor: Editor): void;
abstract unapply(editor: Editor): void;
onDrop(_editor: Editor): void;
abstract description(localizationTable: EditorLocalization): string;
static union(a: Command, b: Command): Command;
static readonly empty: {
description(_localizationTable: EditorLocalization): string;
apply(_editor: Editor): void;
unapply(_editor: Editor): void;
onDrop(_editor: Editor): void;
};
const union: (a: Command, b: Command) => Command;
}
export default Command;

@@ -1,18 +0,14 @@

// eslint-disable-next-line no-redeclare
var Command;
(function (Command) {
Command.empty = {
apply(_editor) { },
unapply(_editor) { },
};
Command.union = (a, b) => {
return {
export class Command {
// Called when the command is being deleted
onDrop(_editor) { }
static union(a, b) {
return new class extends Command {
apply(editor) {
a.apply(editor);
b.apply(editor);
},
}
unapply(editor) {
b.unapply(editor);
a.unapply(editor);
},
}
description(localizationTable) {

@@ -25,6 +21,11 @@ const aDescription = a.description(localizationTable);

return `${aDescription}, ${bDescription}`;
},
}
};
};
})(Command || (Command = {}));
}
}
Command.empty = new class extends Command {
description(_localizationTable) { return ''; }
apply(_editor) { }
unapply(_editor) { }
};
export default Command;
import AbstractComponent from '../components/AbstractComponent';
import Editor from '../Editor';
import { EditorLocalization } from '../localization';
import Command from './Command';
export default class Erase implements Command {
import SerializableCommand from './SerializableCommand';
export default class Erase extends SerializableCommand {
private toRemove;
private applied;
constructor(toRemove: AbstractComponent[]);
apply(editor: Editor): void;
unapply(editor: Editor): void;
onDrop(editor: Editor): void;
description(localizationTable: EditorLocalization): string;
protected serializeToString(): string;
}

@@ -0,6 +1,10 @@

import describeComponentList from '../components/util/describeComponentList';
import EditorImage from '../EditorImage';
export default class Erase {
import SerializableCommand from './SerializableCommand';
export default class Erase extends SerializableCommand {
constructor(toRemove) {
super('erase');
// Clone the list
this.toRemove = toRemove.map(elem => elem);
this.applied = false;
}

@@ -14,2 +18,3 @@ apply(editor) {

}
this.applied = true;
editor.queueRerender();

@@ -20,20 +25,34 @@ }

if (!editor.image.findParent(part)) {
new EditorImage.AddElementCommand(part).apply(editor);
EditorImage.addElement(part).apply(editor);
}
}
this.applied = false;
editor.queueRerender();
}
onDrop(editor) {
if (this.applied) {
for (const part of this.toRemove) {
editor.image.onDestroyElement(part);
}
}
}
description(localizationTable) {
var _a;
if (this.toRemove.length === 0) {
return localizationTable.erasedNoElements;
}
let description = this.toRemove[0].description(localizationTable);
for (const elem of this.toRemove) {
if (elem.description(localizationTable) !== description) {
description = localizationTable.elements;
break;
}
}
const description = (_a = describeComponentList(localizationTable, this.toRemove)) !== null && _a !== void 0 ? _a : localizationTable.elements;
return localizationTable.eraseAction(description, this.toRemove.length);
}
serializeToString() {
const elemIds = this.toRemove.map(elem => elem.getId());
return JSON.stringify(elemIds);
}
}
(() => {
SerializableCommand.register('erase', (data, editor) => {
const json = JSON.parse(data);
const elems = json.map((elemId) => editor.image.lookupElement(elemId));
return new Erase(elems);
});
})();

@@ -11,2 +11,3 @@ import Rect2 from '../geometry/Rect2';

erasedNoElements: string;
duplicatedNoElements: string;
elements: string;

@@ -18,4 +19,5 @@ updatedViewport: string;

eraseAction: (elemDescription: string, numElems: number) => string;
duplicateAction: (elemDescription: string, count: number) => string;
selectedElements: (count: number) => string;
}
export declare const defaultCommandLocalization: CommandLocalization;

@@ -7,4 +7,6 @@ export const defaultCommandLocalization = {

eraseAction: (componentDescription, numElems) => `Erased ${numElems} ${componentDescription}`,
duplicateAction: (componentDescription, numElems) => `Duplicated ${numElems} ${componentDescription}`,
elements: 'Elements',
erasedNoElements: 'Erased nothing',
duplicatedNoElements: 'Duplicated nothing',
rotatedBy: (degrees) => `Rotated by ${Math.abs(degrees)} degrees ${degrees < 0 ? 'clockwise' : 'counter-clockwise'}`,

@@ -11,0 +13,0 @@ movedLeft: 'Moved left',

@@ -7,10 +7,16 @@ import Command from '../commands/Command';

import { ImageComponentLocalization } from './localization';
declare type LoadSaveData = unknown;
declare type LoadSaveData = (string[] | Record<symbol, string | number>);
export declare type LoadSaveDataTable = Record<string, Array<LoadSaveData>>;
declare type DeserializeCallback = (data: string) => AbstractComponent;
export default abstract class AbstractComponent {
private readonly componentKind;
protected lastChangedTime: number;
protected abstract contentBBox: Rect2;
private zIndex;
private id;
private static zIndexCounter;
protected constructor();
protected constructor(componentKind: string);
getId(): string;
private static deserializationCallbacks;
static registerComponent(componentKind: string, deserialize: DeserializeCallback | null): void;
private loadSaveData;

@@ -23,6 +29,13 @@ attachLoadSaveData(key: string, data: LoadSaveData): void;

abstract intersects(lineSegment: LineSegment2): boolean;
protected abstract serializeToString(): string | null;
protected abstract applyTransformation(affineTransfm: Mat33): void;
transformBy(affineTransfm: Mat33): Command;
private static TransformElementCommand;
abstract description(localizationTable: ImageComponentLocalization): string;
protected abstract createClone(): AbstractComponent;
clone(): AbstractComponent;
serialize(): string;
private static isNotDeserializable;
static deserialize(data: string): AbstractComponent;
}
export {};

@@ -0,4 +1,10 @@

var _a;
import SerializableCommand from '../commands/SerializableCommand';
import EditorImage from '../EditorImage';
import Mat33 from '../geometry/Mat33';
export default class AbstractComponent {
constructor() {
constructor(
// A unique identifier for the type of component
componentKind) {
this.componentKind = componentKind;
// Get and manage data attached by a loader.

@@ -8,3 +14,17 @@ this.loadSaveData = {};

this.zIndex = AbstractComponent.zIndexCounter++;
// Create a unique ID.
this.id = `${new Date().getTime()}-${Math.random()}`;
if (AbstractComponent.deserializationCallbacks[componentKind] === undefined) {
throw new Error(`Component ${componentKind} has not been registered using AbstractComponent.registerComponent`);
}
}
getId() {
return this.id;
}
// Store the deserialization callback (or lack of it) for [componentKind].
// If components are registered multiple times (as may be done in automated tests),
// the most recent deserialization callback is used.
static registerComponent(componentKind, deserialize) {
this.deserializationCallbacks[componentKind] = deserialize !== null && deserialize !== void 0 ? deserialize : null;
}
attachLoadSaveData(key, data) {

@@ -28,5 +48,69 @@ if (!this.loadSaveData[key]) {

transformBy(affineTransfm) {
const updateTransform = (editor, newTransfm) => {
return new AbstractComponent.TransformElementCommand(affineTransfm, this);
}
clone() {
const clone = this.createClone();
for (const attachmentKey in this.loadSaveData) {
for (const val of this.loadSaveData[attachmentKey]) {
clone.attachLoadSaveData(attachmentKey, val);
}
}
return clone;
}
serialize() {
const data = this.serializeToString();
if (data === null) {
throw new Error(`${this} cannot be serialized.`);
}
return JSON.stringify({
name: this.componentKind,
zIndex: this.zIndex,
id: this.id,
loadSaveData: this.loadSaveData,
data,
});
}
// Returns true if [data] is not deserializable. May return false even if [data]
// is not deserializable.
static isNotDeserializable(data) {
const json = JSON.parse(data);
if (typeof json !== 'object') {
return true;
}
if (!this.deserializationCallbacks[json === null || json === void 0 ? void 0 : json.name]) {
return true;
}
if (!json.data) {
return true;
}
return false;
}
static deserialize(data) {
if (AbstractComponent.isNotDeserializable(data)) {
throw new Error(`Element with data ${data} cannot be deserialized.`);
}
const json = JSON.parse(data);
const instance = this.deserializationCallbacks[json.name](json.data);
instance.zIndex = json.zIndex;
instance.id = json.id;
// TODO: What should we do with json.loadSaveData?
// If we attach it to [instance], we create a potential security risk — loadSaveData
// is often used to store unrecognised attributes so they can be preserved on output.
// ...but what if we're deserializing data sent across the network?
return instance;
}
}
// Topmost z-index
AbstractComponent.zIndexCounter = 0;
AbstractComponent.deserializationCallbacks = {};
AbstractComponent.TransformElementCommand = (_a = class extends SerializableCommand {
constructor(affineTransfm, component) {
super('transform-element');
this.affineTransfm = affineTransfm;
this.component = component;
this.origZIndex = component.zIndex;
}
updateTransform(editor, newTransfm) {
// Any parent should have only one direct child.
const parent = editor.image.findParent(this);
const parent = editor.image.findParent(this.component);
let hadParent = false;

@@ -37,27 +121,39 @@ if (parent) {

}
this.applyTransformation(newTransfm);
this.component.applyTransformation(newTransfm);
// Add the element back to the document.
if (hadParent) {
new EditorImage.AddElementCommand(this).apply(editor);
EditorImage.addElement(this.component).apply(editor);
}
};
const origZIndex = this.zIndex;
return {
apply: (editor) => {
this.zIndex = AbstractComponent.zIndexCounter++;
updateTransform(editor, affineTransfm);
editor.queueRerender();
},
unapply: (editor) => {
this.zIndex = origZIndex;
updateTransform(editor, affineTransfm.inverse());
editor.queueRerender();
},
description(localizationTable) {
return localizationTable.transformedElements(1);
},
};
}
}
// Topmost z-index
AbstractComponent.zIndexCounter = 0;
}
apply(editor) {
this.component.zIndex = AbstractComponent.zIndexCounter++;
this.updateTransform(editor, this.affineTransfm);
editor.queueRerender();
}
unapply(editor) {
this.component.zIndex = this.origZIndex;
this.updateTransform(editor, this.affineTransfm.inverse());
editor.queueRerender();
}
description(localizationTable) {
return localizationTable.transformedElements(1);
}
serializeToString() {
return JSON.stringify({
id: this.component.getId(),
transfm: this.affineTransfm.toArray(),
});
}
},
(() => {
SerializableCommand.register('transform-element', (data, editor) => {
const json = JSON.parse(data);
const elem = editor.image.lookupElement(json.id);
if (!elem) {
throw new Error(`Unable to retrieve non-existent element, ${elem}`);
}
const transform = json.transfm;
return new AbstractComponent.TransformElementCommand(new Mat33(...transform), elem);
});
})(),
_a);
import LineSegment2 from '../geometry/LineSegment2';
import Mat33 from '../geometry/Mat33';
import Path from '../geometry/Path';
import Rect2 from '../geometry/Rect2';

@@ -15,3 +16,7 @@ import AbstractRenderer, { RenderablePathSpec } from '../rendering/renderers/AbstractRenderer';

protected applyTransformation(affineTransfm: Mat33): void;
getPath(): Path;
description(localization: ImageComponentLocalization): string;
protected createClone(): AbstractComponent;
protected serializeToString(): string | null;
static deserializeFromString(data: string): Stroke;
}
import Path from '../geometry/Path';
import Rect2 from '../geometry/Rect2';
import { styleFromJSON, styleToJSON } from '../rendering/RenderingStyle';
import AbstractComponent from './AbstractComponent';

@@ -7,3 +8,3 @@ export default class Stroke extends AbstractComponent {

var _a;
super();
super('stroke');
this.parts = parts.map(section => {

@@ -77,5 +78,35 @@ const path = Path.fromRenderable(section);

}
getPath() {
var _a;
return (_a = this.parts.reduce((accumulator, current) => {
var _a;
return (_a = accumulator === null || accumulator === void 0 ? void 0 : accumulator.union(current.path)) !== null && _a !== void 0 ? _a : current.path;
}, null)) !== null && _a !== void 0 ? _a : Path.empty;
}
description(localization) {
return localization.stroke;
}
createClone() {
return new Stroke(this.parts);
}
serializeToString() {
return JSON.stringify(this.parts.map(part => {
return {
style: styleToJSON(part.style),
path: part.path.serialize(),
};
}));
}
static deserializeFromString(data) {
const json = JSON.parse(data);
if (typeof json !== 'object' || typeof json.length !== 'number') {
throw new Error(`${data} is missing required field, parts, or parts is of the wrong type.`);
}
const pathSpec = json.map((part) => {
const style = styleFromJSON(part.style);
return Path.fromString(part.path).toRenderable(style);
});
return new Stroke(pathSpec);
}
}
AbstractComponent.registerComponent('stroke', Stroke.deserializeFromString);

@@ -7,10 +7,15 @@ import LineSegment2 from '../geometry/LineSegment2';

import { ImageComponentLocalization } from './localization';
declare type GlobalAttrsList = Array<[string, string | null]>;
export default class SVGGlobalAttributesObject extends AbstractComponent {
private readonly attrs;
protected contentBBox: Rect2;
constructor(attrs: Array<[string, string | null]>);
constructor(attrs: GlobalAttrsList);
render(canvas: AbstractRenderer, _visibleRect?: Rect2): void;
intersects(_lineSegment: LineSegment2): boolean;
protected applyTransformation(_affineTransfm: Mat33): void;
protected createClone(): SVGGlobalAttributesObject;
description(localization: ImageComponentLocalization): string;
protected serializeToString(): string | null;
static deserializeFromString(data: string): AbstractComponent;
}
export {};
import Rect2 from '../geometry/Rect2';
import SVGRenderer from '../rendering/renderers/SVGRenderer';
import AbstractComponent from './AbstractComponent';
const componentKind = 'svg-global-attributes';
// Stores global SVG attributes (e.g. namespace identifiers.)
export default class SVGGlobalAttributesObject extends AbstractComponent {
constructor(attrs) {
super();
super(componentKind);
this.attrs = attrs;

@@ -25,5 +26,26 @@ this.contentBBox = Rect2.empty;

}
createClone() {
return new SVGGlobalAttributesObject(this.attrs);
}
description(localization) {
return localization.svgObject;
}
serializeToString() {
return JSON.stringify(this.attrs);
}
static deserializeFromString(data) {
const json = JSON.parse(data);
const attrs = [];
const numericAndSpaceContentExp = /^[ \t\n0-9.-eE]+$/;
// Don't deserialize all attributes, just those that should be safe.
for (const [key, val] of json) {
if (key === 'viewBox' || key === 'width' || key === 'height') {
if (val && numericAndSpaceContentExp.exec(val)) {
attrs.push([key, val]);
}
}
}
return new SVGGlobalAttributesObject(attrs);
}
}
AbstractComponent.registerComponent(componentKind, SVGGlobalAttributesObject.deserializeFromString);
import LineSegment2 from '../geometry/LineSegment2';
import Mat33 from '../geometry/Mat33';
import Rect2 from '../geometry/Rect2';
import AbstractRenderer, { RenderingStyle } from '../rendering/renderers/AbstractRenderer';
import AbstractRenderer from '../rendering/renderers/AbstractRenderer';
import RenderingStyle from '../rendering/RenderingStyle';
import AbstractComponent from './AbstractComponent';

@@ -14,8 +15,10 @@ import { ImageComponentLocalization } from './localization';

}
declare type GetTextDimensCallback = (text: string, style: TextStyle) => Rect2;
export default class Text extends AbstractComponent {
protected textObjects: Array<string | Text>;
protected readonly textObjects: Array<string | Text>;
private transform;
private style;
private readonly style;
private readonly getTextDimens;
protected contentBBox: Rect2;
constructor(textObjects: Array<string | Text>, transform: Mat33, style: TextStyle);
constructor(textObjects: Array<string | Text>, transform: Mat33, style: TextStyle, getTextDimens?: GetTextDimensCallback);
static applyTextStyles(ctx: CanvasRenderingContext2D, style: TextStyle): void;

@@ -29,4 +32,8 @@ private static textMeasuringCtx;

protected applyTransformation(affineTransfm: Mat33): void;
protected createClone(): AbstractComponent;
private getText;
description(localizationTable: ImageComponentLocalization): string;
protected serializeToString(): string;
static deserializeFromString(data: string, getTextDimens?: GetTextDimensCallback): Text;
}
export {};
import LineSegment2 from '../geometry/LineSegment2';
import Mat33 from '../geometry/Mat33';
import Rect2 from '../geometry/Rect2';
import { styleFromJSON, styleToJSON } from '../rendering/RenderingStyle';
import AbstractComponent from './AbstractComponent';
const componentTypeId = 'text';
export default class Text extends AbstractComponent {
constructor(textObjects, transform, style) {
super();
constructor(textObjects, transform, style,
// If not given, an HtmlCanvasElement is used to determine text boundaries.
getTextDimens = Text.getTextDimens) {
super(componentTypeId);
this.textObjects = textObjects;
this.transform = transform;
this.style = style;
this.getTextDimens = getTextDimens;
this.recomputeBBox();

@@ -37,3 +43,3 @@ }

if (typeof part === 'string') {
const textBBox = Text.getTextDimens(part, this.style);
const textBBox = this.getTextDimens(part, this.style);
return textBBox.transformedBoundingBox(this.transform);

@@ -97,2 +103,5 @@ }

}
createClone() {
return new Text(this.textObjects, this.transform, this.style);
}
getText() {

@@ -113,2 +122,47 @@ const result = [];

}
serializeToString() {
const serializableStyle = Object.assign(Object.assign({}, this.style), { renderingStyle: styleToJSON(this.style.renderingStyle) });
const textObjects = this.textObjects.map(text => {
if (typeof text === 'string') {
return {
text,
};
}
else {
return {
json: text.serializeToString(),
};
}
});
return JSON.stringify({
textObjects,
transform: this.transform.toArray(),
style: serializableStyle,
});
}
static deserializeFromString(data, getTextDimens = Text.getTextDimens) {
const json = JSON.parse(data);
const style = {
renderingStyle: styleFromJSON(json.style.renderingStyle),
size: json.style.size,
fontWeight: json.style.fontWeight,
fontVariant: json.style.fontVariant,
fontFamily: json.style.fontFamily,
};
const textObjects = json.textObjects.map((data) => {
var _a;
if (((_a = data.text) !== null && _a !== void 0 ? _a : null) !== null) {
return data.text;
}
return Text.deserializeFromString(data.json);
});
json.transform = json.transform.filter((elem) => typeof elem === 'number');
if (json.transform.length !== 9) {
throw new Error(`Unable to deserialize transform, ${json.transform}.`);
}
const transformData = json.transform;
const transform = new Mat33(...transformData);
return new Text(textObjects, transform, style, getTextDimens);
}
}
AbstractComponent.registerComponent(componentTypeId, (data) => Text.deserializeFromString(data));

@@ -14,3 +14,5 @@ import LineSegment2 from '../geometry/LineSegment2';

protected applyTransformation(_affineTransfm: Mat33): void;
protected createClone(): AbstractComponent;
description(localization: ImageComponentLocalization): string;
protected serializeToString(): string | null;
}
import Rect2 from '../geometry/Rect2';
import SVGRenderer from '../rendering/renderers/SVGRenderer';
import AbstractComponent from './AbstractComponent';
const componentId = 'unknown-svg-object';
export default class UnknownSVGObject extends AbstractComponent {
constructor(svgObject) {
super();
super(componentId);
this.svgObject = svgObject;

@@ -22,5 +23,15 @@ this.contentBBox = Rect2.of(svgObject.getBoundingClientRect());

}
createClone() {
return new UnknownSVGObject(this.svgObject.cloneNode(true));
}
description(localization) {
return localization.svgObject;
}
serializeToString() {
return JSON.stringify({
html: this.svgObject.outerHTML,
});
}
}
// null: Do not deserialize UnknownSVGObjects.
AbstractComponent.registerComponent(componentId, null);

@@ -41,2 +41,3 @@ import EditorImage from './EditorImage';

dispatch(command: Command, addToHistory?: boolean): void;
dispatchNoAnnounce(command: Command, addToHistory?: boolean): void;
private asyncApplyOrUnapplyCommands;

@@ -43,0 +44,0 @@ asyncApplyCommands(commands: Command[], chunkSize: number): Promise<void>;

@@ -13,2 +13,3 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {

import { InputEvtType, EditorEventType } from './types';
import Command from './commands/Command';
import UndoRedoHistory from './UndoRedoHistory';

@@ -73,3 +74,3 @@ import Viewport from './Viewport';

this.registerListeners();
this.rerender();
this.queueRerender();
this.hideLoadingWarning();

@@ -238,2 +239,3 @@ }

}
// Adds to history by default
dispatch(command, addToHistory = true) {

@@ -249,2 +251,11 @@ if (addToHistory) {

}
// Dispatches a command without announcing it. By default, does not add to history.
dispatchNoAnnounce(command, addToHistory = false) {
if (addToHistory) {
this.history.push(command);
}
else {
command.apply(this);
}
}
// Apply a large transformation in chunks.

@@ -372,3 +383,3 @@ // If [apply] is false, the commands are unapplied.

yield loader.start((component) => {
(new EditorImage.AddElementCommand(component)).apply(this);
this.dispatchNoAnnounce(EditorImage.addElement(component));
}, (countProcessed, totalToProcess) => {

@@ -384,4 +395,4 @@ if (countProcessed % 500 === 0) {

}, (importExportRect) => {
this.setImportExportRect(importExportRect).apply(this);
this.viewport.zoomTo(importExportRect).apply(this);
this.dispatchNoAnnounce(this.setImportExportRect(importExportRect), false);
this.dispatchNoAnnounce(this.viewport.zoomTo(importExportRect), false);
});

@@ -401,3 +412,3 @@ this.hideLoadingWarning();

const origTransform = this.importExportViewport.canvasToScreenTransform;
return {
return new class extends Command {
apply(editor) {

@@ -408,3 +419,3 @@ const viewport = editor.importExportViewport;

editor.queueRerender();
},
}
unapply(editor) {

@@ -415,6 +426,6 @@ const viewport = editor.importExportViewport;

editor.queueRerender();
},
}
description(localizationTable) {
return localizationTable.resizeOutputCommand(imageRect);
},
}
};

@@ -421,0 +432,0 @@ }

@@ -1,7 +0,6 @@

import Editor from './Editor';
import AbstractRenderer from './rendering/renderers/AbstractRenderer';
import Command from './commands/Command';
import Viewport from './Viewport';
import AbstractComponent from './components/AbstractComponent';
import Rect2 from './geometry/Rect2';
import { EditorLocalization } from './localization';
import RenderingCache from './rendering/caching/RenderingCache';

@@ -11,4 +10,4 @@ export declare const sortLeavesByZIndex: (leaves: Array<ImageNode>) => void;

private root;
private componentsById;
constructor();
private addElement;
findParent(elem: AbstractComponent): ImageNode | null;

@@ -19,13 +18,8 @@ renderWithCache(screenRenderer: AbstractRenderer, cache: RenderingCache, viewport: Viewport): void;

getElementsIntersectingRegion(region: Rect2): AbstractComponent[];
static AddElementCommand: {
new (element: AbstractComponent, applyByFlattening?: boolean): {
readonly "__#679@#element": AbstractComponent;
"__#679@#applyByFlattening": boolean;
apply(editor: Editor): void;
unapply(editor: Editor): void;
description(localization: EditorLocalization): string;
};
};
onDestroyElement(elem: AbstractComponent): void;
lookupElement(id: string): AbstractComponent | null;
private addElementDirectly;
static addElement(elem: AbstractComponent, applyByFlattening?: boolean): Command;
private static AddElementCommand;
}
export declare type AddElementCommand = typeof EditorImage.AddElementCommand.prototype;
declare type TooSmallToRenderCheck = (rect: Rect2) => boolean;

@@ -52,2 +46,3 @@ export declare class ImageNode {

recomputeBBox(bubbleUp: boolean): void;
private updateParents;
private rebalance;

@@ -54,0 +49,0 @@ remove(): void;

@@ -1,14 +0,5 @@

var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (receiver, state, value, kind, f) {
if (kind === "m") throw new TypeError("Private method is not writable");
if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a setter");
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot write private member to an object whose class did not declare it");
return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value;
};
var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, state, kind, f) {
if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter");
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
};
var _element, _applyByFlattening, _a;
var _a;
import AbstractComponent from './components/AbstractComponent';
import Rect2 from './geometry/Rect2';
import SerializableCommand from './commands/SerializableCommand';
export const sortLeavesByZIndex = (leaves) => {

@@ -21,6 +12,4 @@ leaves.sort((a, b) => a.getContent().getZIndex() - b.getContent().getZIndex());

this.root = new ImageNode();
this.componentsById = {};
}
addElement(elem) {
return this.root.addLeaf(elem);
}
// Returns the parent of the given element, if it exists.

@@ -55,5 +44,19 @@ findParent(elem) {

}
onDestroyElement(elem) {
delete this.componentsById[elem.getId()];
}
lookupElement(id) {
var _a;
return (_a = this.componentsById[id]) !== null && _a !== void 0 ? _a : null;
}
addElementDirectly(elem) {
this.componentsById[elem.getId()] = elem;
return this.root.addLeaf(elem);
}
static addElement(elem, applyByFlattening = false) {
return new EditorImage.AddElementCommand(elem, applyByFlattening);
}
}
// A Command that can access private [EditorImage] functionality
EditorImage.AddElementCommand = (_a = class {
EditorImage.AddElementCommand = (_a = class extends SerializableCommand {
// If [applyByFlattening], then the rendered content of this element

@@ -63,7 +66,6 @@ // is present on the display's wet ink canvas. As such, no re-render is necessary

constructor(element, applyByFlattening = false) {
_element.set(this, void 0);
_applyByFlattening.set(this, false);
__classPrivateFieldSet(this, _element, element, "f");
__classPrivateFieldSet(this, _applyByFlattening, applyByFlattening, "f");
if (isNaN(__classPrivateFieldGet(this, _element, "f").getBBox().area)) {
super('add-element');
this.element = element;
this.applyByFlattening = applyByFlattening;
if (isNaN(element.getBBox().area)) {
throw new Error('Elements in the image cannot have NaN bounding boxes');

@@ -73,8 +75,8 @@ }

apply(editor) {
editor.image.addElement(__classPrivateFieldGet(this, _element, "f"));
if (!__classPrivateFieldGet(this, _applyByFlattening, "f")) {
editor.image.addElementDirectly(this.element);
if (!this.applyByFlattening) {
editor.queueRerender();
}
else {
__classPrivateFieldSet(this, _applyByFlattening, false, "f");
this.applyByFlattening = false;
editor.display.flatten();

@@ -84,3 +86,3 @@ }

unapply(editor) {
const container = editor.image.findParent(__classPrivateFieldGet(this, _element, "f"));
const container = editor.image.findParent(this.element);
container === null || container === void 0 ? void 0 : container.remove();

@@ -90,7 +92,17 @@ editor.queueRerender();

description(localization) {
return localization.addElementAction(__classPrivateFieldGet(this, _element, "f").description(localization));
return localization.addElementAction(this.element.description(localization));
}
serializeToString() {
return JSON.stringify({
elemData: this.element.serialize(),
});
}
},
_element = new WeakMap(),
_applyByFlattening = new WeakMap(),
(() => {
SerializableCommand.register('add-element', (data, _editor) => {
const json = JSON.parse(data);
const elem = AbstractComponent.deserialize(json.elemData);
return new EditorImage.AddElementCommand(elem);
});
})(),
_a);

@@ -183,2 +195,3 @@ // TODO: Assign leaf nodes to CacheNodes. When leaf nodes are modified, the corresponding CacheNodes can be updated.

nodeForChildren.recomputeBBox(true);
nodeForChildren.updateParents();
return nodeForNewLeaf.addLeaf(leaf);

@@ -231,2 +244,10 @@ }

}
updateParents(recursive = false) {
for (const child of this.children) {
child.parent = this;
if (recursive) {
child.updateParents(recursive);
}
}
}
rebalance() {

@@ -249,2 +270,3 @@ // If the current node is its parent's only child,

this.parent.children = this.children;
this.parent.updateParents();
this.parent = null;

@@ -265,3 +287,3 @@ }

});
console.assert(this.parent.children.length === oldChildCount - 1);
console.assert(this.parent.children.length === oldChildCount - 1, `${oldChildCount - 1} ≠ ${this.parent.children.length} after removing all nodes equal to ${this}. Nodes should only be removed once.`);
this.parent.children.forEach(child => {

@@ -268,0 +290,0 @@ child.rebalance();

import { Bezier } from 'bezier-js';
import { RenderingStyle, RenderablePathSpec } from '../rendering/renderers/AbstractRenderer';
import { RenderablePathSpec } from '../rendering/renderers/AbstractRenderer';
import RenderingStyle from '../rendering/RenderingStyle';
import LineSegment2 from './LineSegment2';

@@ -53,5 +54,7 @@ import Mat33 from './Mat33';

toString(): string;
serialize(): string;
static toString(startPoint: Point2, parts: PathCommand[]): string;
static fromString(pathString: string): Path;
static empty: Path;
}
export {};

@@ -206,2 +206,5 @@ import { Bezier } from 'bezier-js';

}
serialize() {
return this.toString();
}
static toString(startPoint, parts) {

@@ -449,1 +452,2 @@ const result = [];

}
Path.empty = new Path(Vec2.zero, []);
import AbstractRenderer from './renderers/AbstractRenderer';
import { Editor } from '../Editor';
import { Point2 } from '../geometry/Vec2';
import RenderingCache from './caching/RenderingCache';
import Color4 from '../Color4';
export declare enum RenderingMode {

@@ -21,2 +23,3 @@ DummyRenderer = 0,

getCache(): RenderingCache;
getColorAt: (_screenPos: Point2) => Color4 | null;
private initializeCanvasRendering;

@@ -23,0 +26,0 @@ private initializeTextRendering;

@@ -7,2 +7,3 @@ import CanvasRenderer from './renderers/CanvasRenderer';

import TextOnlyRenderer from './renderers/TextOnlyRenderer';
import Color4 from '../Color4';
export var RenderingMode;

@@ -18,2 +19,5 @@ (function (RenderingMode) {

this.parent = parent;
this.getColorAt = (_screenPos) => {
return null;
};
if (mode === RenderingMode.CanvasRenderer) {

@@ -111,2 +115,11 @@ this.initializeCanvasRendering();

};
this.getColorAt = (screenPos) => {
const pixel = dryInkCtx.getImageData(screenPos.x, screenPos.y, 1, 1);
const data = pixel === null || pixel === void 0 ? void 0 : pixel.data;
if (data) {
const color = Color4.ofRGBA(data[0] / 255, data[1] / 255, data[2] / 255, data[3] / 255);
return color;
}
return null;
};
}

@@ -113,0 +126,0 @@ initializeTextRendering() {

@@ -1,2 +0,1 @@

import Color4 from '../../Color4';
import { LoadSaveDataTable } from '../../components/AbstractComponent';

@@ -9,9 +8,3 @@ import { TextStyle } from '../../components/Text';

import Viewport from '../../Viewport';
export interface RenderingStyle {
fill: Color4;
stroke?: {
color: Color4;
width: number;
};
}
import RenderingStyle from '../RenderingStyle';
export interface RenderablePathSpec {

@@ -18,0 +11,0 @@ startPoint: Point2;

import Path, { PathCommandType } from '../../geometry/Path';
import { Vec2 } from '../../geometry/Vec2';
const stylesEqual = (a, b) => {
var _a, _b, _c, _d, _e;
return a === b || (a.fill.eq(b.fill)
&& ((_b = (_a = a.stroke) === null || _a === void 0 ? void 0 : _a.color) === null || _b === void 0 ? void 0 : _b.eq((_c = b.stroke) === null || _c === void 0 ? void 0 : _c.color))
&& ((_d = a.stroke) === null || _d === void 0 ? void 0 : _d.width) === ((_e = b.stroke) === null || _e === void 0 ? void 0 : _e.width));
};
import { stylesEqual } from '../RenderingStyle';
export default class AbstractRenderer {

@@ -10,0 +5,0 @@ constructor(viewport) {

@@ -7,3 +7,4 @@ import { TextStyle } from '../../components/Text';

import Viewport from '../../Viewport';
import AbstractRenderer, { RenderablePathSpec, RenderingStyle } from './AbstractRenderer';
import RenderingStyle from '../RenderingStyle';
import AbstractRenderer, { RenderablePathSpec } from './AbstractRenderer';
export default class CanvasRenderer extends AbstractRenderer {

@@ -10,0 +11,0 @@ private ctx;

@@ -7,3 +7,4 @@ import { TextStyle } from '../../components/Text';

import Viewport from '../../Viewport';
import AbstractRenderer, { RenderingStyle } from './AbstractRenderer';
import RenderingStyle from '../RenderingStyle';
import AbstractRenderer from './AbstractRenderer';
export default class DummyRenderer extends AbstractRenderer {

@@ -10,0 +11,0 @@ clearedCount: number;

@@ -7,3 +7,4 @@ import { LoadSaveDataTable } from '../../components/AbstractComponent';

import Viewport from '../../Viewport';
import AbstractRenderer, { RenderingStyle } from './AbstractRenderer';
import RenderingStyle from '../RenderingStyle';
import AbstractRenderer from './AbstractRenderer';
export default class SVGRenderer extends AbstractRenderer {

@@ -10,0 +11,0 @@ private elem;

@@ -7,3 +7,4 @@ import { TextStyle } from '../../components/Text';

import { TextRendererLocalization } from '../localization';
import AbstractRenderer, { RenderingStyle } from './AbstractRenderer';
import RenderingStyle from '../RenderingStyle';
import AbstractRenderer from './AbstractRenderer';
export default class TextOnlyRenderer extends AbstractRenderer {

@@ -10,0 +11,0 @@ private localizationTable;

import Editor from '../Editor';
import { ToolbarLocalization } from './localization';
import { ActionButtonIcon } from './types';
export declare const toolbarCSSPrefix = "toolbar-";
export default class HTMLToolbar {

@@ -8,3 +9,2 @@ private editor;

private container;
private penTypes;
constructor(editor: Editor, parent: HTMLElement, localizationTable?: ToolbarLocalization);

@@ -11,0 +11,0 @@ setupColorPickers(): void;

@@ -8,499 +8,12 @@ import { ToolType } from '../tools/ToolController';

import SelectionTool from '../tools/SelectionTool';
import { makeFreehandLineBuilder } from '../components/builders/FreehandLineBuilder';
import { makeArrowBuilder } from '../components/builders/ArrowBuilder';
import { makeLineBuilder } from '../components/builders/LineBuilder';
import { makeFilledRectangleBuilder, makeOutlinedRectangleBuilder } from '../components/builders/RectangleBuilder';
import { defaultToolbarLocalization } from './localization';
import { makeDropdownIcon, makeEraserIcon, makeIconFromFactory, makePenIcon, makeRedoIcon, makeSelectionIcon, makeHandToolIcon, makeUndoIcon, makeTextIcon } from './icons';
import PanZoom, { PanZoomMode } from '../tools/PanZoom';
import Mat33 from '../geometry/Mat33';
import Viewport from '../Viewport';
import { makeRedoIcon, makeUndoIcon } from './icons';
import PanZoom from '../tools/PanZoom';
import TextTool from '../tools/TextTool';
const toolbarCSSPrefix = 'toolbar-';
class ToolbarWidget {
constructor(editor, targetTool, localizationTable) {
this.editor = editor;
this.targetTool = targetTool;
this.localizationTable = localizationTable;
this.icon = null;
this.container = document.createElement('div');
this.container.classList.add(`${toolbarCSSPrefix}toolContainer`);
this.dropdownContainer = document.createElement('div');
this.dropdownContainer.classList.add(`${toolbarCSSPrefix}dropdown`);
this.dropdownContainer.classList.add('hidden');
this.hasDropdown = false;
this.button = document.createElement('div');
this.button.classList.add(`${toolbarCSSPrefix}button`);
this.label = document.createElement('label');
this.button.setAttribute('role', 'button');
this.button.tabIndex = 0;
editor.notifier.on(EditorEventType.ToolEnabled, toolEvt => {
if (toolEvt.kind !== EditorEventType.ToolEnabled) {
throw new Error('Incorrect event type! (Expected ToolEnabled)');
}
if (toolEvt.tool === targetTool) {
this.updateSelected(true);
}
});
editor.notifier.on(EditorEventType.ToolDisabled, toolEvt => {
if (toolEvt.kind !== EditorEventType.ToolDisabled) {
throw new Error('Incorrect event type! (Expected ToolDisabled)');
}
if (toolEvt.tool === targetTool) {
this.updateSelected(false);
this.setDropdownVisible(false);
}
});
}
setupActionBtnClickListener(button) {
button.onclick = () => {
this.handleClick();
};
}
handleClick() {
if (this.hasDropdown) {
if (!this.targetTool.isEnabled()) {
this.targetTool.setEnabled(true);
}
else {
this.setDropdownVisible(!this.isDropdownVisible());
}
}
else {
this.targetTool.setEnabled(!this.targetTool.isEnabled());
}
}
// Adds this to [parent]. This can only be called once for each ToolbarWidget.
addTo(parent) {
this.label.innerText = this.getTitle();
this.setupActionBtnClickListener(this.button);
this.icon = null;
this.updateIcon();
this.updateSelected(this.targetTool.isEnabled());
this.button.replaceChildren(this.icon, this.label);
this.container.appendChild(this.button);
this.hasDropdown = this.fillDropdown(this.dropdownContainer);
if (this.hasDropdown) {
this.dropdownIcon = this.createDropdownIcon();
this.button.appendChild(this.dropdownIcon);
this.container.appendChild(this.dropdownContainer);
}
this.setDropdownVisible(false);
parent.appendChild(this.container);
}
updateIcon() {
var _a;
const newIcon = this.createIcon();
(_a = this.icon) === null || _a === void 0 ? void 0 : _a.replaceWith(newIcon);
this.icon = newIcon;
this.icon.classList.add(`${toolbarCSSPrefix}icon`);
}
updateSelected(selected) {
const currentlySelected = this.container.classList.contains('selected');
if (currentlySelected === selected) {
return;
}
if (selected) {
this.container.classList.add('selected');
this.button.ariaSelected = 'true';
}
else {
this.container.classList.remove('selected');
this.button.ariaSelected = 'false';
}
}
setDropdownVisible(visible) {
const currentlyVisible = this.container.classList.contains('dropdownVisible');
if (currentlyVisible === visible) {
return;
}
if (visible) {
this.dropdownContainer.classList.remove('hidden');
this.container.classList.add('dropdownVisible');
this.editor.announceForAccessibility(this.localizationTable.dropdownShown(this.targetTool.description));
}
else {
this.dropdownContainer.classList.add('hidden');
this.container.classList.remove('dropdownVisible');
this.editor.announceForAccessibility(this.localizationTable.dropdownHidden(this.targetTool.description));
}
this.repositionDropdown();
}
repositionDropdown() {
const dropdownBBox = this.dropdownContainer.getBoundingClientRect();
const screenWidth = document.body.clientWidth;
if (dropdownBBox.left > screenWidth / 2) {
this.dropdownContainer.style.marginLeft = this.button.clientWidth + 'px';
this.dropdownContainer.style.transform = 'translate(-100%, 0)';
}
else {
this.dropdownContainer.style.marginLeft = '';
this.dropdownContainer.style.transform = '';
}
}
isDropdownVisible() {
return !this.dropdownContainer.classList.contains('hidden');
}
createDropdownIcon() {
const icon = makeDropdownIcon();
icon.classList.add(`${toolbarCSSPrefix}showHideDropdownIcon`);
return icon;
}
}
class EraserWidget extends ToolbarWidget {
getTitle() {
return this.localizationTable.eraser;
}
createIcon() {
return makeEraserIcon();
}
fillDropdown(_dropdown) {
// No dropdown associated with the eraser
return false;
}
}
class SelectionWidget extends ToolbarWidget {
constructor(editor, tool, localization) {
super(editor, tool, localization);
this.tool = tool;
}
getTitle() {
return this.localizationTable.select;
}
createIcon() {
return makeSelectionIcon();
}
fillDropdown(dropdown) {
const container = document.createElement('div');
const resizeButton = document.createElement('button');
const deleteButton = document.createElement('button');
resizeButton.innerText = this.localizationTable.resizeImageToSelection;
resizeButton.disabled = true;
deleteButton.innerText = this.localizationTable.deleteSelection;
deleteButton.disabled = true;
resizeButton.onclick = () => {
const selection = this.tool.getSelection();
this.editor.dispatch(this.editor.setImportExportRect(selection.region));
};
deleteButton.onclick = () => {
const selection = this.tool.getSelection();
this.editor.dispatch(selection.deleteSelectedObjects());
this.tool.clearSelection();
};
// Enable/disable actions based on whether items are selected
this.editor.notifier.on(EditorEventType.ToolUpdated, toolEvt => {
if (toolEvt.kind !== EditorEventType.ToolUpdated) {
throw new Error('Invalid event type!');
}
if (toolEvt.tool === this.tool) {
const selection = this.tool.getSelection();
const hasSelection = selection && selection.region.area > 0;
resizeButton.disabled = !hasSelection;
deleteButton.disabled = resizeButton.disabled;
}
});
container.replaceChildren(resizeButton, deleteButton);
dropdown.appendChild(container);
return true;
}
}
const makeZoomControl = (localizationTable, editor) => {
const zoomLevelRow = document.createElement('div');
const increaseButton = document.createElement('button');
const decreaseButton = document.createElement('button');
const zoomLevelDisplay = document.createElement('span');
increaseButton.innerText = '+';
decreaseButton.innerText = '-';
zoomLevelRow.replaceChildren(zoomLevelDisplay, increaseButton, decreaseButton);
zoomLevelRow.classList.add(`${toolbarCSSPrefix}zoomLevelEditor`);
zoomLevelDisplay.classList.add('zoomDisplay');
let lastZoom;
const updateZoomDisplay = () => {
let zoomLevel = editor.viewport.getScaleFactor() * 100;
if (zoomLevel > 0.1) {
zoomLevel = Math.round(zoomLevel * 10) / 10;
}
else {
zoomLevel = Math.round(zoomLevel * 1000) / 1000;
}
if (zoomLevel !== lastZoom) {
zoomLevelDisplay.innerText = localizationTable.zoomLevel(zoomLevel);
lastZoom = zoomLevel;
}
};
updateZoomDisplay();
editor.notifier.on(EditorEventType.ViewportChanged, (event) => {
if (event.kind === EditorEventType.ViewportChanged) {
updateZoomDisplay();
}
});
const zoomBy = (factor) => {
const screenCenter = editor.viewport.visibleRect.center;
const transformUpdate = Mat33.scaling2D(factor, screenCenter);
editor.dispatch(new Viewport.ViewportTransform(transformUpdate), false);
};
increaseButton.onclick = () => {
zoomBy(5.0 / 4);
};
decreaseButton.onclick = () => {
zoomBy(4.0 / 5);
};
return zoomLevelRow;
};
class HandToolWidget extends ToolbarWidget {
constructor(editor, tool, localizationTable) {
super(editor, tool, localizationTable);
this.tool = tool;
this.container.classList.add('dropdownShowable');
}
getTitle() {
return this.localizationTable.handTool;
}
createIcon() {
return makeHandToolIcon();
}
fillDropdown(dropdown) {
let idCounter = 0;
const addCheckbox = (label, onToggle) => {
const rowContainer = document.createElement('div');
const labelElem = document.createElement('label');
const checkboxElem = document.createElement('input');
checkboxElem.type = 'checkbox';
checkboxElem.id = `${toolbarCSSPrefix}hand-tool-option-${idCounter++}`;
labelElem.setAttribute('for', checkboxElem.id);
checkboxElem.oninput = () => {
onToggle(checkboxElem.checked);
};
labelElem.innerText = label;
rowContainer.replaceChildren(checkboxElem, labelElem);
dropdown.appendChild(rowContainer);
return checkboxElem;
};
const setModeFlag = (enabled, flag) => {
const mode = this.tool.getMode();
if (enabled) {
this.tool.setMode(mode | flag);
}
else {
this.tool.setMode(mode & ~flag);
}
};
const touchPanningCheckbox = addCheckbox(this.localizationTable.touchPanning, checked => {
setModeFlag(checked, PanZoomMode.OneFingerTouchGestures);
});
const anyDevicePanningCheckbox = addCheckbox(this.localizationTable.anyDevicePanning, checked => {
setModeFlag(checked, PanZoomMode.SinglePointerGestures);
});
dropdown.appendChild(makeZoomControl(this.localizationTable, this.editor));
const updateInputs = () => {
const mode = this.tool.getMode();
anyDevicePanningCheckbox.checked = !!(mode & PanZoomMode.SinglePointerGestures);
if (anyDevicePanningCheckbox.checked) {
touchPanningCheckbox.checked = true;
touchPanningCheckbox.disabled = true;
}
else {
touchPanningCheckbox.checked = !!(mode & PanZoomMode.OneFingerTouchGestures);
touchPanningCheckbox.disabled = false;
}
};
updateInputs();
this.editor.notifier.on(EditorEventType.ToolUpdated, event => {
if (event.kind === EditorEventType.ToolUpdated && event.tool === this.tool) {
updateInputs();
}
});
return true;
}
updateSelected(_active) {
}
handleClick() {
this.setDropdownVisible(!this.isDropdownVisible());
}
}
class TextToolWidget extends ToolbarWidget {
constructor(editor, tool, localization) {
super(editor, tool, localization);
this.tool = tool;
this.updateDropdownInputs = null;
editor.notifier.on(EditorEventType.ToolUpdated, evt => {
var _a;
if (evt.kind === EditorEventType.ToolUpdated && evt.tool === tool) {
this.updateIcon();
(_a = this.updateDropdownInputs) === null || _a === void 0 ? void 0 : _a.call(this);
}
});
}
getTitle() {
return this.targetTool.description;
}
createIcon() {
const textStyle = this.tool.getTextStyle();
return makeTextIcon(textStyle);
}
fillDropdown(dropdown) {
const fontRow = document.createElement('div');
const colorRow = document.createElement('div');
const fontInput = document.createElement('select');
const fontLabel = document.createElement('label');
const colorInput = document.createElement('input');
const colorLabel = document.createElement('label');
const fontsInInput = new Set();
const addFontToInput = (fontName) => {
const option = document.createElement('option');
option.value = fontName;
option.textContent = fontName;
fontInput.appendChild(option);
fontsInInput.add(fontName);
};
fontLabel.innerText = this.localizationTable.fontLabel;
colorLabel.innerText = this.localizationTable.colorLabel;
colorInput.classList.add('coloris_input');
colorInput.type = 'button';
colorInput.id = `${toolbarCSSPrefix}-text-color-input-${TextToolWidget.idCounter++}`;
colorLabel.setAttribute('for', colorInput.id);
addFontToInput('monospace');
addFontToInput('serif');
addFontToInput('sans-serif');
fontInput.id = `${toolbarCSSPrefix}-text-font-input-${TextToolWidget.idCounter++}`;
fontLabel.setAttribute('for', fontInput.id);
fontInput.onchange = () => {
this.tool.setFontFamily(fontInput.value);
};
colorInput.oninput = () => {
this.tool.setColor(Color4.fromString(colorInput.value));
};
colorRow.appendChild(colorLabel);
colorRow.appendChild(colorInput);
fontRow.appendChild(fontLabel);
fontRow.appendChild(fontInput);
this.updateDropdownInputs = () => {
const style = this.tool.getTextStyle();
colorInput.value = style.renderingStyle.fill.toHexString();
if (!fontsInInput.has(style.fontFamily)) {
addFontToInput(style.fontFamily);
}
fontInput.value = style.fontFamily;
};
this.updateDropdownInputs();
dropdown.replaceChildren(colorRow, fontRow);
return true;
}
}
TextToolWidget.idCounter = 0;
class PenWidget extends ToolbarWidget {
constructor(editor, tool, localization, penTypes) {
super(editor, tool, localization);
this.tool = tool;
this.penTypes = penTypes;
this.updateInputs = () => { };
this.editor.notifier.on(EditorEventType.ToolUpdated, toolEvt => {
if (toolEvt.kind !== EditorEventType.ToolUpdated) {
throw new Error('Invalid event type!');
}
// The button icon may depend on tool properties.
if (toolEvt.tool === this.tool) {
this.updateIcon();
this.updateInputs();
}
});
}
getTitle() {
return this.targetTool.description;
}
createIcon() {
const strokeFactory = this.tool.getStrokeFactory();
if (strokeFactory === makeFreehandLineBuilder) {
// Use a square-root scale to prevent the pen's tip from overflowing.
const scale = Math.round(Math.sqrt(this.tool.getThickness()) * 4);
const color = this.tool.getColor();
return makePenIcon(scale, color.toHexString());
}
else {
const strokeFactory = this.tool.getStrokeFactory();
return makeIconFromFactory(this.tool, strokeFactory);
}
}
fillDropdown(dropdown) {
const container = document.createElement('div');
const thicknessRow = document.createElement('div');
const objectTypeRow = document.createElement('div');
// Thickness: Value of the input is squared to allow for finer control/larger values.
const thicknessLabel = document.createElement('label');
const thicknessInput = document.createElement('input');
const objectSelectLabel = document.createElement('label');
const objectTypeSelect = document.createElement('select');
// Give inputs IDs so we can label them with a <label for=...>Label text</label>
thicknessInput.id = `${toolbarCSSPrefix}thicknessInput${PenWidget.idCounter++}`;
objectTypeSelect.id = `${toolbarCSSPrefix}builderSelect${PenWidget.idCounter++}`;
thicknessLabel.innerText = this.localizationTable.thicknessLabel;
thicknessLabel.setAttribute('for', thicknessInput.id);
objectSelectLabel.innerText = this.localizationTable.selectObjectType;
objectSelectLabel.setAttribute('for', objectTypeSelect.id);
thicknessInput.type = 'range';
thicknessInput.min = '1';
thicknessInput.max = '20';
thicknessInput.step = '1';
thicknessInput.oninput = () => {
this.tool.setThickness(Math.pow(parseFloat(thicknessInput.value), 2));
};
thicknessRow.appendChild(thicknessLabel);
thicknessRow.appendChild(thicknessInput);
objectTypeSelect.oninput = () => {
const penTypeIdx = parseInt(objectTypeSelect.value);
if (penTypeIdx < 0 || penTypeIdx >= this.penTypes.length) {
console.error('Invalid pen type index', penTypeIdx);
return;
}
this.tool.setStrokeFactory(this.penTypes[penTypeIdx].factory);
};
objectTypeRow.appendChild(objectSelectLabel);
objectTypeRow.appendChild(objectTypeSelect);
const colorRow = document.createElement('div');
const colorLabel = document.createElement('label');
const colorInput = document.createElement('input');
colorInput.id = `${toolbarCSSPrefix}colorInput${PenWidget.idCounter++}`;
colorLabel.innerText = this.localizationTable.colorLabel;
colorLabel.setAttribute('for', colorInput.id);
colorInput.className = 'coloris_input';
colorInput.type = 'button';
colorInput.oninput = () => {
this.tool.setColor(Color4.fromHex(colorInput.value));
};
colorInput.addEventListener('open', () => {
this.editor.notifier.dispatch(EditorEventType.ColorPickerToggled, {
kind: EditorEventType.ColorPickerToggled,
open: true,
});
});
colorInput.addEventListener('close', () => {
this.editor.notifier.dispatch(EditorEventType.ColorPickerToggled, {
kind: EditorEventType.ColorPickerToggled,
open: false,
});
});
colorRow.appendChild(colorLabel);
colorRow.appendChild(colorInput);
this.updateInputs = () => {
colorInput.value = this.tool.getColor().toHexString();
thicknessInput.value = Math.sqrt(this.tool.getThickness()).toString();
objectTypeSelect.replaceChildren();
for (let i = 0; i < this.penTypes.length; i++) {
const penType = this.penTypes[i];
const option = document.createElement('option');
option.value = i.toString();
option.innerText = penType.name;
objectTypeSelect.appendChild(option);
if (penType.factory === this.tool.getStrokeFactory()) {
objectTypeSelect.value = i.toString();
}
}
};
this.updateInputs();
container.replaceChildren(colorRow, thicknessRow, objectTypeRow);
dropdown.replaceChildren(container);
return true;
}
}
PenWidget.idCounter = 0;
import PenWidget from './widgets/PenWidget';
import EraserWidget from './widgets/EraserWidget';
import { SelectionWidget } from './widgets/SelectionWidget';
import TextToolWidget from './widgets/TextToolWidget';
import HandToolWidget from './widgets/HandToolWidget';
export const toolbarCSSPrefix = 'toolbar-';
export default class HTMLToolbar {

@@ -516,25 +29,2 @@ constructor(editor, parent, localizationTable = defaultToolbarLocalization) {

this.setupColorPickers();
// Default pen types
this.penTypes = [
{
name: localizationTable.freehandPen,
factory: makeFreehandLineBuilder,
},
{
name: localizationTable.arrowPen,
factory: makeArrowBuilder,
},
{
name: localizationTable.linePen,
factory: makeLineBuilder,
},
{
name: localizationTable.filledRectanglePen,
factory: makeFilledRectangleBuilder,
},
{
name: localizationTable.outlinedRectanglePen,
factory: makeOutlinedRectangleBuilder,
},
];
}

@@ -545,17 +35,39 @@ setupColorPickers() {

this.editor.createHTMLOverlay(closePickerOverlay);
coloris({
el: '.coloris_input',
format: 'hex',
selectInput: false,
focusInput: false,
themeMode: 'auto',
swatches: [
Color4.red.toHexString(),
Color4.purple.toHexString(),
Color4.blue.toHexString(),
Color4.clay.toHexString(),
Color4.black.toHexString(),
Color4.white.toHexString(),
],
});
const maxSwatchLen = 12;
const swatches = [
Color4.red.toHexString(),
Color4.purple.toHexString(),
Color4.blue.toHexString(),
Color4.clay.toHexString(),
Color4.black.toHexString(),
Color4.white.toHexString(),
];
const presetColorEnd = swatches.length;
// (Re)init Coloris -- update the swatches list.
const initColoris = () => {
coloris({
el: '.coloris_input',
format: 'hex',
selectInput: false,
focusInput: false,
themeMode: 'auto',
swatches
});
};
initColoris();
const addColorToSwatch = (newColor) => {
let alreadyPresent = false;
for (const color of swatches) {
if (color === newColor) {
alreadyPresent = true;
}
}
if (!alreadyPresent) {
swatches.push(newColor);
if (swatches.length > maxSwatchLen) {
swatches.splice(presetColorEnd, 1);
}
initColoris();
}
};
this.editor.notifier.on(EditorEventType.ColorPickerToggled, event => {

@@ -569,2 +81,8 @@ if (event.kind !== EditorEventType.ColorPickerToggled) {

});
// Add newly-selected colors to the swatch.
this.editor.notifier.on(EditorEventType.ColorPickerColorSelected, event => {
if (event.kind === EditorEventType.ColorPickerColorSelected) {
addColorToSwatch(event.color.toHexString());
}
});
}

@@ -622,3 +140,3 @@ addActionButton(title, command, parent) {

}
const widget = new PenWidget(this.editor, tool, this.localizationTable, this.penTypes);
const widget = new PenWidget(this.editor, tool, this.localizationTable);
widget.addTo(this.container);

@@ -625,0 +143,0 @@ }

@@ -0,1 +1,2 @@

import Color4 from '../Color4';
import { ComponentBuilderFactory } from '../components/builders/types';

@@ -10,4 +11,8 @@ import { TextStyle } from '../components/Text';

export declare const makeHandToolIcon: () => SVGSVGElement;
export declare const makeTouchPanningIcon: () => SVGSVGElement;
export declare const makeAllDevicePanningIcon: () => SVGSVGElement;
export declare const makeZoomIcon: () => SVGSVGElement;
export declare const makeTextIcon: (textStyle: TextStyle) => SVGSVGElement;
export declare const makePenIcon: (tipThickness: number, color: string) => SVGSVGElement;
export declare const makeIconFromFactory: (pen: Pen, factory: ComponentBuilderFactory) => SVGSVGElement;
export declare const makePipetteIcon: (color?: Color4) => SVGSVGElement;

@@ -12,2 +12,16 @@ import EventDispatcher from '../EventDispatcher';

`;
const checkerboardPatternDef = `
<pattern
id='checkerboard'
viewBox='0,0,10,10'
width='20%'
height='20%'
patternUnits='userSpaceOnUse'
>
<rect x=0 y=0 width=10 height=10 fill='white'/>
<rect x=0 y=0 width=5 height=5 fill='gray'/>
<rect x=5 y=5 width=5 height=5 fill='gray'/>
</pattern>
`;
const checkerboardPatternRef = 'url(#checkerboard)';
export const makeUndoIcon = () => {

@@ -80,3 +94,3 @@ return makeRedoIcon(true);

const icon = document.createElementNS(svgNamespace, 'svg');
// Draw a cursor-like shape
// Draw a cursor-like shape (like some of the other icons, made with Inkscape)
icon.innerHTML = `

@@ -116,2 +130,126 @@ <g>

};
export const makeTouchPanningIcon = () => {
const icon = document.createElementNS(svgNamespace, 'svg');
icon.innerHTML = `
<path
d='
M 5,5.5
V 17.2
L 16.25,5.46
Z
m 33.75,0
L 50,17
V 5.5
Z
M 5,40.7
v 11.7
h 11.25
z
M 26,19
C 19.8,19.4 17.65,30.4 21.9,34.8
L 50,70
H 27.5
c -11.25,0 -11.25,17.6 0,17.6
H 61.25
C 94.9,87.8 95,87.6 95,40.7 78.125,23 67,29 55.6,46.5
L 33.1,23
C 30.3125,20.128192 27.9,19 25.830078,19.119756
Z
'
fill='none'
style='
stroke: var(--primary-foreground-color);
stroke-width: 2;
'
/>
`;
icon.setAttribute('viewBox', '0 0 100 100');
return icon;
};
export const makeAllDevicePanningIcon = () => {
const icon = document.createElementNS(svgNamespace, 'svg');
icon.innerHTML = `
<path
d='
M 5 5
L 5 17.5
17.5 5
5 5
z
M 42.5 5
L 55 17.5
55 5
42.5 5
z
M 70 10
L 70 21
61 15
55.5 23
66 30
56 37
61 45
70 39
70 50
80 50
80 39
89 45
95 36
84 30
95 23
89 15
80 21
80 10
70 10
z
M 27.5 26.25
L 27.5 91.25
L 43.75 83.125
L 52 99
L 68 91
L 60 75
L 76.25 66.875
L 27.5 26.25
z
M 5 42.5
L 5 55
L 17.5 55
L 5 42.5
z
'
fill='none'
style='
stroke: var(--primary-foreground-color);
stroke-width: 2;
'
/>
`;
icon.setAttribute('viewBox', '0 0 100 100');
return icon;
};
export const makeZoomIcon = () => {
const icon = document.createElementNS(svgNamespace, 'svg');
icon.setAttribute('viewBox', '0 0 100 100');
const addTextNode = (text, x, y) => {
const textNode = document.createElementNS(svgNamespace, 'text');
textNode.appendChild(document.createTextNode(text));
textNode.setAttribute('x', x.toString());
textNode.setAttribute('y', y.toString());
textNode.style.textAlign = 'center';
textNode.style.textAnchor = 'middle';
textNode.style.fontSize = '55px';
textNode.style.fill = 'var(--primary-foreground-color)';
textNode.style.fontFamily = 'monospace';
icon.appendChild(textNode);
};
addTextNode('+', 40, 45);
addTextNode('-', 70, 75);
return icon;
};
export const makeTextIcon = (textStyle) => {

@@ -144,13 +282,3 @@ var _a, _b;

<defs>
<pattern
id='checkerboard'
viewBox='0,0,10,10'
width='20%'
height='20%'
patternUnits='userSpaceOnUse'
>
<rect x=0 y=0 width=10 height=10 fill='white'/>
<rect x=0 y=0 width=5 height=5 fill='gray'/>
<rect x=5 y=5 width=5 height=5 fill='gray'/>
</pattern>
${checkerboardPatternDef}
</defs>

@@ -166,3 +294,3 @@ <g>

<!-- Checkerboard background for slightly transparent pens -->
<path d='${backgroundStrokeTipPath}' fill='url(#checkerboard)'/>
<path d='${backgroundStrokeTipPath}' fill='${checkerboardPatternRef}'/>

@@ -204,1 +332,46 @@ <!-- Actual pen tip -->

};
export const makePipetteIcon = (color) => {
const icon = document.createElementNS(svgNamespace, 'svg');
const pipette = document.createElementNS(svgNamespace, 'path');
pipette.setAttribute('d', `
M 47,6
C 35,5 25,15 35,30
c -9.2,1.3 -15,0 -15,3
0,2 5,5 15,7
V 81
L 40,90
h 6
L 40,80
V 40
h 15
v 40
l -6,10
h 6
l 5,-9.2
V 40
C 70,38 75,35 75,33
75,30 69.2,31.2 60,30
65,15 65,5 47,6
Z
`);
pipette.style.fill = 'var(--primary-foreground-color)';
if (color) {
const defs = document.createElementNS(svgNamespace, 'defs');
defs.innerHTML = checkerboardPatternDef;
icon.appendChild(defs);
const fluidBackground = document.createElementNS(svgNamespace, 'path');
const fluid = document.createElementNS(svgNamespace, 'path');
const fluidPathData = `
m 40,50 c 5,5 10,0 15,-5 V 80 L 50,90 H 45 L 40,80 Z
`;
fluid.setAttribute('d', fluidPathData);
fluidBackground.setAttribute('d', fluidPathData);
fluid.style.fill = color.toHexString();
fluidBackground.style.fill = checkerboardPatternRef;
icon.appendChild(fluidBackground);
icon.appendChild(fluid);
}
icon.appendChild(pipette);
icon.setAttribute('viewBox', '0 0 100 100');
return icon;
};

@@ -19,8 +19,12 @@ export interface ToolbarLocalization {

deleteSelection: string;
duplicateSelection: string;
pickColorFronScreen: string;
undo: string;
redo: string;
zoom: string;
dropdownShown: (toolName: string) => string;
dropdownHidden: (toolName: string) => string;
zoomLevel: (zoomPercentage: number) => string;
colorChangedAnnouncement: (color: string) => string;
}
export declare const defaultToolbarLocalization: ToolbarLocalization;

@@ -6,2 +6,3 @@ export const defaultToolbarLocalization = {

handTool: 'Pan',
zoom: 'Zoom',
thicknessLabel: 'Thickness: ',

@@ -12,5 +13,7 @@ colorLabel: 'Color: ',

deleteSelection: 'Delete selection',
duplicateSelection: 'Duplicate selection',
undo: 'Undo',
redo: 'Redo',
selectObjectType: 'Object type: ',
pickColorFronScreen: 'Pick color from screen',
touchPanning: 'Touchscreen panning',

@@ -26,2 +29,3 @@ anyDevicePanning: 'Any device panning',

zoomLevel: (zoomPercent) => `Zoom: ${zoomPercent}%`,
colorChangedAnnouncement: (color) => `Color changed to ${color}`,
};
export interface ToolLocalization {
rightClickDragPanTool: string;
penTool: (penId: number) => string;

@@ -9,2 +8,4 @@ selectionTool: string;

undoRedoTool: string;
pipetteTool: string;
rightClickDragPanTool: string;
textTool: string;

@@ -11,0 +12,0 @@ enterTextToInsert: string;

@@ -5,6 +5,7 @@ export const defaultToolLocalization = {

eraserTool: 'Eraser',
touchPanTool: 'Touch Panning',
twoFingerPanZoomTool: 'Panning and Zooming',
touchPanTool: 'Touch panning',
twoFingerPanZoomTool: 'Panning and zooming',
undoRedoTool: 'Undo/Redo',
rightClickDragPanTool: 'Right-click drag',
pipetteTool: 'Pick color from screen',
textTool: 'Text',

@@ -11,0 +12,0 @@ enterTextToInsert: 'Text to insert',

@@ -71,3 +71,3 @@ import EditorImage from '../EditorImage';

const canFlatten = true;
const action = new EditorImage.AddElementCommand(stroke, canFlatten);
const action = EditorImage.addElement(stroke, canFlatten);
this.editor.dispatch(action);

@@ -74,0 +74,0 @@ }

@@ -24,2 +24,3 @@ import Command from '../commands/Command';

finishDragging(): void;
private static ApplyTransformationCommand;
private previewTransformCmds;

@@ -36,2 +37,3 @@ appendBackgroundBoxTo(elem: HTMLElement): void;

deleteSelectedObjects(): Command;
duplicateSelectedObjects(): Command;
}

@@ -38,0 +40,0 @@ export default class SelectionTool extends BaseTool {

@@ -10,2 +10,4 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {

};
import Command from '../commands/Command';
import Duplicate from '../commands/Duplicate';
import Erase from '../commands/Erase';

@@ -223,24 +225,3 @@ import Mat33 from '../geometry/Mat33';

// Make the commands undo-able
this.editor.dispatch({
apply: (editor) => __awaiter(this, void 0, void 0, function* () {
// Approximate the new selection
this.region = this.region.transformedBoundingBox(fullTransform);
this.boxRotation += deltaBoxRotation;
this.updateUI();
yield editor.asyncApplyCommands(currentTransfmCommands, updateChunkSize);
this.recomputeRegion();
this.updateUI();
}),
unapply: (editor) => __awaiter(this, void 0, void 0, function* () {
this.region = this.region.transformedBoundingBox(inverseTransform);
this.boxRotation -= deltaBoxRotation;
this.updateUI();
yield editor.asyncUnapplyCommands(currentTransfmCommands, updateChunkSize);
this.recomputeRegion();
this.updateUI();
}),
description(localizationTable) {
return localizationTable.transformedElements(currentTransfmCommands.length);
},
});
this.editor.dispatch(new Selection.ApplyTransformationCommand(this, currentTransfmCommands, fullTransform, inverseTransform, deltaBoxRotation));
}

@@ -355,3 +336,40 @@ // Preview the effects of the current transformation on the selection

}
duplicateSelectedObjects() {
return new Duplicate(this.selectedElems);
}
}
Selection.ApplyTransformationCommand = class extends Command {
constructor(selection, currentTransfmCommands, fullTransform, inverseTransform, deltaBoxRotation) {
super();
this.selection = selection;
this.currentTransfmCommands = currentTransfmCommands;
this.fullTransform = fullTransform;
this.inverseTransform = inverseTransform;
this.deltaBoxRotation = deltaBoxRotation;
}
apply(editor) {
return __awaiter(this, void 0, void 0, function* () {
// Approximate the new selection
this.selection.region = this.selection.region.transformedBoundingBox(this.fullTransform);
this.selection.boxRotation += this.deltaBoxRotation;
this.selection.updateUI();
yield editor.asyncApplyCommands(this.currentTransfmCommands, updateChunkSize);
this.selection.recomputeRegion();
this.selection.updateUI();
});
}
unapply(editor) {
return __awaiter(this, void 0, void 0, function* () {
this.selection.region = this.selection.region.transformedBoundingBox(this.inverseTransform);
this.selection.boxRotation -= this.deltaBoxRotation;
this.selection.updateUI();
yield editor.asyncUnapplyCommands(this.currentTransfmCommands, updateChunkSize);
this.selection.recomputeRegion();
this.selection.updateUI();
});
}
description(localizationTable) {
return localizationTable.transformedElements(this.currentTransfmCommands.length);
}
};
export default class SelectionTool extends BaseTool {

@@ -402,3 +420,3 @@ constructor(editor, description) {

const selectionRect = this.selectionBox.region;
this.editor.viewport.zoomTo(selectionRect, false).apply(this.editor);
this.editor.dispatchNoAnnounce(this.editor.viewport.zoomTo(selectionRect, false), false);
}

@@ -405,0 +423,0 @@ }

@@ -63,3 +63,3 @@ import Color4 from '../Color4';

const textComponent = new Text([content], textTransform, this.textStyle);
const action = new EditorImage.AddElementCommand(textComponent);
const action = EditorImage.addElement(textComponent);
this.editor.dispatch(action);

@@ -66,0 +66,0 @@ }

@@ -11,3 +11,5 @@ import { InputEvt } from '../types';

Text = 4,
UndoRedoShortcut = 5
UndoRedoShortcut = 5,
Pipette = 6,
Other = 7
}

@@ -14,0 +16,0 @@ export default class ToolController {

@@ -10,2 +10,3 @@ import { InputEvtType, EditorEventType } from '../types';

import TextTool from './TextTool';
import PipetteTool from './PipetteTool';
export var ToolType;

@@ -19,2 +20,4 @@ (function (ToolType) {

ToolType[ToolType["UndoRedoShortcut"] = 5] = "UndoRedoShortcut";
ToolType[ToolType["Pipette"] = 6] = "Pipette";
ToolType[ToolType["Other"] = 7] = "Other";
})(ToolType || (ToolType = {}));

@@ -37,2 +40,3 @@ export default class ToolController {

this.tools = [
new PipetteTool(editor, localization.pipetteTool),
panZoomTool,

@@ -39,0 +43,0 @@ ...primaryTools,

@@ -61,3 +61,4 @@ import EventDispatcher from './EventDispatcher';

DisplayResized = 6,
ColorPickerToggled = 7
ColorPickerToggled = 7,
ColorPickerColorSelected = 8
}

@@ -90,3 +91,7 @@ declare type EditorToolEventType = EditorEventType.ToolEnabled | EditorEventType.ToolDisabled | EditorEventType.ToolUpdated;

}
export declare type EditorEventDataType = EditorToolEvent | EditorObjectEvent | EditorViewportChangedEvent | DisplayResizedEvent | EditorUndoStackUpdated | ColorPickerToggled;
export interface ColorPickerColorSelected {
readonly kind: EditorEventType.ColorPickerColorSelected;
readonly color: Color4;
}
export declare type EditorEventDataType = EditorToolEvent | EditorObjectEvent | EditorViewportChangedEvent | DisplayResizedEvent | EditorUndoStackUpdated | ColorPickerToggled | ColorPickerColorSelected;
export declare type OnProgressListener = (amountProcessed: number, totalToProcess: number) => Promise<void> | null;

@@ -93,0 +98,0 @@ export declare type ComponentAddedListener = (component: AbstractComponent) => void;

@@ -21,2 +21,3 @@ // Types related to the image editor

EditorEventType[EditorEventType["ColorPickerToggled"] = 7] = "ColorPickerToggled";
EditorEventType[EditorEventType["ColorPickerColorSelected"] = 8] = "ColorPickerColorSelected";
})(EditorEventType || (EditorEventType = {}));

@@ -14,3 +14,3 @@ import Command from './commands/Command';

new (transform: Mat33): {
readonly "__#678@#inverseTransform": Mat33;
readonly "__#679@#inverseTransform": Mat33;
readonly transform: Mat33;

@@ -20,3 +20,11 @@ apply(editor: Editor): void;

description(localizationTable: CommandLocalization): string;
onDrop(_editor: Editor): void;
};
union(a: Command, b: Command): Command;
readonly empty: {
description(_localizationTable: import("./localization").EditorLocalization): string;
apply(_editor: Editor): void;
unapply(_editor: Editor): void;
onDrop(_editor: Editor): void;
};
};

@@ -23,0 +31,0 @@ private transform;

@@ -13,2 +13,3 @@ var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (receiver, state, value, kind, f) {

var _inverseTransform, _a;
import Command from './commands/Command';
import Mat33 from './geometry/Mat33';

@@ -133,4 +134,5 @@ import Rect2 from './geometry/Rect2';

// Command that translates/scales the viewport.
Viewport.ViewportTransform = (_a = class {
Viewport.ViewportTransform = (_a = class extends Command {
constructor(transform) {
super();
this.transform = transform;

@@ -137,0 +139,0 @@ _inverseTransform.set(this, void 0);

@@ -20,4 +20,6 @@ // Test configuration

},
testEnvironment: 'jsdom',
};
module.exports = config;
{
"name": "js-draw",
"version": "0.1.6",
"version": "0.1.7",
"description": "Draw pictures using a pen, touchscreen, or mouse! JS-draw is a drawing library for JavaScript and TypeScript. ",

@@ -62,10 +62,10 @@ "main": "dist/src/Editor.js",

"@types/jsdom": "^20.0.0",
"@types/node": "^18.7.9",
"@typescript-eslint/eslint-plugin": "^5.33.1",
"@typescript-eslint/parser": "^5.33.1",
"@types/node": "^18.7.15",
"@typescript-eslint/eslint-plugin": "^5.36.2",
"@typescript-eslint/parser": "^5.36.2",
"css-loader": "^6.7.1",
"eslint": "^8.22.0",
"eslint": "^8.23.0",
"husky": "^8.0.1",
"jest": "^28.1.3",
"jest-environment-jsdom": "^28.1.3",
"jest-environment-jsdom": "^29.0.2",
"jsdom": "^20.0.0",

@@ -72,0 +72,0 @@ "lint-staged": "^13.0.3",

@@ -39,4 +39,4 @@ # js-draw

```html
<!-- Replace 0.1.5 with the latest version of js-draw -->
<script src="https://cdn.jsdelivr.net/npm/js-draw@0.1.5/dist/bundle.js"></script>
<!-- Replace 0.1.7 with the latest version of js-draw -->
<script src="https://cdn.jsdelivr.net/npm/js-draw@0.1.7/dist/bundle.js"></script>
<script>

@@ -43,0 +43,0 @@ const editor = new jsdraw.Editor(document.body);

@@ -8,10 +8,16 @@

public readonly a: number
) { }
) {
}
// Each component should be in the range [0, 1]
public static ofRGB(red: number, green: number, blue: number): Color4 {
return new Color4(red, green, blue, 1.0);
return Color4.ofRGBA(red, green, blue, 1.0);
}
public static ofRGBA(red: number, green: number, blue: number, alpha: number): Color4 {
red = Math.max(0, Math.min(red, 1));
green = Math.max(0, Math.min(green, 1));
blue = Math.max(0, Math.min(blue, 1));
alpha = Math.max(0, Math.min(alpha, 1));
return new Color4(red, green, blue, alpha);

@@ -53,3 +59,3 @@ }

return new Color4(components[0], components[1], components[2], components[3]);
return Color4.ofRGBA(components[0], components[1], components[2], components[3]);
}

@@ -56,0 +62,0 @@

import Editor from '../Editor';
import { EditorLocalization } from '../localization';
interface Command {
apply(editor: Editor): void;
unapply(editor: Editor): void;
export abstract class Command {
public abstract apply(editor: Editor): void;
public abstract unapply(editor: Editor): void;
description(localizationTable: EditorLocalization): string;
}
// Called when the command is being deleted
public onDrop(_editor: Editor) { }
// eslint-disable-next-line no-redeclare
namespace Command {
export const empty = {
apply(_editor: Editor) { },
unapply(_editor: Editor) { },
};
public abstract description(localizationTable: EditorLocalization): string;
export const union = (a: Command, b: Command): Command => {
return {
apply(editor: Editor) {
public static union(a: Command, b: Command): Command {
return new class extends Command {
public apply(editor: Editor) {
a.apply(editor);
b.apply(editor);
},
unapply(editor: Editor) {
}
public unapply(editor: Editor) {
b.unapply(editor);
a.unapply(editor);
},
}
description(localizationTable: EditorLocalization) {
public description(localizationTable: EditorLocalization) {
const aDescription = a.description(localizationTable);

@@ -38,4 +34,10 @@ const bDescription = b.description(localizationTable);

return `${aDescription}, ${bDescription}`;
},
}
};
}
public static readonly empty = new class extends Command {
public description(_localizationTable: EditorLocalization) { return ''; }
public apply(_editor: Editor) { }
public unapply(_editor: Editor) { }
};

@@ -42,0 +44,0 @@ }

import AbstractComponent from '../components/AbstractComponent';
import describeComponentList from '../components/util/describeComponentList';
import Editor from '../Editor';
import EditorImage from '../EditorImage';
import { EditorLocalization } from '../localization';
import Command from './Command';
import SerializableCommand from './SerializableCommand';
export default class Erase implements Command {
export default class Erase extends SerializableCommand {
private toRemove: AbstractComponent[];
private applied: boolean;
public constructor(toRemove: AbstractComponent[]) {
super('erase');
// Clone the list
this.toRemove = toRemove.map(elem => elem);
this.applied = false;
}
public apply(editor: Editor): void {
public apply(editor: Editor) {
for (const part of this.toRemove) {

@@ -24,15 +29,25 @@ const parent = editor.image.findParent(part);

this.applied = true;
editor.queueRerender();
}
public unapply(editor: Editor): void {
public unapply(editor: Editor) {
for (const part of this.toRemove) {
if (!editor.image.findParent(part)) {
new EditorImage.AddElementCommand(part).apply(editor);
EditorImage.addElement(part).apply(editor);
}
}
this.applied = false;
editor.queueRerender();
}
public onDrop(editor: Editor) {
if (this.applied) {
for (const part of this.toRemove) {
editor.image.onDestroyElement(part);
}
}
}
public description(localizationTable: EditorLocalization): string {

@@ -43,12 +58,18 @@ if (this.toRemove.length === 0) {

let description = this.toRemove[0].description(localizationTable);
for (const elem of this.toRemove) {
if (elem.description(localizationTable) !== description) {
description = localizationTable.elements;
break;
}
}
const description = describeComponentList(localizationTable, this.toRemove) ?? localizationTable.elements;
return localizationTable.eraseAction(description, this.toRemove.length);
}
protected serializeToString() {
const elemIds = this.toRemove.map(elem => elem.getId());
return JSON.stringify(elemIds);
}
static {
SerializableCommand.register('erase', (data: string, editor: Editor) => {
const json = JSON.parse(data);
const elems = json.map((elemId: string) => editor.image.lookupElement(elemId));
return new Erase(elems);
});
}
}

@@ -12,2 +12,3 @@ import Rect2 from '../geometry/Rect2';

erasedNoElements: string;
duplicatedNoElements: string;
elements: string;

@@ -19,2 +20,3 @@ updatedViewport: string;

eraseAction: (elemDescription: string, numElems: number) => string;
duplicateAction: (elemDescription: string, count: number)=> string;

@@ -30,4 +32,7 @@ selectedElements: (count: number)=>string;

eraseAction: (componentDescription: string, numElems: number) => `Erased ${numElems} ${componentDescription}`,
duplicateAction: (componentDescription: string, numElems: number) => `Duplicated ${numElems} ${componentDescription}`,
elements: 'Elements',
erasedNoElements: 'Erased nothing',
duplicatedNoElements: 'Duplicated nothing',
rotatedBy: (degrees) => `Rotated by ${Math.abs(degrees)} degrees ${degrees < 0 ? 'clockwise' : 'counter-clockwise'}`,

@@ -34,0 +39,0 @@ movedLeft: 'Moved left',

import Command from '../commands/Command';
import SerializableCommand from '../commands/SerializableCommand';
import Editor from '../Editor';

@@ -7,7 +8,10 @@ import EditorImage from '../EditorImage';

import Rect2 from '../geometry/Rect2';
import { EditorLocalization } from '../localization';
import AbstractRenderer from '../rendering/renderers/AbstractRenderer';
import { ImageComponentLocalization } from './localization';
type LoadSaveData = unknown;
type LoadSaveData = (string[]|Record<symbol, string|number>);
export type LoadSaveDataTable = Record<string, Array<LoadSaveData>>;
type DeserializeCallback = (data: string)=>AbstractComponent;
type ComponentId = string;

@@ -18,2 +22,3 @@ export default abstract class AbstractComponent {

private zIndex: number;
private id: string;

@@ -23,7 +28,33 @@ // Topmost z-index

protected constructor() {
protected constructor(
// A unique identifier for the type of component
private readonly componentKind: string,
) {
this.lastChangedTime = (new Date()).getTime();
this.zIndex = AbstractComponent.zIndexCounter++;
// Create a unique ID.
this.id = `${new Date().getTime()}-${Math.random()}`;
if (AbstractComponent.deserializationCallbacks[componentKind] === undefined) {
throw new Error(`Component ${componentKind} has not been registered using AbstractComponent.registerComponent`);
}
}
public getId() {
return this.id;
}
private static deserializationCallbacks: Record<ComponentId, DeserializeCallback|null> = {};
// Store the deserialization callback (or lack of it) for [componentKind].
// If components are registered multiple times (as may be done in automated tests),
// the most recent deserialization callback is used.
public static registerComponent(
componentKind: string,
deserialize: DeserializeCallback|null,
) {
this.deserializationCallbacks[componentKind] = deserialize ?? null;
}
// Get and manage data attached by a loader.

@@ -51,2 +82,5 @@ private loadSaveData: LoadSaveDataTable = {};

// Return null iff this object cannot be safely serialized/deserialized.
protected abstract serializeToString(): string|null;
// Private helper for transformBy: Apply the given transformation to all points of this.

@@ -58,5 +92,19 @@ protected abstract applyTransformation(affineTransfm: Mat33): void;

public transformBy(affineTransfm: Mat33): Command {
const updateTransform = (editor: Editor, newTransfm: Mat33) => {
return new AbstractComponent.TransformElementCommand(affineTransfm, this);
}
private static TransformElementCommand = class extends SerializableCommand {
private origZIndex: number;
public constructor(
private affineTransfm: Mat33,
private component: AbstractComponent,
) {
super('transform-element');
this.origZIndex = component.zIndex;
}
private updateTransform(editor: Editor, newTransfm: Mat33) {
// Any parent should have only one direct child.
const parent = editor.image.findParent(this);
const parent = editor.image.findParent(this.component);
let hadParent = false;

@@ -68,31 +116,125 @@ if (parent) {

this.applyTransformation(newTransfm);
this.component.applyTransformation(newTransfm);
// Add the element back to the document.
if (hadParent) {
new EditorImage.AddElementCommand(this).apply(editor);
EditorImage.addElement(this.component).apply(editor);
}
};
const origZIndex = this.zIndex;
}
return {
apply: (editor: Editor) => {
this.zIndex = AbstractComponent.zIndexCounter++;
updateTransform(editor, affineTransfm);
editor.queueRerender();
},
unapply: (editor: Editor): void => {
this.zIndex = origZIndex;
updateTransform(
editor, affineTransfm.inverse()
public apply(editor: Editor) {
this.component.zIndex = AbstractComponent.zIndexCounter++;
this.updateTransform(editor, this.affineTransfm);
editor.queueRerender();
}
public unapply(editor: Editor) {
this.component.zIndex = this.origZIndex;
this.updateTransform(editor, this.affineTransfm.inverse());
editor.queueRerender();
}
public description(localizationTable: EditorLocalization) {
return localizationTable.transformedElements(1);
}
static {
SerializableCommand.register('transform-element', (data: string, editor: Editor) => {
const json = JSON.parse(data);
const elem = editor.image.lookupElement(json.id);
if (!elem) {
throw new Error(`Unable to retrieve non-existent element, ${elem}`);
}
const transform = json.transfm as [
number, number, number,
number, number, number,
number, number, number,
];
return new AbstractComponent.TransformElementCommand(
new Mat33(...transform),
elem,
);
editor.queueRerender();
},
description(localizationTable) {
return localizationTable.transformedElements(1);
},
};
});
}
protected serializeToString(): string {
return JSON.stringify({
id: this.component.getId(),
transfm: this.affineTransfm.toArray(),
});
}
};
public abstract description(localizationTable: ImageComponentLocalization): string;
protected abstract createClone(): AbstractComponent;
public clone() {
const clone = this.createClone();
for (const attachmentKey in this.loadSaveData) {
for (const val of this.loadSaveData[attachmentKey]) {
clone.attachLoadSaveData(attachmentKey, val);
}
}
return clone;
}
public abstract description(localizationTable: ImageComponentLocalization): string;
public serialize() {
const data = this.serializeToString();
if (data === null) {
throw new Error(`${this} cannot be serialized.`);
}
return JSON.stringify({
name: this.componentKind,
zIndex: this.zIndex,
id: this.id,
loadSaveData: this.loadSaveData,
data,
});
}
// Returns true if [data] is not deserializable. May return false even if [data]
// is not deserializable.
private static isNotDeserializable(data: string) {
const json = JSON.parse(data);
if (typeof json !== 'object') {
return true;
}
if (!this.deserializationCallbacks[json?.name]) {
return true;
}
if (!json.data) {
return true;
}
return false;
}
public static deserialize(data: string): AbstractComponent {
if (AbstractComponent.isNotDeserializable(data)) {
throw new Error(`Element with data ${data} cannot be deserialized.`);
}
const json = JSON.parse(data);
const instance = this.deserializationCallbacks[json.name]!(json.data);
instance.zIndex = json.zIndex;
instance.id = json.id;
// TODO: What should we do with json.loadSaveData?
// If we attach it to [instance], we create a potential security risk — loadSaveData
// is often used to store unrecognised attributes so they can be preserved on output.
// ...but what if we're deserializing data sent across the network?
return instance;
}
}
import { Bezier } from 'bezier-js';
import AbstractRenderer, { RenderingStyle, RenderablePathSpec } from '../../rendering/renderers/AbstractRenderer';
import AbstractRenderer, { RenderablePathSpec } from '../../rendering/renderers/AbstractRenderer';
import { Point2, Vec2 } from '../../geometry/Vec2';

@@ -11,2 +11,3 @@ import Rect2 from '../../geometry/Rect2';

import { ComponentBuilder, ComponentBuilderFactory } from './types';
import RenderingStyle from '../../rendering/RenderingStyle';

@@ -13,0 +14,0 @@ export const makeFreehandLineBuilder: ComponentBuilderFactory = (initialPoint: StrokeDataPoint, viewport: Viewport) => {

@@ -0,5 +1,13 @@

/* @jest-environment jsdom */
import Color4 from '../Color4';
import Path from '../geometry/Path';
import { Vec2 } from '../geometry/Vec2';
import Stroke from './Stroke';
import { loadExpectExtensions } from '../testing/loadExpectExtensions';
import createEditor from '../testing/createEditor';
import Mat33 from '../geometry/Mat33';
loadExpectExtensions();
describe('Stroke', () => {

@@ -18,2 +26,47 @@ it('empty stroke should have an empty bounding box', () => {

});
it('cloned strokes should have the same points', () => {
const stroke = new Stroke([
Path.fromString('m1,1 2,2 3,3 z').toRenderable({ fill: Color4.red })
]);
const clone = stroke.clone();
expect(
(clone as Stroke).getPath().toString()
).toBe(
stroke.getPath().toString()
);
});
it('transforming a cloned stroke should not affect the original', () => {
const editor = createEditor();
const stroke = new Stroke([
Path.fromString('m1,1 2,2 3,3 z').toRenderable({ fill: Color4.red })
]);
const origBBox = stroke.getBBox();
expect(origBBox).toMatchObject({
x: 1, y: 1,
w: 5, h: 5,
});
const copy = stroke.clone();
expect(copy.getBBox()).objEq(origBBox);
stroke.transformBy(
Mat33.scaling2D(Vec2.of(10, 10))
).apply(editor);
expect(stroke.getBBox()).not.objEq(origBBox);
expect(copy.getBBox()).objEq(origBBox);
});
it('strokes should deserialize from JSON data', () => {
const deserialized = Stroke.deserializeFromString(`[
{
"style": { "fill": "#f00" },
"path": "m0,0 l10,10z"
}
]`);
expect(deserialized.getPath().toString()).toBe('M0,0L10,10L0,0');
});
});

@@ -5,3 +5,4 @@ import LineSegment2 from '../geometry/LineSegment2';

import Rect2 from '../geometry/Rect2';
import AbstractRenderer, { RenderablePathSpec, RenderingStyle } from '../rendering/renderers/AbstractRenderer';
import AbstractRenderer, { RenderablePathSpec } from '../rendering/renderers/AbstractRenderer';
import RenderingStyle, { styleFromJSON, styleToJSON } from '../rendering/RenderingStyle';
import AbstractComponent from './AbstractComponent';

@@ -20,3 +21,3 @@ import { ImageComponentLocalization } from './localization';

public constructor(parts: RenderablePathSpec[]) {
super();
super('stroke');

@@ -101,5 +102,39 @@ this.parts = parts.map(section => {

public getPath() {
return this.parts.reduce((accumulator: Path|null, current: StrokePart) => {
return accumulator?.union(current.path) ?? current.path;
}, null) ?? Path.empty;
}
public description(localization: ImageComponentLocalization): string {
return localization.stroke;
}
protected createClone(): AbstractComponent {
return new Stroke(this.parts);
}
protected serializeToString(): string | null {
return JSON.stringify(this.parts.map(part => {
return {
style: styleToJSON(part.style),
path: part.path.serialize(),
};
}));
}
public static deserializeFromString(data: string): Stroke {
const json = JSON.parse(data);
if (typeof json !== 'object' || typeof json.length !== 'number') {
throw new Error(`${data} is missing required field, parts, or parts is of the wrong type.`);
}
const pathSpec: RenderablePathSpec[] = json.map((part: any) => {
const style = styleFromJSON(part.style);
return Path.fromString(part.path).toRenderable(style);
});
return new Stroke(pathSpec);
}
}
AbstractComponent.registerComponent('stroke', Stroke.deserializeFromString);

@@ -9,7 +9,11 @@ import LineSegment2 from '../geometry/LineSegment2';

type GlobalAttrsList = Array<[string, string|null]>;
const componentKind = 'svg-global-attributes';
// Stores global SVG attributes (e.g. namespace identifiers.)
export default class SVGGlobalAttributesObject extends AbstractComponent {
protected contentBBox: Rect2;
public constructor(private readonly attrs: Array<[string, string|null]>) {
super();
public constructor(private readonly attrs: GlobalAttrsList) {
super(componentKind);
this.contentBBox = Rect2.empty;

@@ -36,5 +40,33 @@ }

protected createClone() {
return new SVGGlobalAttributesObject(this.attrs);
}
public description(localization: ImageComponentLocalization): string {
return localization.svgObject;
}
protected serializeToString(): string | null {
return JSON.stringify(this.attrs);
}
public static deserializeFromString(data: string): AbstractComponent {
const json = JSON.parse(data) as GlobalAttrsList;
const attrs: GlobalAttrsList = [];
const numericAndSpaceContentExp = /^[ \t\n0-9.-eE]+$/;
// Don't deserialize all attributes, just those that should be safe.
for (const [ key, val ] of json) {
if (key === 'viewBox' || key === 'width' || key === 'height') {
if (val && numericAndSpaceContentExp.exec(val)) {
attrs.push([key, val]);
}
}
}
return new SVGGlobalAttributesObject(attrs);
}
}
AbstractComponent.registerComponent(componentKind, SVGGlobalAttributesObject.deserializeFromString);
import LineSegment2 from '../geometry/LineSegment2';
import Mat33 from '../geometry/Mat33';
import Rect2 from '../geometry/Rect2';
import AbstractRenderer, { RenderingStyle } from '../rendering/renderers/AbstractRenderer';
import AbstractRenderer from '../rendering/renderers/AbstractRenderer';
import RenderingStyle, { styleFromJSON, styleToJSON } from '../rendering/RenderingStyle';
import AbstractComponent from './AbstractComponent';

@@ -16,8 +17,17 @@ import { ImageComponentLocalization } from './localization';

type GetTextDimensCallback = (text: string, style: TextStyle) => Rect2;
const componentTypeId = 'text';
export default class Text extends AbstractComponent {
protected contentBBox: Rect2;
public constructor(protected textObjects: Array<string|Text>, private transform: Mat33, private style: TextStyle) {
super();
public constructor(
protected readonly textObjects: Array<string|Text>,
private transform: Mat33,
private readonly style: TextStyle,
// If not given, an HtmlCanvasElement is used to determine text boundaries.
private readonly getTextDimens: GetTextDimensCallback = Text.getTextDimens,
) {
super(componentTypeId);
this.recomputeBBox();

@@ -56,3 +66,3 @@ }

if (typeof part === 'string') {
const textBBox = Text.getTextDimens(part, this.style);
const textBBox = this.getTextDimens(part, this.style);
return textBBox.transformedBoundingBox(this.transform);

@@ -125,2 +135,6 @@ } else {

protected createClone(): AbstractComponent {
return new Text(this.textObjects, this.transform, this.style);
}
private getText() {

@@ -143,2 +157,63 @@ const result: string[] = [];

}
}
protected serializeToString(): string {
const serializableStyle = {
...this.style,
renderingStyle: styleToJSON(this.style.renderingStyle),
};
const textObjects = this.textObjects.map(text => {
if (typeof text === 'string') {
return {
text,
};
} else {
return {
json: text.serializeToString(),
};
}
});
return JSON.stringify({
textObjects,
transform: this.transform.toArray(),
style: serializableStyle,
});
}
public static deserializeFromString(data: string, getTextDimens: GetTextDimensCallback = Text.getTextDimens): Text {
const json = JSON.parse(data);
const style: TextStyle = {
renderingStyle: styleFromJSON(json.style.renderingStyle),
size: json.style.size,
fontWeight: json.style.fontWeight,
fontVariant: json.style.fontVariant,
fontFamily: json.style.fontFamily,
};
const textObjects: Array<string|Text> = json.textObjects.map((data: any) => {
if ((data.text ?? null) !== null) {
return data.text;
}
return Text.deserializeFromString(data.json);
});
json.transform = json.transform.filter((elem: any) => typeof elem === 'number');
if (json.transform.length !== 9) {
throw new Error(`Unable to deserialize transform, ${json.transform}.`);
}
const transformData = json.transform as [
number, number, number,
number, number, number,
number, number, number,
];
const transform = new Mat33(...transformData);
return new Text(textObjects, transform, style, getTextDimens);
}
}
AbstractComponent.registerComponent(componentTypeId, (data: string) => Text.deserializeFromString(data));

@@ -9,2 +9,3 @@ import LineSegment2 from '../geometry/LineSegment2';

const componentId = 'unknown-svg-object';
export default class UnknownSVGObject extends AbstractComponent {

@@ -14,3 +15,3 @@ protected contentBBox: Rect2;

public constructor(private svgObject: SVGElement) {
super();
super(componentId);
this.contentBBox = Rect2.of(svgObject.getBoundingClientRect());

@@ -35,5 +36,18 @@ }

protected createClone(): AbstractComponent {
return new UnknownSVGObject(this.svgObject.cloneNode(true) as SVGElement);
}
public description(localization: ImageComponentLocalization): string {
return localization.svgObject;
}
protected serializeToString(): string | null {
return JSON.stringify({
html: this.svgObject.outerHTML,
});
}
}
// null: Do not deserialize UnknownSVGObjects.
AbstractComponent.registerComponent(componentId, null);

@@ -111,3 +111,3 @@

this.registerListeners();
this.rerender();
this.queueRerender();
this.hideLoadingWarning();

@@ -311,2 +311,3 @@ }

// Adds to history by default
public dispatch(command: Command, addToHistory: boolean = true) {

@@ -323,2 +324,11 @@ if (addToHistory) {

// Dispatches a command without announcing it. By default, does not add to history.
public dispatchNoAnnounce(command: Command, addToHistory: boolean = false) {
if (addToHistory) {
this.history.push(command);
} else {
command.apply(this);
}
}
// Apply a large transformation in chunks.

@@ -492,3 +502,3 @@ // If [apply] is false, the commands are unapplied.

await loader.start((component) => {
(new EditorImage.AddElementCommand(component)).apply(this);
this.dispatchNoAnnounce(EditorImage.addElement(component));
}, (countProcessed: number, totalToProcess: number) => {

@@ -505,4 +515,4 @@ if (countProcessed % 500 === 0) {

}, (importExportRect: Rect2) => {
this.setImportExportRect(importExportRect).apply(this);
this.viewport.zoomTo(importExportRect).apply(this);
this.dispatchNoAnnounce(this.setImportExportRect(importExportRect), false);
this.dispatchNoAnnounce(this.viewport.zoomTo(importExportRect), false);
});

@@ -525,4 +535,4 @@ this.hideLoadingWarning();

return {
apply(editor) {
return new class extends Command {
public apply(editor: Editor) {
const viewport = editor.importExportViewport;

@@ -532,4 +542,5 @@ viewport.updateScreenSize(imageRect.size);

editor.queueRerender();
},
unapply(editor) {
}
public unapply(editor: Editor) {
const viewport = editor.importExportViewport;

@@ -539,6 +550,7 @@ viewport.updateScreenSize(origSize);

editor.queueRerender();
},
description(localizationTable) {
}
public description(localizationTable: EditorLocalization) {
return localizationTable.resizeOutputCommand(imageRect);
},
}
};

@@ -545,0 +557,0 @@ }

@@ -9,4 +9,4 @@ /* @jest-environment jsdom */

import DummyRenderer from './rendering/renderers/DummyRenderer';
import { RenderingStyle } from './rendering/renderers/AbstractRenderer';
import createEditor from './testing/createEditor';
import RenderingStyle from './rendering/RenderingStyle';

@@ -29,3 +29,3 @@ describe('EditorImage', () => {

const testFill: RenderingStyle = { fill: Color4.black };
const addTestStrokeCommand = new EditorImage.AddElementCommand(testStroke);
const addTestStrokeCommand = EditorImage.addElement(testStroke);

@@ -74,3 +74,3 @@ it('elements added to the image should be findable', () => {

(new EditorImage.AddElementCommand(leftmostStroke)).apply(editor);
(EditorImage.addElement(leftmostStroke)).apply(editor);

@@ -83,3 +83,3 @@ // The first node should be at the image's root.

(new EditorImage.AddElementCommand(rightmostStroke)).apply(editor);
(EditorImage.addElement(rightmostStroke)).apply(editor);

@@ -86,0 +86,0 @@ firstParent = image.findParent(leftmostStroke);

@@ -9,2 +9,3 @@ import Editor from './Editor';

import RenderingCache from './rendering/caching/RenderingCache';
import SerializableCommand from './commands/SerializableCommand';

@@ -18,11 +19,9 @@ export const sortLeavesByZIndex = (leaves: Array<ImageNode>) => {

private root: ImageNode;
private componentsById: Record<string, AbstractComponent>;
public constructor() {
this.root = new ImageNode();
this.componentsById = {};
}
private addElement(elem: AbstractComponent): ImageNode {
return this.root.addLeaf(elem);
}
// Returns the parent of the given element, if it exists.

@@ -64,7 +63,21 @@ public findParent(elem: AbstractComponent): ImageNode|null {

public onDestroyElement(elem: AbstractComponent) {
delete this.componentsById[elem.getId()];
}
public lookupElement(id: string): AbstractComponent|null {
return this.componentsById[id] ?? null;
}
private addElementDirectly(elem: AbstractComponent): ImageNode {
this.componentsById[elem.getId()] = elem;
return this.root.addLeaf(elem);
}
public static addElement(elem: AbstractComponent, applyByFlattening: boolean = false): Command {
return new EditorImage.AddElementCommand(elem, applyByFlattening);
}
// A Command that can access private [EditorImage] functionality
public static AddElementCommand = class implements Command {
readonly #element: AbstractComponent;
#applyByFlattening: boolean = false;
private static AddElementCommand = class extends SerializableCommand {
// If [applyByFlattening], then the rendered content of this element

@@ -74,9 +87,8 @@ // is present on the display's wet ink canvas. As such, no re-render is necessary

public constructor(
element: AbstractComponent,
applyByFlattening: boolean = false
private element: AbstractComponent,
private applyByFlattening: boolean = false
) {
this.#element = element;
this.#applyByFlattening = applyByFlattening;
super('add-element');
if (isNaN(this.#element.getBBox().area)) {
if (isNaN(element.getBBox().area)) {
throw new Error('Elements in the image cannot have NaN bounding boxes');

@@ -87,8 +99,8 @@ }

public apply(editor: Editor) {
editor.image.addElement(this.#element);
editor.image.addElementDirectly(this.element);
if (!this.#applyByFlattening) {
if (!this.applyByFlattening) {
editor.queueRerender();
} else {
this.#applyByFlattening = false;
this.applyByFlattening = false;
editor.display.flatten();

@@ -99,3 +111,3 @@ }

public unapply(editor: Editor) {
const container = editor.image.findParent(this.#element);
const container = editor.image.findParent(this.element);
container?.remove();

@@ -106,8 +118,21 @@ editor.queueRerender();

public description(localization: EditorLocalization) {
return localization.addElementAction(this.#element.description(localization));
return localization.addElementAction(this.element.description(localization));
}
protected serializeToString() {
return JSON.stringify({
elemData: this.element.serialize(),
});
}
static {
SerializableCommand.register('add-element', (data: string, _editor: Editor) => {
const json = JSON.parse(data);
const elem = AbstractComponent.deserialize(json.elemData);
return new EditorImage.AddElementCommand(elem);
});
}
};
}
export type AddElementCommand = typeof EditorImage.AddElementCommand.prototype;
type TooSmallToRenderCheck = (rect: Rect2)=> boolean;

@@ -231,2 +256,3 @@

nodeForChildren.recomputeBBox(true);
nodeForChildren.updateParents();

@@ -290,2 +316,12 @@ return nodeForNewLeaf.addLeaf(leaf);

private updateParents(recursive: boolean = false) {
for (const child of this.children) {
child.parent = this;
if (recursive) {
child.updateParents(recursive);
}
}
}
private rebalance() {

@@ -308,2 +344,3 @@ // If the current node is its parent's only child,

this.parent.children = this.children;
this.parent.updateParents();
this.parent = null;

@@ -327,4 +364,8 @@ }

});
console.assert(this.parent.children.length === oldChildCount - 1);
console.assert(
this.parent.children.length === oldChildCount - 1,
`${oldChildCount - 1} ≠ ${this.parent.children.length} after removing all nodes equal to ${this}. Nodes should only be removed once.`
);
this.parent.children.forEach(child => {

@@ -331,0 +372,0 @@ child.rebalance();

import { Bezier } from 'bezier-js';
import { RenderingStyle, RenderablePathSpec } from '../rendering/renderers/AbstractRenderer';
import { RenderablePathSpec } from '../rendering/renderers/AbstractRenderer';
import RenderingStyle from '../rendering/RenderingStyle';
import LineSegment2 from './LineSegment2';

@@ -285,2 +286,6 @@ import Mat33 from './Mat33';

public serialize(): string {
return this.toString();
}
public static toString(startPoint: Point2, parts: PathCommand[]): string {

@@ -559,2 +564,4 @@ const result: string[] = [];

}
public static empty: Path = new Path(Vec2.zero, []);
}

@@ -31,3 +31,3 @@ /* @jest-environment jsdom */

editor.dispatch(new EditorImage.AddElementCommand(testStroke));
editor.dispatch(EditorImage.addElement(testStroke));
editor.image.renderWithCache(screenRenderer, cache, editor.viewport);

@@ -34,0 +34,0 @@

@@ -6,5 +6,6 @@ import AbstractRenderer from './renderers/AbstractRenderer';

import DummyRenderer from './renderers/DummyRenderer';
import { Vec2 } from '../geometry/Vec2';
import { Point2, Vec2 } from '../geometry/Vec2';
import RenderingCache from './caching/RenderingCache';
import TextOnlyRenderer from './renderers/TextOnlyRenderer';
import Color4 from '../Color4';

@@ -92,2 +93,6 @@ export enum RenderingMode {

public getColorAt = (_screenPos: Point2): Color4|null => {
return null;
};
private initializeCanvasRendering() {

@@ -137,2 +142,13 @@ const dryInkCanvas = document.createElement('canvas');

};
this.getColorAt = (screenPos: Point2) => {
const pixel = dryInkCtx.getImageData(screenPos.x, screenPos.y, 1, 1);
const data = pixel?.data;
if (data) {
const color = Color4.ofRGBA(data[0] / 255, data[1] / 255, data[2] / 255, data[3] / 255);
return color;
}
return null;
};
}

@@ -139,0 +155,0 @@

@@ -1,2 +0,1 @@

import Color4 from '../../Color4';
import { LoadSaveDataTable } from '../../components/AbstractComponent';

@@ -9,11 +8,4 @@ import { TextStyle } from '../../components/Text';

import Viewport from '../../Viewport';
import RenderingStyle, { stylesEqual } from '../RenderingStyle';
export interface RenderingStyle {
fill: Color4;
stroke?: {
color: Color4;
width: number;
};
}
export interface RenderablePathSpec {

@@ -25,8 +17,2 @@ startPoint: Point2;

const stylesEqual = (a: RenderingStyle, b: RenderingStyle) => {
return a === b || (a.fill.eq(b.fill)
&& a.stroke?.color?.eq(b.stroke?.color)
&& a.stroke?.width === b.stroke?.width);
};
export default abstract class AbstractRenderer {

@@ -33,0 +19,0 @@ // If null, this' transformation is linked to the Viewport

@@ -8,3 +8,4 @@ import Color4 from '../../Color4';

import Viewport from '../../Viewport';
import AbstractRenderer, { RenderablePathSpec, RenderingStyle } from './AbstractRenderer';
import RenderingStyle from '../RenderingStyle';
import AbstractRenderer, { RenderablePathSpec } from './AbstractRenderer';

@@ -11,0 +12,0 @@ export default class CanvasRenderer extends AbstractRenderer {

@@ -9,3 +9,4 @@ // Renderer that outputs nothing. Useful for automated tests.

import Viewport from '../../Viewport';
import AbstractRenderer, { RenderingStyle } from './AbstractRenderer';
import RenderingStyle from '../RenderingStyle';
import AbstractRenderer from './AbstractRenderer';

@@ -12,0 +13,0 @@ export default class DummyRenderer extends AbstractRenderer {

@@ -10,3 +10,4 @@

import Viewport from '../../Viewport';
import AbstractRenderer, { RenderingStyle } from './AbstractRenderer';
import RenderingStyle from '../RenderingStyle';
import AbstractRenderer from './AbstractRenderer';

@@ -13,0 +14,0 @@ const svgNameSpace = 'http://www.w3.org/2000/svg';

@@ -8,3 +8,4 @@ import { TextStyle } from '../../components/Text';

import { TextRendererLocalization } from '../localization';
import AbstractRenderer, { RenderingStyle } from './AbstractRenderer';
import RenderingStyle from '../RenderingStyle';
import AbstractRenderer from './AbstractRenderer';

@@ -11,0 +12,0 @@ // Outputs a description of what was rendered.

@@ -11,3 +11,4 @@ import Color4 from './Color4';

import { Vec2 } from './geometry/Vec2';
import { RenderablePathSpec, RenderingStyle } from './rendering/renderers/AbstractRenderer';
import { RenderablePathSpec } from './rendering/renderers/AbstractRenderer';
import RenderingStyle from './rendering/RenderingStyle';
import { ComponentAddedListener, ImageLoader, OnDetermineExportRectListener, OnProgressListener } from './types';

@@ -14,0 +15,0 @@

@@ -9,631 +9,20 @@ import Editor from '../Editor';

import Eraser from '../tools/Eraser';
import BaseTool from '../tools/BaseTool';
import SelectionTool from '../tools/SelectionTool';
import { makeFreehandLineBuilder } from '../components/builders/FreehandLineBuilder';
import { ComponentBuilderFactory } from '../components/builders/types';
import { makeArrowBuilder } from '../components/builders/ArrowBuilder';
import { makeLineBuilder } from '../components/builders/LineBuilder';
import { makeFilledRectangleBuilder, makeOutlinedRectangleBuilder } from '../components/builders/RectangleBuilder';
import { defaultToolbarLocalization, ToolbarLocalization } from './localization';
import { ActionButtonIcon } from './types';
import { makeDropdownIcon, makeEraserIcon, makeIconFromFactory, makePenIcon, makeRedoIcon, makeSelectionIcon, makeHandToolIcon, makeUndoIcon, makeTextIcon } from './icons';
import PanZoom, { PanZoomMode } from '../tools/PanZoom';
import Mat33 from '../geometry/Mat33';
import Viewport from '../Viewport';
import { makeRedoIcon, makeUndoIcon } from './icons';
import PanZoom from '../tools/PanZoom';
import TextTool from '../tools/TextTool';
import PenWidget from './widgets/PenWidget';
import EraserWidget from './widgets/EraserWidget';
import { SelectionWidget } from './widgets/SelectionWidget';
import TextToolWidget from './widgets/TextToolWidget';
import HandToolWidget from './widgets/HandToolWidget';
const toolbarCSSPrefix = 'toolbar-';
export const toolbarCSSPrefix = 'toolbar-';
abstract class ToolbarWidget {
protected readonly container: HTMLElement;
private button: HTMLElement;
private icon: Element|null;
private dropdownContainer: HTMLElement;
private dropdownIcon: Element;
private label: HTMLLabelElement;
private hasDropdown: boolean;
public constructor(
protected editor: Editor,
protected targetTool: BaseTool,
protected localizationTable: ToolbarLocalization,
) {
this.icon = null;
this.container = document.createElement('div');
this.container.classList.add(`${toolbarCSSPrefix}toolContainer`);
this.dropdownContainer = document.createElement('div');
this.dropdownContainer.classList.add(`${toolbarCSSPrefix}dropdown`);
this.dropdownContainer.classList.add('hidden');
this.hasDropdown = false;
this.button = document.createElement('div');
this.button.classList.add(`${toolbarCSSPrefix}button`);
this.label = document.createElement('label');
this.button.setAttribute('role', 'button');
this.button.tabIndex = 0;
editor.notifier.on(EditorEventType.ToolEnabled, toolEvt => {
if (toolEvt.kind !== EditorEventType.ToolEnabled) {
throw new Error('Incorrect event type! (Expected ToolEnabled)');
}
if (toolEvt.tool === targetTool) {
this.updateSelected(true);
}
});
editor.notifier.on(EditorEventType.ToolDisabled, toolEvt => {
if (toolEvt.kind !== EditorEventType.ToolDisabled) {
throw new Error('Incorrect event type! (Expected ToolDisabled)');
}
if (toolEvt.tool === targetTool) {
this.updateSelected(false);
this.setDropdownVisible(false);
}
});
}
protected abstract getTitle(): string;
protected abstract createIcon(): Element;
// Add content to the widget's associated dropdown menu.
// Returns true if such a menu should be created, false otherwise.
protected abstract fillDropdown(dropdown: HTMLElement): boolean;
protected setupActionBtnClickListener(button: HTMLElement) {
button.onclick = () => {
this.handleClick();
};
}
protected handleClick() {
if (this.hasDropdown) {
if (!this.targetTool.isEnabled()) {
this.targetTool.setEnabled(true);
} else {
this.setDropdownVisible(!this.isDropdownVisible());
}
} else {
this.targetTool.setEnabled(!this.targetTool.isEnabled());
}
}
// Adds this to [parent]. This can only be called once for each ToolbarWidget.
public addTo(parent: HTMLElement) {
this.label.innerText = this.getTitle();
this.setupActionBtnClickListener(this.button);
this.icon = null;
this.updateIcon();
this.updateSelected(this.targetTool.isEnabled());
this.button.replaceChildren(this.icon!, this.label);
this.container.appendChild(this.button);
this.hasDropdown = this.fillDropdown(this.dropdownContainer);
if (this.hasDropdown) {
this.dropdownIcon = this.createDropdownIcon();
this.button.appendChild(this.dropdownIcon);
this.container.appendChild(this.dropdownContainer);
}
this.setDropdownVisible(false);
parent.appendChild(this.container);
}
protected updateIcon() {
const newIcon = this.createIcon();
this.icon?.replaceWith(newIcon);
this.icon = newIcon;
this.icon.classList.add(`${toolbarCSSPrefix}icon`);
}
protected updateSelected(selected: boolean) {
const currentlySelected = this.container.classList.contains('selected');
if (currentlySelected === selected) {
return;
}
if (selected) {
this.container.classList.add('selected');
this.button.ariaSelected = 'true';
} else {
this.container.classList.remove('selected');
this.button.ariaSelected = 'false';
}
}
protected setDropdownVisible(visible: boolean) {
const currentlyVisible = this.container.classList.contains('dropdownVisible');
if (currentlyVisible === visible) {
return;
}
if (visible) {
this.dropdownContainer.classList.remove('hidden');
this.container.classList.add('dropdownVisible');
this.editor.announceForAccessibility(
this.localizationTable.dropdownShown(this.targetTool.description)
);
} else {
this.dropdownContainer.classList.add('hidden');
this.container.classList.remove('dropdownVisible');
this.editor.announceForAccessibility(
this.localizationTable.dropdownHidden(this.targetTool.description)
);
}
this.repositionDropdown();
}
protected repositionDropdown() {
const dropdownBBox = this.dropdownContainer.getBoundingClientRect();
const screenWidth = document.body.clientWidth;
if (dropdownBBox.left > screenWidth / 2) {
this.dropdownContainer.style.marginLeft = this.button.clientWidth + 'px';
this.dropdownContainer.style.transform = 'translate(-100%, 0)';
} else {
this.dropdownContainer.style.marginLeft = '';
this.dropdownContainer.style.transform = '';
}
}
protected isDropdownVisible(): boolean {
return !this.dropdownContainer.classList.contains('hidden');
}
private createDropdownIcon(): Element {
const icon = makeDropdownIcon();
icon.classList.add(`${toolbarCSSPrefix}showHideDropdownIcon`);
return icon;
}
}
class EraserWidget extends ToolbarWidget {
protected getTitle(): string {
return this.localizationTable.eraser;
}
protected createIcon(): Element {
return makeEraserIcon();
}
protected fillDropdown(_dropdown: HTMLElement): boolean {
// No dropdown associated with the eraser
return false;
}
}
class SelectionWidget extends ToolbarWidget {
public constructor(
editor: Editor, private tool: SelectionTool, localization: ToolbarLocalization
) {
super(editor, tool, localization);
}
protected getTitle(): string {
return this.localizationTable.select;
}
protected createIcon(): Element {
return makeSelectionIcon();
}
protected fillDropdown(dropdown: HTMLElement): boolean {
const container = document.createElement('div');
const resizeButton = document.createElement('button');
const deleteButton = document.createElement('button');
resizeButton.innerText = this.localizationTable.resizeImageToSelection;
resizeButton.disabled = true;
deleteButton.innerText = this.localizationTable.deleteSelection;
deleteButton.disabled = true;
resizeButton.onclick = () => {
const selection = this.tool.getSelection();
this.editor.dispatch(this.editor.setImportExportRect(selection!.region));
};
deleteButton.onclick = () => {
const selection = this.tool.getSelection();
this.editor.dispatch(selection!.deleteSelectedObjects());
this.tool.clearSelection();
};
// Enable/disable actions based on whether items are selected
this.editor.notifier.on(EditorEventType.ToolUpdated, toolEvt => {
if (toolEvt.kind !== EditorEventType.ToolUpdated) {
throw new Error('Invalid event type!');
}
if (toolEvt.tool === this.tool) {
const selection = this.tool.getSelection();
const hasSelection = selection && selection.region.area > 0;
resizeButton.disabled = !hasSelection;
deleteButton.disabled = resizeButton.disabled;
}
});
container.replaceChildren(resizeButton, deleteButton);
dropdown.appendChild(container);
return true;
}
}
const makeZoomControl = (localizationTable: ToolbarLocalization, editor: Editor) => {
const zoomLevelRow = document.createElement('div');
const increaseButton = document.createElement('button');
const decreaseButton = document.createElement('button');
const zoomLevelDisplay = document.createElement('span');
increaseButton.innerText = '+';
decreaseButton.innerText = '-';
zoomLevelRow.replaceChildren(zoomLevelDisplay, increaseButton, decreaseButton);
zoomLevelRow.classList.add(`${toolbarCSSPrefix}zoomLevelEditor`);
zoomLevelDisplay.classList.add('zoomDisplay');
let lastZoom: number|undefined;
const updateZoomDisplay = () => {
let zoomLevel = editor.viewport.getScaleFactor() * 100;
if (zoomLevel > 0.1) {
zoomLevel = Math.round(zoomLevel * 10) / 10;
} else {
zoomLevel = Math.round(zoomLevel * 1000) / 1000;
}
if (zoomLevel !== lastZoom) {
zoomLevelDisplay.innerText = localizationTable.zoomLevel(zoomLevel);
lastZoom = zoomLevel;
}
};
updateZoomDisplay();
editor.notifier.on(EditorEventType.ViewportChanged, (event) => {
if (event.kind === EditorEventType.ViewportChanged) {
updateZoomDisplay();
}
});
const zoomBy = (factor: number) => {
const screenCenter = editor.viewport.visibleRect.center;
const transformUpdate = Mat33.scaling2D(factor, screenCenter);
editor.dispatch(new Viewport.ViewportTransform(transformUpdate), false);
};
increaseButton.onclick = () => {
zoomBy(5.0/4);
};
decreaseButton.onclick = () => {
zoomBy(4.0/5);
};
return zoomLevelRow;
};
class HandToolWidget extends ToolbarWidget {
public constructor(
editor: Editor, protected tool: PanZoom, localizationTable: ToolbarLocalization
) {
super(editor, tool, localizationTable);
this.container.classList.add('dropdownShowable');
}
protected getTitle(): string {
return this.localizationTable.handTool;
}
protected createIcon(): Element {
return makeHandToolIcon();
}
protected fillDropdown(dropdown: HTMLElement): boolean {
type OnToggle = (checked: boolean)=>void;
let idCounter = 0;
const addCheckbox = (label: string, onToggle: OnToggle) => {
const rowContainer = document.createElement('div');
const labelElem = document.createElement('label');
const checkboxElem = document.createElement('input');
checkboxElem.type = 'checkbox';
checkboxElem.id = `${toolbarCSSPrefix}hand-tool-option-${idCounter++}`;
labelElem.setAttribute('for', checkboxElem.id);
checkboxElem.oninput = () => {
onToggle(checkboxElem.checked);
};
labelElem.innerText = label;
rowContainer.replaceChildren(checkboxElem, labelElem);
dropdown.appendChild(rowContainer);
return checkboxElem;
};
const setModeFlag = (enabled: boolean, flag: PanZoomMode) => {
const mode = this.tool.getMode();
if (enabled) {
this.tool.setMode(mode | flag);
} else {
this.tool.setMode(mode & ~flag);
}
};
const touchPanningCheckbox = addCheckbox(this.localizationTable.touchPanning, checked => {
setModeFlag(checked, PanZoomMode.OneFingerTouchGestures);
});
const anyDevicePanningCheckbox = addCheckbox(this.localizationTable.anyDevicePanning, checked => {
setModeFlag(checked, PanZoomMode.SinglePointerGestures);
});
dropdown.appendChild(makeZoomControl(this.localizationTable, this.editor));
const updateInputs = () => {
const mode = this.tool.getMode();
anyDevicePanningCheckbox.checked = !!(mode & PanZoomMode.SinglePointerGestures);
if (anyDevicePanningCheckbox.checked) {
touchPanningCheckbox.checked = true;
touchPanningCheckbox.disabled = true;
} else {
touchPanningCheckbox.checked = !!(mode & PanZoomMode.OneFingerTouchGestures);
touchPanningCheckbox.disabled = false;
}
};
updateInputs();
this.editor.notifier.on(EditorEventType.ToolUpdated, event => {
if (event.kind === EditorEventType.ToolUpdated && event.tool === this.tool) {
updateInputs();
}
});
return true;
}
protected updateSelected(_active: boolean) {
}
protected handleClick() {
this.setDropdownVisible(!this.isDropdownVisible());
}
}
class TextToolWidget extends ToolbarWidget {
private updateDropdownInputs: (()=>void)|null = null;
public constructor(editor: Editor, private tool: TextTool, localization: ToolbarLocalization) {
super(editor, tool, localization);
editor.notifier.on(EditorEventType.ToolUpdated, evt => {
if (evt.kind === EditorEventType.ToolUpdated && evt.tool === tool) {
this.updateIcon();
this.updateDropdownInputs?.();
}
});
}
protected getTitle(): string {
return this.targetTool.description;
}
protected createIcon(): Element {
const textStyle = this.tool.getTextStyle();
return makeTextIcon(textStyle);
}
private static idCounter: number = 0;
protected fillDropdown(dropdown: HTMLElement): boolean {
const fontRow = document.createElement('div');
const colorRow = document.createElement('div');
const fontInput = document.createElement('select');
const fontLabel = document.createElement('label');
const colorInput = document.createElement('input');
const colorLabel = document.createElement('label');
const fontsInInput = new Set();
const addFontToInput = (fontName: string) => {
const option = document.createElement('option');
option.value = fontName;
option.textContent = fontName;
fontInput.appendChild(option);
fontsInInput.add(fontName);
};
fontLabel.innerText = this.localizationTable.fontLabel;
colorLabel.innerText = this.localizationTable.colorLabel;
colorInput.classList.add('coloris_input');
colorInput.type = 'button';
colorInput.id = `${toolbarCSSPrefix}-text-color-input-${TextToolWidget.idCounter++}`;
colorLabel.setAttribute('for', colorInput.id);
addFontToInput('monospace');
addFontToInput('serif');
addFontToInput('sans-serif');
fontInput.id = `${toolbarCSSPrefix}-text-font-input-${TextToolWidget.idCounter++}`;
fontLabel.setAttribute('for', fontInput.id);
fontInput.onchange = () => {
this.tool.setFontFamily(fontInput.value);
};
colorInput.oninput = () => {
this.tool.setColor(Color4.fromString(colorInput.value));
};
colorRow.appendChild(colorLabel);
colorRow.appendChild(colorInput);
fontRow.appendChild(fontLabel);
fontRow.appendChild(fontInput);
this.updateDropdownInputs = () => {
const style = this.tool.getTextStyle();
colorInput.value = style.renderingStyle.fill.toHexString();
if (!fontsInInput.has(style.fontFamily)) {
addFontToInput(style.fontFamily);
}
fontInput.value = style.fontFamily;
};
this.updateDropdownInputs();
dropdown.replaceChildren(colorRow, fontRow);
return true;
}
}
class PenWidget extends ToolbarWidget {
private updateInputs: ()=> void = () => {};
public constructor(
editor: Editor, private tool: Pen, localization: ToolbarLocalization, private penTypes: PenTypeRecord[]
) {
super(editor, tool, localization);
this.editor.notifier.on(EditorEventType.ToolUpdated, toolEvt => {
if (toolEvt.kind !== EditorEventType.ToolUpdated) {
throw new Error('Invalid event type!');
}
// The button icon may depend on tool properties.
if (toolEvt.tool === this.tool) {
this.updateIcon();
this.updateInputs();
}
});
}
protected getTitle(): string {
return this.targetTool.description;
}
protected createIcon(): Element {
const strokeFactory = this.tool.getStrokeFactory();
if (strokeFactory === makeFreehandLineBuilder) {
// Use a square-root scale to prevent the pen's tip from overflowing.
const scale = Math.round(Math.sqrt(this.tool.getThickness()) * 4);
const color = this.tool.getColor();
return makePenIcon(scale, color.toHexString());
} else {
const strokeFactory = this.tool.getStrokeFactory();
return makeIconFromFactory(this.tool, strokeFactory);
}
}
private static idCounter: number = 0;
protected fillDropdown(dropdown: HTMLElement): boolean {
const container = document.createElement('div');
const thicknessRow = document.createElement('div');
const objectTypeRow = document.createElement('div');
// Thickness: Value of the input is squared to allow for finer control/larger values.
const thicknessLabel = document.createElement('label');
const thicknessInput = document.createElement('input');
const objectSelectLabel = document.createElement('label');
const objectTypeSelect = document.createElement('select');
// Give inputs IDs so we can label them with a <label for=...>Label text</label>
thicknessInput.id = `${toolbarCSSPrefix}thicknessInput${PenWidget.idCounter++}`;
objectTypeSelect.id = `${toolbarCSSPrefix}builderSelect${PenWidget.idCounter++}`;
thicknessLabel.innerText = this.localizationTable.thicknessLabel;
thicknessLabel.setAttribute('for', thicknessInput.id);
objectSelectLabel.innerText = this.localizationTable.selectObjectType;
objectSelectLabel.setAttribute('for', objectTypeSelect.id);
thicknessInput.type = 'range';
thicknessInput.min = '1';
thicknessInput.max = '20';
thicknessInput.step = '1';
thicknessInput.oninput = () => {
this.tool.setThickness(parseFloat(thicknessInput.value) ** 2);
};
thicknessRow.appendChild(thicknessLabel);
thicknessRow.appendChild(thicknessInput);
objectTypeSelect.oninput = () => {
const penTypeIdx = parseInt(objectTypeSelect.value);
if (penTypeIdx < 0 || penTypeIdx >= this.penTypes.length) {
console.error('Invalid pen type index', penTypeIdx);
return;
}
this.tool.setStrokeFactory(this.penTypes[penTypeIdx].factory);
};
objectTypeRow.appendChild(objectSelectLabel);
objectTypeRow.appendChild(objectTypeSelect);
const colorRow = document.createElement('div');
const colorLabel = document.createElement('label');
const colorInput = document.createElement('input');
colorInput.id = `${toolbarCSSPrefix}colorInput${PenWidget.idCounter++}`;
colorLabel.innerText = this.localizationTable.colorLabel;
colorLabel.setAttribute('for', colorInput.id);
colorInput.className = 'coloris_input';
colorInput.type = 'button';
colorInput.oninput = () => {
this.tool.setColor(Color4.fromHex(colorInput.value));
};
colorInput.addEventListener('open', () => {
this.editor.notifier.dispatch(EditorEventType.ColorPickerToggled, {
kind: EditorEventType.ColorPickerToggled,
open: true,
});
});
colorInput.addEventListener('close', () => {
this.editor.notifier.dispatch(EditorEventType.ColorPickerToggled, {
kind: EditorEventType.ColorPickerToggled,
open: false,
});
});
colorRow.appendChild(colorLabel);
colorRow.appendChild(colorInput);
this.updateInputs = () => {
colorInput.value = this.tool.getColor().toHexString();
thicknessInput.value = Math.sqrt(this.tool.getThickness()).toString();
objectTypeSelect.replaceChildren();
for (let i = 0; i < this.penTypes.length; i ++) {
const penType = this.penTypes[i];
const option = document.createElement('option');
option.value = i.toString();
option.innerText = penType.name;
objectTypeSelect.appendChild(option);
if (penType.factory === this.tool.getStrokeFactory()) {
objectTypeSelect.value = i.toString();
}
}
};
this.updateInputs();
container.replaceChildren(colorRow, thicknessRow, objectTypeRow);
dropdown.replaceChildren(container);
return true;
}
}
interface PenTypeRecord {
name: string;
factory: ComponentBuilderFactory;
}
export default class HTMLToolbar {
private container: HTMLElement;
private penTypes: PenTypeRecord[];

@@ -651,26 +40,2 @@ public constructor(

this.setupColorPickers();
// Default pen types
this.penTypes = [
{
name: localizationTable.freehandPen,
factory: makeFreehandLineBuilder,
},
{
name: localizationTable.arrowPen,
factory: makeArrowBuilder,
},
{
name: localizationTable.linePen,
factory: makeLineBuilder,
},
{
name: localizationTable.filledRectanglePen,
factory: makeFilledRectangleBuilder,
},
{
name: localizationTable.outlinedRectanglePen,
factory: makeOutlinedRectangleBuilder,
},
];
}

@@ -683,19 +48,45 @@

coloris({
el: '.coloris_input',
format: 'hex',
selectInput: false,
focusInput: false,
themeMode: 'auto',
const maxSwatchLen = 12;
const swatches = [
Color4.red.toHexString(),
Color4.purple.toHexString(),
Color4.blue.toHexString(),
Color4.clay.toHexString(),
Color4.black.toHexString(),
Color4.white.toHexString(),
];
const presetColorEnd = swatches.length;
swatches: [
Color4.red.toHexString(),
Color4.purple.toHexString(),
Color4.blue.toHexString(),
Color4.clay.toHexString(),
Color4.black.toHexString(),
Color4.white.toHexString(),
],
});
// (Re)init Coloris -- update the swatches list.
const initColoris = () => {
coloris({
el: '.coloris_input',
format: 'hex',
selectInput: false,
focusInput: false,
themeMode: 'auto',
swatches
});
};
initColoris();
const addColorToSwatch = (newColor: string) => {
let alreadyPresent = false;
for (const color of swatches) {
if (color === newColor) {
alreadyPresent = true;
}
}
if (!alreadyPresent) {
swatches.push(newColor);
if (swatches.length > maxSwatchLen) {
swatches.splice(presetColorEnd, 1);
}
initColoris();
}
};
this.editor.notifier.on(EditorEventType.ColorPickerToggled, event => {

@@ -710,2 +101,9 @@ if (event.kind !== EditorEventType.ColorPickerToggled) {

});
// Add newly-selected colors to the swatch.
this.editor.notifier.on(EditorEventType.ColorPickerColorSelected, event => {
if (event.kind === EditorEventType.ColorPickerColorSelected) {
addColorToSwatch(event.color.toHexString());
}
});
}

@@ -775,3 +173,3 @@

const widget = new PenWidget(
this.editor, tool, this.localizationTable, this.penTypes,
this.editor, tool, this.localizationTable,
);

@@ -778,0 +176,0 @@ widget.addTo(this.container);

@@ -0,1 +1,2 @@

import Color4 from '../Color4';
import { ComponentBuilderFactory } from '../components/builders/types';

@@ -17,2 +18,16 @@ import { TextStyle } from '../components/Text';

`;
const checkerboardPatternDef = `
<pattern
id='checkerboard'
viewBox='0,0,10,10'
width='20%'
height='20%'
patternUnits='userSpaceOnUse'
>
<rect x=0 y=0 width=10 height=10 fill='white'/>
<rect x=0 y=0 width=5 height=5 fill='gray'/>
<rect x=5 y=5 width=5 height=5 fill='gray'/>
</pattern>
`;
const checkerboardPatternRef = 'url(#checkerboard)';

@@ -95,3 +110,3 @@ export const makeUndoIcon = () => {

// Draw a cursor-like shape
// Draw a cursor-like shape (like some of the other icons, made with Inkscape)
icon.innerHTML = `

@@ -132,2 +147,135 @@ <g>

export const makeTouchPanningIcon = () => {
const icon = document.createElementNS(svgNamespace, 'svg');
icon.innerHTML = `
<path
d='
M 5,5.5
V 17.2
L 16.25,5.46
Z
m 33.75,0
L 50,17
V 5.5
Z
M 5,40.7
v 11.7
h 11.25
z
M 26,19
C 19.8,19.4 17.65,30.4 21.9,34.8
L 50,70
H 27.5
c -11.25,0 -11.25,17.6 0,17.6
H 61.25
C 94.9,87.8 95,87.6 95,40.7 78.125,23 67,29 55.6,46.5
L 33.1,23
C 30.3125,20.128192 27.9,19 25.830078,19.119756
Z
'
fill='none'
style='
stroke: var(--primary-foreground-color);
stroke-width: 2;
'
/>
`;
icon.setAttribute('viewBox', '0 0 100 100');
return icon;
};
export const makeAllDevicePanningIcon = () => {
const icon = document.createElementNS(svgNamespace, 'svg');
icon.innerHTML = `
<path
d='
M 5 5
L 5 17.5
17.5 5
5 5
z
M 42.5 5
L 55 17.5
55 5
42.5 5
z
M 70 10
L 70 21
61 15
55.5 23
66 30
56 37
61 45
70 39
70 50
80 50
80 39
89 45
95 36
84 30
95 23
89 15
80 21
80 10
70 10
z
M 27.5 26.25
L 27.5 91.25
L 43.75 83.125
L 52 99
L 68 91
L 60 75
L 76.25 66.875
L 27.5 26.25
z
M 5 42.5
L 5 55
L 17.5 55
L 5 42.5
z
'
fill='none'
style='
stroke: var(--primary-foreground-color);
stroke-width: 2;
'
/>
`;
icon.setAttribute('viewBox', '0 0 100 100');
return icon;
};
export const makeZoomIcon = () => {
const icon = document.createElementNS(svgNamespace, 'svg');
icon.setAttribute('viewBox', '0 0 100 100');
const addTextNode = (text: string, x: number, y: number) => {
const textNode = document.createElementNS(svgNamespace, 'text');
textNode.appendChild(document.createTextNode(text));
textNode.setAttribute('x', x.toString());
textNode.setAttribute('y', y.toString());
textNode.style.textAlign = 'center';
textNode.style.textAnchor = 'middle';
textNode.style.fontSize = '55px';
textNode.style.fill = 'var(--primary-foreground-color)';
textNode.style.fontFamily = 'monospace';
icon.appendChild(textNode);
};
addTextNode('+', 40, 45);
addTextNode('-', 70, 75);
return icon;
};
export const makeTextIcon = (textStyle: TextStyle) => {

@@ -167,13 +315,3 @@ const icon = document.createElementNS(svgNamespace, 'svg');

<defs>
<pattern
id='checkerboard'
viewBox='0,0,10,10'
width='20%'
height='20%'
patternUnits='userSpaceOnUse'
>
<rect x=0 y=0 width=10 height=10 fill='white'/>
<rect x=0 y=0 width=5 height=5 fill='gray'/>
<rect x=5 y=5 width=5 height=5 fill='gray'/>
</pattern>
${checkerboardPatternDef}
</defs>

@@ -189,3 +327,3 @@ <g>

<!-- Checkerboard background for slightly transparent pens -->
<path d='${backgroundStrokeTipPath}' fill='url(#checkerboard)'/>
<path d='${backgroundStrokeTipPath}' fill='${checkerboardPatternRef}'/>

@@ -233,1 +371,55 @@ <!-- Actual pen tip -->

};
export const makePipetteIcon = (color?: Color4) => {
const icon = document.createElementNS(svgNamespace, 'svg');
const pipette = document.createElementNS(svgNamespace, 'path');
pipette.setAttribute('d', `
M 47,6
C 35,5 25,15 35,30
c -9.2,1.3 -15,0 -15,3
0,2 5,5 15,7
V 81
L 40,90
h 6
L 40,80
V 40
h 15
v 40
l -6,10
h 6
l 5,-9.2
V 40
C 70,38 75,35 75,33
75,30 69.2,31.2 60,30
65,15 65,5 47,6
Z
`);
pipette.style.fill = 'var(--primary-foreground-color)';
if (color) {
const defs = document.createElementNS(svgNamespace, 'defs');
defs.innerHTML = checkerboardPatternDef;
icon.appendChild(defs);
const fluidBackground = document.createElementNS(svgNamespace, 'path');
const fluid = document.createElementNS(svgNamespace, 'path');
const fluidPathData = `
m 40,50 c 5,5 10,0 15,-5 V 80 L 50,90 H 45 L 40,80 Z
`;
fluid.setAttribute('d', fluidPathData);
fluidBackground.setAttribute('d', fluidPathData);
fluid.style.fill = color.toHexString();
fluidBackground.style.fill = checkerboardPatternRef;
icon.appendChild(fluidBackground);
icon.appendChild(fluid);
}
icon.appendChild(pipette);
icon.setAttribute('viewBox', '0 0 100 100');
return icon;
};

@@ -21,8 +21,12 @@

deleteSelection: string;
duplicateSelection: string;
pickColorFronScreen: string;
undo: string;
redo: string;
zoom: string;
dropdownShown: (toolName: string)=>string;
dropdownHidden: (toolName: string)=>string;
dropdownShown: (toolName: string)=> string;
dropdownHidden: (toolName: string)=> string;
zoomLevel: (zoomPercentage: number)=> string;
colorChangedAnnouncement: (color: string)=> string;
}

@@ -35,2 +39,3 @@

handTool: 'Pan',
zoom: 'Zoom',
thicknessLabel: 'Thickness: ',

@@ -41,5 +46,7 @@ colorLabel: 'Color: ',

deleteSelection: 'Delete selection',
duplicateSelection: 'Duplicate selection',
undo: 'Undo',
redo: 'Redo',
selectObjectType: 'Object type: ',
pickColorFronScreen: 'Pick color from screen',

@@ -58,2 +65,3 @@ touchPanning: 'Touchscreen panning',

zoomLevel: (zoomPercent: number) => `Zoom: ${zoomPercent}%`,
colorChangedAnnouncement: (color: string)=> `Color changed to ${color}`,
};
export interface ToolLocalization {
rightClickDragPanTool: string;
penTool: (penId: number)=>string;

@@ -10,2 +9,5 @@ selectionTool: string;

undoRedoTool: string;
pipetteTool: string;
rightClickDragPanTool: string;
textTool: string;

@@ -22,6 +24,7 @@ enterTextToInsert: string;

eraserTool: 'Eraser',
touchPanTool: 'Touch Panning',
twoFingerPanZoomTool: 'Panning and Zooming',
touchPanTool: 'Touch panning',
twoFingerPanZoomTool: 'Panning and zooming',
undoRedoTool: 'Undo/Redo',
rightClickDragPanTool: 'Right-click drag',
pipetteTool: 'Pick color from screen',

@@ -28,0 +31,0 @@ textTool: 'Text',

@@ -96,3 +96,3 @@ import Color4 from '../Color4';

const canFlatten = true;
const action = new EditorImage.AddElementCommand(stroke, canFlatten);
const action = EditorImage.addElement(stroke, canFlatten);
this.editor.dispatch(action);

@@ -99,0 +99,0 @@ } else {

@@ -5,3 +5,2 @@ /* @jest-environment jsdom */

import Stroke from '../components/Stroke';
import { RenderingMode } from '../rendering/Display';
import Editor from '../Editor';

@@ -14,2 +13,3 @@ import EditorImage from '../EditorImage';

import { ToolType } from './ToolController';
import createEditor from '../testing/createEditor';

@@ -20,4 +20,2 @@ const getSelectionTool = (editor: Editor): SelectionTool => {

const createEditor = () => new Editor(document.body, { renderingMode: RenderingMode.DummyRenderer });
const createSquareStroke = () => {

@@ -28,3 +26,3 @@ const testStroke = new Stroke([

]);
const addTestStrokeCommand = new EditorImage.AddElementCommand(testStroke);
const addTestStrokeCommand = EditorImage.addElement(testStroke);

@@ -31,0 +29,0 @@ return { testStroke, addTestStrokeCommand };

import Command from '../commands/Command';
import Duplicate from '../commands/Duplicate';
import Erase from '../commands/Erase';

@@ -9,2 +10,3 @@ import AbstractComponent from '../components/AbstractComponent';

import { Point2, Vec2 } from '../geometry/Vec2';
import { EditorLocalization } from '../localization';
import { EditorEventType, PointerEvt } from '../types';

@@ -267,29 +269,43 @@ import BaseTool from './BaseTool';

// Make the commands undo-able
this.editor.dispatch({
apply: async (editor) => {
// Approximate the new selection
this.region = this.region.transformedBoundingBox(fullTransform);
this.boxRotation += deltaBoxRotation;
this.updateUI();
this.editor.dispatch(new Selection.ApplyTransformationCommand(
this, currentTransfmCommands, fullTransform, inverseTransform, deltaBoxRotation
));
}
await editor.asyncApplyCommands(currentTransfmCommands, updateChunkSize);
this.recomputeRegion();
this.updateUI();
},
unapply: async (editor) => {
this.region = this.region.transformedBoundingBox(inverseTransform);
this.boxRotation -= deltaBoxRotation;
this.updateUI();
private static ApplyTransformationCommand = class extends Command {
public constructor(
private selection: Selection,
private currentTransfmCommands: Command[],
private fullTransform: Mat33, private inverseTransform: Mat33,
private deltaBoxRotation: number,
) {
super();
}
await editor.asyncUnapplyCommands(currentTransfmCommands, updateChunkSize);
this.recomputeRegion();
this.updateUI();
},
public async apply(editor: Editor) {
// Approximate the new selection
this.selection.region = this.selection.region.transformedBoundingBox(this.fullTransform);
this.selection.boxRotation += this.deltaBoxRotation;
this.selection.updateUI();
description(localizationTable) {
return localizationTable.transformedElements(currentTransfmCommands.length);
},
});
}
await editor.asyncApplyCommands(this.currentTransfmCommands, updateChunkSize);
this.selection.recomputeRegion();
this.selection.updateUI();
}
public async unapply(editor: Editor) {
this.selection.region = this.selection.region.transformedBoundingBox(this.inverseTransform);
this.selection.boxRotation -= this.deltaBoxRotation;
this.selection.updateUI();
await editor.asyncUnapplyCommands(this.currentTransfmCommands, updateChunkSize);
this.selection.recomputeRegion();
this.selection.updateUI();
}
public description(localizationTable: EditorLocalization) {
return localizationTable.transformedElements(this.currentTransfmCommands.length);
}
};
// Preview the effects of the current transformation on the selection

@@ -436,2 +452,6 @@ private previewTransformCmds() {

}
public duplicateSelectedObjects(): Command {
return new Duplicate(this.selectedElems);
}
}

@@ -500,3 +520,3 @@

const selectionRect = this.selectionBox.region;
this.editor.viewport.zoomTo(selectionRect, false).apply(this.editor);
this.editor.dispatchNoAnnounce(this.editor.viewport.zoomTo(selectionRect, false), false);
}

@@ -503,0 +523,0 @@ }

@@ -87,3 +87,3 @@ import Color4 from '../Color4';

const action = new EditorImage.AddElementCommand(textComponent);
const action = EditorImage.addElement(textComponent);
this.editor.dispatch(action);

@@ -90,0 +90,0 @@ }

@@ -13,10 +13,13 @@ import { InputEvtType, InputEvt, EditorEventType } from '../types';

import TextTool from './TextTool';
import PipetteTool from './PipetteTool';
export enum ToolType {
Pen,
Selection,
Eraser,
PanZoom,
Text,
UndoRedoShortcut,
Pen,
Selection,
Eraser,
PanZoom,
Text,
UndoRedoShortcut,
Pipette,
Other,
}

@@ -46,2 +49,3 @@

this.tools = [
new PipetteTool(editor, localization.pipetteTool),
panZoomTool,

@@ -48,0 +52,0 @@ ...primaryTools,

@@ -12,3 +12,3 @@ /* @jest-environment jsdom */

const testStroke = new Stroke([Path.fromString('M0,0L10,10').toRenderable({ fill: Color4.red })]);
const addTestStrokeCommand = new EditorImage.AddElementCommand(testStroke);
const addTestStrokeCommand = EditorImage.addElement(testStroke);

@@ -15,0 +15,0 @@ it('ctrl+z should undo', () => {

@@ -80,2 +80,4 @@ // Types related to the image editor

export enum EditorEventType {

@@ -90,2 +92,3 @@ ToolEnabled,

ColorPickerToggled,
ColorPickerColorSelected
}

@@ -129,5 +132,13 @@

export type EditorEventDataType = EditorToolEvent | EditorObjectEvent | EditorViewportChangedEvent | DisplayResizedEvent | EditorUndoStackUpdated | ColorPickerToggled;
export interface ColorPickerColorSelected {
readonly kind: EditorEventType.ColorPickerColorSelected;
readonly color: Color4;
}
export type EditorEventDataType = EditorToolEvent | EditorObjectEvent
| EditorViewportChangedEvent | DisplayResizedEvent
| EditorUndoStackUpdated
| ColorPickerToggled| ColorPickerColorSelected;
// Returns a Promise to indicate that the event source should pause until the Promise resolves.

@@ -134,0 +145,0 @@ // Returns null to continue loading without pause.

@@ -16,6 +16,7 @@ import Command from './commands/Command';

// Command that translates/scales the viewport.
public static ViewportTransform = class implements Command {
public static ViewportTransform = class extends Command {
readonly #inverseTransform: Mat33;
public constructor(public readonly transform: Mat33) {
super();
this.#inverseTransform = transform.inverse();

@@ -22,0 +23,0 @@ }

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is not supported yet

SocketSocket SOC 2 Logo

Product

  • Package Alerts
  • Integrations
  • Docs
  • Pricing
  • FAQ
  • Roadmap
  • Changelog

Packages

npm

Stay in touch

Get open source security insights delivered straight into your inbox.


  • Terms
  • Privacy
  • Security

Made with ⚡️ by Socket Inc