Comparing version 0.3.1 to 0.3.2
@@ -86,5 +86,8 @@ --- | ||
textNode: t=>`Text: ${t}`, | ||
imageNodeCount: nodeCount => `There are ${nodeCount} visible image nodes.`, | ||
imageNode: label => `Image: ${label}`, | ||
unlabeledImageNode: 'Unlabeled image', | ||
rerenderAsText: "Re-render as text", | ||
accessibilityInputInstructions: "Press \"t\" to read the contents of the viewport as text. Use the arrow keys to move the viewport, click and drag to draw strokes. Press \"w\" to zoom in and \"s\" to zoom out.", | ||
loading: t=>`Loading ${t}%...`, | ||
loading: percentage => `Loading ${percentage}%...`, | ||
imageEditor: "Image Editor", | ||
@@ -91,0 +94,0 @@ doneLoading: "Done loading", |
@@ -0,1 +1,9 @@ | ||
# 0.3.2 | ||
* PNG/JPEG image loading | ||
* Copy and paste | ||
* Open images when dropped into editor | ||
* Keyboard shortcuts: | ||
* `Delete`/`Backspace` deletes selected content. | ||
* `Ctrl+C`, `Ctrl+V` for copy/paste. | ||
# 0.3.1 | ||
@@ -2,0 +10,0 @@ * Keyboard shortcuts: |
@@ -6,2 +6,3 @@ import Command from './Command'; | ||
import SerializableCommand from './SerializableCommand'; | ||
export { Command, Duplicate, Erase, SerializableCommand, invertCommand, }; | ||
import uniteCommands from './uniteCommands'; | ||
export { Command, Duplicate, Erase, SerializableCommand, invertCommand, uniteCommands, }; |
@@ -6,2 +6,3 @@ import Command from './Command'; | ||
import SerializableCommand from './SerializableCommand'; | ||
export { Command, Duplicate, Erase, SerializableCommand, invertCommand, }; | ||
import uniteCommands from './uniteCommands'; | ||
export { Command, Duplicate, Erase, SerializableCommand, invertCommand, uniteCommands, }; |
@@ -20,4 +20,5 @@ import Rect2 from '../math/Rect2'; | ||
inverseOf: (actionDescription: string) => string; | ||
unionOf: (actionDescription: string, actionCount: number) => string; | ||
selectedElements: (count: number) => string; | ||
} | ||
export declare const defaultCommandLocalization: CommandLocalization; |
@@ -8,2 +8,3 @@ export const defaultCommandLocalization = { | ||
duplicateAction: (componentDescription, numElems) => `Duplicated ${numElems} ${componentDescription}`, | ||
unionOf: (actionDescription, actionCount) => `Union: ${actionCount} ${actionDescription}`, | ||
inverseOf: (actionDescription) => `Inverse of ${actionDescription}`, | ||
@@ -10,0 +11,0 @@ elements: 'Elements', |
@@ -31,2 +31,4 @@ import SerializableCommand from '../commands/SerializableCommand'; | ||
transformBy(affineTransfm: Mat33): SerializableCommand; | ||
private static transformElementCommandId; | ||
private static UnresolvedTransformElementCommand; | ||
private static TransformElementCommand; | ||
@@ -33,0 +35,0 @@ abstract description(localizationTable: ImageComponentLocalization): string; |
@@ -116,5 +116,41 @@ var _a; | ||
AbstractComponent.deserializationCallbacks = {}; | ||
AbstractComponent.transformElementCommandId = 'transform-element'; | ||
AbstractComponent.UnresolvedTransformElementCommand = class extends SerializableCommand { | ||
constructor(affineTransfm, componentID) { | ||
super(AbstractComponent.transformElementCommandId); | ||
this.affineTransfm = affineTransfm; | ||
this.componentID = componentID; | ||
this.command = null; | ||
} | ||
resolveCommand(editor) { | ||
if (this.command) { | ||
return; | ||
} | ||
const component = editor.image.lookupElement(this.componentID); | ||
if (!component) { | ||
throw new Error(`Unable to resolve component with ID ${this.componentID}`); | ||
} | ||
this.command = new AbstractComponent.TransformElementCommand(this.affineTransfm, component); | ||
} | ||
apply(editor) { | ||
this.resolveCommand(editor); | ||
this.command.apply(editor); | ||
} | ||
unapply(editor) { | ||
this.resolveCommand(editor); | ||
this.command.unapply(editor); | ||
} | ||
description(_editor, localizationTable) { | ||
return localizationTable.transformedElements(1); | ||
} | ||
serializeToJSON() { | ||
return { | ||
id: this.componentID, | ||
transfm: this.affineTransfm.toArray(), | ||
}; | ||
} | ||
}; | ||
AbstractComponent.TransformElementCommand = (_a = class extends SerializableCommand { | ||
constructor(affineTransfm, component) { | ||
super('transform-element'); | ||
super(AbstractComponent.transformElementCommandId); | ||
this.affineTransfm = affineTransfm; | ||
@@ -159,11 +195,11 @@ this.component = component; | ||
(() => { | ||
SerializableCommand.register('transform-element', (json, editor) => { | ||
SerializableCommand.register(AbstractComponent.transformElementCommandId, (json, editor) => { | ||
const elem = editor.image.lookupElement(json.id); | ||
const transform = new Mat33(...json.transfm); | ||
if (!elem) { | ||
throw new Error(`Unable to retrieve non-existent element, ${elem}`); | ||
return new AbstractComponent.UnresolvedTransformElementCommand(transform, json.id); | ||
} | ||
const transform = json.transfm; | ||
return new AbstractComponent.TransformElementCommand(new Mat33(...transform), elem); | ||
return new AbstractComponent.TransformElementCommand(transform, elem); | ||
}); | ||
})(), | ||
_a); |
@@ -240,5 +240,5 @@ import { Bezier } from 'bezier-js'; | ||
}; | ||
// If the boundaries have two intersections, increasing the half vector's length could fix this. | ||
// If the boundaries have intersections, increasing the half vector's length could fix this. | ||
if (boundariesIntersect()) { | ||
halfVec = halfVec.times(2); | ||
halfVec = halfVec.times(1.1); | ||
} | ||
@@ -245,0 +245,0 @@ // Each starts at startPt ± startVec |
export * from './builders/types'; | ||
export { makeFreehandLineBuilder } from './builders/FreehandLineBuilder'; | ||
import AbstractComponent from './AbstractComponent'; | ||
export * from './AbstractComponent'; | ||
export { default as AbstractComponent } from './AbstractComponent'; | ||
import Stroke from './Stroke'; | ||
import Text from './Text'; | ||
export { AbstractComponent, Stroke, Text, }; | ||
import ImageComponent from './ImageComponent'; | ||
export { Stroke, Text, Text as TextComponent, Stroke as StrokeComponent, ImageComponent, }; |
export * from './builders/types'; | ||
export { makeFreehandLineBuilder } from './builders/FreehandLineBuilder'; | ||
import AbstractComponent from './AbstractComponent'; | ||
export * from './AbstractComponent'; | ||
export { default as AbstractComponent } from './AbstractComponent'; | ||
import Stroke from './Stroke'; | ||
import Text from './Text'; | ||
export { AbstractComponent, Stroke, Text, }; | ||
import ImageComponent from './ImageComponent'; | ||
export { Stroke, Text, Text as TextComponent, Stroke as StrokeComponent, ImageComponent, }; |
export interface ImageComponentLocalization { | ||
unlabeledImageNode: string; | ||
text: (text: string) => string; | ||
imageNode: (description: string) => string; | ||
stroke: string; | ||
@@ -4,0 +6,0 @@ svgObject: string; |
export const defaultComponentLocalization = { | ||
unlabeledImageNode: 'Unlabeled image node', | ||
stroke: 'Stroke', | ||
svgObject: 'SVG Object', | ||
text: (text) => `Text object: ${text}`, | ||
imageNode: (description) => `Image: ${description}`, | ||
}; |
@@ -97,2 +97,3 @@ /** | ||
private accessibilityControlArea; | ||
private eventListenerTargets; | ||
private settings; | ||
@@ -143,2 +144,4 @@ /** | ||
private registerListeners; | ||
private isEventSink; | ||
private handlePaste; | ||
/** Adds event listners for keypresses to `elem` and forwards those events to the editor. */ | ||
@@ -196,4 +199,4 @@ handleKeyEventsFrom(elem: HTMLElement): void; | ||
*/ | ||
loadFromSVG(svgData: string): Promise<void>; | ||
loadFromSVG(svgData: string, sanitize?: boolean): Promise<void>; | ||
} | ||
export default Editor; |
@@ -71,2 +71,3 @@ /** | ||
var _a, _b, _c, _d; | ||
this.eventListenerTargets = []; | ||
this.previousAccessibilityAnnouncement = ''; | ||
@@ -309,3 +310,107 @@ this.announceUndoCallback = (command) => { | ||
}); | ||
document.addEventListener('copy', evt => { | ||
if (!this.isEventSink(document.querySelector(':focus'))) { | ||
return; | ||
} | ||
const clipboardData = evt.clipboardData; | ||
if (this.toolController.dispatchInputEvent({ | ||
kind: InputEvtType.CopyEvent, | ||
setData: (mime, data) => { | ||
clipboardData === null || clipboardData === void 0 ? void 0 : clipboardData.setData(mime, data); | ||
}, | ||
})) { | ||
evt.preventDefault(); | ||
} | ||
}); | ||
document.addEventListener('paste', evt => { | ||
this.handlePaste(evt); | ||
}); | ||
} | ||
isEventSink(evtTarget) { | ||
let currentElem = evtTarget; | ||
while (currentElem !== null) { | ||
for (const elem of this.eventListenerTargets) { | ||
if (elem === currentElem) { | ||
return true; | ||
} | ||
} | ||
currentElem = currentElem.parentElement; | ||
} | ||
return false; | ||
} | ||
handlePaste(evt) { | ||
var _a, _b; | ||
return __awaiter(this, void 0, void 0, function* () { | ||
const target = (_a = document.querySelector(':focus')) !== null && _a !== void 0 ? _a : evt.target; | ||
if (!this.isEventSink(target)) { | ||
return; | ||
} | ||
const clipboardData = (_b = evt.dataTransfer) !== null && _b !== void 0 ? _b : evt.clipboardData; | ||
if (!clipboardData) { | ||
return; | ||
} | ||
// Handle SVG files (prefer to PNG/JPEG) | ||
for (const file of clipboardData.files) { | ||
if (file.type.toLowerCase() === 'image/svg+xml') { | ||
const text = yield file.text(); | ||
if (this.toolController.dispatchInputEvent({ | ||
kind: InputEvtType.PasteEvent, | ||
mime: file.type, | ||
data: text, | ||
})) { | ||
evt.preventDefault(); | ||
return; | ||
} | ||
} | ||
} | ||
// Handle image files. | ||
for (const file of clipboardData.files) { | ||
const fileType = file.type.toLowerCase(); | ||
if (fileType === 'image/png' || fileType === 'image/jpg') { | ||
const reader = new FileReader(); | ||
this.showLoadingWarning(0); | ||
try { | ||
const data = yield new Promise((resolve, reject) => { | ||
reader.onload = () => resolve(reader.result); | ||
reader.onerror = reject; | ||
reader.onabort = reject; | ||
reader.onprogress = (evt) => { | ||
this.showLoadingWarning(evt.loaded / evt.total); | ||
}; | ||
reader.readAsDataURL(file); | ||
}); | ||
if (data && this.toolController.dispatchInputEvent({ | ||
kind: InputEvtType.PasteEvent, | ||
mime: fileType, | ||
data: data, | ||
})) { | ||
evt.preventDefault(); | ||
this.hideLoadingWarning(); | ||
return; | ||
} | ||
} | ||
catch (e) { | ||
console.error('Error reading image:', e); | ||
} | ||
this.hideLoadingWarning(); | ||
} | ||
} | ||
// Supported MIMEs for text data, in order of preference | ||
const supportedMIMEs = [ | ||
'image/svg+xml', | ||
'text/plain', | ||
]; | ||
for (const mime of supportedMIMEs) { | ||
const data = clipboardData.getData(mime); | ||
if (data && this.toolController.dispatchInputEvent({ | ||
kind: InputEvtType.PasteEvent, | ||
mime, | ||
data, | ||
})) { | ||
evt.preventDefault(); | ||
return; | ||
} | ||
} | ||
}); | ||
} | ||
/** Adds event listners for keypresses to `elem` and forwards those events to the editor. */ | ||
@@ -338,2 +443,11 @@ handleKeyEventsFrom(elem) { | ||
}); | ||
// Allow drop. | ||
elem.ondragover = evt => { | ||
evt.preventDefault(); | ||
}; | ||
elem.ondrop = evt => { | ||
evt.preventDefault(); | ||
this.handlePaste(evt); | ||
}; | ||
this.eventListenerTargets.push(elem); | ||
} | ||
@@ -382,2 +496,3 @@ /** `apply` a command. `command` will be announced for accessibility. */ | ||
return __awaiter(this, void 0, void 0, function* () { | ||
console.assert(updateChunkSize > 0); | ||
this.display.setDraftMode(true); | ||
@@ -560,5 +675,5 @@ for (let i = 0; i < commands.length; i += updateChunkSize) { | ||
*/ | ||
loadFromSVG(svgData) { | ||
loadFromSVG(svgData, sanitize = false) { | ||
return __awaiter(this, void 0, void 0, function* () { | ||
const loader = SVGLoader.fromString(svgData); | ||
const loader = SVGLoader.fromString(svgData, sanitize); | ||
yield this.loadFrom(loader); | ||
@@ -565,0 +680,0 @@ }); |
@@ -72,2 +72,5 @@ var _a; | ||
this.applyByFlattening = applyByFlattening; | ||
// Store the element's serialization --- .serializeToJSON may be called on this | ||
// even when this is not at the top of the undo/redo stack. | ||
this.serializedElem = element.serialize(); | ||
if (isNaN(element.getBBox().area)) { | ||
@@ -97,3 +100,3 @@ throw new Error('Elements in the image cannot have NaN bounding boxes'); | ||
return { | ||
elemData: this.element.serialize(), | ||
elemData: this.serializedElem, | ||
}; | ||
@@ -100,0 +103,0 @@ } |
@@ -0,1 +1,2 @@ | ||
import Mat33 from './Mat33'; | ||
import Rect2 from './Rect2'; | ||
@@ -20,4 +21,5 @@ import { Vec2, Point2 } from './Vec2'; | ||
closestPointTo(target: Point2): import("./Vec3").default; | ||
transformedBy(affineTransfm: Mat33): LineSegment2; | ||
toString(): string; | ||
} | ||
export {}; |
@@ -119,2 +119,5 @@ import Rect2 from './Rect2'; | ||
} | ||
transformedBy(affineTransfm) { | ||
return new LineSegment2(affineTransfm.transformVec2(this.p1), affineTransfm.transformVec2(this.p2)); | ||
} | ||
toString() { | ||
@@ -121,0 +124,0 @@ return `LineSegment(${this.p1.toString()}, ${this.p2.toString()})`; |
export interface TextRendererLocalization { | ||
pathNodeCount(pathCount: number): string; | ||
textNodeCount(nodeCount: number): string; | ||
imageNodeCount(nodeCount: number): string; | ||
textNode(content: string): string; | ||
unlabeledImageNode: string; | ||
imageNode(label: string): string; | ||
rerenderAsText: string; | ||
} | ||
export declare const defaultTextRendererLocalization: TextRendererLocalization; |
export const defaultTextRendererLocalization = { | ||
pathNodeCount: (count) => `There are ${count} visible path objects.`, | ||
textNodeCount: (count) => `There are ${count} visible text nodes.`, | ||
imageNodeCount: (nodeCount) => `There are ${nodeCount} visible image nodes.`, | ||
textNode: (content) => `Text: ${content}`, | ||
imageNode: (label) => `Image: ${label}`, | ||
unlabeledImageNode: 'Unlabeled image', | ||
rerenderAsText: 'Re-render as text', | ||
}; |
@@ -15,2 +15,8 @@ import { LoadSaveDataTable } from '../../components/AbstractComponent'; | ||
} | ||
export interface RenderableImage { | ||
transform: Mat33; | ||
image: HTMLImageElement | HTMLCanvasElement; | ||
base64Url: string; | ||
label?: string; | ||
} | ||
export default abstract class AbstractRenderer { | ||
@@ -31,2 +37,3 @@ private viewport; | ||
abstract drawText(text: string, transform: Mat33, style: TextStyle): void; | ||
abstract drawImage(image: RenderableImage): void; | ||
abstract isTooSmallToRender(rect: Rect2): boolean; | ||
@@ -33,0 +40,0 @@ setDraftMode(_draftMode: boolean): void; |
@@ -8,3 +8,3 @@ import { TextStyle } from '../../components/Text'; | ||
import RenderingStyle from '../RenderingStyle'; | ||
import AbstractRenderer, { RenderablePathSpec } from './AbstractRenderer'; | ||
import AbstractRenderer, { RenderableImage, RenderablePathSpec } from './AbstractRenderer'; | ||
export default class CanvasRenderer extends AbstractRenderer { | ||
@@ -32,2 +32,3 @@ private ctx; | ||
drawText(text: string, transform: Mat33, style: TextStyle): void; | ||
drawImage(image: RenderableImage): void; | ||
private clipLevels; | ||
@@ -34,0 +35,0 @@ startObject(boundingBox: Rect2, clip: boolean): void; |
@@ -128,2 +128,9 @@ import Color4 from '../../Color4'; | ||
} | ||
drawImage(image) { | ||
this.ctx.save(); | ||
const transform = this.getCanvasToScreenTransform().rightMul(image.transform); | ||
this.transformBy(transform); | ||
this.ctx.drawImage(image.image, 0, 0); | ||
this.ctx.restore(); | ||
} | ||
startObject(boundingBox, clip) { | ||
@@ -130,0 +137,0 @@ if (this.isTooSmallToRender(boundingBox)) { |
@@ -8,3 +8,3 @@ import { TextStyle } from '../../components/Text'; | ||
import RenderingStyle from '../RenderingStyle'; | ||
import AbstractRenderer from './AbstractRenderer'; | ||
import AbstractRenderer, { RenderableImage } from './AbstractRenderer'; | ||
export default class DummyRenderer extends AbstractRenderer { | ||
@@ -17,2 +17,3 @@ clearedCount: number; | ||
lastText: string | null; | ||
lastImage: RenderableImage | null; | ||
pointBuffer: Point2[]; | ||
@@ -30,2 +31,3 @@ constructor(viewport: Viewport); | ||
drawText(text: string, _transform: Mat33, _style: TextStyle): void; | ||
drawImage(image: RenderableImage): void; | ||
startObject(boundingBox: Rect2, _clip: boolean): void; | ||
@@ -32,0 +34,0 @@ endObject(): void; |
@@ -14,2 +14,3 @@ // Renderer that outputs nothing. Useful for automated tests. | ||
this.lastText = null; | ||
this.lastImage = null; | ||
// List of points drawn since the last clear. | ||
@@ -34,2 +35,3 @@ this.pointBuffer = []; | ||
this.lastText = null; | ||
this.lastImage = null; | ||
// Ensure all objects finished rendering | ||
@@ -78,2 +80,5 @@ if (this.objectNestingLevel > 0) { | ||
} | ||
drawImage(image) { | ||
this.lastImage = image; | ||
} | ||
startObject(boundingBox, _clip) { | ||
@@ -80,0 +85,0 @@ super.startObject(boundingBox); |
@@ -8,5 +8,6 @@ import { LoadSaveDataTable } from '../../components/AbstractComponent'; | ||
import RenderingStyle from '../RenderingStyle'; | ||
import AbstractRenderer, { RenderablePathSpec } from './AbstractRenderer'; | ||
import AbstractRenderer, { RenderableImage, RenderablePathSpec } from './AbstractRenderer'; | ||
export default class SVGRenderer extends AbstractRenderer { | ||
private elem; | ||
private sanitize; | ||
private lastPathStyle; | ||
@@ -16,3 +17,3 @@ private lastPathString; | ||
private overwrittenAttrs; | ||
constructor(elem: SVGSVGElement, viewport: Viewport); | ||
constructor(elem: SVGSVGElement, viewport: Viewport, sanitize?: boolean); | ||
setRootSVGAttribute(name: string, value: string | null): void; | ||
@@ -23,3 +24,5 @@ displaySize(): Vec2; | ||
drawPath(pathSpec: RenderablePathSpec): void; | ||
private transformFrom; | ||
drawText(text: string, transform: Mat33, style: TextStyle): void; | ||
drawImage(image: RenderableImage): void; | ||
startObject(boundingBox: Rect2): void; | ||
@@ -26,0 +29,0 @@ endObject(loaderData?: LoadSaveDataTable): void; |
@@ -9,5 +9,7 @@ import Mat33 from '../../math/Mat33'; | ||
export default class SVGRenderer extends AbstractRenderer { | ||
constructor(elem, viewport) { | ||
// Renders onto `elem`. If `sanitize`, don't render potentially untrusted data. | ||
constructor(elem, viewport, sanitize = false) { | ||
super(viewport); | ||
this.elem = elem; | ||
this.sanitize = sanitize; | ||
this.lastPathStyle = null; | ||
@@ -21,2 +23,5 @@ this.lastPathString = []; | ||
setRootSVGAttribute(name, value) { | ||
if (this.sanitize) { | ||
return; | ||
} | ||
// Make the original value of the attribute restorable on clear | ||
@@ -37,14 +42,16 @@ if (!(name in this.overwrittenAttrs)) { | ||
clear() { | ||
// Restore all alltributes | ||
for (const attrName in this.overwrittenAttrs) { | ||
const value = this.overwrittenAttrs[attrName]; | ||
if (value) { | ||
this.elem.setAttribute(attrName, value); | ||
this.lastPathString = []; | ||
if (!this.sanitize) { | ||
// Restore all all attributes | ||
for (const attrName in this.overwrittenAttrs) { | ||
const value = this.overwrittenAttrs[attrName]; | ||
if (value) { | ||
this.elem.setAttribute(attrName, value); | ||
} | ||
else { | ||
this.elem.removeAttribute(attrName); | ||
} | ||
} | ||
else { | ||
this.elem.removeAttribute(attrName); | ||
} | ||
this.overwrittenAttrs = {}; | ||
} | ||
this.overwrittenAttrs = {}; | ||
this.lastPathString = []; | ||
} | ||
@@ -80,10 +87,8 @@ // Push [this.fullPath] to the SVG | ||
} | ||
drawText(text, transform, style) { | ||
var _a, _b, _c; | ||
transform = this.getCanvasToScreenTransform().rightMul(transform); | ||
// Apply [elemTransform] to [elem]. | ||
transformFrom(elemTransform, elem) { | ||
let transform = this.getCanvasToScreenTransform().rightMul(elemTransform); | ||
const translation = transform.transformVec2(Vec2.zero); | ||
transform = transform.rightMul(Mat33.translation(translation.times(-1))); | ||
const textElem = document.createElementNS(svgNameSpace, 'text'); | ||
textElem.appendChild(document.createTextNode(text)); | ||
textElem.style.transform = `matrix( | ||
elem.style.transform = `matrix( | ||
${transform.a1}, ${transform.b1}, | ||
@@ -93,2 +98,10 @@ ${transform.a2}, ${transform.b2}, | ||
)`; | ||
elem.setAttribute('x', `${toRoundedString(translation.x)}`); | ||
elem.setAttribute('y', `${toRoundedString(translation.y)}`); | ||
} | ||
drawText(text, transform, style) { | ||
var _a, _b, _c; | ||
const textElem = document.createElementNS(svgNameSpace, 'text'); | ||
textElem.appendChild(document.createTextNode(text)); | ||
this.transformFrom(transform, textElem); | ||
textElem.style.fontFamily = style.fontFamily; | ||
@@ -99,4 +112,2 @@ textElem.style.fontVariant = (_a = style.fontVariant) !== null && _a !== void 0 ? _a : ''; | ||
textElem.style.fill = style.renderingStyle.fill.toHexString(); | ||
textElem.setAttribute('x', `${toRoundedString(translation.x)}`); | ||
textElem.setAttribute('y', `${toRoundedString(translation.y)}`); | ||
if (style.renderingStyle.stroke) { | ||
@@ -110,2 +121,13 @@ const strokeStyle = style.renderingStyle.stroke; | ||
} | ||
drawImage(image) { | ||
var _a, _b, _c, _d, _e; | ||
const svgImgElem = document.createElementNS(svgNameSpace, 'image'); | ||
svgImgElem.setAttribute('href', image.base64Url); | ||
svgImgElem.setAttribute('width', (_a = image.image.getAttribute('width')) !== null && _a !== void 0 ? _a : ''); | ||
svgImgElem.setAttribute('height', (_b = image.image.getAttribute('height')) !== null && _b !== void 0 ? _b : ''); | ||
svgImgElem.setAttribute('aria-label', (_d = (_c = image.image.getAttribute('aria-label')) !== null && _c !== void 0 ? _c : image.image.getAttribute('alt')) !== null && _d !== void 0 ? _d : ''); | ||
this.transformFrom(image.transform, svgImgElem); | ||
this.elem.appendChild(svgImgElem); | ||
(_e = this.objectElems) === null || _e === void 0 ? void 0 : _e.push(svgImgElem); | ||
} | ||
startObject(boundingBox) { | ||
@@ -123,3 +145,3 @@ super.startObject(boundingBox); | ||
this.addPathToSVG(); | ||
if (loaderData) { | ||
if (loaderData && !this.sanitize) { | ||
// Restore any attributes unsupported by the app. | ||
@@ -161,2 +183,5 @@ for (const elem of (_a = this.objectElems) !== null && _a !== void 0 ? _a : []) { | ||
drawSVGElem(elem) { | ||
if (this.sanitize) { | ||
return; | ||
} | ||
this.elem.appendChild(elem.cloneNode(true)); | ||
@@ -163,0 +188,0 @@ } |
@@ -8,3 +8,3 @@ import { TextStyle } from '../../components/Text'; | ||
import RenderingStyle from '../RenderingStyle'; | ||
import AbstractRenderer from './AbstractRenderer'; | ||
import AbstractRenderer, { RenderableImage } from './AbstractRenderer'; | ||
export default class TextOnlyRenderer extends AbstractRenderer { | ||
@@ -15,2 +15,3 @@ private localizationTable; | ||
private textNodeCount; | ||
private imageNodeCount; | ||
constructor(viewport: Viewport, localizationTable: TextRendererLocalization); | ||
@@ -27,4 +28,5 @@ displaySize(): Vec3; | ||
drawText(text: string, _transform: Mat33, _style: TextStyle): void; | ||
drawImage(image: RenderableImage): void; | ||
isTooSmallToRender(rect: Rect2): boolean; | ||
drawPoints(..._points: Vec3[]): void; | ||
} |
@@ -11,2 +11,3 @@ import { Vec2 } from '../../math/Vec2'; | ||
this.textNodeCount = 0; | ||
this.imageNodeCount = 0; | ||
} | ||
@@ -25,3 +26,4 @@ displaySize() { | ||
this.localizationTable.pathNodeCount(this.pathCount), | ||
this.localizationTable.textNodeCount(this.textNodeCount), | ||
...(this.textNodeCount > 0 ? this.localizationTable.textNodeCount(this.textNodeCount) : []), | ||
...(this.imageNodeCount > 0 ? this.localizationTable.imageNodeCount(this.imageNodeCount) : []), | ||
...this.descriptionBuilder | ||
@@ -47,2 +49,7 @@ ].join('\n'); | ||
} | ||
drawImage(image) { | ||
const label = image.label ? this.localizationTable.imageNode(image.label) : this.localizationTable.unlabeledImageNode; | ||
this.descriptionBuilder.push(label); | ||
this.imageNodeCount++; | ||
} | ||
isTooSmallToRender(rect) { | ||
@@ -49,0 +56,0 @@ return rect.maxDimension < 15 / this.getSizeOfCanvasPixelOnScreen(); |
@@ -15,2 +15,3 @@ import Rect2 from './math/Rect2'; | ||
private onFinish?; | ||
private readonly storeUnknown; | ||
private onAddComponent; | ||
@@ -27,4 +28,6 @@ private onProgress; | ||
private addPath; | ||
private getTransform; | ||
private makeText; | ||
private addText; | ||
private addImage; | ||
private addUnknownNode; | ||
@@ -36,3 +39,3 @@ private updateViewBox; | ||
start(onAddComponent: ComponentAddedListener, onProgress: OnProgressListener, onDetermineExportRect?: OnDetermineExportRectListener | null): Promise<void>; | ||
static fromString(text: string): SVGLoader; | ||
static fromString(text: string, sanitize?: boolean): SVGLoader; | ||
} |
@@ -11,2 +11,3 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { | ||
import Color4 from './Color4'; | ||
import ImageComponent from './components/ImageComponent'; | ||
import Stroke from './components/Stroke'; | ||
@@ -26,5 +27,6 @@ import SVGGlobalAttributesObject from './components/SVGGlobalAttributesObject'; | ||
export default class SVGLoader { | ||
constructor(source, onFinish) { | ||
constructor(source, onFinish, storeUnknown = true) { | ||
this.source = source; | ||
this.onFinish = onFinish; | ||
this.storeUnknown = storeUnknown; | ||
this.onAddComponent = null; | ||
@@ -93,2 +95,5 @@ this.onProgress = null; | ||
attachUnrecognisedAttrs(elem, node, supportedAttrs, supportedStyleAttrs) { | ||
if (!this.storeUnknown) { | ||
return; | ||
} | ||
for (const attr of node.getAttributeNames()) { | ||
@@ -129,6 +134,41 @@ if (supportedAttrs.has(attr) || (attr === 'style' && supportedStyleAttrs)) { | ||
console.error('Invalid path in node', node, '\nError:', e, '\nAdding as an unknown object.'); | ||
elem = new UnknownSVGObject(node); | ||
if (this.storeUnknown) { | ||
elem = new UnknownSVGObject(node); | ||
} | ||
else { | ||
return; | ||
} | ||
} | ||
(_a = this.onAddComponent) === null || _a === void 0 ? void 0 : _a.call(this, elem); | ||
} | ||
// If given, 'supportedAttrs' will have x, y, etc. attributes that were used in computing the transform added to it, | ||
// to prevent storing duplicate transform information when saving the component. | ||
getTransform(elem, supportedAttrs, computedStyles) { | ||
computedStyles !== null && computedStyles !== void 0 ? computedStyles : (computedStyles = window.getComputedStyle(elem)); | ||
let transformProperty = computedStyles.transform; | ||
if (transformProperty === '' || transformProperty === 'none') { | ||
transformProperty = elem.style.transform || 'none'; | ||
} | ||
// Prefer the actual .style.transform | ||
// to the computed stylesheet -- in some browsers, the computedStyles version | ||
// can have lower precision. | ||
let transform; | ||
try { | ||
transform = Mat33.fromCSSMatrix(elem.style.transform); | ||
} | ||
catch (_e) { | ||
transform = Mat33.fromCSSMatrix(transformProperty); | ||
} | ||
const elemX = elem.getAttribute('x'); | ||
const elemY = elem.getAttribute('y'); | ||
if (elemX && elemY) { | ||
const x = parseFloat(elemX); | ||
const y = parseFloat(elemY); | ||
if (!isNaN(x) && !isNaN(y)) { | ||
supportedAttrs === null || supportedAttrs === void 0 ? void 0 : supportedAttrs.push('x', 'y'); | ||
transform = transform.rightMul(Mat33.translation(Vec2.of(x, y))); | ||
} | ||
} | ||
return transform; | ||
} | ||
makeText(elem) { | ||
@@ -174,27 +214,4 @@ var _a; | ||
}; | ||
let transformProperty = computedStyles.transform; | ||
if (transformProperty === '' || transformProperty === 'none') { | ||
transformProperty = elem.style.transform || 'none'; | ||
} | ||
// Compute transform matrix. Prefer the actual .style.transform | ||
// to the computed stylesheet -- in some browsers, the computedStyles version | ||
// can have lower precision. | ||
let transform; | ||
try { | ||
transform = Mat33.fromCSSMatrix(elem.style.transform); | ||
} | ||
catch (_e) { | ||
transform = Mat33.fromCSSMatrix(transformProperty); | ||
} | ||
const supportedAttrs = []; | ||
const elemX = elem.getAttribute('x'); | ||
const elemY = elem.getAttribute('y'); | ||
if (elemX && elemY) { | ||
const x = parseFloat(elemX); | ||
const y = parseFloat(elemY); | ||
if (!isNaN(x) && !isNaN(y)) { | ||
supportedAttrs.push('x', 'y'); | ||
transform = transform.rightMul(Mat33.translation(Vec2.of(x, y))); | ||
} | ||
} | ||
const transform = this.getTransform(elem, supportedAttrs, computedStyles); | ||
const result = new Text(contentList, transform, style); | ||
@@ -211,10 +228,30 @@ this.attachUnrecognisedAttrs(result, elem, new Set(supportedAttrs), new Set(supportedStyleAttrs)); | ||
catch (e) { | ||
console.error('Invalid text object in node', elem, '. Adding as an unknown object. Error:', e); | ||
console.error('Invalid text object in node', elem, '. Continuing.... Error:', e); | ||
this.addUnknownNode(elem); | ||
} | ||
} | ||
addImage(elem) { | ||
var _a, _b; | ||
return __awaiter(this, void 0, void 0, function* () { | ||
const image = new Image(); | ||
image.src = (_a = elem.getAttribute('xlink:href')) !== null && _a !== void 0 ? _a : elem.href.baseVal; | ||
try { | ||
const supportedAttrs = []; | ||
const transform = this.getTransform(elem, supportedAttrs); | ||
const imageElem = yield ImageComponent.fromImage(image, transform); | ||
this.attachUnrecognisedAttrs(imageElem, elem, new Set(supportedAttrs), new Set(['transform'])); | ||
(_b = this.onAddComponent) === null || _b === void 0 ? void 0 : _b.call(this, imageElem); | ||
} | ||
catch (e) { | ||
console.error('Error loading image:', e, '. Element: ', elem, '. Continuing...'); | ||
this.addUnknownNode(elem); | ||
} | ||
}); | ||
} | ||
addUnknownNode(node) { | ||
var _a; | ||
const component = new UnknownSVGObject(node); | ||
(_a = this.onAddComponent) === null || _a === void 0 ? void 0 : _a.call(this, component); | ||
if (this.storeUnknown) { | ||
const component = new UnknownSVGObject(node); | ||
(_a = this.onAddComponent) === null || _a === void 0 ? void 0 : _a.call(this, component); | ||
} | ||
} | ||
@@ -241,3 +278,5 @@ updateViewBox(node) { | ||
var _a; | ||
(_a = this.onAddComponent) === null || _a === void 0 ? void 0 : _a.call(this, new SVGGlobalAttributesObject(this.getSourceAttrs(node))); | ||
if (this.storeUnknown) { | ||
(_a = this.onAddComponent) === null || _a === void 0 ? void 0 : _a.call(this, new SVGGlobalAttributesObject(this.getSourceAttrs(node))); | ||
} | ||
} | ||
@@ -260,2 +299,7 @@ visit(node) { | ||
break; | ||
case 'image': | ||
yield this.addImage(node); | ||
// Images should not have children. | ||
visitChildren = false; | ||
break; | ||
case 'svg': | ||
@@ -268,3 +312,3 @@ this.updateViewBox(node); | ||
if (!(node instanceof SVGElement)) { | ||
console.warn('Element', node, 'is not an SVGElement! Continuing anyway.'); | ||
console.warn('Element', node, 'is not an SVGElement!', this.storeUnknown ? 'Continuing anyway.' : 'Skipping.'); | ||
} | ||
@@ -308,3 +352,4 @@ this.addUnknownNode(node); | ||
// TODO: Handling unsafe data! Tripple-check that this is secure! | ||
static fromString(text) { | ||
// @param sanitize - if `true`, don't store unknown attributes. | ||
static fromString(text, sanitize = false) { | ||
var _a, _b; | ||
@@ -349,4 +394,4 @@ const sandbox = document.createElement('iframe'); | ||
sandbox.remove(); | ||
}); | ||
}, !sanitize); | ||
} | ||
} |
@@ -1,2 +0,2 @@ | ||
import { PointerEvtListener, WheelEvt, PointerEvt, EditorNotifier, KeyPressEvent, KeyUpEvent } from '../types'; | ||
import { PointerEvtListener, WheelEvt, PointerEvt, EditorNotifier, KeyPressEvent, KeyUpEvent, PasteEvent, CopyEvent } from '../types'; | ||
import ToolEnabledGroup from './ToolEnabledGroup'; | ||
@@ -14,2 +14,4 @@ export default abstract class BaseTool implements PointerEvtListener { | ||
onWheel(_event: WheelEvt): boolean; | ||
onCopy(_event: CopyEvent): boolean; | ||
onPaste(_event: PasteEvent): boolean; | ||
onKeyPress(_event: KeyPressEvent): boolean; | ||
@@ -16,0 +18,0 @@ onKeyUp(_event: KeyUpEvent): boolean; |
@@ -16,2 +16,8 @@ import { EditorEventType } from '../types'; | ||
} | ||
onCopy(_event) { | ||
return false; | ||
} | ||
onPaste(_event) { | ||
return false; | ||
} | ||
onKeyPress(_event) { | ||
@@ -18,0 +24,0 @@ return false; |
@@ -14,1 +14,2 @@ /** | ||
export { default as EraserTool } from './Eraser'; | ||
export { default as PasteHandler } from './PasteHandler'; |
@@ -14,1 +14,2 @@ /** | ||
export { default as EraserTool } from './Eraser'; | ||
export { default as PasteHandler } from './PasteHandler'; |
@@ -14,2 +14,5 @@ export interface ToolLocalization { | ||
changeTool: string; | ||
pasteHandler: string; | ||
copied: (count: number, description: string) => string; | ||
pasted: (count: number, description: string) => string; | ||
toolEnabledAnnouncement: (toolName: string) => string; | ||
@@ -16,0 +19,0 @@ toolDisabledAnnouncement: (toolName: string) => string; |
@@ -14,4 +14,7 @@ export const defaultToolLocalization = { | ||
changeTool: 'Change tool', | ||
pasteHandler: 'Copy paste handler', | ||
copied: (count, description) => `Copied ${count} ${description}`, | ||
pasted: (count, description) => `Pasted ${count} ${description}`, | ||
toolEnabledAnnouncement: (toolName) => `${toolName} enabled`, | ||
toolDisabledAnnouncement: (toolName) => `${toolName} disabled`, | ||
}; |
import Command from '../commands/Command'; | ||
import AbstractComponent from '../components/AbstractComponent'; | ||
import Editor from '../Editor'; | ||
@@ -6,3 +7,3 @@ import Mat33 from '../math/Mat33'; | ||
import { Point2, Vec2 } from '../math/Vec2'; | ||
import { KeyPressEvent, KeyUpEvent, PointerEvt } from '../types'; | ||
import { CopyEvent, KeyPressEvent, KeyUpEvent, PointerEvt } from '../types'; | ||
import BaseTool from './BaseTool'; | ||
@@ -31,2 +32,4 @@ declare class Selection { | ||
cancelSelection(): void; | ||
setSelectedObjects(objects: AbstractComponent[], bbox: Rect2): void; | ||
getSelectedObjects(): AbstractComponent[]; | ||
resolveToObjects(): boolean; | ||
@@ -48,2 +51,3 @@ recomputeRegion(): boolean; | ||
constructor(editor: Editor, description: string); | ||
private makeSelectionBox; | ||
onPointerDown(event: PointerEvt): boolean; | ||
@@ -58,6 +62,8 @@ onPointerMove(event: PointerEvt): void; | ||
onKeyUp(evt: KeyUpEvent): boolean; | ||
onCopy(event: CopyEvent): boolean; | ||
setEnabled(enabled: boolean): void; | ||
getSelection(): Selection | null; | ||
setSelection(objects: AbstractComponent[]): void; | ||
clearSelection(): void; | ||
} | ||
export {}; |
@@ -23,2 +23,3 @@ // Allows users to select/transform portions of the `EditorImage`. | ||
import SerializableCommand from '../commands/SerializableCommand'; | ||
import SVGRenderer from '../rendering/renderers/SVGRenderer'; | ||
const handleScreenSize = 30; | ||
@@ -266,2 +267,10 @@ const styles = ` | ||
} | ||
setSelectedObjects(objects, bbox) { | ||
this.region = bbox; | ||
this.selectedElems = objects; | ||
this.updateUI(); | ||
} | ||
getSelectedObjects() { | ||
return this.selectedElems; | ||
} | ||
// Find the objects corresponding to this in the document, | ||
@@ -432,9 +441,12 @@ // select them. | ||
} | ||
makeSelectionBox(selectionStartPos) { | ||
this.prevSelectionBox = this.selectionBox; | ||
this.selectionBox = new Selection(selectionStartPos, this.editor); | ||
// Remove any previous selection rects | ||
this.handleOverlay.replaceChildren(); | ||
this.selectionBox.appendBackgroundBoxTo(this.handleOverlay); | ||
} | ||
onPointerDown(event) { | ||
if (event.allPointers.length === 1 && event.current.isPrimary) { | ||
this.prevSelectionBox = this.selectionBox; | ||
this.selectionBox = new Selection(event.current.canvasPos, this.editor); | ||
// Remove any previous selection rects | ||
this.handleOverlay.replaceChildren(); | ||
this.selectionBox.appendBackgroundBoxTo(this.handleOverlay); | ||
this.makeSelectionBox(event.current.canvasPos); | ||
return true; | ||
@@ -548,2 +560,7 @@ } | ||
} | ||
if (this.selectionBox && !handled && (event.key === 'Delete' || event.key === 'Backspace')) { | ||
this.editor.dispatch(this.selectionBox.deleteSelectedObjects()); | ||
this.clearSelection(); | ||
handled = true; | ||
} | ||
return handled; | ||
@@ -558,2 +575,24 @@ } | ||
} | ||
onCopy(event) { | ||
if (!this.selectionBox) { | ||
return false; | ||
} | ||
const selectedElems = this.selectionBox.getSelectedObjects(); | ||
const bbox = this.selectionBox.region; | ||
if (selectedElems.length === 0) { | ||
return false; | ||
} | ||
const exportViewport = new Viewport(this.editor.notifier); | ||
exportViewport.updateScreenSize(Vec2.of(bbox.w, bbox.h)); | ||
exportViewport.resetTransform(Mat33.translation(bbox.topLeft)); | ||
const svgNameSpace = 'http://www.w3.org/2000/svg'; | ||
const exportElem = document.createElementNS(svgNameSpace, 'svg'); | ||
const sanitize = true; | ||
const renderer = new SVGRenderer(exportElem, exportViewport, sanitize); | ||
for (const elem of selectedElems) { | ||
elem.render(renderer); | ||
} | ||
event.setData('image/svg+xml', exportElem.outerHTML); | ||
return true; | ||
} | ||
setEnabled(enabled) { | ||
@@ -577,2 +616,21 @@ super.setEnabled(enabled); | ||
} | ||
setSelection(objects) { | ||
let bbox = null; | ||
for (const object of objects) { | ||
if (bbox) { | ||
bbox = bbox.union(object.getBBox()); | ||
} | ||
else { | ||
bbox = object.getBBox(); | ||
} | ||
} | ||
if (!bbox) { | ||
return; | ||
} | ||
this.clearSelection(); | ||
if (!this.selectionBox) { | ||
this.makeSelectionBox(bbox.topLeft); | ||
} | ||
this.selectionBox.setSelectedObjects(objects, bbox); | ||
} | ||
clearSelection() { | ||
@@ -579,0 +637,0 @@ this.handleOverlay.replaceChildren(); |
@@ -12,2 +12,3 @@ import { InputEvtType, EditorEventType } from '../types'; | ||
import ToolSwitcherShortcut from './ToolSwitcherShortcut'; | ||
import PasteHandler from './PasteHandler'; | ||
export default class ToolController { | ||
@@ -39,2 +40,3 @@ /** @internal */ | ||
new ToolSwitcherShortcut(editor), | ||
new PasteHandler(editor), | ||
]; | ||
@@ -102,6 +104,16 @@ primaryTools.forEach(tool => tool.setToolGroup(primaryToolGroup)); | ||
} | ||
else if (event.kind === InputEvtType.WheelEvt || event.kind === InputEvtType.KeyPressEvent || event.kind === InputEvtType.KeyUpEvent) { | ||
const isKeyPressEvt = event.kind === InputEvtType.KeyPressEvent; | ||
const isKeyReleaseEvt = event.kind === InputEvtType.KeyUpEvent; | ||
const isWheelEvt = event.kind === InputEvtType.WheelEvt; | ||
else if (event.kind === InputEvtType.PointerMoveEvt) { | ||
if (this.activeTool !== null) { | ||
this.activeTool.onPointerMove(event); | ||
handled = true; | ||
} | ||
} | ||
else if (event.kind === InputEvtType.GestureCancelEvt) { | ||
if (this.activeTool !== null) { | ||
this.activeTool.onGestureCancel(); | ||
this.activeTool = null; | ||
} | ||
} | ||
else { | ||
let allCasesHandledGuard; | ||
for (const tool of this.tools) { | ||
@@ -111,6 +123,22 @@ if (!tool.isEnabled()) { | ||
} | ||
const wheelResult = isWheelEvt && tool.onWheel(event); | ||
const keyPressResult = isKeyPressEvt && tool.onKeyPress(event); | ||
const keyReleaseResult = isKeyReleaseEvt && tool.onKeyUp(event); | ||
handled = keyPressResult || wheelResult || keyReleaseResult; | ||
switch (event.kind) { | ||
case InputEvtType.KeyPressEvent: | ||
handled = tool.onKeyPress(event); | ||
break; | ||
case InputEvtType.KeyUpEvent: | ||
handled = tool.onKeyUp(event); | ||
break; | ||
case InputEvtType.WheelEvt: | ||
handled = tool.onWheel(event); | ||
break; | ||
case InputEvtType.CopyEvent: | ||
handled = tool.onCopy(event); | ||
break; | ||
case InputEvtType.PasteEvent: | ||
handled = tool.onPaste(event); | ||
break; | ||
default: | ||
allCasesHandledGuard = event; | ||
return allCasesHandledGuard; | ||
} | ||
if (handled) { | ||
@@ -121,21 +149,2 @@ break; | ||
} | ||
else if (this.activeTool !== null) { | ||
let allCasesHandledGuard; | ||
switch (event.kind) { | ||
case InputEvtType.PointerMoveEvt: | ||
this.activeTool.onPointerMove(event); | ||
break; | ||
case InputEvtType.GestureCancelEvt: | ||
this.activeTool.onGestureCancel(); | ||
this.activeTool = null; | ||
break; | ||
default: | ||
allCasesHandledGuard = event; | ||
return allCasesHandledGuard; | ||
} | ||
handled = true; | ||
} | ||
else { | ||
handled = false; | ||
} | ||
return handled; | ||
@@ -142,0 +151,0 @@ } |
@@ -25,3 +25,5 @@ import EventDispatcher from './EventDispatcher'; | ||
KeyPressEvent = 5, | ||
KeyUpEvent = 6 | ||
KeyUpEvent = 6, | ||
CopyEvent = 7, | ||
PasteEvent = 8 | ||
} | ||
@@ -43,2 +45,11 @@ export interface WheelEvt { | ||
} | ||
export interface CopyEvent { | ||
readonly kind: InputEvtType.CopyEvent; | ||
setData(mime: string, data: string): void; | ||
} | ||
export interface PasteEvent { | ||
readonly kind: InputEvtType.PasteEvent; | ||
readonly data: string; | ||
readonly mime: string; | ||
} | ||
export interface GestureCancelEvt { | ||
@@ -61,3 +72,3 @@ readonly kind: InputEvtType.GestureCancelEvt; | ||
export declare type PointerEvt = PointerDownEvt | PointerMoveEvt | PointerUpEvt; | ||
export declare type InputEvt = KeyPressEvent | KeyUpEvent | WheelEvt | GestureCancelEvt | PointerEvt; | ||
export declare type InputEvt = KeyPressEvent | KeyUpEvent | WheelEvt | GestureCancelEvt | PointerEvt | CopyEvent | PasteEvent; | ||
export declare type EditorNotifier = EventDispatcher<EditorEventType, EditorEventDataType>; | ||
@@ -64,0 +75,0 @@ export declare enum EditorEventType { |
@@ -11,2 +11,4 @@ // Types related to the image editor | ||
InputEvtType[InputEvtType["KeyUpEvent"] = 6] = "KeyUpEvent"; | ||
InputEvtType[InputEvtType["CopyEvent"] = 7] = "CopyEvent"; | ||
InputEvtType[InputEvtType["PasteEvent"] = 8] = "PasteEvent"; | ||
})(InputEvtType || (InputEvtType = {})); | ||
@@ -13,0 +15,0 @@ export var EditorEventType; |
@@ -11,2 +11,3 @@ import Editor from './Editor'; | ||
private redoStack; | ||
private maxUndoRedoStackSize; | ||
constructor(editor: Editor, announceRedoCallback: AnnounceRedoCallback, announceUndoCallback: AnnounceUndoCallback); | ||
@@ -13,0 +14,0 @@ private fireUpdateEvent; |
@@ -8,2 +8,3 @@ import { EditorEventType } from './types'; | ||
this.announceUndoCallback = announceUndoCallback; | ||
this.maxUndoRedoStackSize = 700; | ||
this.undoStack = []; | ||
@@ -29,2 +30,7 @@ this.redoStack = []; | ||
this.redoStack = []; | ||
if (this.undoStack.length > this.maxUndoRedoStackSize) { | ||
const removeAtOnceCount = 10; | ||
const removedElements = this.undoStack.splice(0, removeAtOnceCount); | ||
removedElements.forEach(elem => elem.onDrop(this.editor)); | ||
} | ||
this.fireUpdateEvent(); | ||
@@ -31,0 +37,0 @@ this.editor.notifier.dispatch(EditorEventType.CommandDone, { |
@@ -32,4 +32,5 @@ import Command from './commands/Command'; | ||
roundPoint(point: Point2): Point2; | ||
computeZoomToTransform(toMakeVisible: Rect2, allowZoomIn?: boolean, allowZoomOut?: boolean): Mat33; | ||
zoomTo(toMakeVisible: Rect2, allowZoomIn?: boolean, allowZoomOut?: boolean): Command; | ||
} | ||
export default Viewport; |
@@ -32,2 +32,3 @@ var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (receiver, state, value, kind, f) { | ||
} | ||
// Get the screen's visible region transformed into canvas space. | ||
get visibleRect() { | ||
@@ -97,6 +98,4 @@ return this.screenRect.transformedBoundingBox(this.inverseTransform); | ||
} | ||
// Returns a Command that transforms the view such that [rect] is visible, and perhaps | ||
// centered in the viewport. | ||
// Returns null if no transformation is necessary | ||
zoomTo(toMakeVisible, allowZoomIn = true, allowZoomOut = true) { | ||
// Computes and returns an affine transformation that makes `toMakeVisible` visible and roughly centered on the screen. | ||
computeZoomToTransform(toMakeVisible, allowZoomIn = true, allowZoomOut = true) { | ||
let transform = Mat33.identity; | ||
@@ -141,2 +140,11 @@ if (toMakeVisible.w === 0 || toMakeVisible.h === 0) { | ||
} | ||
return transform; | ||
} | ||
// Returns a Command that transforms the view such that [rect] is visible, and perhaps | ||
// centered in the viewport. | ||
// Returns null if no transformation is necessary | ||
// | ||
// @see {@link computeZoomToTransform} | ||
zoomTo(toMakeVisible, allowZoomIn = true, allowZoomOut = true) { | ||
const transform = this.computeZoomToTransform(toMakeVisible, allowZoomIn, allowZoomOut); | ||
return new Viewport.ViewportTransform(transform); | ||
@@ -143,0 +151,0 @@ } |
{ | ||
"name": "js-draw", | ||
"version": "0.3.1", | ||
"version": "0.3.2", | ||
"description": "Draw pictures using a pen, touchscreen, or mouse! JS-draw is a drawing library for JavaScript and TypeScript. ", | ||
@@ -5,0 +5,0 @@ "main": "./dist/src/lib.d.ts", |
@@ -6,2 +6,3 @@ import Command from './Command'; | ||
import SerializableCommand from './SerializableCommand'; | ||
import uniteCommands from './uniteCommands'; | ||
@@ -15,2 +16,3 @@ export { | ||
invertCommand, | ||
uniteCommands, | ||
}; |
@@ -21,2 +21,3 @@ import Rect2 from '../math/Rect2'; | ||
inverseOf: (actionDescription: string)=> string; | ||
unionOf: (actionDescription: string, actionCount: number)=> string; | ||
@@ -33,2 +34,3 @@ selectedElements: (count: number)=>string; | ||
duplicateAction: (componentDescription: string, numElems: number) => `Duplicated ${numElems} ${componentDescription}`, | ||
unionOf: (actionDescription: string, actionCount: number) => `Union: ${actionCount} ${actionDescription}`, | ||
inverseOf: (actionDescription: string) => `Inverse of ${actionDescription}`, | ||
@@ -35,0 +37,0 @@ elements: 'Elements', |
@@ -92,2 +92,48 @@ import SerializableCommand from '../commands/SerializableCommand'; | ||
private static transformElementCommandId = 'transform-element'; | ||
private static UnresolvedTransformElementCommand = class extends SerializableCommand { | ||
private command: SerializableCommand|null = null; | ||
public constructor( | ||
private affineTransfm: Mat33, | ||
private componentID: string, | ||
) { | ||
super(AbstractComponent.transformElementCommandId); | ||
} | ||
private resolveCommand(editor: Editor) { | ||
if (this.command) { | ||
return; | ||
} | ||
const component = editor.image.lookupElement(this.componentID); | ||
if (!component) { | ||
throw new Error(`Unable to resolve component with ID ${this.componentID}`); | ||
} | ||
this.command = new AbstractComponent.TransformElementCommand(this.affineTransfm, component); | ||
} | ||
public apply(editor: Editor) { | ||
this.resolveCommand(editor); | ||
this.command!.apply(editor); | ||
} | ||
public unapply(editor: Editor) { | ||
this.resolveCommand(editor); | ||
this.command!.unapply(editor); | ||
} | ||
public description(_editor: Editor, localizationTable: EditorLocalization) { | ||
return localizationTable.transformedElements(1); | ||
} | ||
protected serializeToJSON() { | ||
return { | ||
id: this.componentID, | ||
transfm: this.affineTransfm.toArray(), | ||
}; | ||
} | ||
}; | ||
private static TransformElementCommand = class extends SerializableCommand { | ||
@@ -100,3 +146,3 @@ private origZIndex: number; | ||
) { | ||
super('transform-element'); | ||
super(AbstractComponent.transformElementCommandId); | ||
this.origZIndex = component.zIndex; | ||
@@ -139,17 +185,17 @@ } | ||
static { | ||
SerializableCommand.register('transform-element', (json: any, editor: Editor) => { | ||
SerializableCommand.register(AbstractComponent.transformElementCommandId, (json: any, editor: Editor) => { | ||
const elem = editor.image.lookupElement(json.id); | ||
if (!elem) { | ||
throw new Error(`Unable to retrieve non-existent element, ${elem}`); | ||
} | ||
const transform = json.transfm as [ | ||
const transform = new Mat33(...(json.transfm as [ | ||
number, number, number, | ||
number, number, number, | ||
number, number, number, | ||
]; | ||
])); | ||
if (!elem) { | ||
return new AbstractComponent.UnresolvedTransformElementCommand(transform, json.id); | ||
} | ||
return new AbstractComponent.TransformElementCommand( | ||
new Mat33(...transform), | ||
transform, | ||
elem, | ||
@@ -156,0 +202,0 @@ ); |
@@ -332,5 +332,5 @@ import { Bezier } from 'bezier-js'; | ||
// If the boundaries have two intersections, increasing the half vector's length could fix this. | ||
// If the boundaries have intersections, increasing the half vector's length could fix this. | ||
if (boundariesIntersect()) { | ||
halfVec = halfVec.times(2); | ||
halfVec = halfVec.times(1.1); | ||
} | ||
@@ -337,0 +337,0 @@ |
export * from './builders/types'; | ||
export { makeFreehandLineBuilder } from './builders/FreehandLineBuilder'; | ||
import AbstractComponent from './AbstractComponent'; | ||
export * from './AbstractComponent'; | ||
export { default as AbstractComponent } from './AbstractComponent'; | ||
import Stroke from './Stroke'; | ||
import Text from './Text'; | ||
import ImageComponent from './ImageComponent'; | ||
export { | ||
AbstractComponent, | ||
Stroke, | ||
Text, | ||
Text as TextComponent, | ||
Stroke as StrokeComponent, | ||
ImageComponent, | ||
}; |
export interface ImageComponentLocalization { | ||
unlabeledImageNode: string; | ||
text: (text: string)=> string; | ||
imageNode: (description: string)=> string; | ||
stroke: string; | ||
@@ -8,5 +10,7 @@ svgObject: string; | ||
export const defaultComponentLocalization: ImageComponentLocalization = { | ||
unlabeledImageNode: 'Unlabeled image node', | ||
stroke: 'Stroke', | ||
svgObject: 'SVG Object', | ||
text: (text) => `Text object: ${text}`, | ||
imageNode: (description: string) => `Image: ${description}`, | ||
}; |
@@ -121,2 +121,3 @@ /** | ||
private accessibilityControlArea: HTMLTextAreaElement; | ||
private eventListenerTargets: HTMLElement[] = []; | ||
@@ -439,4 +440,119 @@ private settings: EditorSettings; | ||
}); | ||
document.addEventListener('copy', evt => { | ||
if (!this.isEventSink(document.querySelector(':focus'))) { | ||
return; | ||
} | ||
const clipboardData = evt.clipboardData; | ||
if (this.toolController.dispatchInputEvent({ | ||
kind: InputEvtType.CopyEvent, | ||
setData: (mime, data) => { | ||
clipboardData?.setData(mime, data); | ||
}, | ||
})) { | ||
evt.preventDefault(); | ||
} | ||
}); | ||
document.addEventListener('paste', evt => { | ||
this.handlePaste(evt); | ||
}); | ||
} | ||
private isEventSink(evtTarget: Element|EventTarget|null) { | ||
let currentElem: Element|null = evtTarget as Element|null; | ||
while (currentElem !== null) { | ||
for (const elem of this.eventListenerTargets) { | ||
if (elem === currentElem) { | ||
return true; | ||
} | ||
} | ||
currentElem = (currentElem as Element).parentElement; | ||
} | ||
return false; | ||
} | ||
private async handlePaste(evt: DragEvent|ClipboardEvent) { | ||
const target = document.querySelector(':focus') ?? evt.target; | ||
if (!this.isEventSink(target)) { | ||
return; | ||
} | ||
const clipboardData: DataTransfer = (evt as any).dataTransfer ?? (evt as any).clipboardData; | ||
if (!clipboardData) { | ||
return; | ||
} | ||
// Handle SVG files (prefer to PNG/JPEG) | ||
for (const file of clipboardData.files) { | ||
if (file.type.toLowerCase() === 'image/svg+xml') { | ||
const text = await file.text(); | ||
if (this.toolController.dispatchInputEvent({ | ||
kind: InputEvtType.PasteEvent, | ||
mime: file.type, | ||
data: text, | ||
})) { | ||
evt.preventDefault(); | ||
return; | ||
} | ||
} | ||
} | ||
// Handle image files. | ||
for (const file of clipboardData.files) { | ||
const fileType = file.type.toLowerCase(); | ||
if (fileType === 'image/png' || fileType === 'image/jpg') { | ||
const reader = new FileReader(); | ||
this.showLoadingWarning(0); | ||
try { | ||
const data = await new Promise((resolve: (result: string|null)=>void, reject) => { | ||
reader.onload = () => resolve(reader.result as string|null); | ||
reader.onerror = reject; | ||
reader.onabort = reject; | ||
reader.onprogress = (evt) => { | ||
this.showLoadingWarning(evt.loaded / evt.total); | ||
}; | ||
reader.readAsDataURL(file); | ||
}); | ||
if (data && this.toolController.dispatchInputEvent({ | ||
kind: InputEvtType.PasteEvent, | ||
mime: fileType, | ||
data: data, | ||
})) { | ||
evt.preventDefault(); | ||
this.hideLoadingWarning(); | ||
return; | ||
} | ||
} catch (e) { | ||
console.error('Error reading image:', e); | ||
} | ||
this.hideLoadingWarning(); | ||
} | ||
} | ||
// Supported MIMEs for text data, in order of preference | ||
const supportedMIMEs = [ | ||
'image/svg+xml', | ||
'text/plain', | ||
]; | ||
for (const mime of supportedMIMEs) { | ||
const data = clipboardData.getData(mime); | ||
if (data && this.toolController.dispatchInputEvent({ | ||
kind: InputEvtType.PasteEvent, | ||
mime, | ||
data, | ||
})) { | ||
evt.preventDefault(); | ||
return; | ||
} | ||
} | ||
} | ||
/** Adds event listners for keypresses to `elem` and forwards those events to the editor. */ | ||
@@ -468,2 +584,14 @@ public handleKeyEventsFrom(elem: HTMLElement) { | ||
}); | ||
// Allow drop. | ||
elem.ondragover = evt => { | ||
evt.preventDefault(); | ||
}; | ||
elem.ondrop = evt => { | ||
evt.preventDefault(); | ||
this.handlePaste(evt); | ||
}; | ||
this.eventListenerTargets.push(elem); | ||
} | ||
@@ -515,2 +643,3 @@ | ||
) { | ||
console.assert(updateChunkSize > 0); | ||
this.display.setDraftMode(true); | ||
@@ -746,4 +875,4 @@ for (let i = 0; i < commands.length; i += updateChunkSize) { | ||
*/ | ||
public async loadFromSVG(svgData: string) { | ||
const loader = SVGLoader.fromString(svgData); | ||
public async loadFromSVG(svgData: string, sanitize: boolean = false) { | ||
const loader = SVGLoader.fromString(svgData, sanitize); | ||
await this.loadFrom(loader); | ||
@@ -750,0 +879,0 @@ } |
@@ -84,2 +84,4 @@ import Editor from './Editor'; | ||
private static AddElementCommand = class extends SerializableCommand { | ||
private serializedElem: any; | ||
// If [applyByFlattening], then the rendered content of this element | ||
@@ -94,2 +96,6 @@ // is present on the display's wet ink canvas. As such, no re-render is necessary | ||
// Store the element's serialization --- .serializeToJSON may be called on this | ||
// even when this is not at the top of the undo/redo stack. | ||
this.serializedElem = element.serialize(); | ||
if (isNaN(element.getBBox().area)) { | ||
@@ -123,3 +129,3 @@ throw new Error('Elements in the image cannot have NaN bounding boxes'); | ||
return { | ||
elemData: this.element.serialize(), | ||
elemData: this.serializedElem, | ||
}; | ||
@@ -126,0 +132,0 @@ } |
import LineSegment2 from './LineSegment2'; | ||
import { loadExpectExtensions } from '../testing/loadExpectExtensions'; | ||
import { Vec2 } from './Vec2'; | ||
import Mat33 from './Mat33'; | ||
@@ -92,2 +93,10 @@ loadExpectExtensions(); | ||
}); | ||
it('Should translate when translated by a translation matrix', () => { | ||
const line = new LineSegment2(Vec2.of(-1, 1), Vec2.of(2, 100)); | ||
expect(line.transformedBy(Mat33.translation(Vec2.of(1, -2)))).toMatchObject({ | ||
p1: Vec2.of(0, -1), | ||
p2: Vec2.of(3, 98), | ||
}); | ||
}); | ||
}); |
@@ -0,1 +1,2 @@ | ||
import Mat33 from './Mat33'; | ||
import Rect2 from './Rect2'; | ||
@@ -152,2 +153,6 @@ import { Vec2, Point2 } from './Vec2'; | ||
public transformedBy(affineTransfm: Mat33): LineSegment2 { | ||
return new LineSegment2(affineTransfm.transformVec2(this.p1), affineTransfm.transformVec2(this.p2)); | ||
} | ||
public toString() { | ||
@@ -154,0 +159,0 @@ return `LineSegment(${this.p1.toString()}, ${this.p2.toString()})`; |
@@ -5,3 +5,6 @@ | ||
textNodeCount(nodeCount: number): string; | ||
imageNodeCount(nodeCount: number): string; | ||
textNode(content: string): string; | ||
unlabeledImageNode: string; | ||
imageNode(label: string): string; | ||
rerenderAsText: string; | ||
@@ -13,4 +16,7 @@ } | ||
textNodeCount: (count: number) => `There are ${count} visible text nodes.`, | ||
imageNodeCount: (nodeCount: number) => `There are ${nodeCount} visible image nodes.`, | ||
textNode: (content: string) => `Text: ${content}`, | ||
imageNode: (label: string) => `Image: ${label}`, | ||
unlabeledImageNode: 'Unlabeled image', | ||
rerenderAsText: 'Re-render as text', | ||
}; |
@@ -17,2 +17,17 @@ import { LoadSaveDataTable } from '../../components/AbstractComponent'; | ||
export interface RenderableImage { | ||
transform: Mat33; | ||
// An Image or HTMLCanvasElement. If an Image, it must be loaded from the same origin as this | ||
// (and should have `src=this.base64Url`). | ||
image: HTMLImageElement|HTMLCanvasElement; | ||
// All images that can be drawn **must** have a base64 URL in the form | ||
// data:image/[format];base64,[data here] | ||
// If `image` is an Image, this should be equivalent to `image.src`. | ||
base64Url: string; | ||
label?: string; | ||
} | ||
export default abstract class AbstractRenderer { | ||
@@ -45,2 +60,3 @@ // If null, this' transformation is linked to the Viewport | ||
public abstract drawText(text: string, transform: Mat33, style: TextStyle): void; | ||
public abstract drawImage(image: RenderableImage): void; | ||
@@ -47,0 +63,0 @@ // Returns true iff the given rectangle is so small, rendering anything within |
@@ -9,3 +9,3 @@ import Color4 from '../../Color4'; | ||
import RenderingStyle from '../RenderingStyle'; | ||
import AbstractRenderer, { RenderablePathSpec } from './AbstractRenderer'; | ||
import AbstractRenderer, { RenderableImage, RenderablePathSpec } from './AbstractRenderer'; | ||
@@ -172,2 +172,11 @@ export default class CanvasRenderer extends AbstractRenderer { | ||
public drawImage(image: RenderableImage) { | ||
this.ctx.save(); | ||
const transform = this.getCanvasToScreenTransform().rightMul(image.transform); | ||
this.transformBy(transform); | ||
this.ctx.drawImage(image.image, 0, 0); | ||
this.ctx.restore(); | ||
} | ||
private clipLevels: number[] = []; | ||
@@ -174,0 +183,0 @@ public startObject(boundingBox: Rect2, clip: boolean) { |
@@ -10,3 +10,3 @@ // Renderer that outputs nothing. Useful for automated tests. | ||
import RenderingStyle from '../RenderingStyle'; | ||
import AbstractRenderer from './AbstractRenderer'; | ||
import AbstractRenderer, { RenderableImage } from './AbstractRenderer'; | ||
@@ -21,2 +21,3 @@ export default class DummyRenderer extends AbstractRenderer { | ||
public lastText: string|null = null; | ||
public lastImage: RenderableImage|null = null; | ||
@@ -49,2 +50,3 @@ // List of points drawn since the last clear. | ||
this.lastText = null; | ||
this.lastImage = null; | ||
@@ -102,2 +104,5 @@ // Ensure all objects finished rendering | ||
} | ||
public drawImage(image: RenderableImage) { | ||
this.lastImage = image; | ||
} | ||
@@ -104,0 +109,0 @@ public startObject(boundingBox: Rect2, _clip: boolean) { |
@@ -12,3 +12,3 @@ | ||
import RenderingStyle from '../RenderingStyle'; | ||
import AbstractRenderer, { RenderablePathSpec } from './AbstractRenderer'; | ||
import AbstractRenderer, { RenderableImage, RenderablePathSpec } from './AbstractRenderer'; | ||
@@ -23,3 +23,4 @@ const svgNameSpace = 'http://www.w3.org/2000/svg'; | ||
public constructor(private elem: SVGSVGElement, viewport: Viewport) { | ||
// Renders onto `elem`. If `sanitize`, don't render potentially untrusted data. | ||
public constructor(private elem: SVGSVGElement, viewport: Viewport, private sanitize: boolean = false) { | ||
super(viewport); | ||
@@ -31,2 +32,6 @@ this.clear(); | ||
public setRootSVGAttribute(name: string, value: string|null) { | ||
if (this.sanitize) { | ||
return; | ||
} | ||
// Make the original value of the attribute restorable on clear | ||
@@ -49,14 +54,17 @@ if (!(name in this.overwrittenAttrs)) { | ||
public clear() { | ||
// Restore all alltributes | ||
for (const attrName in this.overwrittenAttrs) { | ||
const value = this.overwrittenAttrs[attrName]; | ||
this.lastPathString = []; | ||
if (value) { | ||
this.elem.setAttribute(attrName, value); | ||
} else { | ||
this.elem.removeAttribute(attrName); | ||
if (!this.sanitize) { | ||
// Restore all all attributes | ||
for (const attrName in this.overwrittenAttrs) { | ||
const value = this.overwrittenAttrs[attrName]; | ||
if (value) { | ||
this.elem.setAttribute(attrName, value); | ||
} else { | ||
this.elem.removeAttribute(attrName); | ||
} | ||
} | ||
this.overwrittenAttrs = {}; | ||
} | ||
this.overwrittenAttrs = {}; | ||
this.lastPathString = []; | ||
} | ||
@@ -98,11 +106,9 @@ | ||
public drawText(text: string, transform: Mat33, style: TextStyle): void { | ||
transform = this.getCanvasToScreenTransform().rightMul(transform); | ||
// Apply [elemTransform] to [elem]. | ||
private transformFrom(elemTransform: Mat33, elem: SVGElement) { | ||
let transform = this.getCanvasToScreenTransform().rightMul(elemTransform); | ||
const translation = transform.transformVec2(Vec2.zero); | ||
transform = transform.rightMul(Mat33.translation(translation.times(-1))); | ||
const textElem = document.createElementNS(svgNameSpace, 'text'); | ||
textElem.appendChild(document.createTextNode(text)); | ||
textElem.style.transform = `matrix( | ||
elem.style.transform = `matrix( | ||
${transform.a1}, ${transform.b1}, | ||
@@ -112,2 +118,11 @@ ${transform.a2}, ${transform.b2}, | ||
)`; | ||
elem.setAttribute('x', `${toRoundedString(translation.x)}`); | ||
elem.setAttribute('y', `${toRoundedString(translation.y)}`); | ||
} | ||
public drawText(text: string, transform: Mat33, style: TextStyle): void { | ||
const textElem = document.createElementNS(svgNameSpace, 'text'); | ||
textElem.appendChild(document.createTextNode(text)); | ||
this.transformFrom(transform, textElem); | ||
textElem.style.fontFamily = style.fontFamily; | ||
@@ -118,4 +133,2 @@ textElem.style.fontVariant = style.fontVariant ?? ''; | ||
textElem.style.fill = style.renderingStyle.fill.toHexString(); | ||
textElem.setAttribute('x', `${toRoundedString(translation.x)}`); | ||
textElem.setAttribute('y', `${toRoundedString(translation.y)}`); | ||
@@ -132,2 +145,14 @@ if (style.renderingStyle.stroke) { | ||
public drawImage(image: RenderableImage) { | ||
const svgImgElem = document.createElementNS(svgNameSpace, 'image'); | ||
svgImgElem.setAttribute('href', image.base64Url); | ||
svgImgElem.setAttribute('width', image.image.getAttribute('width') ?? ''); | ||
svgImgElem.setAttribute('height', image.image.getAttribute('height') ?? ''); | ||
svgImgElem.setAttribute('aria-label', image.image.getAttribute('aria-label') ?? image.image.getAttribute('alt') ?? ''); | ||
this.transformFrom(image.transform, svgImgElem); | ||
this.elem.appendChild(svgImgElem); | ||
this.objectElems?.push(svgImgElem); | ||
} | ||
public startObject(boundingBox: Rect2) { | ||
@@ -148,3 +173,3 @@ super.startObject(boundingBox); | ||
if (loaderData) { | ||
if (loaderData && !this.sanitize) { | ||
// Restore any attributes unsupported by the app. | ||
@@ -193,2 +218,6 @@ for (const elem of this.objectElems ?? []) { | ||
public drawSVGElem(elem: SVGElement) { | ||
if (this.sanitize) { | ||
return; | ||
} | ||
this.elem.appendChild(elem.cloneNode(true)); | ||
@@ -195,0 +224,0 @@ } |
@@ -9,3 +9,3 @@ import { TextStyle } from '../../components/Text'; | ||
import RenderingStyle from '../RenderingStyle'; | ||
import AbstractRenderer from './AbstractRenderer'; | ||
import AbstractRenderer, { RenderableImage } from './AbstractRenderer'; | ||
@@ -18,2 +18,3 @@ // Outputs a description of what was rendered. | ||
private textNodeCount: number = 0; | ||
private imageNodeCount: number = 0; | ||
@@ -38,3 +39,4 @@ public constructor(viewport: Viewport, private localizationTable: TextRendererLocalization) { | ||
this.localizationTable.pathNodeCount(this.pathCount), | ||
this.localizationTable.textNodeCount(this.textNodeCount), | ||
...(this.textNodeCount > 0 ? this.localizationTable.textNodeCount(this.textNodeCount) : []), | ||
...(this.imageNodeCount > 0 ? this.localizationTable.imageNodeCount(this.imageNodeCount) : []), | ||
...this.descriptionBuilder | ||
@@ -61,2 +63,8 @@ ].join('\n'); | ||
} | ||
public drawImage(image: RenderableImage) { | ||
const label = image.label ? this.localizationTable.imageNode(image.label) : this.localizationTable.unlabeledImageNode; | ||
this.descriptionBuilder.push(label); | ||
this.imageNodeCount ++; | ||
} | ||
public isTooSmallToRender(rect: Rect2): boolean { | ||
@@ -63,0 +71,0 @@ return rect.maxDimension < 15 / this.getSizeOfCanvasPixelOnScreen(); |
import Color4 from './Color4'; | ||
import AbstractComponent from './components/AbstractComponent'; | ||
import ImageComponent from './components/ImageComponent'; | ||
import Stroke from './components/Stroke'; | ||
@@ -39,3 +40,4 @@ import SVGGlobalAttributesObject from './components/SVGGlobalAttributesObject'; | ||
private constructor(private source: SVGSVGElement, private onFinish?: OnFinishListener) { | ||
private constructor( | ||
private source: SVGSVGElement, private onFinish?: OnFinishListener, private readonly storeUnknown: boolean = true) { | ||
} | ||
@@ -112,2 +114,6 @@ | ||
) { | ||
if (!this.storeUnknown) { | ||
return; | ||
} | ||
for (const attr of node.getAttributeNames()) { | ||
@@ -166,3 +172,7 @@ if (supportedAttrs.has(attr) || (attr === 'style' && supportedStyleAttrs)) { | ||
elem = new UnknownSVGObject(node); | ||
if (this.storeUnknown) { | ||
elem = new UnknownSVGObject(node); | ||
} else { | ||
return; | ||
} | ||
} | ||
@@ -172,2 +182,36 @@ this.onAddComponent?.(elem); | ||
// If given, 'supportedAttrs' will have x, y, etc. attributes that were used in computing the transform added to it, | ||
// to prevent storing duplicate transform information when saving the component. | ||
private getTransform(elem: SVGElement, supportedAttrs?: string[], computedStyles?: CSSStyleDeclaration): Mat33 { | ||
computedStyles ??= window.getComputedStyle(elem); | ||
let transformProperty = computedStyles.transform; | ||
if (transformProperty === '' || transformProperty === 'none') { | ||
transformProperty = elem.style.transform || 'none'; | ||
} | ||
// Prefer the actual .style.transform | ||
// to the computed stylesheet -- in some browsers, the computedStyles version | ||
// can have lower precision. | ||
let transform; | ||
try { | ||
transform = Mat33.fromCSSMatrix(elem.style.transform); | ||
} catch(_e) { | ||
transform = Mat33.fromCSSMatrix(transformProperty); | ||
} | ||
const elemX = elem.getAttribute('x'); | ||
const elemY = elem.getAttribute('y'); | ||
if (elemX && elemY) { | ||
const x = parseFloat(elemX); | ||
const y = parseFloat(elemY); | ||
if (!isNaN(x) && !isNaN(y)) { | ||
supportedAttrs?.push('x', 'y'); | ||
transform = transform.rightMul(Mat33.translation(Vec2.of(x, y))); | ||
} | ||
} | ||
return transform; | ||
} | ||
private makeText(elem: SVGTextElement|SVGTSpanElement): Text { | ||
@@ -212,29 +256,4 @@ const contentList: Array<Text|string> = []; | ||
let transformProperty = computedStyles.transform; | ||
if (transformProperty === '' || transformProperty === 'none') { | ||
transformProperty = elem.style.transform || 'none'; | ||
} | ||
// Compute transform matrix. Prefer the actual .style.transform | ||
// to the computed stylesheet -- in some browsers, the computedStyles version | ||
// can have lower precision. | ||
let transform; | ||
try { | ||
transform = Mat33.fromCSSMatrix(elem.style.transform); | ||
} catch(_e) { | ||
transform = Mat33.fromCSSMatrix(transformProperty); | ||
} | ||
const supportedAttrs = []; | ||
const elemX = elem.getAttribute('x'); | ||
const elemY = elem.getAttribute('y'); | ||
if (elemX && elemY) { | ||
const x = parseFloat(elemX); | ||
const y = parseFloat(elemY); | ||
if (!isNaN(x) && !isNaN(y)) { | ||
supportedAttrs.push('x', 'y'); | ||
transform = transform.rightMul(Mat33.translation(Vec2.of(x, y))); | ||
} | ||
} | ||
const supportedAttrs: string[] = []; | ||
const transform = this.getTransform(elem, supportedAttrs, computedStyles); | ||
const result = new Text(contentList, transform, style); | ||
@@ -256,3 +275,3 @@ this.attachUnrecognisedAttrs( | ||
} catch (e) { | ||
console.error('Invalid text object in node', elem, '. Adding as an unknown object. Error:', e); | ||
console.error('Invalid text object in node', elem, '. Continuing.... Error:', e); | ||
this.addUnknownNode(elem); | ||
@@ -262,5 +281,29 @@ } | ||
private async addImage(elem: SVGImageElement) { | ||
const image = new Image(); | ||
image.src = elem.getAttribute('xlink:href') ?? elem.href.baseVal; | ||
try { | ||
const supportedAttrs: string[] = []; | ||
const transform = this.getTransform(elem, supportedAttrs); | ||
const imageElem = await ImageComponent.fromImage(image, transform); | ||
this.attachUnrecognisedAttrs( | ||
imageElem, | ||
elem, | ||
new Set(supportedAttrs), | ||
new Set([ 'transform' ]) | ||
); | ||
this.onAddComponent?.(imageElem); | ||
} catch (e) { | ||
console.error('Error loading image:', e, '. Element: ', elem, '. Continuing...'); | ||
this.addUnknownNode(elem); | ||
} | ||
} | ||
private addUnknownNode(node: SVGElement) { | ||
const component = new UnknownSVGObject(node); | ||
this.onAddComponent?.(component); | ||
if (this.storeUnknown) { | ||
const component = new UnknownSVGObject(node); | ||
this.onAddComponent?.(component); | ||
} | ||
} | ||
@@ -290,3 +333,5 @@ | ||
private updateSVGAttrs(node: SVGSVGElement) { | ||
this.onAddComponent?.(new SVGGlobalAttributesObject(this.getSourceAttrs(node))); | ||
if (this.storeUnknown) { | ||
this.onAddComponent?.(new SVGGlobalAttributesObject(this.getSourceAttrs(node))); | ||
} | ||
} | ||
@@ -309,2 +354,8 @@ | ||
break; | ||
case 'image': | ||
await this.addImage(node as SVGImageElement); | ||
// Images should not have children. | ||
visitChildren = false; | ||
break; | ||
case 'svg': | ||
@@ -317,3 +368,5 @@ this.updateViewBox(node as SVGSVGElement); | ||
if (!(node instanceof SVGElement)) { | ||
console.warn('Element', node, 'is not an SVGElement! Continuing anyway.'); | ||
console.warn( | ||
'Element', node, 'is not an SVGElement!', this.storeUnknown ? 'Continuing anyway.' : 'Skipping.' | ||
); | ||
} | ||
@@ -367,3 +420,4 @@ | ||
// TODO: Handling unsafe data! Tripple-check that this is secure! | ||
public static fromString(text: string): SVGLoader { | ||
// @param sanitize - if `true`, don't store unknown attributes. | ||
public static fromString(text: string, sanitize: boolean = false): SVGLoader { | ||
const sandbox = document.createElement('iframe'); | ||
@@ -414,4 +468,4 @@ sandbox.src = 'about:blank'; | ||
sandbox.remove(); | ||
}); | ||
}, !sanitize); | ||
} | ||
} |
@@ -1,2 +0,2 @@ | ||
import { PointerEvtListener, WheelEvt, PointerEvt, EditorNotifier, EditorEventType, KeyPressEvent, KeyUpEvent } from '../types'; | ||
import { PointerEvtListener, WheelEvt, PointerEvt, EditorNotifier, EditorEventType, KeyPressEvent, KeyUpEvent, PasteEvent, CopyEvent } from '../types'; | ||
import ToolEnabledGroup from './ToolEnabledGroup'; | ||
@@ -20,2 +20,10 @@ | ||
public onCopy(_event: CopyEvent): boolean { | ||
return false; | ||
} | ||
public onPaste(_event: PasteEvent): boolean { | ||
return false; | ||
} | ||
public onKeyPress(_event: KeyPressEvent): boolean { | ||
@@ -22,0 +30,0 @@ return false; |
@@ -18,2 +18,3 @@ | ||
export { default as EraserTool } from './Eraser'; | ||
export { default as PasteHandler } from './PasteHandler'; | ||
@@ -16,3 +16,7 @@ | ||
changeTool: string; | ||
pasteHandler: string; | ||
copied: (count: number, description: string) => string; | ||
pasted: (count: number, description: string) => string; | ||
toolEnabledAnnouncement: (toolName: string) => string; | ||
@@ -36,5 +40,9 @@ toolDisabledAnnouncement: (toolName: string) => string; | ||
changeTool: 'Change tool', | ||
pasteHandler: 'Copy paste handler', | ||
copied: (count: number, description: string) => `Copied ${count} ${description}`, | ||
pasted: (count: number, description: string) => `Pasted ${count} ${description}`, | ||
toolEnabledAnnouncement: (toolName) => `${toolName} enabled`, | ||
toolDisabledAnnouncement: (toolName) => `${toolName} disabled`, | ||
}; |
@@ -14,6 +14,7 @@ // Allows users to select/transform portions of the `EditorImage`. | ||
import { EditorLocalization } from '../localization'; | ||
import { EditorEventType, KeyPressEvent, KeyUpEvent, PointerEvt } from '../types'; | ||
import { CopyEvent, EditorEventType, KeyPressEvent, KeyUpEvent, PointerEvt } from '../types'; | ||
import Viewport from '../Viewport'; | ||
import BaseTool from './BaseTool'; | ||
import SerializableCommand from '../commands/SerializableCommand'; | ||
import SVGRenderer from '../rendering/renderers/SVGRenderer'; | ||
@@ -385,2 +386,12 @@ const handleScreenSize = 30; | ||
public setSelectedObjects(objects: AbstractComponent[], bbox: Rect2) { | ||
this.region = bbox; | ||
this.selectedElems = objects; | ||
this.updateUI(); | ||
} | ||
public getSelectedObjects(): AbstractComponent[] { | ||
return this.selectedElems; | ||
} | ||
// Find the objects corresponding to this in the document, | ||
@@ -533,11 +544,15 @@ // select them. | ||
private makeSelectionBox(selectionStartPos: Point2) { | ||
this.prevSelectionBox = this.selectionBox; | ||
this.selectionBox = new Selection( | ||
selectionStartPos, this.editor | ||
); | ||
// Remove any previous selection rects | ||
this.handleOverlay.replaceChildren(); | ||
this.selectionBox.appendBackgroundBoxTo(this.handleOverlay); | ||
} | ||
public onPointerDown(event: PointerEvt): boolean { | ||
if (event.allPointers.length === 1 && event.current.isPrimary) { | ||
this.prevSelectionBox = this.selectionBox; | ||
this.selectionBox = new Selection( | ||
event.current.canvasPos, this.editor | ||
); | ||
// Remove any previous selection rects | ||
this.handleOverlay.replaceChildren(); | ||
this.selectionBox.appendBackgroundBoxTo(this.handleOverlay); | ||
this.makeSelectionBox(event.current.canvasPos); | ||
@@ -685,2 +700,8 @@ return true; | ||
if (this.selectionBox && !handled && (event.key === 'Delete' || event.key === 'Backspace')) { | ||
this.editor.dispatch(this.selectionBox.deleteSelectedObjects()); | ||
this.clearSelection(); | ||
handled = true; | ||
} | ||
return handled; | ||
@@ -697,2 +718,31 @@ } | ||
public onCopy(event: CopyEvent): boolean { | ||
if (!this.selectionBox) { | ||
return false; | ||
} | ||
const selectedElems = this.selectionBox.getSelectedObjects(); | ||
const bbox = this.selectionBox.region; | ||
if (selectedElems.length === 0) { | ||
return false; | ||
} | ||
const exportViewport = new Viewport(this.editor.notifier); | ||
exportViewport.updateScreenSize(Vec2.of(bbox.w, bbox.h)); | ||
exportViewport.resetTransform(Mat33.translation(bbox.topLeft)); | ||
const svgNameSpace = 'http://www.w3.org/2000/svg'; | ||
const exportElem = document.createElementNS(svgNameSpace, 'svg'); | ||
const sanitize = true; | ||
const renderer = new SVGRenderer(exportElem, exportViewport, sanitize); | ||
for (const elem of selectedElems) { | ||
elem.render(renderer); | ||
} | ||
event.setData('image/svg+xml', exportElem.outerHTML); | ||
return true; | ||
} | ||
public setEnabled(enabled: boolean) { | ||
@@ -720,2 +770,24 @@ super.setEnabled(enabled); | ||
public setSelection(objects: AbstractComponent[]) { | ||
let bbox: Rect2|null = null; | ||
for (const object of objects) { | ||
if (bbox) { | ||
bbox = bbox.union(object.getBBox()); | ||
} else { | ||
bbox = object.getBBox(); | ||
} | ||
} | ||
if (!bbox) { | ||
return; | ||
} | ||
this.clearSelection(); | ||
if (!this.selectionBox) { | ||
this.makeSelectionBox(bbox.topLeft); | ||
} | ||
this.selectionBox!.setSelectedObjects(objects, bbox); | ||
} | ||
public clearSelection() { | ||
@@ -722,0 +794,0 @@ this.handleOverlay.replaceChildren(); |
@@ -15,2 +15,3 @@ import { InputEvtType, InputEvt, EditorEventType } from '../types'; | ||
import ToolSwitcherShortcut from './ToolSwitcherShortcut'; | ||
import PasteHandler from './PasteHandler'; | ||
@@ -21,3 +22,3 @@ export default class ToolController { | ||
private primaryToolGroup: ToolEnabledGroup; | ||
/** @internal */ | ||
@@ -27,3 +28,3 @@ public constructor(editor: Editor, localization: ToolLocalization) { | ||
this.primaryToolGroup = primaryToolGroup; | ||
const panZoomTool = new PanZoom(editor, PanZoomMode.TwoFingerTouchGestures | PanZoomMode.RightClickDrags, localization.touchPanTool); | ||
@@ -36,6 +37,6 @@ const keyboardPanZoomTool = new PanZoom(editor, PanZoomMode.Keyboard, localization.keyboardPanZoom); | ||
new Pen(editor, localization.penTool(2), { color: Color4.clay, thickness: 4 }), | ||
// Highlighter-like pen with width=64 | ||
new Pen(editor, localization.penTool(3), { color: Color4.ofRGBA(1, 1, 0, 0.5), thickness: 64 }), | ||
new Eraser(editor, localization.eraserTool), | ||
@@ -52,2 +53,3 @@ new SelectionTool(editor, localization.selectionTool), | ||
new ToolSwitcherShortcut(editor), | ||
new PasteHandler(editor), | ||
]; | ||
@@ -57,3 +59,3 @@ primaryTools.forEach(tool => tool.setToolGroup(primaryToolGroup)); | ||
primaryPenTool.setEnabled(true); | ||
editor.notifier.on(EditorEventType.ToolEnabled, event => { | ||
@@ -69,6 +71,6 @@ if (event.kind === EditorEventType.ToolEnabled) { | ||
}); | ||
this.activeTool = null; | ||
} | ||
// Replaces the current set of tools with `tools`. This should only be done before | ||
@@ -80,3 +82,3 @@ // the creation of the app's toolbar (if using `HTMLToolbar`). | ||
} | ||
// Add a tool that acts like one of the primary tools (only one primary tool can be enabled at a time). | ||
@@ -89,6 +91,6 @@ // This should be called before creating the app's toolbar. | ||
} | ||
this.addTool(tool); | ||
} | ||
public getPrimaryTools(): BaseTool[] { | ||
@@ -99,3 +101,3 @@ return this.tools.filter(tool => { | ||
} | ||
// Add a tool to the end of this' tool list (the added tool receives events after tools already added to this). | ||
@@ -106,3 +108,3 @@ // This should be called before creating the app's toolbar. | ||
} | ||
// Returns true if the event was handled | ||
@@ -117,3 +119,3 @@ public dispatchInputEvent(event: InputEvt): boolean { | ||
} | ||
this.activeTool = tool; | ||
@@ -128,8 +130,15 @@ handled = true; | ||
handled = true; | ||
} else if ( | ||
event.kind === InputEvtType.WheelEvt || event.kind === InputEvtType.KeyPressEvent || event.kind === InputEvtType.KeyUpEvent | ||
) { | ||
const isKeyPressEvt = event.kind === InputEvtType.KeyPressEvent; | ||
const isKeyReleaseEvt = event.kind === InputEvtType.KeyUpEvent; | ||
const isWheelEvt = event.kind === InputEvtType.WheelEvt; | ||
} else if (event.kind === InputEvtType.PointerMoveEvt) { | ||
if (this.activeTool !== null) { | ||
this.activeTool.onPointerMove(event); | ||
handled = true; | ||
} | ||
} else if (event.kind === InputEvtType.GestureCancelEvt) { | ||
if (this.activeTool !== null) { | ||
this.activeTool.onGestureCancel(); | ||
this.activeTool = null; | ||
} | ||
} else { | ||
let allCasesHandledGuard: never; | ||
for (const tool of this.tools) { | ||
@@ -139,8 +148,24 @@ if (!tool.isEnabled()) { | ||
} | ||
switch (event.kind) { | ||
case InputEvtType.KeyPressEvent: | ||
handled = tool.onKeyPress(event); | ||
break; | ||
case InputEvtType.KeyUpEvent: | ||
handled = tool.onKeyUp(event); | ||
break; | ||
case InputEvtType.WheelEvt: | ||
handled = tool.onWheel(event); | ||
break; | ||
case InputEvtType.CopyEvent: | ||
handled = tool.onCopy(event); | ||
break; | ||
case InputEvtType.PasteEvent: | ||
handled = tool.onPaste(event); | ||
break; | ||
default: | ||
allCasesHandledGuard = event; | ||
return allCasesHandledGuard; | ||
} | ||
const wheelResult = isWheelEvt && tool.onWheel(event); | ||
const keyPressResult = isKeyPressEvt && tool.onKeyPress(event); | ||
const keyReleaseResult = isKeyReleaseEvt && tool.onKeyUp(event); | ||
handled = keyPressResult || wheelResult || keyReleaseResult; | ||
if (handled) { | ||
@@ -150,25 +175,7 @@ break; | ||
} | ||
} else if (this.activeTool !== null) { | ||
let allCasesHandledGuard: never; | ||
switch (event.kind) { | ||
case InputEvtType.PointerMoveEvt: | ||
this.activeTool.onPointerMove(event); | ||
break; | ||
case InputEvtType.GestureCancelEvt: | ||
this.activeTool.onGestureCancel(); | ||
this.activeTool = null; | ||
break; | ||
default: | ||
allCasesHandledGuard = event; | ||
return allCasesHandledGuard; | ||
} | ||
handled = true; | ||
} else { | ||
handled = false; | ||
} | ||
return handled; | ||
} | ||
public getMatchingTools<Type extends BaseTool>(type: new (...args: any[])=>Type): Type[] { | ||
@@ -175,0 +182,0 @@ return this.tools.filter(tool => tool instanceof type) as Type[]; |
@@ -38,3 +38,6 @@ // Types related to the image editor | ||
KeyPressEvent, | ||
KeyUpEvent | ||
KeyUpEvent, | ||
CopyEvent, | ||
PasteEvent, | ||
} | ||
@@ -63,2 +66,13 @@ | ||
export interface CopyEvent { | ||
readonly kind: InputEvtType.CopyEvent; | ||
setData(mime: string, data: string): void; | ||
} | ||
export interface PasteEvent { | ||
readonly kind: InputEvtType.PasteEvent; | ||
readonly data: string; | ||
readonly mime: string; | ||
} | ||
// Event triggered when pointer capture is taken by a different [PointerEvtListener]. | ||
@@ -87,3 +101,3 @@ export interface GestureCancelEvt { | ||
export type PointerEvt = PointerDownEvt | PointerMoveEvt | PointerUpEvt; | ||
export type InputEvt = KeyPressEvent | KeyUpEvent | WheelEvt | GestureCancelEvt | PointerEvt; | ||
export type InputEvt = KeyPressEvent | KeyUpEvent | WheelEvt | GestureCancelEvt | PointerEvt | CopyEvent | PasteEvent; | ||
@@ -90,0 +104,0 @@ export type EditorNotifier = EventDispatcher<EditorEventType, EditorEventDataType>; |
@@ -12,2 +12,4 @@ import Editor from './Editor'; | ||
private maxUndoRedoStackSize: number = 700; | ||
// @internal | ||
@@ -43,2 +45,8 @@ public constructor( | ||
if (this.undoStack.length > this.maxUndoRedoStackSize) { | ||
const removeAtOnceCount = 10; | ||
const removedElements = this.undoStack.splice(0, removeAtOnceCount); | ||
removedElements.forEach(elem => elem.onDrop(this.editor)); | ||
} | ||
this.fireUpdateEvent(); | ||
@@ -45,0 +53,0 @@ this.editor.notifier.dispatch(EditorEventType.CommandDone, { |
@@ -95,2 +95,3 @@ import Command from './commands/Command'; | ||
// Get the screen's visible region transformed into canvas space. | ||
public get visibleRect(): Rect2 { | ||
@@ -184,6 +185,4 @@ return this.screenRect.transformedBoundingBox(this.inverseTransform); | ||
// Returns a Command that transforms the view such that [rect] is visible, and perhaps | ||
// centered in the viewport. | ||
// Returns null if no transformation is necessary | ||
public zoomTo(toMakeVisible: Rect2, allowZoomIn: boolean = true, allowZoomOut: boolean = true): Command { | ||
// Computes and returns an affine transformation that makes `toMakeVisible` visible and roughly centered on the screen. | ||
public computeZoomToTransform(toMakeVisible: Rect2, allowZoomIn: boolean = true, allowZoomOut: boolean = true): Mat33 { | ||
let transform = Mat33.identity; | ||
@@ -242,2 +241,12 @@ | ||
return transform; | ||
} | ||
// Returns a Command that transforms the view such that [rect] is visible, and perhaps | ||
// centered in the viewport. | ||
// Returns null if no transformation is necessary | ||
// | ||
// @see {@link computeZoomToTransform} | ||
public zoomTo(toMakeVisible: Rect2, allowZoomIn: boolean = true, allowZoomOut: boolean = true): Command { | ||
const transform = this.computeZoomToTransform(toMakeVisible, allowZoomIn, allowZoomOut); | ||
return new Viewport.ViewportTransform(transform); | ||
@@ -244,0 +253,0 @@ } |
Sorry, the diff of this file is too big to display
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
1172818
330
24914