Comparing version 0.1.2 to 0.1.3
@@ -0,1 +1,6 @@ | ||
# 0.1.3 | ||
* Very minimalistic text tool. | ||
* Ability to load and save text. | ||
* Fix a rounding bug where small strokes could be stretched/moved to the wrong locations. | ||
# 0.1.2 | ||
@@ -2,0 +7,0 @@ * Replace 'touch drawing' with a hand tool. |
export interface ImageComponentLocalization { | ||
text: (text: string) => string; | ||
stroke: string; | ||
@@ -3,0 +4,0 @@ svgObject: string; |
export const defaultComponentLocalization = { | ||
stroke: 'Stroke', | ||
svgObject: 'SVG Object', | ||
text: (text) => `Text object: ${text}`, | ||
}; |
@@ -16,3 +16,2 @@ import Rect2 from '../geometry/Rect2'; | ||
} | ||
console.log('Rendering to SVG.', this.attrs); | ||
for (const [attr, value] of this.attrs) { | ||
@@ -19,0 +18,0 @@ canvas.setRootSVGAttribute(attr, value); |
@@ -284,9 +284,8 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { | ||
const renderer = this.display.getDryInkRenderer(); | ||
this.image.renderWithCache(renderer, this.display.getCache(), this.viewport); | ||
if (showImageBounds) { | ||
const exportRectFill = { fill: Color4.fromHex('#44444455') }; | ||
const exportRectStrokeWidth = 12; | ||
const exportRectStrokeWidth = 5 * this.viewport.getSizeOfPixelOnCanvas(); | ||
renderer.drawRect(this.importExportViewport.visibleRect, exportRectStrokeWidth, exportRectFill); | ||
} | ||
//this.image.render(renderer, this.viewport); | ||
this.image.renderWithCache(renderer, this.display.getCache(), this.viewport); | ||
this.rerenderQueued = false; | ||
@@ -293,0 +292,0 @@ } |
@@ -31,2 +31,3 @@ import { Point2, Vec2 } from './Vec2'; | ||
static scaling2D(amount: number | Vec2, center?: Point2): Mat33; | ||
static fromCSSMatrix(cssString: string): Mat33; | ||
} |
@@ -189,3 +189,33 @@ import { Vec2 } from './Vec2'; | ||
} | ||
// Converts a CSS-form matrix(a, b, c, d, e, f) to a Mat33. | ||
static fromCSSMatrix(cssString) { | ||
if (cssString === '' || cssString === 'none') { | ||
return Mat33.identity; | ||
} | ||
const numberExp = '([-]?\\d*(?:\\.\\d*)?(?:[eE][-]?\\d+)?)'; | ||
const numberSepExp = '[, \\t\\n]'; | ||
const regExpSource = `^\\s*matrix\\s*\\(${[ | ||
// According to MDN, matrix(a,b,c,d,e,f) has form: | ||
// ⎡ a c e ⎤ | ||
// ⎢ b d f ⎥ | ||
// ⎣ 0 0 1 ⎦ | ||
numberExp, numberExp, numberExp, | ||
numberExp, numberExp, numberExp, // b, d, f | ||
].join(`${numberSepExp}+`)}${numberSepExp}*\\)\\s*$`; | ||
const matrixExp = new RegExp(regExpSource, 'i'); | ||
const match = matrixExp.exec(cssString); | ||
if (!match) { | ||
throw new Error(`Unsupported transformation: ${cssString}`); | ||
} | ||
const matrixData = match.slice(1).map(entry => parseFloat(entry)); | ||
const a = matrixData[0]; | ||
const b = matrixData[1]; | ||
const c = matrixData[2]; | ||
const d = matrixData[3]; | ||
const e = matrixData[4]; | ||
const f = matrixData[5]; | ||
const transform = new Mat33(a, c, e, b, d, f, 0, 0, 1); | ||
return transform; | ||
} | ||
} | ||
Mat33.identity = new Mat33(1, 0, 0, 0, 1, 0, 0, 0, 1); |
@@ -223,2 +223,3 @@ import { Bezier } from 'bezier-js'; | ||
const preDecimal = parseInt(roundingDownMatch[2], 10); | ||
const origPostDecimalString = roundingDownMatch[3]; | ||
let newPostDecimal = (postDecimal + 10 - lastDigit).toString(); | ||
@@ -231,2 +232,7 @@ let carry = 0; | ||
} | ||
// parseInt(...).toString() removes leading zeroes. Add them back. | ||
while (newPostDecimal.length < origPostDecimalString.length) { | ||
newPostDecimal = carry.toString(10) + newPostDecimal; | ||
carry = 0; | ||
} | ||
text = `${negativeSign + (preDecimal + carry).toString()}.${newPostDecimal}`; | ||
@@ -236,3 +242,4 @@ } | ||
// Remove trailing zeroes | ||
text = text.replace(/([.][^0]*)0+$/, '$1'); | ||
text = text.replace(/([.]\d*[^0]+)0+$/, '$1'); | ||
text = text.replace(/[.]0+$/, '.'); | ||
// Remove trailing period | ||
@@ -239,0 +246,0 @@ return text.replace(/[.]$/, ''); |
@@ -37,2 +37,4 @@ import LineSegment2 from './LineSegment2'; | ||
get bottomLeft(): import("./Vec3").default; | ||
get width(): number; | ||
get height(): number; | ||
getEdges(): LineSegment2[]; | ||
@@ -39,0 +41,0 @@ transformedBoundingBox(affineTransform: Mat33): Rect2; |
@@ -129,2 +129,8 @@ import LineSegment2 from './LineSegment2'; | ||
} | ||
get width() { | ||
return this.w; | ||
} | ||
get height() { | ||
return this.h; | ||
} | ||
// Returns edges in the order | ||
@@ -131,0 +137,0 @@ // [ rightEdge, topEdge, leftEdge, bottomEdge ] |
import Color4 from '../../Color4'; | ||
import { LoadSaveDataTable } from '../../components/AbstractComponent'; | ||
import { TextStyle } from '../../components/Text'; | ||
import Mat33 from '../../geometry/Mat33'; | ||
@@ -23,2 +24,3 @@ import { PathCommand } from '../../geometry/Path'; | ||
private selfTransform; | ||
private transformStack; | ||
protected constructor(viewport: Viewport); | ||
@@ -34,2 +36,3 @@ protected getViewport(): Viewport; | ||
protected abstract traceQuadraticBezierCurve(controlPoint: Point2, endPoint: Point2): void; | ||
abstract drawText(text: string, transform: Mat33, style: TextStyle): void; | ||
abstract isTooSmallToRender(rect: Rect2): boolean; | ||
@@ -49,2 +52,4 @@ setDraftMode(_draftMode: boolean): void; | ||
setTransform(transform: Mat33 | null): void; | ||
pushTransform(transform: Mat33): void; | ||
popTransform(): void; | ||
getCanvasToScreenTransform(): Mat33; | ||
@@ -51,0 +56,0 @@ canvasToScreen(vec: Vec2): Vec2; |
@@ -14,2 +14,3 @@ import Path, { PathCommandType } from '../../geometry/Path'; | ||
this.selfTransform = null; | ||
this.transformStack = []; | ||
this.objectLevel = 0; | ||
@@ -108,2 +109,13 @@ this.currentPaths = null; | ||
} | ||
pushTransform(transform) { | ||
this.transformStack.push(this.selfTransform); | ||
this.setTransform(this.getCanvasToScreenTransform().rightMul(transform)); | ||
} | ||
popTransform() { | ||
var _a; | ||
if (this.transformStack.length === 0) { | ||
throw new Error('Unable to pop more transforms than have been pushed!'); | ||
} | ||
this.setTransform((_a = this.transformStack.pop()) !== null && _a !== void 0 ? _a : null); | ||
} | ||
// Get the matrix that transforms a vector on the canvas to a vector on this' | ||
@@ -110,0 +122,0 @@ // rendering target. |
@@ -0,1 +1,2 @@ | ||
import { TextStyle } from '../../components/Text'; | ||
import Mat33 from '../../geometry/Mat33'; | ||
@@ -15,2 +16,3 @@ import Rect2 from '../../geometry/Rect2'; | ||
constructor(ctx: CanvasRenderingContext2D, viewport: Viewport); | ||
private transformBy; | ||
canRenderFromWithoutDataLoss(other: AbstractRenderer): boolean; | ||
@@ -28,2 +30,3 @@ renderFromOtherOfSameType(transformBy: Mat33, other: AbstractRenderer): void; | ||
drawPath(path: RenderablePathSpec): void; | ||
drawText(text: string, transform: Mat33, style: TextStyle): void; | ||
private clipLevels; | ||
@@ -30,0 +33,0 @@ startObject(boundingBox: Rect2, clip: boolean): void; |
import Color4 from '../../Color4'; | ||
import Text from '../../components/Text'; | ||
import { Vec2 } from '../../geometry/Vec2'; | ||
@@ -13,2 +14,12 @@ import AbstractRenderer from './AbstractRenderer'; | ||
} | ||
transformBy(transformBy) { | ||
// From MDN, transform(a,b,c,d,e,f) | ||
// takes input such that | ||
// ⎡ a c e ⎤ | ||
// ⎢ b d f ⎥ transforms content drawn to [ctx]. | ||
// ⎣ 0 0 1 ⎦ | ||
this.ctx.transform(transformBy.a1, transformBy.b1, // a, b | ||
transformBy.a2, transformBy.b2, // c, d | ||
transformBy.a3, transformBy.b3); | ||
} | ||
canRenderFromWithoutDataLoss(other) { | ||
@@ -23,10 +34,3 @@ return other instanceof CanvasRenderer; | ||
this.ctx.save(); | ||
// From MDN, transform(a,b,c,d,e,f) | ||
// takes input such that | ||
// ⎡ a c e ⎤ | ||
// ⎢ b d f ⎥ transforms content drawn to [ctx]. | ||
// ⎣ 0 0 1 ⎦ | ||
this.ctx.transform(transformBy.a1, transformBy.b1, // a, b | ||
transformBy.a2, transformBy.b2, // c, d | ||
transformBy.a3, transformBy.b3); | ||
this.transformBy(transformBy); | ||
this.ctx.drawImage(other.ctx.canvas, 0, 0); | ||
@@ -110,2 +114,18 @@ this.ctx.restore(); | ||
} | ||
drawText(text, transform, style) { | ||
this.ctx.save(); | ||
transform = this.getCanvasToScreenTransform().rightMul(transform); | ||
this.transformBy(transform); | ||
Text.applyTextStyles(this.ctx, style); | ||
if (style.renderingStyle.fill.a !== 0) { | ||
this.ctx.fillStyle = style.renderingStyle.fill.toHexString(); | ||
this.ctx.fillText(text, 0, 0); | ||
} | ||
if (style.renderingStyle.stroke) { | ||
this.ctx.strokeStyle = style.renderingStyle.stroke.color.toHexString(); | ||
this.ctx.lineWidth = style.renderingStyle.stroke.width; | ||
this.ctx.strokeText(text, 0, 0); | ||
} | ||
this.ctx.restore(); | ||
} | ||
startObject(boundingBox, clip) { | ||
@@ -112,0 +132,0 @@ if (this.isTooSmallToRender(boundingBox)) { |
@@ -0,1 +1,2 @@ | ||
import { TextStyle } from '../../components/Text'; | ||
import Mat33 from '../../geometry/Mat33'; | ||
@@ -13,2 +14,3 @@ import Rect2 from '../../geometry/Rect2'; | ||
objectNestingLevel: number; | ||
lastText: string | null; | ||
pointBuffer: Point2[]; | ||
@@ -25,2 +27,3 @@ constructor(viewport: Viewport); | ||
drawPoints(..._points: Vec3[]): void; | ||
drawText(text: string, _transform: Mat33, _style: TextStyle): void; | ||
startObject(boundingBox: Rect2, _clip: boolean): void; | ||
@@ -27,0 +30,0 @@ endObject(): void; |
@@ -13,2 +13,3 @@ // Renderer that outputs nothing. Useful for automated tests. | ||
this.objectNestingLevel = 0; | ||
this.lastText = null; | ||
// List of points drawn since the last clear. | ||
@@ -32,2 +33,3 @@ this.pointBuffer = []; | ||
this.pointBuffer = []; | ||
this.lastText = null; | ||
// Ensure all objects finished rendering | ||
@@ -73,2 +75,5 @@ if (this.objectNestingLevel > 0) { | ||
} | ||
drawText(text, _transform, _style) { | ||
this.lastText = text; | ||
} | ||
startObject(boundingBox, _clip) { | ||
@@ -75,0 +80,0 @@ super.startObject(boundingBox); |
import { LoadSaveDataTable } from '../../components/AbstractComponent'; | ||
import { TextStyle } from '../../components/Text'; | ||
import Mat33 from '../../geometry/Mat33'; | ||
import Rect2 from '../../geometry/Rect2'; | ||
@@ -22,2 +24,3 @@ import { Point2, Vec2 } from '../../geometry/Vec2'; | ||
private addPathToSVG; | ||
drawText(text: string, transform: Mat33, style: TextStyle): void; | ||
startObject(boundingBox: Rect2): void; | ||
@@ -24,0 +27,0 @@ endObject(loaderData?: LoadSaveDataTable): void; |
import Path, { PathCommandType } from '../../geometry/Path'; | ||
import { Vec2 } from '../../geometry/Vec2'; | ||
import { svgAttributesDataKey } from '../../SVGLoader'; | ||
import { svgAttributesDataKey, svgStyleAttributesDataKey } from '../../SVGLoader'; | ||
import AbstractRenderer from './AbstractRenderer'; | ||
@@ -89,2 +89,25 @@ const svgNameSpace = 'http://www.w3.org/2000/svg'; | ||
} | ||
drawText(text, transform, style) { | ||
var _a, _b, _c; | ||
transform = this.getCanvasToScreenTransform().rightMul(transform); | ||
const textElem = document.createElementNS(svgNameSpace, 'text'); | ||
textElem.appendChild(document.createTextNode(text)); | ||
textElem.style.transform = `matrix( | ||
${transform.a1}, ${transform.b1}, | ||
${transform.a2}, ${transform.b2}, | ||
${transform.a3}, ${transform.b3} | ||
)`; | ||
textElem.style.fontFamily = style.fontFamily; | ||
textElem.style.fontVariant = (_a = style.fontVariant) !== null && _a !== void 0 ? _a : ''; | ||
textElem.style.fontWeight = (_b = style.fontWeight) !== null && _b !== void 0 ? _b : ''; | ||
textElem.style.fontSize = style.size + 'px'; | ||
textElem.style.fill = style.renderingStyle.fill.toHexString(); | ||
if (style.renderingStyle.stroke) { | ||
const strokeStyle = style.renderingStyle.stroke; | ||
textElem.style.stroke = strokeStyle.color.toHexString(); | ||
textElem.style.strokeWidth = strokeStyle.width + 'px'; | ||
} | ||
this.elem.appendChild(textElem); | ||
(_c = this.objectElems) === null || _c === void 0 ? void 0 : _c.push(textElem); | ||
} | ||
startObject(boundingBox) { | ||
@@ -107,2 +130,3 @@ super.startObject(boundingBox); | ||
const attrs = loaderData[svgAttributesDataKey]; | ||
const styleAttrs = loaderData[svgStyleAttributesDataKey]; | ||
if (attrs) { | ||
@@ -113,2 +137,7 @@ for (const [attr, value] of attrs) { | ||
} | ||
if (styleAttrs) { | ||
for (const attr of styleAttrs) { | ||
elem.style.setProperty(attr.key, attr.value, attr.priority); | ||
} | ||
} | ||
} | ||
@@ -115,0 +144,0 @@ } |
@@ -5,3 +5,9 @@ import Rect2 from './geometry/Rect2'; | ||
export declare const svgAttributesDataKey = "svgAttrs"; | ||
export declare const svgStyleAttributesDataKey = "svgStyleAttrs"; | ||
export declare type SVGLoaderUnknownAttribute = [string, string]; | ||
export declare type SVGLoaderUnknownStyleAttribute = { | ||
key: string; | ||
value: string; | ||
priority?: string; | ||
}; | ||
export default class SVGLoader implements ImageLoader { | ||
@@ -21,2 +27,4 @@ private source; | ||
private addPath; | ||
private makeText; | ||
private addText; | ||
private addUnknownNode; | ||
@@ -23,0 +31,0 @@ private updateViewBox; |
@@ -13,5 +13,8 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { | ||
import SVGGlobalAttributesObject from './components/SVGGlobalAttributesObject'; | ||
import Text from './components/Text'; | ||
import UnknownSVGObject from './components/UnknownSVGObject'; | ||
import Mat33 from './geometry/Mat33'; | ||
import Path from './geometry/Path'; | ||
import Rect2 from './geometry/Rect2'; | ||
import { Vec2 } from './geometry/Vec2'; | ||
// Size of a loaded image if no size is specified. | ||
@@ -21,2 +24,3 @@ export const defaultSVGViewRect = new Rect2(0, 0, 500, 500); | ||
export const svgAttributesDataKey = 'svgAttrs'; | ||
export const svgStyleAttributesDataKey = 'svgStyleAttrs'; | ||
export default class SVGLoader { | ||
@@ -88,5 +92,5 @@ constructor(source, onFinish) { | ||
} | ||
attachUnrecognisedAttrs(elem, node, supportedAttrs) { | ||
attachUnrecognisedAttrs(elem, node, supportedAttrs, supportedStyleAttrs) { | ||
for (const attr of node.getAttributeNames()) { | ||
if (supportedAttrs.has(attr)) { | ||
if (supportedAttrs.has(attr) || (attr === 'style' && supportedStyleAttrs)) { | ||
continue; | ||
@@ -96,2 +100,18 @@ } | ||
} | ||
if (supportedStyleAttrs) { | ||
for (const attr of node.style) { | ||
if (attr === '' || !attr) { | ||
continue; | ||
} | ||
if (supportedStyleAttrs.has(attr)) { | ||
continue; | ||
} | ||
// TODO: Do we need special logic for !important properties? | ||
elem.attachLoadSaveData(svgStyleAttributesDataKey, { | ||
key: attr, | ||
value: node.style.getPropertyValue(attr), | ||
priority: node.style.getPropertyPriority(attr) | ||
}); | ||
} | ||
} | ||
} | ||
@@ -105,3 +125,4 @@ // Adds a stroke with a single path | ||
elem = new Stroke(strokeData); | ||
this.attachUnrecognisedAttrs(elem, node, new Set(['stroke', 'fill', 'stroke-width', 'd'])); | ||
const supportedStyleAttrs = ['stroke', 'fill', 'stroke-width']; | ||
this.attachUnrecognisedAttrs(elem, node, new Set([...supportedStyleAttrs, 'd']), new Set(supportedStyleAttrs)); | ||
} | ||
@@ -114,2 +135,70 @@ catch (e) { | ||
} | ||
makeText(elem) { | ||
var _a; | ||
const contentList = []; | ||
for (const child of elem.childNodes) { | ||
if (child.nodeType === Node.TEXT_NODE) { | ||
contentList.push((_a = child.nodeValue) !== null && _a !== void 0 ? _a : ''); | ||
} | ||
else if (child.nodeType === Node.ELEMENT_NODE) { | ||
const subElem = child; | ||
if (subElem.tagName.toLowerCase() === 'tspan') { | ||
contentList.push(this.makeText(subElem)); | ||
} | ||
else { | ||
throw new Error(`Unrecognized text child element: ${subElem}`); | ||
} | ||
} | ||
else { | ||
throw new Error(`Unrecognized text child node: ${child}.`); | ||
} | ||
} | ||
// Compute styles. | ||
const computedStyles = window.getComputedStyle(elem); | ||
const fontSizeMatch = /^([-0-9.e]+)px/i.exec(computedStyles.fontSize); | ||
const supportedStyleAttrs = [ | ||
'fontFamily', | ||
'fill', | ||
'transform' | ||
]; | ||
let fontSize = 12; | ||
if (fontSizeMatch) { | ||
supportedStyleAttrs.push('fontSize'); | ||
fontSize = parseFloat(fontSizeMatch[1]); | ||
} | ||
const style = { | ||
size: fontSize, | ||
fontFamily: computedStyles.fontFamily || 'sans', | ||
renderingStyle: { | ||
fill: Color4.fromString(computedStyles.fill) | ||
}, | ||
}; | ||
// Compute transform matrix | ||
let transform = Mat33.fromCSSMatrix(computedStyles.transform); | ||
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 result = new Text(contentList, transform, style); | ||
this.attachUnrecognisedAttrs(result, elem, new Set(supportedAttrs), new Set(supportedStyleAttrs)); | ||
return result; | ||
} | ||
addText(elem) { | ||
var _a; | ||
try { | ||
const textElem = this.makeText(elem); | ||
(_a = this.onAddComponent) === null || _a === void 0 ? void 0 : _a.call(this, textElem); | ||
} | ||
catch (e) { | ||
console.error('Invalid text object in node', elem, '. Adding as an unknown object. Error:', e); | ||
this.addUnknownNode(elem); | ||
} | ||
} | ||
addUnknownNode(node) { | ||
@@ -126,3 +215,3 @@ var _a; | ||
} | ||
const components = viewBoxAttr.split(/[ \t,]/); | ||
const components = viewBoxAttr.split(/[ \t\n,]+/); | ||
const x = parseFloat(components[0]); | ||
@@ -133,2 +222,3 @@ const y = parseFloat(components[1]); | ||
if (isNaN(x) || isNaN(y) || isNaN(width) || isNaN(height)) { | ||
console.warn(`node ${node} has an unparsable viewbox. Viewbox: ${viewBoxAttr}. Match: ${components}.`); | ||
return; | ||
@@ -147,2 +237,3 @@ } | ||
this.totalToProcess += node.childElementCount; | ||
let visitChildren = true; | ||
switch (node.tagName.toLowerCase()) { | ||
@@ -155,2 +246,6 @@ case 'g': | ||
break; | ||
case 'text': | ||
this.addText(node); | ||
visitChildren = false; | ||
break; | ||
case 'svg': | ||
@@ -168,4 +263,6 @@ this.updateViewBox(node); | ||
} | ||
for (const child of node.children) { | ||
yield this.visit(child); | ||
if (visitChildren) { | ||
for (const child of node.children) { | ||
yield this.visit(child); | ||
} | ||
} | ||
@@ -237,3 +334,5 @@ this.processedCount++; | ||
svgElem.innerHTML = text; | ||
sandboxDoc.body.appendChild(svgElem); | ||
return new SVGLoader(svgElem, () => { | ||
svgElem.remove(); | ||
sandbox.remove(); | ||
@@ -240,0 +339,0 @@ }); |
@@ -18,6 +18,3 @@ export const loadExpectExtensions = () => { | ||
message: () => { | ||
if (pass) { | ||
return `Expected ${expected} not to .eq ${actual}. Options(${eqArgs})`; | ||
} | ||
return `Expected ${expected} to .eq ${actual}. Options(${eqArgs})`; | ||
return `Expected ${pass ? '!' : ''}(${actual}).eq(${expected}). Options(${eqArgs})`; | ||
}, | ||
@@ -24,0 +21,0 @@ }; |
@@ -13,6 +13,7 @@ import { ToolType } from '../tools/ToolController'; | ||
import { defaultToolbarLocalization } from './localization'; | ||
import { makeDropdownIcon, makeEraserIcon, makeIconFromFactory, makePenIcon, makeRedoIcon, makeSelectionIcon, makeHandToolIcon, makeUndoIcon } from './icons'; | ||
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 TextTool from '../tools/TextTool'; | ||
const toolbarCSSPrefix = 'toolbar-'; | ||
@@ -320,2 +321,46 @@ class ToolbarWidget { | ||
} | ||
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 colorRow = document.createElement('div'); | ||
const colorInput = document.createElement('input'); | ||
const colorLabel = document.createElement('label'); | ||
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); | ||
colorInput.oninput = () => { | ||
this.tool.setColor(Color4.fromString(colorInput.value)); | ||
}; | ||
colorRow.appendChild(colorLabel); | ||
colorRow.appendChild(colorInput); | ||
this.updateDropdownInputs = () => { | ||
const style = this.tool.getTextStyle(); | ||
colorInput.value = style.renderingStyle.fill.toHexString(); | ||
}; | ||
this.updateDropdownInputs(); | ||
dropdown.appendChild(colorRow); | ||
return true; | ||
} | ||
} | ||
TextToolWidget.idCounter = 0; | ||
class PenWidget extends ToolbarWidget { | ||
@@ -564,2 +609,8 @@ constructor(editor, tool, localization, penTypes) { | ||
} | ||
for (const tool of toolController.getMatchingTools(ToolType.Text)) { | ||
if (!(tool instanceof TextTool)) { | ||
throw new Error('All text tools must have kind === ToolType.Text'); | ||
} | ||
(new TextToolWidget(this.editor, tool, this.localizationTable)).addTo(this.container); | ||
} | ||
for (const tool of toolController.getMatchingTools(ToolType.PanZoom)) { | ||
@@ -566,0 +617,0 @@ if (!(tool instanceof PanZoom)) { |
import { ComponentBuilderFactory } from '../components/builders/types'; | ||
import { TextStyle } from '../components/Text'; | ||
import Pen from '../tools/Pen'; | ||
@@ -9,3 +10,4 @@ export declare const makeUndoIcon: () => SVGSVGElement; | ||
export declare const makeHandToolIcon: () => 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; |
@@ -114,2 +114,19 @@ import EventDispatcher from '../EventDispatcher'; | ||
}; | ||
export const makeTextIcon = (textStyle) => { | ||
var _a, _b; | ||
const icon = document.createElementNS(svgNamespace, 'svg'); | ||
icon.setAttribute('viewBox', '0 0 100 100'); | ||
const textNode = document.createElementNS(svgNamespace, 'text'); | ||
textNode.appendChild(document.createTextNode('T')); | ||
textNode.style.fontFamily = textStyle.fontFamily; | ||
textNode.style.fontWeight = (_a = textStyle.fontWeight) !== null && _a !== void 0 ? _a : ''; | ||
textNode.style.fontVariant = (_b = textStyle.fontVariant) !== null && _b !== void 0 ? _b : ''; | ||
textNode.style.fill = textStyle.renderingStyle.fill.toHexString(); | ||
textNode.style.textAnchor = 'middle'; | ||
textNode.setAttribute('x', '50'); | ||
textNode.setAttribute('y', '75'); | ||
textNode.style.fontSize = '65px'; | ||
icon.appendChild(textNode); | ||
return icon; | ||
}; | ||
export const makePenIcon = (tipThickness, color) => { | ||
@@ -116,0 +133,0 @@ const icon = document.createElementNS(svgNamespace, 'svg'); |
export interface ToolLocalization { | ||
RightClickDragPanTool: string; | ||
rightClickDragPanTool: string; | ||
penTool: (penId: number) => string; | ||
@@ -9,2 +9,4 @@ selectionTool: string; | ||
undoRedoTool: string; | ||
textTool: string; | ||
enterTextToInsert: string; | ||
toolEnabledAnnouncement: (toolName: string) => string; | ||
@@ -11,0 +13,0 @@ toolDisabledAnnouncement: (toolName: string) => string; |
@@ -8,5 +8,7 @@ export const defaultToolLocalization = { | ||
undoRedoTool: 'Undo/Redo', | ||
RightClickDragPanTool: 'Right-click drag', | ||
rightClickDragPanTool: 'Right-click drag', | ||
textTool: 'Text', | ||
enterTextToInsert: 'Text to insert', | ||
toolEnabledAnnouncement: (toolName) => `${toolName} enabled`, | ||
toolDisabledAnnouncement: (toolName) => `${toolName} disabled`, | ||
}; |
@@ -399,3 +399,3 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { | ||
const selectionRect = this.selectionBox.region; | ||
this.editor.viewport.zoomTo(selectionRect).apply(this.editor); | ||
this.editor.viewport.zoomTo(selectionRect, false).apply(this.editor); | ||
} | ||
@@ -402,0 +402,0 @@ } |
@@ -10,3 +10,4 @@ import { InputEvt } from '../types'; | ||
PanZoom = 3, | ||
UndoRedoShortcut = 4 | ||
Text = 4, | ||
UndoRedoShortcut = 5 | ||
} | ||
@@ -13,0 +14,0 @@ export default class ToolController { |
@@ -9,2 +9,3 @@ import { InputEvtType, EditorEventType } from '../types'; | ||
import UndoRedoShortcut from './UndoRedoShortcut'; | ||
import TextTool from './TextTool'; | ||
export var ToolType; | ||
@@ -16,3 +17,4 @@ (function (ToolType) { | ||
ToolType[ToolType["PanZoom"] = 3] = "PanZoom"; | ||
ToolType[ToolType["UndoRedoShortcut"] = 4] = "UndoRedoShortcut"; | ||
ToolType[ToolType["Text"] = 4] = "Text"; | ||
ToolType[ToolType["UndoRedoShortcut"] = 5] = "UndoRedoShortcut"; | ||
})(ToolType || (ToolType = {})); | ||
@@ -32,2 +34,3 @@ export default class ToolController { | ||
new Pen(editor, localization.penTool(3), { color: Color4.ofRGBA(1, 1, 0, 0.5), thickness: 64 }), | ||
new TextTool(editor, localization.textTool, localization), | ||
]; | ||
@@ -34,0 +37,0 @@ this.tools = [ |
@@ -38,3 +38,3 @@ import Command from './commands/Command'; | ||
roundPoint(point: Point2): Point2; | ||
zoomTo(toMakeVisible: Rect2): Command; | ||
zoomTo(toMakeVisible: Rect2, allowZoomIn?: boolean, allowZoomOut?: boolean): Command; | ||
} | ||
@@ -41,0 +41,0 @@ export declare namespace Viewport { |
@@ -88,3 +88,3 @@ var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (receiver, state, value, kind, f) { | ||
// Returns null if no transformation is necessary | ||
zoomTo(toMakeVisible) { | ||
zoomTo(toMakeVisible, allowZoomIn = true, allowZoomOut = true) { | ||
let transform = Mat33.identity; | ||
@@ -108,3 +108,3 @@ if (toMakeVisible.w === 0 || toMakeVisible.h === 0) { | ||
const muchSmallerThanTarget = toMakeVisible.maxDimension / targetRect.maxDimension < 0.125; | ||
if (largerThanTarget || muchSmallerThanTarget) { | ||
if ((largerThanTarget && allowZoomOut) || (muchSmallerThanTarget && allowZoomIn)) { | ||
// If larger than the target, ensure that the longest axis is visible. | ||
@@ -111,0 +111,0 @@ // If smaller, shrink the visible rectangle as much as possible |
{ | ||
"name": "js-draw", | ||
"version": "0.1.2", | ||
"version": "0.1.3", | ||
"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/Editor.js", |
@@ -39,4 +39,4 @@ # js-draw | ||
```html | ||
<!-- Replace 0.0.5 with the latest version of js-draw --> | ||
<script src="https://cdn.jsdelivr.net/npm/js-draw@0.0.5/dist/bundle.js"></script> | ||
<!-- Replace 0.1.2 with the latest version of js-draw --> | ||
<script src="https://cdn.jsdelivr.net/npm/js-draw@0.1.2/dist/bundle.js"></script> | ||
<script> | ||
@@ -43,0 +43,0 @@ const editor = new jsdraw.Editor(document.body); |
export interface ImageComponentLocalization { | ||
text: (text: string)=> string; | ||
stroke: string; | ||
@@ -9,2 +10,3 @@ svgObject: string; | ||
svgObject: 'SVG Object', | ||
text: (text) => `Text object: ${text}`, | ||
}; |
@@ -23,3 +23,2 @@ import LineSegment2 from '../geometry/LineSegment2'; | ||
console.log('Rendering to SVG.', this.attrs); | ||
for (const [ attr, value ] of this.attrs) { | ||
@@ -26,0 +25,0 @@ canvas.setRootSVGAttribute(attr, value); |
@@ -377,5 +377,7 @@ | ||
this.image.renderWithCache(renderer, this.display.getCache(), this.viewport); | ||
if (showImageBounds) { | ||
const exportRectFill = { fill: Color4.fromHex('#44444455') }; | ||
const exportRectStrokeWidth = 12; | ||
const exportRectStrokeWidth = 5 * this.viewport.getSizeOfPixelOnCanvas(); | ||
renderer.drawRect( | ||
@@ -388,4 +390,2 @@ this.importExportViewport.visibleRect, | ||
//this.image.render(renderer, this.viewport); | ||
this.image.renderWithCache(renderer, this.display.getCache(), this.viewport); | ||
this.rerenderQueued = false; | ||
@@ -392,0 +392,0 @@ } |
@@ -144,2 +144,46 @@ import Mat33 from './Mat33'; | ||
}); | ||
it('should convert CSS matrix(...) strings to matricies', () => { | ||
// From MDN: | ||
// ⎡ a c e ⎤ | ||
// ⎢ b d f ⎥ = matrix(a,b,c,d,e,f) | ||
// ⎣ 0 0 1 ⎦ | ||
const identity = Mat33.fromCSSMatrix('matrix(1, 0, 0, 1, 0, 0)'); | ||
expect(identity).objEq(Mat33.identity); | ||
expect(Mat33.fromCSSMatrix('matrix(1, 2, 3, 4, 5, 6)')).objEq(new Mat33( | ||
1, 3, 5, | ||
2, 4, 6, | ||
0, 0, 1, | ||
)); | ||
expect(Mat33.fromCSSMatrix('matrix(1e2, 2, 3, 4, 5, 6)')).objEq(new Mat33( | ||
1e2, 3, 5, | ||
2, 4, 6, | ||
0, 0, 1, | ||
)); | ||
expect(Mat33.fromCSSMatrix('matrix(1.6, 2, .3, 4, 5, 6)')).objEq(new Mat33( | ||
1.6, .3, 5, | ||
2, 4, 6, | ||
0, 0, 1, | ||
)); | ||
expect(Mat33.fromCSSMatrix('matrix(-1, 2, 3.E-2, 4, -5.123, -6.5)')).objEq(new Mat33( | ||
-1, 0.03, -5.123, | ||
2, 4, -6.5, | ||
0, 0, 1, | ||
)); | ||
expect(Mat33.fromCSSMatrix('matrix(1.6,\n\t2, .3, 4, 5, 6)')).objEq(new Mat33( | ||
1.6, .3, 5, | ||
2, 4, 6, | ||
0, 0, 1, | ||
)); | ||
expect(Mat33.fromCSSMatrix('matrix(1.6,2, .3E-2, 4, 5, 6)')).objEq(new Mat33( | ||
1.6, 3e-3, 5, | ||
2, 4, 6, | ||
0, 0, 1, | ||
)); | ||
expect(Mat33.fromCSSMatrix('matrix(-1, 2e6, 3E-2,-5.123, -6.5e-1, 0.01)')).objEq(new Mat33( | ||
-1, 3E-2, -6.5e-1, | ||
2e6, -5.123, 0.01, | ||
0, 0, 1, | ||
)); | ||
}); | ||
}); |
@@ -271,2 +271,43 @@ import { Point2, Vec2 } from './Vec2'; | ||
} | ||
// Converts a CSS-form matrix(a, b, c, d, e, f) to a Mat33. | ||
public static fromCSSMatrix(cssString: string): Mat33 { | ||
if (cssString === '' || cssString === 'none') { | ||
return Mat33.identity; | ||
} | ||
const numberExp = '([-]?\\d*(?:\\.\\d*)?(?:[eE][-]?\\d+)?)'; | ||
const numberSepExp = '[, \\t\\n]'; | ||
const regExpSource = `^\\s*matrix\\s*\\(${ | ||
[ | ||
// According to MDN, matrix(a,b,c,d,e,f) has form: | ||
// ⎡ a c e ⎤ | ||
// ⎢ b d f ⎥ | ||
// ⎣ 0 0 1 ⎦ | ||
numberExp, numberExp, numberExp, // a, c, e | ||
numberExp, numberExp, numberExp, // b, d, f | ||
].join(`${numberSepExp}+`) | ||
}${numberSepExp}*\\)\\s*$`; | ||
const matrixExp = new RegExp(regExpSource, 'i'); | ||
const match = matrixExp.exec(cssString); | ||
if (!match) { | ||
throw new Error(`Unsupported transformation: ${cssString}`); | ||
} | ||
const matrixData = match.slice(1).map(entry => parseFloat(entry)); | ||
const a = matrixData[0]; | ||
const b = matrixData[1]; | ||
const c = matrixData[2]; | ||
const d = matrixData[3]; | ||
const e = matrixData[4]; | ||
const f = matrixData[5]; | ||
const transform = new Mat33( | ||
a, c, e, | ||
b, d, f, | ||
0, 0, 1 | ||
); | ||
return transform; | ||
} | ||
} |
@@ -22,10 +22,14 @@ import Path, { PathCommandType } from './Path'; | ||
it('should fix rounding errors', () => { | ||
const path = new Path(Vec2.of(0.10000001, 0.19999999), [ | ||
const path = new Path(Vec2.of(0.100000001, 0.199999999), [ | ||
{ | ||
kind: PathCommandType.QuadraticBezierTo, | ||
controlPoint: Vec2.of(9999, -10.999999995), | ||
endPoint: Vec2.of(0.000300001, 1.40000002), | ||
endPoint: Vec2.of(0.000300001, 1.400000002), | ||
}, | ||
{ | ||
kind: PathCommandType.LineTo, | ||
point: Vec2.of(184.00482359999998, 1) | ||
} | ||
]); | ||
expect(path.toString()).toBe('M0.1,0.2Q9999,-11 0.0003,1.4'); | ||
expect(path.toString()).toBe('M0.1,0.2Q9999,-11 0.0003,1.4L184.0048236,1'); | ||
}); | ||
@@ -32,0 +36,0 @@ |
@@ -306,2 +306,4 @@ import { Bezier } from 'bezier-js'; | ||
const origPostDecimalString = roundingDownMatch[3]; | ||
let newPostDecimal = (postDecimal + 10 - lastDigit).toString(); | ||
@@ -314,2 +316,9 @@ let carry = 0; | ||
} | ||
// parseInt(...).toString() removes leading zeroes. Add them back. | ||
while (newPostDecimal.length < origPostDecimalString.length) { | ||
newPostDecimal = carry.toString(10) + newPostDecimal; | ||
carry = 0; | ||
} | ||
text = `${negativeSign + (preDecimal + carry).toString()}.${newPostDecimal}`; | ||
@@ -321,3 +330,4 @@ } | ||
// Remove trailing zeroes | ||
text = text.replace(/([.][^0]*)0+$/, '$1'); | ||
text = text.replace(/([.]\d*[^0]+)0+$/, '$1'); | ||
text = text.replace(/[.]0+$/, '.'); | ||
@@ -324,0 +334,0 @@ // Remove trailing period |
@@ -187,2 +187,10 @@ import LineSegment2 from './LineSegment2'; | ||
public get width() { | ||
return this.w; | ||
} | ||
public get height() { | ||
return this.h; | ||
} | ||
// Returns edges in the order | ||
@@ -189,0 +197,0 @@ // [ rightEdge, topEdge, leftEdge, bottomEdge ] |
import Color4 from '../../Color4'; | ||
import { LoadSaveDataTable } from '../../components/AbstractComponent'; | ||
import { TextStyle } from '../../components/Text'; | ||
import Mat33 from '../../geometry/Mat33'; | ||
@@ -32,2 +33,3 @@ import Path, { PathCommand, PathCommandType } from '../../geometry/Path'; | ||
private selfTransform: Mat33|null = null; | ||
private transformStack: Array<Mat33|null> = []; | ||
@@ -55,2 +57,3 @@ protected constructor(private viewport: Viewport) { } | ||
): void; | ||
public abstract drawText(text: string, transform: Mat33, style: TextStyle): void; | ||
@@ -171,2 +174,15 @@ // Returns true iff the given rectangle is so small, rendering anything within | ||
public pushTransform(transform: Mat33) { | ||
this.transformStack.push(this.selfTransform); | ||
this.setTransform(this.getCanvasToScreenTransform().rightMul(transform)); | ||
} | ||
public popTransform() { | ||
if (this.transformStack.length === 0) { | ||
throw new Error('Unable to pop more transforms than have been pushed!'); | ||
} | ||
this.setTransform(this.transformStack.pop() ?? null); | ||
} | ||
// Get the matrix that transforms a vector on the canvas to a vector on this' | ||
@@ -173,0 +189,0 @@ // rendering target. |
import Color4 from '../../Color4'; | ||
import Text, { TextStyle } from '../../components/Text'; | ||
import Mat33 from '../../geometry/Mat33'; | ||
@@ -29,2 +30,15 @@ import Rect2 from '../../geometry/Rect2'; | ||
private transformBy(transformBy: Mat33) { | ||
// From MDN, transform(a,b,c,d,e,f) | ||
// takes input such that | ||
// ⎡ a c e ⎤ | ||
// ⎢ b d f ⎥ transforms content drawn to [ctx]. | ||
// ⎣ 0 0 1 ⎦ | ||
this.ctx.transform( | ||
transformBy.a1, transformBy.b1, // a, b | ||
transformBy.a2, transformBy.b2, // c, d | ||
transformBy.a3, transformBy.b3, // e, f | ||
); | ||
} | ||
public canRenderFromWithoutDataLoss(other: AbstractRenderer) { | ||
@@ -40,12 +54,3 @@ return other instanceof CanvasRenderer; | ||
this.ctx.save(); | ||
// From MDN, transform(a,b,c,d,e,f) | ||
// takes input such that | ||
// ⎡ a c e ⎤ | ||
// ⎢ b d f ⎥ transforms content drawn to [ctx]. | ||
// ⎣ 0 0 1 ⎦ | ||
this.ctx.transform( | ||
transformBy.a1, transformBy.b1, // a, b | ||
transformBy.a2, transformBy.b2, // c, d | ||
transformBy.a3, transformBy.b3, // e, f | ||
); | ||
this.transformBy(transformBy); | ||
this.ctx.drawImage(other.ctx.canvas, 0, 0); | ||
@@ -148,2 +153,21 @@ this.ctx.restore(); | ||
public drawText(text: string, transform: Mat33, style: TextStyle): void { | ||
this.ctx.save(); | ||
transform = this.getCanvasToScreenTransform().rightMul(transform); | ||
this.transformBy(transform); | ||
Text.applyTextStyles(this.ctx, style); | ||
if (style.renderingStyle.fill.a !== 0) { | ||
this.ctx.fillStyle = style.renderingStyle.fill.toHexString(); | ||
this.ctx.fillText(text, 0, 0); | ||
} | ||
if (style.renderingStyle.stroke) { | ||
this.ctx.strokeStyle = style.renderingStyle.stroke.color.toHexString(); | ||
this.ctx.lineWidth = style.renderingStyle.stroke.width; | ||
this.ctx.strokeText(text, 0, 0); | ||
} | ||
this.ctx.restore(); | ||
} | ||
private clipLevels: number[] = []; | ||
@@ -150,0 +174,0 @@ public startObject(boundingBox: Rect2, clip: boolean) { |
// Renderer that outputs nothing. Useful for automated tests. | ||
import { TextStyle } from '../../components/Text'; | ||
import Mat33 from '../../geometry/Mat33'; | ||
@@ -17,2 +18,3 @@ import Rect2 from '../../geometry/Rect2'; | ||
public objectNestingLevel: number = 0; | ||
public lastText: string|null = null; | ||
@@ -44,2 +46,3 @@ // List of points drawn since the last clear. | ||
this.pointBuffer = []; | ||
this.lastText = null; | ||
@@ -93,2 +96,7 @@ // Ensure all objects finished rendering | ||
public drawText(text: string, _transform: Mat33, _style: TextStyle): void { | ||
this.lastText = text; | ||
} | ||
public startObject(boundingBox: Rect2, _clip: boolean) { | ||
@@ -95,0 +103,0 @@ super.startObject(boundingBox); |
import { LoadSaveDataTable } from '../../components/AbstractComponent'; | ||
import { TextStyle } from '../../components/Text'; | ||
import Mat33 from '../../geometry/Mat33'; | ||
import Path, { PathCommand, PathCommandType } from '../../geometry/Path'; | ||
import Rect2 from '../../geometry/Rect2'; | ||
import { Point2, Vec2 } from '../../geometry/Vec2'; | ||
import { svgAttributesDataKey, SVGLoaderUnknownAttribute } from '../../SVGLoader'; | ||
import { svgAttributesDataKey, SVGLoaderUnknownAttribute, SVGLoaderUnknownStyleAttribute, svgStyleAttributesDataKey } from '../../SVGLoader'; | ||
import Viewport from '../../Viewport'; | ||
@@ -110,2 +112,28 @@ import AbstractRenderer, { RenderingStyle } from './AbstractRenderer'; | ||
public drawText(text: string, transform: Mat33, style: TextStyle): void { | ||
transform = this.getCanvasToScreenTransform().rightMul(transform); | ||
const textElem = document.createElementNS(svgNameSpace, 'text'); | ||
textElem.appendChild(document.createTextNode(text)); | ||
textElem.style.transform = `matrix( | ||
${transform.a1}, ${transform.b1}, | ||
${transform.a2}, ${transform.b2}, | ||
${transform.a3}, ${transform.b3} | ||
)`; | ||
textElem.style.fontFamily = style.fontFamily; | ||
textElem.style.fontVariant = style.fontVariant ?? ''; | ||
textElem.style.fontWeight = style.fontWeight ?? ''; | ||
textElem.style.fontSize = style.size + 'px'; | ||
textElem.style.fill = style.renderingStyle.fill.toHexString(); | ||
if (style.renderingStyle.stroke) { | ||
const strokeStyle = style.renderingStyle.stroke; | ||
textElem.style.stroke = strokeStyle.color.toHexString(); | ||
textElem.style.strokeWidth = strokeStyle.width + 'px'; | ||
} | ||
this.elem.appendChild(textElem); | ||
this.objectElems?.push(textElem); | ||
} | ||
public startObject(boundingBox: Rect2) { | ||
@@ -131,2 +159,3 @@ super.startObject(boundingBox); | ||
const attrs = loaderData[svgAttributesDataKey] as SVGLoaderUnknownAttribute[]|undefined; | ||
const styleAttrs = loaderData[svgStyleAttributesDataKey] as SVGLoaderUnknownStyleAttribute[]|undefined; | ||
@@ -138,2 +167,8 @@ if (attrs) { | ||
} | ||
if (styleAttrs) { | ||
for (const attr of styleAttrs) { | ||
elem.style.setProperty(attr.key, attr.value, attr.priority); | ||
} | ||
} | ||
} | ||
@@ -140,0 +175,0 @@ } |
@@ -5,5 +5,8 @@ import Color4 from './Color4'; | ||
import SVGGlobalAttributesObject from './components/SVGGlobalAttributesObject'; | ||
import Text, { TextStyle } from './components/Text'; | ||
import UnknownSVGObject from './components/UnknownSVGObject'; | ||
import Mat33 from './geometry/Mat33'; | ||
import Path from './geometry/Path'; | ||
import Rect2 from './geometry/Rect2'; | ||
import { Vec2 } from './geometry/Vec2'; | ||
import { RenderablePathSpec, RenderingStyle } from './rendering/renderers/AbstractRenderer'; | ||
@@ -19,2 +22,3 @@ import { ComponentAddedListener, ImageLoader, OnDetermineExportRectListener, OnProgressListener } from './types'; | ||
export const svgAttributesDataKey = 'svgAttrs'; | ||
export const svgStyleAttributesDataKey = 'svgStyleAttrs'; | ||
@@ -24,2 +28,5 @@ // [key, value] | ||
// [key, value, priority] | ||
export type SVGLoaderUnknownStyleAttribute = { key: string, value: string, priority?: string }; | ||
export default class SVGLoader implements ImageLoader { | ||
@@ -103,6 +110,7 @@ private onAddComponent: ComponentAddedListener|null = null; | ||
node: SVGElement, | ||
supportedAttrs: Set<string> | ||
supportedAttrs: Set<string>, | ||
supportedStyleAttrs?: Set<string> | ||
) { | ||
for (const attr of node.getAttributeNames()) { | ||
if (supportedAttrs.has(attr)) { | ||
if (supportedAttrs.has(attr) || (attr === 'style' && supportedStyleAttrs)) { | ||
continue; | ||
@@ -115,2 +123,23 @@ } | ||
} | ||
if (supportedStyleAttrs) { | ||
for (const attr of node.style) { | ||
if (attr === '' || !attr) { | ||
continue; | ||
} | ||
if (supportedStyleAttrs.has(attr)) { | ||
continue; | ||
} | ||
// TODO: Do we need special logic for !important properties? | ||
elem.attachLoadSaveData(svgStyleAttributesDataKey, | ||
{ | ||
key: attr, | ||
value: node.style.getPropertyValue(attr), | ||
priority: node.style.getPropertyPriority(attr) | ||
} as SVGLoaderUnknownStyleAttribute | ||
); | ||
} | ||
} | ||
} | ||
@@ -123,5 +152,10 @@ | ||
const strokeData = this.strokeDataFromElem(node); | ||
elem = new Stroke(strokeData); | ||
const supportedStyleAttrs = [ 'stroke', 'fill', 'stroke-width' ]; | ||
this.attachUnrecognisedAttrs( | ||
elem, node, new Set([ 'stroke', 'fill', 'stroke-width', 'd' ]), | ||
elem, node, | ||
new Set([ ...supportedStyleAttrs, 'd' ]), | ||
new Set(supportedStyleAttrs) | ||
); | ||
@@ -140,2 +174,76 @@ } catch (e) { | ||
private makeText(elem: SVGTextElement|SVGTSpanElement): Text { | ||
const contentList: Array<Text|string> = []; | ||
for (const child of elem.childNodes) { | ||
if (child.nodeType === Node.TEXT_NODE) { | ||
contentList.push(child.nodeValue ?? ''); | ||
} else if (child.nodeType === Node.ELEMENT_NODE) { | ||
const subElem = child as SVGElement; | ||
if (subElem.tagName.toLowerCase() === 'tspan') { | ||
contentList.push(this.makeText(subElem as SVGTSpanElement)); | ||
} else { | ||
throw new Error(`Unrecognized text child element: ${subElem}`); | ||
} | ||
} else { | ||
throw new Error(`Unrecognized text child node: ${child}.`); | ||
} | ||
} | ||
// Compute styles. | ||
const computedStyles = window.getComputedStyle(elem); | ||
const fontSizeMatch = /^([-0-9.e]+)px/i.exec(computedStyles.fontSize); | ||
const supportedStyleAttrs = [ | ||
'fontFamily', | ||
'fill', | ||
'transform' | ||
]; | ||
let fontSize = 12; | ||
if (fontSizeMatch) { | ||
supportedStyleAttrs.push('fontSize'); | ||
fontSize = parseFloat(fontSizeMatch[1]); | ||
} | ||
const style: TextStyle = { | ||
size: fontSize, | ||
fontFamily: computedStyles.fontFamily || 'sans', | ||
renderingStyle: { | ||
fill: Color4.fromString(computedStyles.fill) | ||
}, | ||
}; | ||
// Compute transform matrix | ||
let transform = Mat33.fromCSSMatrix(computedStyles.transform); | ||
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 result = new Text(contentList, transform, style); | ||
this.attachUnrecognisedAttrs( | ||
result, | ||
elem, | ||
new Set(supportedAttrs), | ||
new Set(supportedStyleAttrs) | ||
); | ||
return result; | ||
} | ||
private addText(elem: SVGTextElement|SVGTSpanElement) { | ||
try { | ||
const textElem = this.makeText(elem); | ||
this.onAddComponent?.(textElem); | ||
} catch (e) { | ||
console.error('Invalid text object in node', elem, '. Adding as an unknown object. Error:', e); | ||
this.addUnknownNode(elem); | ||
} | ||
} | ||
private addUnknownNode(node: SVGElement) { | ||
@@ -152,3 +260,3 @@ const component = new UnknownSVGObject(node); | ||
const components = viewBoxAttr.split(/[ \t,]/); | ||
const components = viewBoxAttr.split(/[ \t\n,]+/); | ||
const x = parseFloat(components[0]); | ||
@@ -160,2 +268,3 @@ const y = parseFloat(components[1]); | ||
if (isNaN(x) || isNaN(y) || isNaN(width) || isNaN(height)) { | ||
console.warn(`node ${node} has an unparsable viewbox. Viewbox: ${viewBoxAttr}. Match: ${components}.`); | ||
return; | ||
@@ -174,2 +283,3 @@ } | ||
this.totalToProcess += node.childElementCount; | ||
let visitChildren = true; | ||
@@ -183,2 +293,6 @@ switch (node.tagName.toLowerCase()) { | ||
break; | ||
case 'text': | ||
this.addText(node as SVGTextElement); | ||
visitChildren = false; | ||
break; | ||
case 'svg': | ||
@@ -198,4 +312,6 @@ this.updateViewBox(node as SVGSVGElement); | ||
for (const child of node.children) { | ||
await this.visit(child); | ||
if (visitChildren) { | ||
for (const child of node.children) { | ||
await this.visit(child); | ||
} | ||
} | ||
@@ -280,4 +396,6 @@ | ||
svgElem.innerHTML = text; | ||
sandboxDoc.body.appendChild(svgElem); | ||
return new SVGLoader(svgElem, () => { | ||
svgElem.remove(); | ||
sandbox.remove(); | ||
@@ -284,0 +402,0 @@ }); |
@@ -18,6 +18,3 @@ export const loadExpectExtensions = () => { | ||
message: () => { | ||
if (pass) { | ||
return `Expected ${expected} not to .eq ${actual}. Options(${eqArgs})`; | ||
} | ||
return `Expected ${expected} to .eq ${actual}. Options(${eqArgs})`; | ||
return `Expected ${pass ? '!' : ''}(${actual}).eq(${expected}). Options(${eqArgs})`; | ||
}, | ||
@@ -24,0 +21,0 @@ }; |
@@ -18,6 +18,7 @@ import Editor from '../Editor'; | ||
import { ActionButtonIcon } from './types'; | ||
import { makeDropdownIcon, makeEraserIcon, makeIconFromFactory, makePenIcon, makeRedoIcon, makeSelectionIcon, makeHandToolIcon, makeUndoIcon } from './icons'; | ||
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 TextTool from '../tools/TextTool'; | ||
@@ -407,2 +408,56 @@ | ||
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 colorRow = document.createElement('div'); | ||
const colorInput = document.createElement('input'); | ||
const colorLabel = document.createElement('label'); | ||
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); | ||
colorInput.oninput = () => { | ||
this.tool.setColor(Color4.fromString(colorInput.value)); | ||
}; | ||
colorRow.appendChild(colorLabel); | ||
colorRow.appendChild(colorInput); | ||
this.updateDropdownInputs = () => { | ||
const style = this.tool.getTextStyle(); | ||
colorInput.value = style.renderingStyle.fill.toHexString(); | ||
}; | ||
this.updateDropdownInputs(); | ||
dropdown.appendChild(colorRow); | ||
return true; | ||
} | ||
} | ||
class PenWidget extends ToolbarWidget { | ||
@@ -707,2 +762,10 @@ private updateInputs: ()=> void = () => {}; | ||
for (const tool of toolController.getMatchingTools(ToolType.Text)) { | ||
if (!(tool instanceof TextTool)) { | ||
throw new Error('All text tools must have kind === ToolType.Text'); | ||
} | ||
(new TextToolWidget(this.editor, tool, this.localizationTable)).addTo(this.container); | ||
} | ||
for (const tool of toolController.getMatchingTools(ToolType.PanZoom)) { | ||
@@ -709,0 +772,0 @@ if (!(tool instanceof PanZoom)) { |
import { ComponentBuilderFactory } from '../components/builders/types'; | ||
import { TextStyle } from '../components/Text'; | ||
import EventDispatcher from '../EventDispatcher'; | ||
@@ -129,2 +130,24 @@ import { Vec2 } from '../geometry/Vec2'; | ||
export const makeTextIcon = (textStyle: TextStyle) => { | ||
const icon = document.createElementNS(svgNamespace, 'svg'); | ||
icon.setAttribute('viewBox', '0 0 100 100'); | ||
const textNode = document.createElementNS(svgNamespace, 'text'); | ||
textNode.appendChild(document.createTextNode('T')); | ||
textNode.style.fontFamily = textStyle.fontFamily; | ||
textNode.style.fontWeight = textStyle.fontWeight ?? ''; | ||
textNode.style.fontVariant = textStyle.fontVariant ?? ''; | ||
textNode.style.fill = textStyle.renderingStyle.fill.toHexString(); | ||
textNode.style.textAnchor = 'middle'; | ||
textNode.setAttribute('x', '50'); | ||
textNode.setAttribute('y', '75'); | ||
textNode.style.fontSize = '65px'; | ||
icon.appendChild(textNode); | ||
return icon; | ||
}; | ||
export const makePenIcon = (tipThickness: number, color: string) => { | ||
@@ -131,0 +154,0 @@ const icon = document.createElementNS(svgNamespace, 'svg'); |
export interface ToolLocalization { | ||
RightClickDragPanTool: string; | ||
rightClickDragPanTool: string; | ||
penTool: (penId: number)=>string; | ||
@@ -10,2 +10,4 @@ selectionTool: string; | ||
undoRedoTool: string; | ||
textTool: string; | ||
enterTextToInsert: string; | ||
@@ -23,6 +25,9 @@ toolEnabledAnnouncement: (toolName: string) => string; | ||
undoRedoTool: 'Undo/Redo', | ||
RightClickDragPanTool: 'Right-click drag', | ||
rightClickDragPanTool: 'Right-click drag', | ||
textTool: 'Text', | ||
enterTextToInsert: 'Text to insert', | ||
toolEnabledAnnouncement: (toolName) => `${toolName} enabled`, | ||
toolDisabledAnnouncement: (toolName) => `${toolName} disabled`, | ||
}; |
@@ -497,3 +497,3 @@ import Command from '../commands/Command'; | ||
const selectionRect = this.selectionBox.region; | ||
this.editor.viewport.zoomTo(selectionRect).apply(this.editor); | ||
this.editor.viewport.zoomTo(selectionRect, false).apply(this.editor); | ||
} | ||
@@ -500,0 +500,0 @@ } |
@@ -12,2 +12,3 @@ import { InputEvtType, InputEvt, EditorEventType } from '../types'; | ||
import UndoRedoShortcut from './UndoRedoShortcut'; | ||
import TextTool from './TextTool'; | ||
@@ -19,2 +20,3 @@ export enum ToolType { | ||
PanZoom, | ||
Text, | ||
UndoRedoShortcut, | ||
@@ -41,2 +43,4 @@ } | ||
new Pen(editor, localization.penTool(3), { color: Color4.ofRGBA(1, 1, 0, 0.5), thickness: 64 }), | ||
new TextTool(editor, localization.textTool, localization), | ||
]; | ||
@@ -43,0 +47,0 @@ this.tools = [ |
@@ -173,3 +173,3 @@ import Command from './commands/Command'; | ||
// Returns null if no transformation is necessary | ||
public zoomTo(toMakeVisible: Rect2): Command { | ||
public zoomTo(toMakeVisible: Rect2, allowZoomIn: boolean = true, allowZoomOut: boolean = true): Command { | ||
let transform = Mat33.identity; | ||
@@ -199,3 +199,3 @@ | ||
if (largerThanTarget || muchSmallerThanTarget) { | ||
if ((largerThanTarget && allowZoomOut) || (muchSmallerThanTarget && allowZoomIn)) { | ||
// If larger than the target, ensure that the longest axis is visible. | ||
@@ -202,0 +202,0 @@ // If smaller, shrink the visible rectangle as much as possible |
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
825344
2678
213
17297
19
119
0
3
0