Comparing version 0.1.6 to 0.1.7
@@ -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. |
@@ -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
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
935379
261
19781