Comparing version 0.6.0 to 0.7.0
@@ -49,3 +49,3 @@ --- | ||
colorChangedAnnouncement: t=>`Color changed to ${t}`, | ||
penTool: t=>`Pen ${t}`, | ||
penTool: penNumber=>`Pen ${penNumber}`, | ||
selectionTool: "Selection", | ||
@@ -52,0 +52,0 @@ eraserTool: "Eraser", |
@@ -0,1 +1,9 @@ | ||
# 0.7.0 | ||
* Text tool | ||
* Edit existing text. | ||
* Shift+enter to insert a new line. | ||
* Preserve multi-line text when loading/saving | ||
* Pen | ||
* Decrease smoothing amount for thick strokes. | ||
# 0.6.0 | ||
@@ -2,0 +10,0 @@ * Selection tool: |
import AbstractRenderer from '../../rendering/renderers/AbstractRenderer'; | ||
import Rect2 from '../../math/Rect2'; | ||
import Stroke from '../Stroke'; | ||
import Viewport from '../../Viewport'; | ||
import { StrokeDataPoint } from '../../types'; | ||
@@ -11,2 +12,3 @@ import { ComponentBuilder, ComponentBuilderFactory } from './types'; | ||
private maxFitAllowed; | ||
private viewport; | ||
private isFirstSegment; | ||
@@ -17,2 +19,5 @@ private pathStartConnector; | ||
private lowerSegments; | ||
private lastUpperBezier; | ||
private lastLowerBezier; | ||
private parts; | ||
private buffer; | ||
@@ -26,6 +31,7 @@ private lastPoint; | ||
private bbox; | ||
constructor(startPoint: StrokeDataPoint, minFitAllowed: number, maxFitAllowed: number); | ||
constructor(startPoint: StrokeDataPoint, minFitAllowed: number, maxFitAllowed: number, viewport: Viewport); | ||
getBBox(): Rect2; | ||
private getRenderingStyle; | ||
private previewPath; | ||
private previewCurrentPath; | ||
private previewFullPath; | ||
private previewStroke; | ||
@@ -35,2 +41,3 @@ preview(renderer: AbstractRenderer): void; | ||
private roundPoint; | ||
private shouldStartNewSegment; | ||
private approxCurrentCurveLength; | ||
@@ -37,0 +44,0 @@ private finalizeCurrentCurve; |
@@ -9,7 +9,7 @@ import { Bezier } from 'bezier-js'; | ||
export const makeFreehandLineBuilder = (initialPoint, viewport) => { | ||
// Don't smooth if input is more than ± 7 pixels from the true curve, do smooth if | ||
// Don't smooth if input is more than ± 3 pixels from the true curve, do smooth if | ||
// less than ±1 px from the curve. | ||
const maxSmoothingDist = viewport.getSizeOfPixelOnCanvas() * 7; | ||
const maxSmoothingDist = viewport.getSizeOfPixelOnCanvas() * 3; | ||
const minSmoothingDist = viewport.getSizeOfPixelOnCanvas(); | ||
return new FreehandLineBuilder(initialPoint, minSmoothingDist, maxSmoothingDist); | ||
return new FreehandLineBuilder(initialPoint, minSmoothingDist, maxSmoothingDist, viewport); | ||
}; | ||
@@ -23,9 +23,13 @@ // Handles stroke smoothing and creates Strokes from user/stylus input. | ||
// [maxFitAllowed]. | ||
minFitAllowed, maxFitAllowed) { | ||
minFitAllowed, maxFitAllowed, viewport) { | ||
this.startPoint = startPoint; | ||
this.minFitAllowed = minFitAllowed; | ||
this.maxFitAllowed = maxFitAllowed; | ||
this.viewport = viewport; | ||
this.isFirstSegment = true; | ||
this.pathStartConnector = null; | ||
this.mostRecentConnector = null; | ||
this.lastUpperBezier = null; | ||
this.lastLowerBezier = null; | ||
this.parts = []; | ||
this.lastExitingVec = null; | ||
@@ -51,12 +55,12 @@ this.currentCurve = null; | ||
} | ||
previewPath() { | ||
previewCurrentPath() { | ||
var _a; | ||
let upperPath; | ||
let lowerPath; | ||
const upperPath = this.upperSegments.slice(); | ||
const lowerPath = this.lowerSegments.slice(); | ||
let lowerToUpperCap; | ||
let pathStartConnector; | ||
if (this.currentCurve) { | ||
const { upperCurve, lowerToUpperConnector, upperToLowerConnector, lowerCurve } = this.currentSegmentToPath(); | ||
upperPath = this.upperSegments.concat(upperCurve); | ||
lowerPath = this.lowerSegments.concat(lowerCurve); | ||
const { upperCurveCommand, lowerToUpperConnector, upperToLowerConnector, lowerCurveCommand } = this.currentSegmentToPath(); | ||
upperPath.push(upperCurveCommand); | ||
lowerPath.push(lowerCurveCommand); | ||
lowerToUpperCap = lowerToUpperConnector; | ||
@@ -69,8 +73,13 @@ pathStartConnector = (_a = this.pathStartConnector) !== null && _a !== void 0 ? _a : upperToLowerConnector; | ||
} | ||
upperPath = this.upperSegments.slice(); | ||
lowerPath = this.lowerSegments.slice(); | ||
lowerToUpperCap = this.mostRecentConnector; | ||
pathStartConnector = this.pathStartConnector; | ||
} | ||
const startPoint = lowerPath[lowerPath.length - 1].endPoint; | ||
let startPoint; | ||
const lastLowerSegment = lowerPath[lowerPath.length - 1]; | ||
if (lastLowerSegment.kind === PathCommandType.LineTo || lastLowerSegment.kind === PathCommandType.MoveTo) { | ||
startPoint = lastLowerSegment.point; | ||
} | ||
else { | ||
startPoint = lastLowerSegment.endPoint; | ||
} | ||
return { | ||
@@ -110,6 +119,13 @@ // Start at the end of the lower curve: | ||
} | ||
previewFullPath() { | ||
const preview = this.previewCurrentPath(); | ||
if (preview) { | ||
return [...this.parts, preview]; | ||
} | ||
return null; | ||
} | ||
previewStroke() { | ||
const pathPreview = this.previewPath(); | ||
const pathPreview = this.previewFullPath(); | ||
if (pathPreview) { | ||
return new Stroke([pathPreview]); | ||
return new Stroke(pathPreview); | ||
} | ||
@@ -119,9 +135,14 @@ return null; | ||
preview(renderer) { | ||
const path = this.previewPath(); | ||
if (path) { | ||
renderer.drawPath(path); | ||
const paths = this.previewFullPath(); | ||
if (paths) { | ||
const approxBBox = this.viewport.visibleRect; | ||
renderer.startObject(approxBBox); | ||
for (const path of paths) { | ||
renderer.drawPath(path); | ||
} | ||
renderer.endObject(); | ||
} | ||
} | ||
build() { | ||
if (this.lastPoint && (this.lowerSegments.length === 0 || this.approxCurrentCurveLength() > this.curveStartWidth * 2)) { | ||
if (this.lastPoint) { | ||
this.finalizeCurrentCurve(); | ||
@@ -138,2 +159,57 @@ } | ||
} | ||
// Returns true if, due to overlap with previous segments, a new RenderablePathSpec should be created. | ||
shouldStartNewSegment(lowerCurve, upperCurve) { | ||
if (!this.lastLowerBezier || !this.lastUpperBezier) { | ||
return false; | ||
} | ||
const getIntersection = (curve1, curve2) => { | ||
const intersection = curve1.intersects(curve2); | ||
if (!intersection || intersection.length === 0) { | ||
return null; | ||
} | ||
// From http://pomax.github.io/bezierjs/#intersect-curve, | ||
// .intersects returns an array of 't1/t2' pairs, where curve1.at(t1) gives the point. | ||
const firstTPair = intersection[0]; | ||
const match = /^([-0-9.eE]+)\/([-0-9.eE]+)$/.exec(firstTPair); | ||
if (!match) { | ||
throw new Error(`Incorrect format returned by .intersects: ${intersection} should be array of "number/number"!`); | ||
} | ||
const t = parseFloat(match[1]); | ||
return Vec2.ofXY(curve1.get(t)); | ||
}; | ||
const getExitDirection = (curve) => { | ||
return Vec2.ofXY(curve.points[2]).minus(Vec2.ofXY(curve.points[1])).normalized(); | ||
}; | ||
const getEnterDirection = (curve) => { | ||
return Vec2.ofXY(curve.points[1]).minus(Vec2.ofXY(curve.points[0])).normalized(); | ||
}; | ||
// Prevent | ||
// / | ||
// / / | ||
// / / /| | ||
// / / | | ||
// / | | ||
// where the next stroke and the previous stroke are in different directions. | ||
// | ||
// Are the exit/enter directions of the previous and current curves in different enough directions? | ||
if (getEnterDirection(upperCurve).dot(getExitDirection(this.lastUpperBezier)) < 0.3 | ||
|| getEnterDirection(lowerCurve).dot(getExitDirection(this.lastLowerBezier)) < 0.3 | ||
// Also handle if the curves exit/enter directions differ | ||
|| getEnterDirection(upperCurve).dot(getExitDirection(upperCurve)) < 0 | ||
|| getEnterDirection(lowerCurve).dot(getExitDirection(lowerCurve)) < 0) { | ||
return true; | ||
} | ||
// Check whether the lower curve intersects the other wall: | ||
// / / ← lower | ||
// / / / | ||
// / / / | ||
// // | ||
// / / | ||
const lowerIntersection = getIntersection(lowerCurve, this.lastUpperBezier); | ||
const upperIntersection = getIntersection(upperCurve, this.lastLowerBezier); | ||
if (lowerIntersection || upperIntersection) { | ||
return true; | ||
} | ||
return false; | ||
} | ||
// Returns the distance between the start, control, and end points of the curve. | ||
@@ -195,4 +271,13 @@ approxCurrentCurveLength() { | ||
} | ||
const { upperCurve, lowerToUpperConnector, upperToLowerConnector, lowerCurve } = this.currentSegmentToPath(); | ||
if (this.isFirstSegment) { | ||
const { upperCurveCommand, lowerToUpperConnector, upperToLowerConnector, lowerCurveCommand, lowerCurve, upperCurve, } = this.currentSegmentToPath(); | ||
const shouldStartNew = this.shouldStartNewSegment(lowerCurve, upperCurve); | ||
if (shouldStartNew) { | ||
const part = this.previewCurrentPath(); | ||
if (part) { | ||
this.parts.push(part); | ||
this.upperSegments = []; | ||
this.lowerSegments = []; | ||
} | ||
} | ||
if (this.isFirstSegment || shouldStartNew) { | ||
// We draw the upper path (reversed), then the lower path, so we need the | ||
@@ -206,4 +291,6 @@ // upperToLowerConnector to join the two paths. | ||
this.mostRecentConnector = lowerToUpperConnector; | ||
this.upperSegments.push(upperCurve); | ||
this.lowerSegments.push(lowerCurve); | ||
this.lowerSegments.push(lowerCurveCommand); | ||
this.upperSegments.push(upperCurveCommand); | ||
this.lastLowerBezier = lowerCurve; | ||
this.lastUpperBezier = upperCurve; | ||
const lastPoint = this.buffer[this.buffer.length - 1]; | ||
@@ -250,2 +337,3 @@ this.lastExitingVec = Vec2.ofXY(this.currentCurve.points[2]).minus(Vec2.ofXY(this.currentCurve.points[1])); | ||
// Each starts at startPt ± startVec | ||
const lowerCurveStartPoint = this.roundPoint(startPt.plus(startVec)); | ||
const lowerCurveControlPoint = this.roundPoint(controlPoint.plus(halfVec)); | ||
@@ -255,3 +343,4 @@ const lowerCurveEndPoint = this.roundPoint(endPt.plus(endVec)); | ||
const upperCurveStartPoint = this.roundPoint(endPt.minus(endVec)); | ||
const lowerCurve = { | ||
const upperCurveEndPoint = this.roundPoint(startPt.minus(startVec)); | ||
const lowerCurveCommand = { | ||
kind: PathCommandType.QuadraticBezierTo, | ||
@@ -264,3 +353,3 @@ controlPoint: lowerCurveControlPoint, | ||
kind: PathCommandType.LineTo, | ||
point: this.roundPoint(startPt.plus(startVec)), | ||
point: lowerCurveStartPoint, | ||
}; | ||
@@ -272,8 +361,13 @@ // From the end of lowerCurve to the start of upperCurve: | ||
}; | ||
const upperCurve = { | ||
const upperCurveCommand = { | ||
kind: PathCommandType.QuadraticBezierTo, | ||
controlPoint: upperCurveControlPoint, | ||
endPoint: this.roundPoint(startPt.minus(startVec)), | ||
endPoint: upperCurveEndPoint, | ||
}; | ||
return { upperCurve, upperToLowerConnector, lowerToUpperConnector, lowerCurve }; | ||
const upperCurve = new Bezier(upperCurveStartPoint, upperCurveControlPoint, upperCurveEndPoint); | ||
const lowerCurve = new Bezier(lowerCurveStartPoint, lowerCurveControlPoint, lowerCurveEndPoint); | ||
return { | ||
upperCurveCommand, upperToLowerConnector, lowerToUpperConnector, lowerCurveCommand, | ||
upperCurve, lowerCurve, | ||
}; | ||
} | ||
@@ -315,2 +409,6 @@ // Compute the direction of the velocity at the end of this.buffer | ||
this.curveEndWidth = pointRadius; | ||
if (this.isFirstSegment) { | ||
// The start of a curve often lacks accurate pressure information. Update it. | ||
this.curveStartWidth = (this.curveStartWidth + pointRadius) / 2; | ||
} | ||
// recompute bbox | ||
@@ -327,2 +425,3 @@ this.bbox = this.bbox.grownToPoint(newPoint.pos, pointRadius); | ||
} | ||
// If there isn't an entering vector (e.g. because this.isFirstCurve), approximate it. | ||
let enteringVec = this.lastExitingVec; | ||
@@ -329,0 +428,0 @@ if (!enteringVec) { |
@@ -6,4 +6,4 @@ export * from './builders/types'; | ||
import Stroke from './Stroke'; | ||
import Text from './Text'; | ||
import TextComponent from './Text'; | ||
import ImageComponent from './ImageComponent'; | ||
export { Stroke, Text, Text as TextComponent, Stroke as StrokeComponent, ImageComponent, }; | ||
export { Stroke, TextComponent as Text, TextComponent as TextComponent, Stroke as StrokeComponent, ImageComponent, }; |
@@ -6,4 +6,4 @@ export * from './builders/types'; | ||
import Stroke from './Stroke'; | ||
import Text from './Text'; | ||
import TextComponent from './Text'; | ||
import ImageComponent from './ImageComponent'; | ||
export { Stroke, Text, Text as TextComponent, Stroke as StrokeComponent, ImageComponent, }; | ||
export { Stroke, TextComponent as Text, TextComponent as TextComponent, Stroke as StrokeComponent, ImageComponent, }; |
@@ -6,2 +6,3 @@ import Path from '../math/Path'; | ||
export default class Stroke extends AbstractComponent { | ||
// Creates a `Stroke` from the given `parts`. | ||
constructor(parts) { | ||
@@ -8,0 +9,0 @@ var _a; |
@@ -15,8 +15,8 @@ import LineSegment2 from '../math/LineSegment2'; | ||
} | ||
export default class Text extends AbstractComponent { | ||
protected readonly textObjects: Array<string | Text>; | ||
export default class TextComponent extends AbstractComponent { | ||
protected readonly textObjects: Array<string | TextComponent>; | ||
private transform; | ||
private readonly style; | ||
private style; | ||
protected contentBBox: Rect2; | ||
constructor(textObjects: Array<string | Text>, transform: Mat33, style: TextStyle); | ||
constructor(textObjects: Array<string | TextComponent>, transform: Mat33, style: TextStyle); | ||
static applyTextStyles(ctx: CanvasRenderingContext2D, style: TextStyle): void; | ||
@@ -28,4 +28,8 @@ private static textMeasuringCtx; | ||
private recomputeBBox; | ||
private renderInternal; | ||
render(canvas: AbstractRenderer, _visibleRect?: Rect2): void; | ||
intersects(lineSegment: LineSegment2): boolean; | ||
getBaselinePos(): import("../lib").Vec3; | ||
getTextStyle(): TextStyle; | ||
getTransform(): Mat33; | ||
protected applyTransformation(affineTransfm: Mat33): void; | ||
@@ -36,3 +40,4 @@ protected createClone(): AbstractComponent; | ||
protected serializeToJSON(): Record<string, any>; | ||
static deserializeFromString(json: any): Text; | ||
static deserializeFromString(json: any): TextComponent; | ||
static fromLines(lines: string[], transform: Mat33, style: TextStyle): AbstractComponent; | ||
} |
import LineSegment2 from '../math/LineSegment2'; | ||
import Mat33 from '../math/Mat33'; | ||
import Rect2 from '../math/Rect2'; | ||
import { Vec2 } from '../math/Vec2'; | ||
import { styleFromJSON, styleToJSON } from '../rendering/RenderingStyle'; | ||
import AbstractComponent from './AbstractComponent'; | ||
const componentTypeId = 'text'; | ||
export default class Text extends AbstractComponent { | ||
export default class TextComponent extends AbstractComponent { | ||
constructor(textObjects, transform, style) { | ||
@@ -14,2 +15,8 @@ super(componentTypeId); | ||
this.recomputeBBox(); | ||
// If this has no direct children, choose a style representative of this' content | ||
// (useful for estimating the style of the TextComponent). | ||
const hasDirectContent = textObjects.some(obj => typeof obj === 'string'); | ||
if (!hasDirectContent && textObjects.length > 0) { | ||
this.style = textObjects[0].getTextStyle(); | ||
} | ||
} | ||
@@ -39,8 +46,8 @@ static applyTextStyles(ctx, style) { | ||
var _a, _b; | ||
(_a = Text.textMeasuringCtx) !== null && _a !== void 0 ? _a : (Text.textMeasuringCtx = (_b = document.createElement('canvas').getContext('2d')) !== null && _b !== void 0 ? _b : null); | ||
if (!Text.textMeasuringCtx) { | ||
(_a = TextComponent.textMeasuringCtx) !== null && _a !== void 0 ? _a : (TextComponent.textMeasuringCtx = (_b = document.createElement('canvas').getContext('2d')) !== null && _b !== void 0 ? _b : null); | ||
if (!TextComponent.textMeasuringCtx) { | ||
return this.estimateTextDimens(text, style); | ||
} | ||
const ctx = Text.textMeasuringCtx; | ||
Text.applyTextStyles(ctx, style); | ||
const ctx = TextComponent.textMeasuringCtx; | ||
TextComponent.applyTextStyles(ctx, style); | ||
const measure = ctx.measureText(text); | ||
@@ -54,3 +61,3 @@ // Text is drawn with (0,0) at the bottom left of the baseline. | ||
if (typeof part === 'string') { | ||
const textBBox = Text.getTextDimens(part, this.style); | ||
const textBBox = TextComponent.getTextDimens(part, this.style); | ||
return textBBox.transformedBoundingBox(this.transform); | ||
@@ -72,5 +79,4 @@ } | ||
} | ||
render(canvas, _visibleRect) { | ||
renderInternal(canvas) { | ||
const cursor = this.transform; | ||
canvas.startObject(this.contentBBox); | ||
for (const textObject of this.textObjects) { | ||
@@ -82,6 +88,10 @@ if (typeof textObject === 'string') { | ||
canvas.pushTransform(cursor); | ||
textObject.render(canvas); | ||
textObject.renderInternal(canvas); | ||
canvas.popTransform(); | ||
} | ||
} | ||
} | ||
render(canvas, _visibleRect) { | ||
canvas.startObject(this.contentBBox); | ||
this.renderInternal(canvas); | ||
canvas.endObject(this.getLoadSaveData()); | ||
@@ -97,3 +107,3 @@ } | ||
if (typeof subObject === 'string') { | ||
const textBBox = Text.getTextDimens(subObject, this.style); | ||
const textBBox = TextComponent.getTextDimens(subObject, this.style); | ||
// TODO: Use a better intersection check. Perhaps draw the text onto a CanvasElement and | ||
@@ -113,2 +123,11 @@ // use pixel-testing to check for intersection with its contour. | ||
} | ||
getBaselinePos() { | ||
return this.transform.transformVec2(Vec2.zero); | ||
} | ||
getTextStyle() { | ||
return this.style; | ||
} | ||
getTransform() { | ||
return this.transform; | ||
} | ||
applyTransformation(affineTransfm) { | ||
@@ -119,3 +138,3 @@ this.transform = affineTransfm.rightMul(this.transform); | ||
createClone() { | ||
return new Text(this.textObjects, this.transform, this.style); | ||
return new TextComponent(this.textObjects, this.transform, this.style); | ||
} | ||
@@ -170,3 +189,3 @@ getText() { | ||
} | ||
return Text.deserializeFromString(data.json); | ||
return TextComponent.deserializeFromString(data.json); | ||
}); | ||
@@ -179,6 +198,21 @@ json.transform = json.transform.filter((elem) => typeof elem === 'number'); | ||
const transform = new Mat33(...transformData); | ||
return new Text(textObjects, transform, style); | ||
return new TextComponent(textObjects, transform, style); | ||
} | ||
static fromLines(lines, transform, style) { | ||
let lastComponent = null; | ||
const components = []; | ||
for (const line of lines) { | ||
let position = Vec2.zero; | ||
if (lastComponent) { | ||
const lineMargin = Math.floor(style.size); | ||
position = lastComponent.getBBox().bottomLeft.plus(Vec2.unitY.times(lineMargin)); | ||
} | ||
const component = new TextComponent([line], Mat33.translation(position), style); | ||
components.push(component); | ||
lastComponent = component; | ||
} | ||
return new TextComponent(components, transform, style); | ||
} | ||
} | ||
Text.textMeasuringCtx = null; | ||
AbstractComponent.registerComponent(componentTypeId, (data) => Text.deserializeFromString(data)); | ||
TextComponent.textMeasuringCtx = null; | ||
AbstractComponent.registerComponent(componentTypeId, (data) => TextComponent.deserializeFromString(data)); |
@@ -96,3 +96,2 @@ /** | ||
* Global event dispatcher/subscriber. | ||
* @see {@link types.EditorEventType} | ||
*/ | ||
@@ -99,0 +98,0 @@ readonly notifier: EditorNotifier; |
@@ -45,2 +45,3 @@ /** | ||
import IconProvider from './toolbar/IconProvider'; | ||
import { toRoundedString } from './math/rounding'; | ||
// { @inheritDoc Editor! } | ||
@@ -642,5 +643,5 @@ export class Editor { | ||
const rect = importExportViewport.visibleRect; | ||
result.setAttribute('viewBox', `${rect.x} ${rect.y} ${rect.w} ${rect.h}`); | ||
result.setAttribute('width', `${rect.w}`); | ||
result.setAttribute('height', `${rect.h}`); | ||
result.setAttribute('viewBox', [rect.x, rect.y, rect.w, rect.h].map(part => toRoundedString(part)).join(' ')); | ||
result.setAttribute('width', toRoundedString(rect.w)); | ||
result.setAttribute('height', toRoundedString(rect.h)); | ||
// Ensure the image can be identified as an SVG if downloaded. | ||
@@ -647,0 +648,0 @@ // See https://jwatt.org/svg/authoring/ |
import Color4 from '../../Color4'; | ||
import Text from '../../components/Text'; | ||
import TextComponent from '../../components/Text'; | ||
import { Vec2 } from '../../math/Vec2'; | ||
@@ -116,3 +116,3 @@ import AbstractRenderer from './AbstractRenderer'; | ||
this.transformBy(transform); | ||
Text.applyTextStyles(this.ctx, style); | ||
TextComponent.applyTextStyles(this.ctx, style); | ||
if (style.renderingStyle.fill.a !== 0) { | ||
@@ -119,0 +119,0 @@ this.ctx.fillStyle = style.renderingStyle.fill.toHexString(); |
@@ -23,2 +23,4 @@ import { LoadSaveDataTable } from '../../components/AbstractComponent'; | ||
private transformFrom; | ||
private textContainer; | ||
private textContainerTransform; | ||
drawText(text: string, transform: Mat33, style: TextStyle): void; | ||
@@ -25,0 +27,0 @@ drawImage(image: RenderableImage): void; |
@@ -18,2 +18,4 @@ import Mat33 from '../../math/Mat33'; | ||
this.overwrittenAttrs = {}; | ||
this.textContainer = null; | ||
this.textContainerTransform = null; | ||
this.clear(); | ||
@@ -86,11 +88,16 @@ } | ||
// Apply [elemTransform] to [elem]. | ||
transformFrom(elemTransform, elem) { | ||
let transform = this.getCanvasToScreenTransform().rightMul(elemTransform); | ||
transformFrom(elemTransform, elem, inCanvasSpace = false) { | ||
let transform = !inCanvasSpace ? this.getCanvasToScreenTransform().rightMul(elemTransform) : elemTransform; | ||
const translation = transform.transformVec2(Vec2.zero); | ||
transform = transform.rightMul(Mat33.translation(translation.times(-1))); | ||
elem.style.transform = `matrix( | ||
${transform.a1}, ${transform.b1}, | ||
${transform.a2}, ${transform.b2}, | ||
${transform.a3}, ${transform.b3} | ||
)`; | ||
if (!transform.eq(Mat33.identity)) { | ||
elem.style.transform = `matrix( | ||
${transform.a1}, ${transform.b1}, | ||
${transform.a2}, ${transform.b2}, | ||
${transform.a3}, ${transform.b3} | ||
)`; | ||
} | ||
else { | ||
elem.style.transform = ''; | ||
} | ||
elem.setAttribute('x', `${toRoundedString(translation.x)}`); | ||
@@ -100,18 +107,37 @@ 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; | ||
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'; | ||
var _a; | ||
const applyTextStyles = (elem, style) => { | ||
var _a, _b; | ||
elem.style.fontFamily = style.fontFamily; | ||
elem.style.fontVariant = (_a = style.fontVariant) !== null && _a !== void 0 ? _a : ''; | ||
elem.style.fontWeight = (_b = style.fontWeight) !== null && _b !== void 0 ? _b : ''; | ||
elem.style.fontSize = style.size + 'px'; | ||
elem.style.fill = style.renderingStyle.fill.toHexString(); | ||
if (style.renderingStyle.stroke) { | ||
const strokeStyle = style.renderingStyle.stroke; | ||
elem.style.stroke = strokeStyle.color.toHexString(); | ||
elem.style.strokeWidth = strokeStyle.width + 'px'; | ||
} | ||
}; | ||
transform = this.getCanvasToScreenTransform().rightMul(transform); | ||
if (!this.textContainer) { | ||
const container = document.createElementNS(svgNameSpace, 'text'); | ||
container.appendChild(document.createTextNode(text)); | ||
this.transformFrom(transform, container, true); | ||
applyTextStyles(container, style); | ||
this.elem.appendChild(container); | ||
(_a = this.objectElems) === null || _a === void 0 ? void 0 : _a.push(container); | ||
if (this.objectLevel > 0) { | ||
this.textContainer = container; | ||
this.textContainerTransform = transform; | ||
} | ||
} | ||
this.elem.appendChild(textElem); | ||
(_c = this.objectElems) === null || _c === void 0 ? void 0 : _c.push(textElem); | ||
else { | ||
const elem = document.createElementNS(svgNameSpace, 'tspan'); | ||
elem.appendChild(document.createTextNode(text)); | ||
this.textContainer.appendChild(elem); | ||
transform = this.textContainerTransform.inverse().rightMul(transform); | ||
this.transformFrom(transform, elem, true); | ||
applyTextStyles(elem, style); | ||
} | ||
} | ||
@@ -134,2 +160,3 @@ drawImage(image) { | ||
this.lastPathStyle = null; | ||
this.textContainer = null; | ||
this.objectElems = []; | ||
@@ -136,0 +163,0 @@ } |
@@ -14,3 +14,3 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { | ||
import SVGGlobalAttributesObject from './components/SVGGlobalAttributesObject'; | ||
import Text from './components/Text'; | ||
import TextComponent from './components/Text'; | ||
import UnknownSVGObject from './components/UnknownSVGObject'; | ||
@@ -213,3 +213,3 @@ import Mat33 from './math/Mat33'; | ||
const transform = this.getTransform(elem, supportedAttrs, computedStyles); | ||
const result = new Text(contentList, transform, style); | ||
const result = new TextComponent(contentList, transform, style); | ||
this.attachUnrecognisedAttrs(result, elem, new Set(supportedAttrs), new Set(supportedStyleAttrs)); | ||
@@ -216,0 +216,0 @@ return result; |
@@ -5,21 +5,27 @@ import Color4 from '../Color4'; | ||
import Pen from '../tools/Pen'; | ||
declare type IconType = SVGSVGElement | HTMLImageElement; | ||
export default class IconProvider { | ||
makeUndoIcon(): SVGSVGElement; | ||
makeRedoIcon(mirror?: boolean): SVGSVGElement; | ||
makeDropdownIcon(): SVGSVGElement; | ||
makeEraserIcon(): SVGSVGElement; | ||
makeSelectionIcon(): SVGSVGElement; | ||
protected makeIconFromPath(pathData: string, fill?: string, strokeColor?: string, strokeWidth?: string): SVGSVGElement; | ||
makeHandToolIcon(): SVGSVGElement; | ||
makeTouchPanningIcon(): SVGSVGElement; | ||
makeAllDevicePanningIcon(): SVGSVGElement; | ||
makeZoomIcon: () => SVGSVGElement; | ||
makeTextIcon(textStyle: TextStyle): SVGSVGElement; | ||
makePenIcon(tipThickness: number, color: string | Color4): SVGSVGElement; | ||
makeIconFromFactory(pen: Pen, factory: ComponentBuilderFactory): SVGSVGElement; | ||
makePipetteIcon(color?: Color4): SVGSVGElement; | ||
makeResizeViewportIcon(): SVGSVGElement; | ||
makeDuplicateSelectionIcon(): SVGSVGElement; | ||
makeDeleteSelectionIcon(): SVGSVGElement; | ||
makeSaveIcon(): SVGSVGElement; | ||
makeUndoIcon(): IconType; | ||
makeRedoIcon(mirror?: boolean): IconType; | ||
makeDropdownIcon(): IconType; | ||
makeEraserIcon(): IconType; | ||
makeSelectionIcon(): IconType; | ||
/** | ||
* @param pathData - SVG path data (e.g. `m10,10l30,30z`) | ||
* @param fill - A valid CSS color (e.g. `var(--icon-color)` or `#f0f`). This can be `none`. | ||
*/ | ||
protected makeIconFromPath(pathData: string, fill?: string, strokeColor?: string, strokeWidth?: string): IconType; | ||
makeHandToolIcon(): IconType; | ||
makeTouchPanningIcon(): IconType; | ||
makeAllDevicePanningIcon(): IconType; | ||
makeZoomIcon(): IconType; | ||
makeTextIcon(textStyle: TextStyle): IconType; | ||
makePenIcon(tipThickness: number, color: string | Color4): IconType; | ||
makeIconFromFactory(pen: Pen, factory: ComponentBuilderFactory): IconType; | ||
makePipetteIcon(color?: Color4): IconType; | ||
makeResizeViewportIcon(): IconType; | ||
makeDuplicateSelectionIcon(): IconType; | ||
makeDeleteSelectionIcon(): IconType; | ||
makeSaveIcon(): IconType; | ||
} | ||
export {}; |
@@ -30,23 +30,2 @@ import Color4 from '../Color4'; | ||
export default class IconProvider { | ||
constructor() { | ||
this.makeZoomIcon = () => { | ||
const icon = document.createElementNS(svgNamespace, 'svg'); | ||
icon.setAttribute('viewBox', '0 0 100 100'); | ||
const addTextNode = (text, x, y) => { | ||
const textNode = document.createElementNS(svgNamespace, 'text'); | ||
textNode.appendChild(document.createTextNode(text)); | ||
textNode.setAttribute('x', x.toString()); | ||
textNode.setAttribute('y', y.toString()); | ||
textNode.style.textAlign = 'center'; | ||
textNode.style.textAnchor = 'middle'; | ||
textNode.style.fontSize = '55px'; | ||
textNode.style.fill = 'var(--icon-color)'; | ||
textNode.style.fontFamily = 'monospace'; | ||
icon.appendChild(textNode); | ||
}; | ||
addTextNode('+', 40, 45); | ||
addTextNode('-', 70, 75); | ||
return icon; | ||
}; | ||
} | ||
makeUndoIcon() { | ||
@@ -119,2 +98,6 @@ return this.makeRedoIcon(true); | ||
} | ||
/** | ||
* @param pathData - SVG path data (e.g. `m10,10l30,30z`) | ||
* @param fill - A valid CSS color (e.g. `var(--icon-color)` or `#f0f`). This can be `none`. | ||
*/ | ||
makeIconFromPath(pathData, fill = 'var(--icon-color)', strokeColor = 'none', strokeWidth = '0px') { | ||
@@ -245,2 +228,21 @@ const icon = document.createElementNS(svgNamespace, 'svg'); | ||
} | ||
makeZoomIcon() { | ||
const icon = document.createElementNS(svgNamespace, 'svg'); | ||
icon.setAttribute('viewBox', '0 0 100 100'); | ||
const addTextNode = (text, x, y) => { | ||
const textNode = document.createElementNS(svgNamespace, 'text'); | ||
textNode.appendChild(document.createTextNode(text)); | ||
textNode.setAttribute('x', x.toString()); | ||
textNode.setAttribute('y', y.toString()); | ||
textNode.style.textAlign = 'center'; | ||
textNode.style.textAnchor = 'middle'; | ||
textNode.style.fontSize = '55px'; | ||
textNode.style.fill = 'var(--icon-color)'; | ||
textNode.style.fontFamily = 'monospace'; | ||
icon.appendChild(textNode); | ||
}; | ||
addTextNode('+', 40, 45); | ||
addTextNode('-', 70, 75); | ||
return icon; | ||
} | ||
makeTextIcon(textStyle) { | ||
@@ -247,0 +249,0 @@ var _a, _b; |
@@ -80,8 +80,11 @@ import { makeArrowBuilder } from '../../components/builders/ArrowBuilder'; | ||
objectSelectLabel.setAttribute('for', objectTypeSelect.id); | ||
// Use a logarithmic scale for thicknessInput (finer control over thinner strokewidths.) | ||
const inverseThicknessInputFn = (t) => Math.log10(t); | ||
const thicknessInputFn = (t) => Math.pow(10, t); | ||
thicknessInput.type = 'range'; | ||
thicknessInput.min = '2'; | ||
thicknessInput.max = '20'; | ||
thicknessInput.step = '1'; | ||
thicknessInput.min = `${inverseThicknessInputFn(2)}`; | ||
thicknessInput.max = `${inverseThicknessInputFn(400)}`; | ||
thicknessInput.step = '0.1'; | ||
thicknessInput.oninput = () => { | ||
this.tool.setThickness(Math.pow(parseFloat(thicknessInput.value), 2)); | ||
this.tool.setThickness(thicknessInputFn(parseFloat(thicknessInput.value))); | ||
}; | ||
@@ -112,3 +115,3 @@ thicknessRow.appendChild(thicknessLabel); | ||
colorInput.value = this.tool.getColor().toHexString(); | ||
thicknessInput.value = Math.sqrt(this.tool.getThickness()).toString(); | ||
thicknessInput.value = inverseThicknessInputFn(this.tool.getThickness()).toString(); | ||
objectTypeSelect.replaceChildren(); | ||
@@ -115,0 +118,0 @@ for (let i = 0; i < this.penTypes.length; i++) { |
@@ -17,3 +17,3 @@ /** | ||
import SVGLoader from '../SVGLoader'; | ||
import { Mat33, Vec2 } from '../math/lib'; | ||
import { Mat33 } from '../math/lib'; | ||
import BaseTool from './BaseTool'; | ||
@@ -114,23 +114,3 @@ import EditorImage from '../EditorImage'; | ||
const lines = text.split('\n'); | ||
let lastComponent = null; | ||
const components = []; | ||
for (const line of lines) { | ||
let position = Vec2.zero; | ||
if (lastComponent) { | ||
const lineMargin = Math.floor(pastedTextStyle.size); | ||
position = lastComponent.getBBox().bottomLeft.plus(Vec2.unitY.times(lineMargin)); | ||
} | ||
const component = new TextComponent([line], Mat33.translation(position), pastedTextStyle); | ||
components.push(component); | ||
lastComponent = component; | ||
} | ||
if (components.length === 1) { | ||
yield this.addComponentsFromPaste([components[0]]); | ||
} | ||
else { | ||
// Wrap the existing `TextComponent`s --- dragging one component should drag all. | ||
yield this.addComponentsFromPaste([ | ||
new TextComponent(components, Mat33.identity, pastedTextStyle) | ||
]); | ||
} | ||
yield this.addComponentsFromPaste([TextComponent.fromLines(lines, Mat33.identity, pastedTextStyle)]); | ||
}); | ||
@@ -137,0 +117,0 @@ } |
@@ -16,5 +16,8 @@ import Color4 from '../Color4'; | ||
private textRotation; | ||
private textScale; | ||
private removeExistingCommand; | ||
constructor(editor: Editor, description: string, localizationTable: ToolLocalization); | ||
private getTextAscent; | ||
private flushInput; | ||
private getTextScaleMatrix; | ||
private updateTextInput; | ||
@@ -30,2 +33,3 @@ private startTextInput; | ||
getTextStyle(): TextStyle; | ||
private setTextStyle; | ||
} |
import Color4 from '../Color4'; | ||
import Text from '../components/Text'; | ||
import TextComponent from '../components/Text'; | ||
import EditorImage from '../EditorImage'; | ||
import Rect2 from '../math/Rect2'; | ||
import Mat33 from '../math/Mat33'; | ||
import { Vec2 } from '../math/Vec2'; | ||
import { PointerDevice } from '../Pointer'; | ||
import { EditorEventType } from '../types'; | ||
import BaseTool from './BaseTool'; | ||
import Erase from '../commands/Erase'; | ||
import uniteCommands from '../commands/uniteCommands'; | ||
const overlayCssClass = 'textEditorOverlay'; | ||
@@ -17,2 +21,4 @@ export default class TextTool extends BaseTool { | ||
this.textMeasuringCtx = null; | ||
this.textScale = Vec2.of(1, 1); | ||
this.removeExistingCommand = null; | ||
this.textStyle = { | ||
@@ -33,6 +39,14 @@ size: 32, | ||
.${overlayCssClass} input { | ||
.${overlayCssClass} textarea { | ||
background-color: rgba(0, 0, 0, 0); | ||
white-space: pre; | ||
padding: 0; | ||
margin: 0; | ||
border: none; | ||
padding: 0; | ||
min-width: 100px; | ||
min-height: 1.1em; | ||
} | ||
@@ -47,3 +61,3 @@ `); | ||
if (this.textMeasuringCtx) { | ||
Text.applyTextStyles(this.textMeasuringCtx, style); | ||
TextComponent.applyTextStyles(this.textMeasuringCtx, style); | ||
return this.textMeasuringCtx.measureText(text).actualBoundingBoxAscent; | ||
@@ -56,3 +70,3 @@ } | ||
if (this.textInputElem && this.textTargetPosition) { | ||
const content = this.textInputElem.value; | ||
const content = this.textInputElem.value.trimEnd(); | ||
this.textInputElem.remove(); | ||
@@ -63,8 +77,19 @@ this.textInputElem = null; | ||
} | ||
const textTransform = Mat33.translation(this.textTargetPosition).rightMul(Mat33.scaling2D(this.editor.viewport.getSizeOfPixelOnCanvas())).rightMul(Mat33.zRotation(this.textRotation)); | ||
const textComponent = new Text([content], textTransform, this.textStyle); | ||
const textTransform = Mat33.translation(this.textTargetPosition).rightMul(this.getTextScaleMatrix()).rightMul(Mat33.scaling2D(this.editor.viewport.getSizeOfPixelOnCanvas())).rightMul(Mat33.zRotation(this.textRotation)); | ||
const textComponent = TextComponent.fromLines(content.split('\n'), textTransform, this.textStyle); | ||
const action = EditorImage.addElement(textComponent); | ||
this.editor.dispatch(action); | ||
if (this.removeExistingCommand) { | ||
// Unapply so that `removeExistingCommand` can be added to the undo stack. | ||
this.removeExistingCommand.unapply(this.editor); | ||
this.editor.dispatch(uniteCommands([this.removeExistingCommand, action])); | ||
this.removeExistingCommand = null; | ||
} | ||
else { | ||
this.editor.dispatch(action); | ||
} | ||
} | ||
} | ||
getTextScaleMatrix() { | ||
return Mat33.scaling2D(this.textScale.times(1 / this.editor.viewport.getSizeOfPixelOnCanvas())); | ||
} | ||
updateTextInput() { | ||
@@ -78,3 +103,2 @@ var _a, _b, _c; | ||
const textScreenPos = viewport.canvasToScreen(this.textTargetPosition); | ||
this.textInputElem.type = 'text'; | ||
this.textInputElem.placeholder = this.localizationTable.enterTextToInsert; | ||
@@ -90,5 +114,8 @@ this.textInputElem.style.fontFamily = this.textStyle.fontFamily; | ||
this.textInputElem.style.margin = '0'; | ||
this.textInputElem.style.width = `${this.textInputElem.scrollWidth}px`; | ||
this.textInputElem.style.height = `${this.textInputElem.scrollHeight}px`; | ||
const rotation = this.textRotation + viewport.getRotationAngle(); | ||
const ascent = this.getTextAscent(this.textInputElem.value || 'W', this.textStyle); | ||
this.textInputElem.style.transform = `rotate(${rotation * 180 / Math.PI}deg) translate(0, ${-ascent}px)`; | ||
const scale = this.getTextScaleMatrix(); | ||
this.textInputElem.style.transform = `${scale.toCSSMatrix()} rotate(${rotation * 180 / Math.PI}deg) translate(0, ${-ascent}px)`; | ||
this.textInputElem.style.transformOrigin = 'top left'; | ||
@@ -98,11 +125,13 @@ } | ||
this.flushInput(); | ||
this.textInputElem = document.createElement('input'); | ||
this.textInputElem = document.createElement('textarea'); | ||
this.textInputElem.value = initialText; | ||
this.textInputElem.style.display = 'inline-block'; | ||
this.textTargetPosition = textCanvasPos; | ||
this.textRotation = -this.editor.viewport.getRotationAngle(); | ||
this.textScale = Vec2.of(1, 1).times(this.editor.viewport.getSizeOfPixelOnCanvas()); | ||
this.updateTextInput(); | ||
this.textInputElem.oninput = () => { | ||
var _a; | ||
if (this.textInputElem) { | ||
this.textInputElem.size = ((_a = this.textInputElem) === null || _a === void 0 ? void 0 : _a.value.length) || 10; | ||
this.textInputElem.style.width = `${this.textInputElem.scrollWidth}px`; | ||
this.textInputElem.style.height = `${this.textInputElem.scrollHeight}px`; | ||
} | ||
@@ -116,4 +145,4 @@ }; | ||
this.textInputElem.onkeyup = (evt) => { | ||
var _a; | ||
if (evt.key === 'Enter') { | ||
var _a, _b; | ||
if (evt.key === 'Enter' && !evt.shiftKey) { | ||
this.flushInput(); | ||
@@ -127,2 +156,4 @@ this.editor.focus(); | ||
this.editor.focus(); | ||
(_b = this.removeExistingCommand) === null || _b === void 0 ? void 0 : _b.unapply(this.editor); | ||
this.removeExistingCommand = null; | ||
} | ||
@@ -145,3 +176,25 @@ }; | ||
if (allPointers.length === 1) { | ||
this.startTextInput(current.canvasPos, ''); | ||
// Are we clicking on a text node? | ||
const canvasPos = current.canvasPos; | ||
const halfTestRegionSize = Vec2.of(2.5, 2.5).times(this.editor.viewport.getSizeOfPixelOnCanvas()); | ||
const testRegion = Rect2.fromCorners(canvasPos.minus(halfTestRegionSize), canvasPos.plus(halfTestRegionSize)); | ||
const targetNodes = this.editor.image.getElementsIntersectingRegion(testRegion); | ||
const targetTextNodes = targetNodes.filter(node => node instanceof TextComponent); | ||
if (targetTextNodes.length > 0) { | ||
const targetNode = targetTextNodes[targetTextNodes.length - 1]; | ||
this.setTextStyle(targetNode.getTextStyle()); | ||
// Create and temporarily apply removeExistingCommand. | ||
this.removeExistingCommand = new Erase([targetNode]); | ||
this.removeExistingCommand.apply(this.editor); | ||
this.startTextInput(targetNode.getBaselinePos(), targetNode.getText()); | ||
const transform = targetNode.getTransform(); | ||
this.textRotation = transform.transformVec3(Vec2.unitX).angle(); | ||
const scaleFactor = transform.transformVec3(Vec2.unitX).magnitude(); | ||
this.textScale = Vec2.of(1, 1).times(scaleFactor); | ||
this.updateTextInput(); | ||
} | ||
else { | ||
this.removeExistingCommand = null; | ||
this.startTextInput(current.canvasPos, ''); | ||
} | ||
return true; | ||
@@ -183,2 +236,7 @@ } | ||
} | ||
setTextStyle(style) { | ||
// Copy the style — we may change parts of it. | ||
this.textStyle = Object.assign({}, style); | ||
this.dispatchUpdateEvent(); | ||
} | ||
} |
{ | ||
"name": "js-draw", | ||
"version": "0.6.0", | ||
"version": "0.7.0", | ||
"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", |
@@ -14,9 +14,9 @@ import { Bezier } from 'bezier-js'; | ||
export const makeFreehandLineBuilder: ComponentBuilderFactory = (initialPoint: StrokeDataPoint, viewport: Viewport) => { | ||
// Don't smooth if input is more than ± 7 pixels from the true curve, do smooth if | ||
// Don't smooth if input is more than ± 3 pixels from the true curve, do smooth if | ||
// less than ±1 px from the curve. | ||
const maxSmoothingDist = viewport.getSizeOfPixelOnCanvas() * 7; | ||
const maxSmoothingDist = viewport.getSizeOfPixelOnCanvas() * 3; | ||
const minSmoothingDist = viewport.getSizeOfPixelOnCanvas(); | ||
return new FreehandLineBuilder( | ||
initialPoint, minSmoothingDist, maxSmoothingDist | ||
initialPoint, minSmoothingDist, maxSmoothingDist, viewport | ||
); | ||
@@ -26,6 +26,9 @@ }; | ||
type CurrentSegmentToPathResult = { | ||
upperCurve: QuadraticBezierPathCommand, | ||
upperCurveCommand: QuadraticBezierPathCommand, | ||
lowerToUpperConnector: PathCommand, | ||
upperToLowerConnector: PathCommand, | ||
lowerCurve: QuadraticBezierPathCommand, | ||
lowerCurveCommand: QuadraticBezierPathCommand, | ||
upperCurve: Bezier, | ||
lowerCurve: Bezier, | ||
}; | ||
@@ -52,4 +55,7 @@ | ||
// recent edge. | ||
private upperSegments: QuadraticBezierPathCommand[]; | ||
private lowerSegments: QuadraticBezierPathCommand[]; | ||
private upperSegments: PathCommand[]; | ||
private lowerSegments: PathCommand[]; | ||
private lastUpperBezier: Bezier|null = null; | ||
private lastLowerBezier: Bezier|null = null; | ||
private parts: RenderablePathSpec[] = []; | ||
@@ -75,3 +81,5 @@ private buffer: Point2[]; | ||
private minFitAllowed: number, | ||
private maxFitAllowed: number | ||
private maxFitAllowed: number, | ||
private viewport: Viewport, | ||
) { | ||
@@ -100,11 +108,15 @@ this.lastPoint = this.startPoint; | ||
private previewPath(): RenderablePathSpec|null { | ||
let upperPath: QuadraticBezierPathCommand[]; | ||
let lowerPath: QuadraticBezierPathCommand[]; | ||
private previewCurrentPath(): RenderablePathSpec|null { | ||
const upperPath = this.upperSegments.slice(); | ||
const lowerPath = this.lowerSegments.slice(); | ||
let lowerToUpperCap: PathCommand; | ||
let pathStartConnector: PathCommand; | ||
if (this.currentCurve) { | ||
const { upperCurve, lowerToUpperConnector, upperToLowerConnector, lowerCurve } = this.currentSegmentToPath(); | ||
upperPath = this.upperSegments.concat(upperCurve); | ||
lowerPath = this.lowerSegments.concat(lowerCurve); | ||
const { | ||
upperCurveCommand, lowerToUpperConnector, upperToLowerConnector, lowerCurveCommand | ||
} = this.currentSegmentToPath(); | ||
upperPath.push(upperCurveCommand); | ||
lowerPath.push(lowerCurveCommand); | ||
lowerToUpperCap = lowerToUpperConnector; | ||
@@ -117,9 +129,13 @@ pathStartConnector = this.pathStartConnector ?? upperToLowerConnector; | ||
upperPath = this.upperSegments.slice(); | ||
lowerPath = this.lowerSegments.slice(); | ||
lowerToUpperCap = this.mostRecentConnector; | ||
pathStartConnector = this.pathStartConnector; | ||
} | ||
const startPoint = lowerPath[lowerPath.length - 1].endPoint; | ||
let startPoint: Point2; | ||
const lastLowerSegment = lowerPath[lowerPath.length - 1]; | ||
if (lastLowerSegment.kind === PathCommandType.LineTo || lastLowerSegment.kind === PathCommandType.MoveTo) { | ||
startPoint = lastLowerSegment.point; | ||
} else { | ||
startPoint = lastLowerSegment.endPoint; | ||
} | ||
@@ -165,7 +181,15 @@ return { | ||
private previewFullPath(): RenderablePathSpec[]|null { | ||
const preview = this.previewCurrentPath(); | ||
if (preview) { | ||
return [ ...this.parts, preview ]; | ||
} | ||
return null; | ||
} | ||
private previewStroke(): Stroke|null { | ||
const pathPreview = this.previewPath(); | ||
const pathPreview = this.previewFullPath(); | ||
if (pathPreview) { | ||
return new Stroke([ pathPreview ]); | ||
return new Stroke(pathPreview); | ||
} | ||
@@ -176,5 +200,10 @@ return null; | ||
public preview(renderer: AbstractRenderer) { | ||
const path = this.previewPath(); | ||
if (path) { | ||
renderer.drawPath(path); | ||
const paths = this.previewFullPath(); | ||
if (paths) { | ||
const approxBBox = this.viewport.visibleRect; | ||
renderer.startObject(approxBBox); | ||
for (const path of paths) { | ||
renderer.drawPath(path); | ||
} | ||
renderer.endObject(); | ||
} | ||
@@ -184,3 +213,3 @@ } | ||
public build(): Stroke { | ||
if (this.lastPoint && (this.lowerSegments.length === 0 || this.approxCurrentCurveLength() > this.curveStartWidth * 2)) { | ||
if (this.lastPoint) { | ||
this.finalizeCurrentCurve(); | ||
@@ -201,2 +230,70 @@ } | ||
// Returns true if, due to overlap with previous segments, a new RenderablePathSpec should be created. | ||
private shouldStartNewSegment(lowerCurve: Bezier, upperCurve: Bezier): boolean { | ||
if (!this.lastLowerBezier || !this.lastUpperBezier) { | ||
return false; | ||
} | ||
const getIntersection = (curve1: Bezier, curve2: Bezier): Point2|null => { | ||
const intersection = curve1.intersects(curve2) as (string[] | null | undefined); | ||
if (!intersection || intersection.length === 0) { | ||
return null; | ||
} | ||
// From http://pomax.github.io/bezierjs/#intersect-curve, | ||
// .intersects returns an array of 't1/t2' pairs, where curve1.at(t1) gives the point. | ||
const firstTPair = intersection[0]; | ||
const match = /^([-0-9.eE]+)\/([-0-9.eE]+)$/.exec(firstTPair); | ||
if (!match) { | ||
throw new Error( | ||
`Incorrect format returned by .intersects: ${intersection} should be array of "number/number"!` | ||
); | ||
} | ||
const t = parseFloat(match[1]); | ||
return Vec2.ofXY(curve1.get(t)); | ||
}; | ||
const getExitDirection = (curve: Bezier): Vec2 => { | ||
return Vec2.ofXY(curve.points[2]).minus(Vec2.ofXY(curve.points[1])).normalized(); | ||
}; | ||
const getEnterDirection = (curve: Bezier): Vec2 => { | ||
return Vec2.ofXY(curve.points[1]).minus(Vec2.ofXY(curve.points[0])).normalized(); | ||
}; | ||
// Prevent | ||
// / | ||
// / / | ||
// / / /| | ||
// / / | | ||
// / | | ||
// where the next stroke and the previous stroke are in different directions. | ||
// | ||
// Are the exit/enter directions of the previous and current curves in different enough directions? | ||
if (getEnterDirection(upperCurve).dot(getExitDirection(this.lastUpperBezier)) < 0.3 | ||
|| getEnterDirection(lowerCurve).dot(getExitDirection(this.lastLowerBezier)) < 0.3 | ||
// Also handle if the curves exit/enter directions differ | ||
|| getEnterDirection(upperCurve).dot(getExitDirection(upperCurve)) < 0 | ||
|| getEnterDirection(lowerCurve).dot(getExitDirection(lowerCurve)) < 0) { | ||
return true; | ||
} | ||
// Check whether the lower curve intersects the other wall: | ||
// / / ← lower | ||
// / / / | ||
// / / / | ||
// // | ||
// / / | ||
const lowerIntersection = getIntersection(lowerCurve, this.lastUpperBezier); | ||
const upperIntersection = getIntersection(upperCurve, this.lastLowerBezier); | ||
if (lowerIntersection || upperIntersection) { | ||
return true; | ||
} | ||
return false; | ||
} | ||
// Returns the distance between the start, control, and end points of the curve. | ||
@@ -270,5 +367,19 @@ private approxCurrentCurveLength() { | ||
const { upperCurve, lowerToUpperConnector, upperToLowerConnector, lowerCurve } = this.currentSegmentToPath(); | ||
const { | ||
upperCurveCommand, lowerToUpperConnector, upperToLowerConnector, lowerCurveCommand, | ||
lowerCurve, upperCurve, | ||
} = this.currentSegmentToPath(); | ||
if (this.isFirstSegment) { | ||
const shouldStartNew = this.shouldStartNewSegment(lowerCurve, upperCurve); | ||
if (shouldStartNew) { | ||
const part = this.previewCurrentPath(); | ||
if (part) { | ||
this.parts.push(part); | ||
this.upperSegments = []; | ||
this.lowerSegments = []; | ||
} | ||
} | ||
if (this.isFirstSegment || shouldStartNew) { | ||
// We draw the upper path (reversed), then the lower path, so we need the | ||
@@ -283,5 +394,8 @@ // upperToLowerConnector to join the two paths. | ||
this.upperSegments.push(upperCurve); | ||
this.lowerSegments.push(lowerCurve); | ||
this.lowerSegments.push(lowerCurveCommand); | ||
this.upperSegments.push(upperCurveCommand); | ||
this.lastLowerBezier = lowerCurve; | ||
this.lastUpperBezier = upperCurve; | ||
const lastPoint = this.buffer[this.buffer.length - 1]; | ||
@@ -340,2 +454,3 @@ this.lastExitingVec = Vec2.ofXY( | ||
// Each starts at startPt ± startVec | ||
const lowerCurveStartPoint = this.roundPoint(startPt.plus(startVec)); | ||
const lowerCurveControlPoint = this.roundPoint(controlPoint.plus(halfVec)); | ||
@@ -345,4 +460,5 @@ const lowerCurveEndPoint = this.roundPoint(endPt.plus(endVec)); | ||
const upperCurveStartPoint = this.roundPoint(endPt.minus(endVec)); | ||
const upperCurveEndPoint = this.roundPoint(startPt.minus(startVec)); | ||
const lowerCurve: QuadraticBezierPathCommand = { | ||
const lowerCurveCommand: QuadraticBezierPathCommand = { | ||
kind: PathCommandType.QuadraticBezierTo, | ||
@@ -356,3 +472,3 @@ controlPoint: lowerCurveControlPoint, | ||
kind: PathCommandType.LineTo, | ||
point: this.roundPoint(startPt.plus(startVec)), | ||
point: lowerCurveStartPoint, | ||
}; | ||
@@ -366,9 +482,15 @@ | ||
const upperCurve: QuadraticBezierPathCommand = { | ||
const upperCurveCommand: QuadraticBezierPathCommand = { | ||
kind: PathCommandType.QuadraticBezierTo, | ||
controlPoint: upperCurveControlPoint, | ||
endPoint: this.roundPoint(startPt.minus(startVec)), | ||
endPoint: upperCurveEndPoint, | ||
}; | ||
return { upperCurve, upperToLowerConnector, lowerToUpperConnector, lowerCurve }; | ||
const upperCurve = new Bezier(upperCurveStartPoint, upperCurveControlPoint, upperCurveEndPoint); | ||
const lowerCurve = new Bezier(lowerCurveStartPoint, lowerCurveControlPoint, lowerCurveEndPoint); | ||
return { | ||
upperCurveCommand, upperToLowerConnector, lowerToUpperConnector, lowerCurveCommand, | ||
upperCurve, lowerCurve, | ||
}; | ||
} | ||
@@ -416,2 +538,7 @@ | ||
if (this.isFirstSegment) { | ||
// The start of a curve often lacks accurate pressure information. Update it. | ||
this.curveStartWidth = (this.curveStartWidth + pointRadius) / 2; | ||
} | ||
// recompute bbox | ||
@@ -433,2 +560,3 @@ this.bbox = this.bbox.grownToPoint(newPoint.pos, pointRadius); | ||
// If there isn't an entering vector (e.g. because this.isFirstCurve), approximate it. | ||
let enteringVec = this.lastExitingVec; | ||
@@ -435,0 +563,0 @@ if (!enteringVec) { |
@@ -7,3 +7,3 @@ export * from './builders/types'; | ||
import Stroke from './Stroke'; | ||
import Text from './Text'; | ||
import TextComponent from './Text'; | ||
import ImageComponent from './ImageComponent'; | ||
@@ -13,7 +13,7 @@ | ||
Stroke, | ||
Text, | ||
TextComponent as Text, | ||
Text as TextComponent, | ||
TextComponent as TextComponent, | ||
Stroke as StrokeComponent, | ||
ImageComponent, | ||
}; |
@@ -18,2 +18,3 @@ import LineSegment2 from '../math/LineSegment2'; | ||
// Creates a `Stroke` from the given `parts`. | ||
public constructor(parts: RenderablePathSpec[]) { | ||
@@ -20,0 +21,0 @@ super('stroke'); |
import Color4 from '../Color4'; | ||
import Mat33 from '../math/Mat33'; | ||
import AbstractComponent from './AbstractComponent'; | ||
import Text, { TextStyle } from './Text'; | ||
import TextComponent, { TextStyle } from './Text'; | ||
@@ -14,5 +14,5 @@ | ||
}; | ||
const text = new Text([ 'Foo' ], Mat33.identity, style); | ||
const text = new TextComponent([ 'Foo' ], Mat33.identity, style); | ||
const serialized = text.serialize(); | ||
const deserialized = AbstractComponent.deserialize(serialized) as Text; | ||
const deserialized = AbstractComponent.deserialize(serialized) as TextComponent; | ||
expect(deserialized.getBBox()).objEq(text.getBBox()); | ||
@@ -19,0 +19,0 @@ expect(deserialized['getText']()).toContain('Foo'); |
import LineSegment2 from '../math/LineSegment2'; | ||
import Mat33, { Mat33Array } from '../math/Mat33'; | ||
import Rect2 from '../math/Rect2'; | ||
import { Vec2 } from '../math/Vec2'; | ||
import AbstractRenderer from '../rendering/renderers/AbstractRenderer'; | ||
@@ -18,12 +19,19 @@ import RenderingStyle, { styleFromJSON, styleToJSON } from '../rendering/RenderingStyle'; | ||
const componentTypeId = 'text'; | ||
export default class Text extends AbstractComponent { | ||
export default class TextComponent extends AbstractComponent { | ||
protected contentBBox: Rect2; | ||
public constructor( | ||
protected readonly textObjects: Array<string|Text>, | ||
protected readonly textObjects: Array<string|TextComponent>, | ||
private transform: Mat33, | ||
private readonly style: TextStyle, | ||
private style: TextStyle, | ||
) { | ||
super(componentTypeId); | ||
this.recomputeBBox(); | ||
// If this has no direct children, choose a style representative of this' content | ||
// (useful for estimating the style of the TextComponent). | ||
const hasDirectContent = textObjects.some(obj => typeof obj === 'string'); | ||
if (!hasDirectContent && textObjects.length > 0) { | ||
this.style = (textObjects[0] as TextComponent).getTextStyle(); | ||
} | ||
} | ||
@@ -59,9 +67,9 @@ | ||
private static getTextDimens(text: string, style: TextStyle): Rect2 { | ||
Text.textMeasuringCtx ??= document.createElement('canvas').getContext('2d') ?? null; | ||
if (!Text.textMeasuringCtx) { | ||
TextComponent.textMeasuringCtx ??= document.createElement('canvas').getContext('2d') ?? null; | ||
if (!TextComponent.textMeasuringCtx) { | ||
return this.estimateTextDimens(text, style); | ||
} | ||
const ctx = Text.textMeasuringCtx; | ||
Text.applyTextStyles(ctx, style); | ||
const ctx = TextComponent.textMeasuringCtx; | ||
TextComponent.applyTextStyles(ctx, style); | ||
@@ -76,5 +84,5 @@ const measure = ctx.measureText(text); | ||
private computeBBoxOfPart(part: string|Text) { | ||
private computeBBoxOfPart(part: string|TextComponent) { | ||
if (typeof part === 'string') { | ||
const textBBox = Text.getTextDimens(part, this.style); | ||
const textBBox = TextComponent.getTextDimens(part, this.style); | ||
return textBBox.transformedBoundingBox(this.transform); | ||
@@ -99,6 +107,5 @@ } else { | ||
public render(canvas: AbstractRenderer, _visibleRect?: Rect2): void { | ||
private renderInternal(canvas: AbstractRenderer) { | ||
const cursor = this.transform; | ||
canvas.startObject(this.contentBBox); | ||
for (const textObject of this.textObjects) { | ||
@@ -109,6 +116,11 @@ if (typeof textObject === 'string') { | ||
canvas.pushTransform(cursor); | ||
textObject.render(canvas); | ||
textObject.renderInternal(canvas); | ||
canvas.popTransform(); | ||
} | ||
} | ||
} | ||
public render(canvas: AbstractRenderer, _visibleRect?: Rect2): void { | ||
canvas.startObject(this.contentBBox); | ||
this.renderInternal(canvas); | ||
canvas.endObject(this.getLoadSaveData()); | ||
@@ -127,3 +139,3 @@ } | ||
if (typeof subObject === 'string') { | ||
const textBBox = Text.getTextDimens(subObject, this.style); | ||
const textBBox = TextComponent.getTextDimens(subObject, this.style); | ||
@@ -145,2 +157,14 @@ // TODO: Use a better intersection check. Perhaps draw the text onto a CanvasElement and | ||
public getBaselinePos() { | ||
return this.transform.transformVec2(Vec2.zero); | ||
} | ||
public getTextStyle() { | ||
return this.style; | ||
} | ||
public getTransform(): Mat33 { | ||
return this.transform; | ||
} | ||
protected applyTransformation(affineTransfm: Mat33): void { | ||
@@ -152,3 +176,3 @@ this.transform = affineTransfm.rightMul(this.transform); | ||
protected createClone(): AbstractComponent { | ||
return new Text(this.textObjects, this.transform, this.style); | ||
return new TextComponent(this.textObjects, this.transform, this.style); | ||
} | ||
@@ -199,3 +223,3 @@ | ||
public static deserializeFromString(json: any): Text { | ||
public static deserializeFromString(json: any): TextComponent { | ||
const style: TextStyle = { | ||
@@ -209,3 +233,3 @@ renderingStyle: styleFromJSON(json.style.renderingStyle), | ||
const textObjects: Array<string|Text> = json.textObjects.map((data: any) => { | ||
const textObjects: Array<string|TextComponent> = json.textObjects.map((data: any) => { | ||
if ((data.text ?? null) !== null) { | ||
@@ -215,3 +239,3 @@ return data.text; | ||
return Text.deserializeFromString(data.json); | ||
return TextComponent.deserializeFromString(data.json); | ||
}); | ||
@@ -227,6 +251,25 @@ | ||
return new Text(textObjects, transform, style); | ||
return new TextComponent(textObjects, transform, style); | ||
} | ||
public static fromLines(lines: string[], transform: Mat33, style: TextStyle): AbstractComponent { | ||
let lastComponent: TextComponent|null = null; | ||
const components: TextComponent[] = []; | ||
for (const line of lines) { | ||
let position = Vec2.zero; | ||
if (lastComponent) { | ||
const lineMargin = Math.floor(style.size); | ||
position = lastComponent.getBBox().bottomLeft.plus(Vec2.unitY.times(lineMargin)); | ||
} | ||
const component = new TextComponent([ line ], Mat33.translation(position), style); | ||
components.push(component); | ||
lastComponent = component; | ||
} | ||
return new TextComponent(components, transform, style); | ||
} | ||
} | ||
AbstractComponent.registerComponent(componentTypeId, (data: string) => Text.deserializeFromString(data)); | ||
AbstractComponent.registerComponent(componentTypeId, (data: string) => TextComponent.deserializeFromString(data)); |
@@ -41,2 +41,3 @@ /** | ||
import IconProvider from './toolbar/IconProvider'; | ||
import { toRoundedString } from './math/rounding'; | ||
@@ -122,3 +123,2 @@ type HTMLPointerEventType = 'pointerdown'|'pointermove'|'pointerup'|'pointercancel'; | ||
* Global event dispatcher/subscriber. | ||
* @see {@link types.EditorEventType} | ||
*/ | ||
@@ -845,5 +845,5 @@ public readonly notifier: EditorNotifier; | ||
const rect = importExportViewport.visibleRect; | ||
result.setAttribute('viewBox', `${rect.x} ${rect.y} ${rect.w} ${rect.h}`); | ||
result.setAttribute('width', `${rect.w}`); | ||
result.setAttribute('height', `${rect.h}`); | ||
result.setAttribute('viewBox', [rect.x, rect.y, rect.w, rect.h].map(part => toRoundedString(part)).join(' ')); | ||
result.setAttribute('width', toRoundedString(rect.w)); | ||
result.setAttribute('height', toRoundedString(rect.h)); | ||
@@ -850,0 +850,0 @@ // Ensure the image can be identified as an SVG if downloaded. |
import Color4 from '../../Color4'; | ||
import Text, { TextStyle } from '../../components/Text'; | ||
import TextComponent, { TextStyle } from '../../components/Text'; | ||
import Mat33 from '../../math/Mat33'; | ||
@@ -156,3 +156,3 @@ import Rect2 from '../../math/Rect2'; | ||
this.transformBy(transform); | ||
Text.applyTextStyles(this.ctx, style); | ||
TextComponent.applyTextStyles(this.ctx, style); | ||
@@ -159,0 +159,0 @@ if (style.renderingStyle.fill.a !== 0) { |
@@ -103,12 +103,17 @@ | ||
// Apply [elemTransform] to [elem]. | ||
private transformFrom(elemTransform: Mat33, elem: SVGElement) { | ||
let transform = this.getCanvasToScreenTransform().rightMul(elemTransform); | ||
private transformFrom(elemTransform: Mat33, elem: SVGElement, inCanvasSpace: boolean = false) { | ||
let transform = !inCanvasSpace ? this.getCanvasToScreenTransform().rightMul(elemTransform) : elemTransform; | ||
const translation = transform.transformVec2(Vec2.zero); | ||
transform = transform.rightMul(Mat33.translation(translation.times(-1))); | ||
elem.style.transform = `matrix( | ||
${transform.a1}, ${transform.b1}, | ||
${transform.a2}, ${transform.b2}, | ||
${transform.a3}, ${transform.b3} | ||
)`; | ||
if (!transform.eq(Mat33.identity)) { | ||
elem.style.transform = `matrix( | ||
${transform.a1}, ${transform.b1}, | ||
${transform.a2}, ${transform.b2}, | ||
${transform.a3}, ${transform.b3} | ||
)`; | ||
} else { | ||
elem.style.transform = ''; | ||
} | ||
elem.setAttribute('x', `${toRoundedString(translation.x)}`); | ||
@@ -118,21 +123,41 @@ elem.setAttribute('y', `${toRoundedString(translation.y)}`); | ||
private textContainer: SVGTextElement|null = null; | ||
private textContainerTransform: Mat33|null = null; | ||
public drawText(text: string, transform: Mat33, style: TextStyle): void { | ||
const textElem = document.createElementNS(svgNameSpace, 'text'); | ||
textElem.appendChild(document.createTextNode(text)); | ||
this.transformFrom(transform, textElem); | ||
const applyTextStyles = (elem: SVGTextElement|SVGTSpanElement, style: TextStyle) => { | ||
elem.style.fontFamily = style.fontFamily; | ||
elem.style.fontVariant = style.fontVariant ?? ''; | ||
elem.style.fontWeight = style.fontWeight ?? ''; | ||
elem.style.fontSize = style.size + 'px'; | ||
elem.style.fill = style.renderingStyle.fill.toHexString(); | ||
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; | ||
elem.style.stroke = strokeStyle.color.toHexString(); | ||
elem.style.strokeWidth = strokeStyle.width + 'px'; | ||
} | ||
}; | ||
transform = this.getCanvasToScreenTransform().rightMul(transform); | ||
if (style.renderingStyle.stroke) { | ||
const strokeStyle = style.renderingStyle.stroke; | ||
textElem.style.stroke = strokeStyle.color.toHexString(); | ||
textElem.style.strokeWidth = strokeStyle.width + 'px'; | ||
if (!this.textContainer) { | ||
const container = document.createElementNS(svgNameSpace, 'text'); | ||
container.appendChild(document.createTextNode(text)); | ||
this.transformFrom(transform, container, true); | ||
applyTextStyles(container, style); | ||
this.elem.appendChild(container); | ||
this.objectElems?.push(container); | ||
if (this.objectLevel > 0) { | ||
this.textContainer = container; | ||
this.textContainerTransform = transform; | ||
} | ||
} else { | ||
const elem = document.createElementNS(svgNameSpace, 'tspan'); | ||
elem.appendChild(document.createTextNode(text)); | ||
this.textContainer.appendChild(elem); | ||
transform = this.textContainerTransform!.inverse().rightMul(transform); | ||
this.transformFrom(transform, elem, true); | ||
applyTextStyles(elem, style); | ||
} | ||
this.elem.appendChild(textElem); | ||
this.objectElems?.push(textElem); | ||
} | ||
@@ -158,2 +183,3 @@ | ||
this.lastPathStyle = null; | ||
this.textContainer = null; | ||
this.objectElems = []; | ||
@@ -160,0 +186,0 @@ } |
@@ -37,2 +37,22 @@ import { Rect2, TextComponent, Vec2 } from './lib'; | ||
}); | ||
it('should correctly load tspans within texts nodes', async () => { | ||
const editor = createEditor(); | ||
await editor.loadFrom(SVGLoader.fromString(` | ||
<svg> | ||
<text> | ||
Testing... | ||
<tspan x=0 y=100>Test 2...</tspan> | ||
<tspan x=0 y=200>Test 2...</tspan> | ||
</text> | ||
</svg> | ||
`, true)); | ||
const elem = editor.image | ||
.getElementsIntersectingRegion(new Rect2(-1000, -1000, 10000, 10000)) | ||
.filter(elem => elem instanceof TextComponent)[0]; | ||
expect(elem).not.toBeNull(); | ||
expect(elem.getBBox().topLeft.y).toBeLessThan(0); | ||
expect(elem.getBBox().topLeft.x).toBe(0); | ||
expect(elem.getBBox().h).toBeGreaterThan(200); | ||
}); | ||
}); |
@@ -6,3 +6,3 @@ import Color4 from './Color4'; | ||
import SVGGlobalAttributesObject from './components/SVGGlobalAttributesObject'; | ||
import Text, { TextStyle } from './components/Text'; | ||
import TextComponent, { TextStyle } from './components/Text'; | ||
import UnknownSVGObject from './components/UnknownSVGObject'; | ||
@@ -214,4 +214,4 @@ import Mat33 from './math/Mat33'; | ||
private makeText(elem: SVGTextElement|SVGTSpanElement): Text { | ||
const contentList: Array<Text|string> = []; | ||
private makeText(elem: SVGTextElement|SVGTSpanElement): TextComponent { | ||
const contentList: Array<TextComponent|string> = []; | ||
for (const child of elem.childNodes) { | ||
@@ -256,3 +256,3 @@ if (child.nodeType === Node.TEXT_NODE) { | ||
const transform = this.getTransform(elem, supportedAttrs, computedStyles); | ||
const result = new Text(contentList, transform, style); | ||
const result = new TextComponent(contentList, transform, style); | ||
this.attachUnrecognisedAttrs( | ||
@@ -259,0 +259,0 @@ result, |
@@ -11,4 +11,4 @@ import Color4 from '../Color4'; | ||
type IconType = SVGSVGElement|HTMLImageElement; | ||
const svgNamespace = 'http://www.w3.org/2000/svg'; | ||
@@ -40,3 +40,3 @@ const iconColorFill = ` | ||
public makeUndoIcon() { | ||
public makeUndoIcon(): IconType { | ||
return this.makeRedoIcon(true); | ||
@@ -47,3 +47,3 @@ } | ||
// @returns a redo icon. | ||
public makeRedoIcon(mirror: boolean = false) { | ||
public makeRedoIcon(mirror: boolean = false): IconType { | ||
const icon = document.createElementNS(svgNamespace, 'svg'); | ||
@@ -71,3 +71,3 @@ icon.innerHTML = ` | ||
public makeDropdownIcon() { | ||
public makeDropdownIcon(): IconType { | ||
const icon = document.createElementNS(svgNamespace, 'svg'); | ||
@@ -86,3 +86,3 @@ icon.innerHTML = ` | ||
public makeEraserIcon() { | ||
public makeEraserIcon(): IconType { | ||
const icon = document.createElementNS(svgNamespace, 'svg'); | ||
@@ -104,3 +104,3 @@ | ||
public makeSelectionIcon() { | ||
public makeSelectionIcon(): IconType { | ||
const icon = document.createElementNS(svgNamespace, 'svg'); | ||
@@ -120,2 +120,6 @@ | ||
/** | ||
* @param pathData - SVG path data (e.g. `m10,10l30,30z`) | ||
* @param fill - A valid CSS color (e.g. `var(--icon-color)` or `#f0f`). This can be `none`. | ||
*/ | ||
protected makeIconFromPath( | ||
@@ -126,3 +130,3 @@ pathData: string, | ||
strokeWidth: string = '0px', | ||
) { | ||
): IconType { | ||
const icon = document.createElementNS(svgNamespace, 'svg'); | ||
@@ -140,3 +144,3 @@ const path = document.createElementNS(svgNamespace, 'path'); | ||
public makeHandToolIcon() { | ||
public makeHandToolIcon(): IconType { | ||
const fill = 'none'; | ||
@@ -170,3 +174,3 @@ const strokeColor = 'var(--icon-color)'; | ||
public makeTouchPanningIcon() { | ||
public makeTouchPanningIcon(): IconType { | ||
const fill = 'none'; | ||
@@ -205,3 +209,3 @@ const strokeColor = 'var(--icon-color)'; | ||
public makeAllDevicePanningIcon() { | ||
public makeAllDevicePanningIcon(): IconType { | ||
const fill = 'none'; | ||
@@ -262,3 +266,3 @@ const strokeColor = 'var(--icon-color)'; | ||
public makeZoomIcon = () => { | ||
public makeZoomIcon(): IconType { | ||
const icon = document.createElementNS(svgNamespace, 'svg'); | ||
@@ -285,5 +289,5 @@ icon.setAttribute('viewBox', '0 0 100 100'); | ||
return icon; | ||
}; | ||
} | ||
public makeTextIcon(textStyle: TextStyle) { | ||
public makeTextIcon(textStyle: TextStyle): IconType { | ||
const icon = document.createElementNS(svgNamespace, 'svg'); | ||
@@ -311,3 +315,3 @@ icon.setAttribute('viewBox', '0 0 100 100'); | ||
public makePenIcon(tipThickness: number, color: string|Color4) { | ||
public makePenIcon(tipThickness: number, color: string|Color4): IconType { | ||
if (color instanceof Color4) { | ||
@@ -351,3 +355,3 @@ color = color.toHexString(); | ||
public makeIconFromFactory(pen: Pen, factory: ComponentBuilderFactory) { | ||
public makeIconFromFactory(pen: Pen, factory: ComponentBuilderFactory): IconType { | ||
const toolThickness = pen.getThickness(); | ||
@@ -383,3 +387,3 @@ | ||
public makePipetteIcon(color?: Color4) { | ||
public makePipetteIcon(color?: Color4): IconType { | ||
const icon = document.createElementNS(svgNamespace, 'svg'); | ||
@@ -438,3 +442,3 @@ const pipette = document.createElementNS(svgNamespace, 'path'); | ||
public makeResizeViewportIcon() { | ||
public makeResizeViewportIcon(): IconType { | ||
return this.makeIconFromPath(` | ||
@@ -452,3 +456,3 @@ M 75 5 75 10 90 10 90 25 95 25 95 5 75 5 z | ||
public makeDuplicateSelectionIcon() { | ||
public makeDuplicateSelectionIcon(): IconType { | ||
return this.makeIconFromPath(` | ||
@@ -460,3 +464,3 @@ M 45,10 45,55 90,55 90,10 45,10 z | ||
public makeDeleteSelectionIcon() { | ||
public makeDeleteSelectionIcon(): IconType { | ||
const strokeWidth = '5px'; | ||
@@ -472,3 +476,3 @@ const strokeColor = 'var(--icon-color)'; | ||
public makeSaveIcon() { | ||
public makeSaveIcon(): IconType { | ||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); | ||
@@ -475,0 +479,0 @@ svg.innerHTML = ` |
@@ -108,8 +108,12 @@ import { makeArrowBuilder } from '../../components/builders/ArrowBuilder'; | ||
// Use a logarithmic scale for thicknessInput (finer control over thinner strokewidths.) | ||
const inverseThicknessInputFn = (t: number) => Math.log10(t); | ||
const thicknessInputFn = (t: number) => 10**t; | ||
thicknessInput.type = 'range'; | ||
thicknessInput.min = '2'; | ||
thicknessInput.max = '20'; | ||
thicknessInput.step = '1'; | ||
thicknessInput.min = `${inverseThicknessInputFn(2)}`; | ||
thicknessInput.max = `${inverseThicknessInputFn(400)}`; | ||
thicknessInput.step = '0.1'; | ||
thicknessInput.oninput = () => { | ||
this.tool.setThickness(parseFloat(thicknessInput.value) ** 2); | ||
this.tool.setThickness(thicknessInputFn(parseFloat(thicknessInput.value))); | ||
}; | ||
@@ -146,3 +150,3 @@ thicknessRow.appendChild(thicknessLabel); | ||
colorInput.value = this.tool.getColor().toHexString(); | ||
thicknessInput.value = Math.sqrt(this.tool.getThickness()).toString(); | ||
thicknessInput.value = inverseThicknessInputFn(this.tool.getThickness()).toString(); | ||
@@ -149,0 +153,0 @@ objectTypeSelect.replaceChildren(); |
@@ -11,3 +11,3 @@ /** | ||
import { PasteEvent } from '../types'; | ||
import { Mat33, Rect2, Vec2 } from '../math/lib'; | ||
import { Mat33, Rect2 } from '../math/lib'; | ||
import BaseTool from './BaseTool'; | ||
@@ -129,25 +129,3 @@ import EditorImage from '../EditorImage'; | ||
const lines = text.split('\n'); | ||
let lastComponent: TextComponent|null = null; | ||
const components: TextComponent[] = []; | ||
for (const line of lines) { | ||
let position = Vec2.zero; | ||
if (lastComponent) { | ||
const lineMargin = Math.floor(pastedTextStyle.size); | ||
position = lastComponent.getBBox().bottomLeft.plus(Vec2.unitY.times(lineMargin)); | ||
} | ||
const component = new TextComponent([ line ], Mat33.translation(position), pastedTextStyle); | ||
components.push(component); | ||
lastComponent = component; | ||
} | ||
if (components.length === 1) { | ||
await this.addComponentsFromPaste([ components[0] ]); | ||
} else { | ||
// Wrap the existing `TextComponent`s --- dragging one component should drag all. | ||
await this.addComponentsFromPaste([ | ||
new TextComponent(components, Mat33.identity, pastedTextStyle) | ||
]); | ||
} | ||
await this.addComponentsFromPaste([ TextComponent.fromLines(lines, Mat33.identity, pastedTextStyle) ]); | ||
} | ||
@@ -154,0 +132,0 @@ |
import Color4 from '../Color4'; | ||
import Text, { TextStyle } from '../components/Text'; | ||
import TextComponent, { TextStyle } from '../components/Text'; | ||
import Editor from '../Editor'; | ||
import EditorImage from '../EditorImage'; | ||
import Rect2 from '../math/Rect2'; | ||
import Mat33 from '../math/Mat33'; | ||
@@ -11,2 +12,4 @@ import { Vec2 } from '../math/Vec2'; | ||
import { ToolLocalization } from './localization'; | ||
import Erase from '../commands/Erase'; | ||
import uniteCommands from '../commands/uniteCommands'; | ||
@@ -18,7 +21,10 @@ const overlayCssClass = 'textEditorOverlay'; | ||
private textEditOverlay: HTMLElement; | ||
private textInputElem: HTMLInputElement|null = null; | ||
private textInputElem: HTMLTextAreaElement|null = null; | ||
private textTargetPosition: Vec2|null = null; | ||
private textMeasuringCtx: CanvasRenderingContext2D|null = null; | ||
private textRotation: number; | ||
private textScale: Vec2 = Vec2.of(1, 1); | ||
private removeExistingCommand: Erase|null = null; | ||
public constructor(private editor: Editor, description: string, private localizationTable: ToolLocalization) { | ||
@@ -42,6 +48,14 @@ super(editor.notifier, description); | ||
.${overlayCssClass} input { | ||
.${overlayCssClass} textarea { | ||
background-color: rgba(0, 0, 0, 0); | ||
white-space: pre; | ||
padding: 0; | ||
margin: 0; | ||
border: none; | ||
padding: 0; | ||
min-width: 100px; | ||
min-height: 1.1em; | ||
} | ||
@@ -56,3 +70,3 @@ `); | ||
if (this.textMeasuringCtx) { | ||
Text.applyTextStyles(this.textMeasuringCtx, style); | ||
TextComponent.applyTextStyles(this.textMeasuringCtx, style); | ||
return this.textMeasuringCtx.measureText(text).actualBoundingBoxAscent; | ||
@@ -67,3 +81,3 @@ } | ||
if (this.textInputElem && this.textTargetPosition) { | ||
const content = this.textInputElem.value; | ||
const content = this.textInputElem.value.trimEnd(); | ||
this.textInputElem.remove(); | ||
@@ -79,2 +93,4 @@ this.textInputElem = null; | ||
).rightMul( | ||
this.getTextScaleMatrix() | ||
).rightMul( | ||
Mat33.scaling2D(this.editor.viewport.getSizeOfPixelOnCanvas()) | ||
@@ -85,13 +101,21 @@ ).rightMul( | ||
const textComponent = new Text( | ||
[ content ], | ||
textTransform, | ||
this.textStyle, | ||
); | ||
const textComponent = TextComponent.fromLines(content.split('\n'), textTransform, this.textStyle); | ||
const action = EditorImage.addElement(textComponent); | ||
this.editor.dispatch(action); | ||
if (this.removeExistingCommand) { | ||
// Unapply so that `removeExistingCommand` can be added to the undo stack. | ||
this.removeExistingCommand.unapply(this.editor); | ||
this.editor.dispatch(uniteCommands([ this.removeExistingCommand, action ])); | ||
this.removeExistingCommand = null; | ||
} else { | ||
this.editor.dispatch(action); | ||
} | ||
} | ||
} | ||
private getTextScaleMatrix() { | ||
return Mat33.scaling2D(this.textScale.times(1/this.editor.viewport.getSizeOfPixelOnCanvas())); | ||
} | ||
private updateTextInput() { | ||
@@ -105,3 +129,2 @@ if (!this.textInputElem || !this.textTargetPosition) { | ||
const textScreenPos = viewport.canvasToScreen(this.textTargetPosition); | ||
this.textInputElem.type = 'text'; | ||
this.textInputElem.placeholder = this.localizationTable.enterTextToInsert; | ||
@@ -119,5 +142,9 @@ this.textInputElem.style.fontFamily = this.textStyle.fontFamily; | ||
this.textInputElem.style.width = `${this.textInputElem.scrollWidth}px`; | ||
this.textInputElem.style.height = `${this.textInputElem.scrollHeight}px`; | ||
const rotation = this.textRotation + viewport.getRotationAngle(); | ||
const ascent = this.getTextAscent(this.textInputElem.value || 'W', this.textStyle); | ||
this.textInputElem.style.transform = `rotate(${rotation * 180 / Math.PI}deg) translate(0, ${-ascent}px)`; | ||
const scale: Mat33 = this.getTextScaleMatrix(); | ||
this.textInputElem.style.transform = `${scale.toCSSMatrix()} rotate(${rotation * 180 / Math.PI}deg) translate(0, ${-ascent}px)`; | ||
this.textInputElem.style.transformOrigin = 'top left'; | ||
@@ -129,6 +156,8 @@ } | ||
this.textInputElem = document.createElement('input'); | ||
this.textInputElem = document.createElement('textarea'); | ||
this.textInputElem.value = initialText; | ||
this.textInputElem.style.display = 'inline-block'; | ||
this.textTargetPosition = textCanvasPos; | ||
this.textRotation = -this.editor.viewport.getRotationAngle(); | ||
this.textScale = Vec2.of(1, 1).times(this.editor.viewport.getSizeOfPixelOnCanvas()); | ||
this.updateTextInput(); | ||
@@ -138,3 +167,4 @@ | ||
if (this.textInputElem) { | ||
this.textInputElem.size = this.textInputElem?.value.length || 10; | ||
this.textInputElem.style.width = `${this.textInputElem.scrollWidth}px`; | ||
this.textInputElem.style.height = `${this.textInputElem.scrollHeight}px`; | ||
} | ||
@@ -148,3 +178,3 @@ }; | ||
this.textInputElem.onkeyup = (evt) => { | ||
if (evt.key === 'Enter') { | ||
if (evt.key === 'Enter' && !evt.shiftKey) { | ||
this.flushInput(); | ||
@@ -157,2 +187,5 @@ this.editor.focus(); | ||
this.editor.focus(); | ||
this.removeExistingCommand?.unapply(this.editor); | ||
this.removeExistingCommand = null; | ||
} | ||
@@ -181,3 +214,29 @@ }; | ||
if (allPointers.length === 1) { | ||
this.startTextInput(current.canvasPos, ''); | ||
// Are we clicking on a text node? | ||
const canvasPos = current.canvasPos; | ||
const halfTestRegionSize = Vec2.of(2.5, 2.5).times(this.editor.viewport.getSizeOfPixelOnCanvas()); | ||
const testRegion = Rect2.fromCorners(canvasPos.minus(halfTestRegionSize), canvasPos.plus(halfTestRegionSize)); | ||
const targetNodes = this.editor.image.getElementsIntersectingRegion(testRegion); | ||
const targetTextNodes = targetNodes.filter(node => node instanceof TextComponent) as TextComponent[]; | ||
if (targetTextNodes.length > 0) { | ||
const targetNode = targetTextNodes[targetTextNodes.length - 1]; | ||
this.setTextStyle(targetNode.getTextStyle()); | ||
// Create and temporarily apply removeExistingCommand. | ||
this.removeExistingCommand = new Erase([ targetNode ]); | ||
this.removeExistingCommand.apply(this.editor); | ||
this.startTextInput(targetNode.getBaselinePos(), targetNode.getText()); | ||
const transform = targetNode.getTransform(); | ||
this.textRotation = transform.transformVec3(Vec2.unitX).angle(); | ||
const scaleFactor = transform.transformVec3(Vec2.unitX).magnitude(); | ||
this.textScale = Vec2.of(1, 1).times(scaleFactor); | ||
this.updateTextInput(); | ||
} else { | ||
this.removeExistingCommand = null; | ||
this.startTextInput(current.canvasPos, ''); | ||
} | ||
return true; | ||
@@ -241,2 +300,8 @@ } | ||
} | ||
private setTextStyle(style: TextStyle) { | ||
// Copy the style — we may change parts of it. | ||
this.textStyle = {...style}; | ||
this.dispatchUpdateEvent(); | ||
} | ||
} |
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
1314420
358
26872