/* * Copyright 2019 Abakkk * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . * * SPDX-FileCopyrightText: 2019 Abakkk * SPDX-License-Identifier: GPL-3.0-or-later */ /* jslint esversion: 6 */ /* exported Shape, TextAlignment, Transformation, getAllFontFamilies, DrawingElement */ const Cairo = imports.cairo; const Clutter = imports.gi.Clutter; const Lang = imports.lang; const Pango = imports.gi.Pango; const PangoCairo = imports.gi.PangoCairo; const Me = imports.misc.extensionUtils.getCurrentExtension(); const UUID = Me.uuid.replace(/@/gi, '_at_').replace(/[^a-z0-9+_-]/gi, '_'); var Shape = { NONE: 0, LINE: 1, ELLIPSE: 2, RECTANGLE: 3, TEXT: 4, POLYGON: 5, POLYLINE: 6, IMAGE: 7 }; var TextAlignment = { LEFT: 0, CENTER: 1, RIGHT: 2 }; var Transformation = { TRANSLATION: 0, ROTATION: 1, SCALE_PRESERVE: 2, STRETCH: 3, REFLECTION: 4, INVERSION: 5, SMOOTH: 100 }; var getAllFontFamilies = function() { return PangoCairo.font_map_get_default().list_families().map(fontFamily => fontFamily.get_name()).sort((a,b) => a.localeCompare(b)); }; const getFillRuleSvgName = function(fillRule) { return fillRule == Cairo.FillRule.EVEN_ODD ? 'evenodd' : 'nonzero'; }; const getLineCapSvgName = function(lineCap) { return lineCap == Cairo.LineCap.BUTT ? 'butt' : lineCap == Cairo.LineCap.SQUASH ? 'square' : 'round'; }; const getLineJoinSvgName = function(lineJoin) { return lineJoin == Cairo.LineJoin.MITER ? 'miter' : lineJoin == Cairo.LineJoin.BEVEL ? 'bevel' : 'round'; }; const SVG_DEBUG_SUPERPOSES_CAIRO = false; const RADIAN = 180 / Math.PI; // degree const INVERSION_CIRCLE_RADIUS = 12; // px const REFLECTION_TOLERANCE = 5; // px, to select vertical and horizontal directions const STRETCH_TOLERANCE = Math.PI / 8; // rad, to select vertical and horizontal directions const MIN_REFLECTION_LINE_LENGTH = 10; // px const MIN_TRANSLATION_DISTANCE = 1; // px const MIN_ROTATION_ANGLE = Math.PI / 1000; // rad const MIN_DRAWING_SIZE = 3; // px const MIN_INTERMEDIATE_POINT_DISTANCE = 1; // px, the higher it is, the fewer points there will be const MARK_COLOR = Clutter.Color.get_static(Clutter.StaticColor.BLUE); var DrawingElement = function(params) { return params.shape == Shape.TEXT ? new TextElement(params) : params.shape == Shape.IMAGE ? new ImageElement(params) : new _DrawingElement(params); }; // DrawingElement represents a "brushstroke". // It can be converted into a cairo path as well as a svg element. // See DrawingArea._startDrawing() to know its params. const _DrawingElement = new Lang.Class({ Name: `${UUID}-DrawingElement`, _init: function(params) { for (let key in params) this[key] = params[key]; // compatibility with json generated by old extension versions if (params.transformations === undefined) this.transformations = []; if (params.font && !(params.font instanceof Pango.FontDescription)) { // compatibility with v6.2- if (params.font.weight === 0) this.font.weight = 400; else if (params.font.weight === 1) this.font.weight = 700; this.font = new Pango.FontDescription(); ['family', 'weight', 'style', 'stretch', 'variant'].forEach(attribute => { if (params.font[attribute] !== undefined) try { this.font[`set_${attribute}`](params.font[attribute]); } catch(e) {} }); } if (params.transform && params.transform.center) { let angle = (params.transform.angle || 0) + (params.transform.startAngle || 0); if (angle) this.transformations.push({ type: Transformation.ROTATION, angle: angle }); } if (params.shape == Shape.ELLIPSE && params.transform && params.transform.ratio && params.transform.ratio != 1 && params.points.length >= 2) { let [ratio, p0, p1] = [params.transform.ratio, params.points[0], params.points[1]]; // Add a fake point that will give the right ellipse ratio when building the element. this.points.push([ratio * (p1[0] - p0[0]) + p0[0], ratio * (p1[1] - p0[1]) + p0[1]]); } delete this.transform; // v10- if (this.textRightAligned) this.textAlignment = TextAlignment.RIGHT; delete this.textRightAligned; }, // toJSON is called by JSON.stringify toJSON: function() { return { shape: this.shape, color: this.color, line: this.line, dash: this.dash, fill: this.fill, fillRule: this.fillRule, eraser: this.eraser, transformations: this.transformations.filter(transformation => transformation.type != Transformation.SMOOTH) .map(transformation => Object.assign({}, transformation, { undoable: undefined })), points: this.points.map((point) => [Math.round(point[0]*100)/100, Math.round(point[1]*100)/100]) }; }, buildCairo: function(cr, params) { if (this.color) Clutter.cairo_set_source_color(cr, this.color); if (this.line) { cr.setLineCap(this.line.lineCap); cr.setLineJoin(this.line.lineJoin); cr.setLineWidth(this.line.lineWidth); } if (this.fillRule) cr.setFillRule(this.fillRule); if (this.dash && this.dash.active && this.dash.array && this.dash.array[0] && this.dash.array[1]) cr.setDash(this.dash.array, this.dash.offset); if (this.eraser) cr.setOperator(Cairo.Operator.CLEAR); else cr.setOperator(Cairo.Operator.OVER); if (params.dummyStroke) setDummyStroke(cr); if (SVG_DEBUG_SUPERPOSES_CAIRO) { Clutter.cairo_set_source_color(cr, Clutter.Color.new(255, 0, 0, 255)); cr.setLineWidth(this.line.lineWidth / 2 || 1); } this.transformations.slice(0).reverse().forEach(transformation => { if (transformation.type == Transformation.TRANSLATION) { cr.translate(transformation.slideX, transformation.slideY); } else if (transformation.type == Transformation.ROTATION) { let center = this._getTransformedCenter(transformation); cr.translate(center[0], center[1]); cr.rotate(transformation.angle); cr.translate(-center[0], -center[1]); } else if (transformation.type == Transformation.SCALE_PRESERVE || transformation.type == Transformation.STRETCH) { let center = this._getTransformedCenter(transformation); cr.translate(center[0], center[1]); cr.rotate(transformation.angle); cr.scale(transformation.scaleX, transformation.scaleY); cr.rotate(-transformation.angle); cr.translate(-center[0], -center[1]); } else if (transformation.type == Transformation.REFLECTION || transformation.type == Transformation.INVERSION) { cr.translate(transformation.slideX, transformation.slideY); cr.rotate(transformation.angle); cr.scale(transformation.scaleX, transformation.scaleY); cr.rotate(-transformation.angle); cr.translate(-transformation.slideX, -transformation.slideY); } }); this._drawCairo(cr, params); cr.identityMatrix(); }, _addMarks: function(cr) { if (this.showSymmetryElement) { setDummyStroke(cr); Clutter.cairo_set_source_color(cr, MARK_COLOR); let transformation = this.lastTransformation; if (transformation.type == Transformation.REFLECTION) { cr.moveTo(transformation.startX, transformation.startY); cr.lineTo(transformation.endX, transformation.endY); } else { cr.arc(transformation.endX, transformation.endY, INVERSION_CIRCLE_RADIUS, 0, 2 * Math.PI); } cr.stroke(); } if (this.showRotationCenter) { setDummyStroke(cr); Clutter.cairo_set_source_color(cr, MARK_COLOR); let center = this._getTransformedCenter(this.lastTransformation); cr.arc(center[0], center[1], INVERSION_CIRCLE_RADIUS, 0, 2 * Math.PI); cr.stroke(); } if (this.showStretchAxes) { setDummyStroke(cr); Clutter.cairo_set_source_color(cr, MARK_COLOR); let center = this._getTransformedCenter(this.lastTransformation); for (let i = 0; i <=1; i++) { cr.moveTo(center[0] - 1000 * Math.cos(i * Math.PI / 2), center[1] - 1000 * Math.sin(i * Math.PI / 2)); cr.lineTo(center[0] + 1000 * Math.cos(i * Math.PI / 2), center[1] + 1000 * Math.sin(i * Math.PI / 2)); } cr.stroke(); } }, _drawCairo: function(cr, params) { let [points, shape] = [this.points, this.shape]; if (shape == Shape.LINE && points.length == 3) { cr.moveTo(points[0][0], points[0][1]); cr.curveTo(points[0][0], points[0][1], points[1][0], points[1][1], points[2][0], points[2][1]); } else if (shape == Shape.LINE && points.length == 4) { cr.moveTo(points[0][0], points[0][1]); cr.curveTo(points[1][0], points[1][1], points[2][0], points[2][1], points[3][0], points[3][1]); } else if (shape == Shape.NONE || shape == Shape.LINE) { cr.moveTo(points[0][0], points[0][1]); for (let j = 1; j < points.length; j++) { cr.lineTo(points[j][0], points[j][1]); } } else if (shape == Shape.ELLIPSE && points.length >= 2) { let radius = Math.hypot(points[1][0] - points[0][0], points[1][1] - points[0][1]); let ratio = 1; if (points[2]) { ratio = Math.hypot(points[2][0] - points[0][0], points[2][1] - points[0][1]) / radius; cr.translate(points[0][0], points[0][1]); cr.scale(ratio, 1); cr.translate(-points[0][0], -points[0][1]); cr.arc(points[0][0], points[0][1], radius, 0, 2 * Math.PI); cr.translate(points[0][0], points[0][1]); cr.scale(1 / ratio, 1); cr.translate(-points[0][0], -points[0][1]); } else cr.arc(points[0][0], points[0][1], radius, 0, 2 * Math.PI); } else if (shape == Shape.RECTANGLE && points.length == 2) { cr.rectangle(points[0][0], points[0][1], points[1][0] - points[0][0], points[1][1] - points[0][1]); } else if ((shape == Shape.POLYGON || shape == Shape.POLYLINE) && points.length >= 2) { cr.moveTo(points[0][0], points[0][1]); for (let j = 1; j < points.length; j++) { cr.lineTo(points[j][0], points[j][1]); } if (shape == Shape.POLYGON) cr.closePath(); } }, getContainsPoint: function(cr, x, y) { cr.save(); cr.setLineWidth(Math.max(this.line.lineWidth, 25)); cr.setDash([], 0); // Check whether the point is inside/on/near the element. let inElement = cr.inStroke(x, y) || this.fill && cr.inFill(x, y); cr.restore(); return inElement; }, buildSVG: function(bgcolorString) { let transforms = []; this.transformations.slice(0).reverse().forEach(transformation => { let center = this._getTransformedCenter(transformation); if (transformation.type == Transformation.TRANSLATION) { transforms.push(['translate', transformation.slideX, transformation.slideY]); } else if (transformation.type == Transformation.ROTATION) { transforms.push(['translate', center[0], center[1]]); transforms.push(['rotate', transformation.angle * RADIAN]); transforms.push(['translate', -center[0], -center[1]]); } else if (transformation.type == Transformation.SCALE_PRESERVE || transformation.type == Transformation.STRETCH) { transforms.push(['translate', center[0], center[1]]); transforms.push(['rotate', transformation.angle * RADIAN]); transforms.push(['scale', transformation.scaleX, transformation.scaleY]); transforms.push(['rotate', -transformation.angle * RADIAN]); transforms.push(['translate', -center[0], -center[1]]); } else if (transformation.type == Transformation.REFLECTION || transformation.type == Transformation.INVERSION) { transforms.push(['translate', transformation.slideX, transformation.slideY]); transforms.push(['rotate', transformation.angle * RADIAN]); transforms.push(['scale', transformation.scaleX, transformation.scaleY]); transforms.push(['rotate', -transformation.angle * RADIAN]); transforms.push(['translate', -transformation.slideX, -transformation.slideY]); } }); let grouped = []; transforms.forEach((transform, index) => { let [type, ...values] = transform; if (grouped.length && grouped[grouped.length - 1][0] == type) values.forEach((value, valueIndex) => grouped[grouped.length - 1][valueIndex + 1] += value); else grouped.push(transform); }); let filtered = grouped.filter(transform => { let [type, ...values] = transform; if (type == 'scale') return values.some(value => value != 1); else return values.some(value => value != 0); }); let transAttribute = ''; if (filtered.length) { transAttribute = ' transform="'; filtered.forEach((transform, index) => { let [type, ...values] = transform; transAttribute += `${index == 0 ? '' : ' '}${type}(${values.map(value => Number(value).toFixed(2))})`; }); transAttribute += '"'; } return this._drawSvg(transAttribute, bgcolorString); }, _drawSvg: function(transAttribute, bgcolorString) { let row = "\n "; let points = this.points.map((point) => [Math.round(point[0]*100)/100, Math.round(point[1]*100)/100]); let color = this.eraser ? bgcolorString : this.color.toJSON(); let fill = this.fill && !this.isStraightLine; let attributes = this.eraser ? `class="eraser" ` : ''; if (fill) { attributes += `fill="${color}"`; if (this.fillRule) attributes += ` fill-rule="${getFillRuleSvgName(this.fillRule)}"`; } else { attributes += `fill="none"`; } if (this.line && this.line.lineWidth) { attributes += ` stroke="${color}"` + ` stroke-width="${this.line.lineWidth}"`; if (this.line.lineCap) attributes += ` stroke-linecap="${getLineCapSvgName(this.line.lineCap)}"`; if (this.line.lineJoin && !this.isStraightLine) attributes += ` stroke-linejoin="${getLineJoinSvgName(this.line.lineJoin)}"`; if (this.dash && this.dash.active && this.dash.array && this.dash.array[0] && this.dash.array[1]) attributes += ` stroke-dasharray="${this.dash.array[0]} ${this.dash.array[1]}" stroke-dashoffset="${this.dash.offset}"`; } if (this.shape == Shape.LINE && points.length == 4) { row += ``; } else if (this.shape == Shape.LINE && points.length == 3) { row += ``; } else if (this.shape == Shape.LINE) { row += ``; } else if (this.shape == Shape.NONE) { row += ``; } else if (this.shape == Shape.ELLIPSE && points.length == 3) { let ry = Math.hypot(points[1][0] - points[0][0], points[1][1] - points[0][1]); let rx = Math.hypot(points[2][0] - points[0][0], points[2][1] - points[0][1]); row += ``; } else if (this.shape == Shape.ELLIPSE && points.length == 2) { let r = Math.hypot(points[1][0] - points[0][0], points[1][1] - points[0][1]); row += ``; } else if (this.shape == Shape.RECTANGLE && points.length == 2) { row += ``; } else if (this.shape == Shape.POLYGON && points.length >= 3) { row += ``; } else if (this.shape == Shape.POLYLINE && points.length >= 2) { row += ``; } return row; }, get lastTransformation() { if (!this.transformations.length) return null; return this.transformations[this.transformations.length - 1]; }, get isStraightLine() { return this.shape == Shape.LINE && this.points.length == 2; }, smoothAll: function() { let oldPoints = this.points.slice(); for (let i = 0; i < this.points.length; i++) this._smooth(i); let newPoints = this.points.slice(); this.transformations.push({ type: Transformation.SMOOTH, undoable: true, undo: () => this.points = oldPoints, redo: () => this.points = newPoints }); if (this._undoneTransformations) this._undoneTransformations = this._undoneTransformations.filter(transformation => transformation.type != Transformation.SMOOTH); }, addPoint: function() { if (this.shape == Shape.POLYGON || this.shape == Shape.POLYLINE) { // copy last point let [lastPoint, secondToLastPoint] = [this.points[this.points.length - 1], this.points[this.points.length - 2]]; if (!getNearness(secondToLastPoint, lastPoint, MIN_DRAWING_SIZE)) this.points.push([lastPoint[0], lastPoint[1]]); } else if (this.shape == Shape.LINE) { if (this.points.length == 2) { this.points[2] = this.points[1]; } else if (this.points.length == 3) { this.points[3] = this.points[2]; this.points[2] = this.points[1]; } } }, // For free drawing only. addIntermediatePoint: function(x, y, transform) { let points = this.points; if (getNearness(points[points.length - 1], [x, y], MIN_INTERMEDIATE_POINT_DISTANCE)) return; points.push([x, y]); if (transform) this._smooth(points.length - 1); }, startDrawing: function(startX, startY) { this.points.push([startX, startY]); if (this.shape == Shape.POLYGON || this.shape == Shape.POLYLINE) this.points.push([startX, startY]); }, updateDrawing: function(x, y, transform) { let points = this.points; if (x == points[points.length - 1][0] && y == points[points.length - 1][1]) return; transform = transform || this.transformations.length >= 1; if (this.shape == Shape.NONE) { points.push([x, y]); if (transform) this._smooth(points.length - 1); } else if ((this.shape == Shape.RECTANGLE || this.shape == Shape.POLYGON || this.shape == Shape.POLYLINE) && transform) { if (points.length < 2) return; let center = this._getOriginalCenter(); this.transformations[0] = { type: Transformation.ROTATION, angle: getAngle(center[0], center[1], points[points.length - 1][0], points[points.length - 1][1], x, y) }; } else if (this.shape == Shape.ELLIPSE && transform) { if (points.length < 2) return; points[2] = [x, y]; let center = this._getOriginalCenter(); this.transformations[0] = { type: Transformation.ROTATION, angle: getAngle(center[0], center[1], center[0] + 1, center[1], x, y) }; } else if (this.shape == Shape.POLYGON || this.shape == Shape.POLYLINE) { points[points.length - 1] = [x, y]; } else { points[1] = [x, y]; } }, stopDrawing: function() { // skip when the size is too small to be visible (3px) (except for free drawing) if (this.shape != Shape.NONE && this.points.length >= 2) { let lastPoint = this.points[this.points.length - 1]; let secondToLastPoint = this.points[this.points.length - 2]; if (getNearness(secondToLastPoint, lastPoint, MIN_DRAWING_SIZE)) this.points.pop(); } if (this.transformations[0] && this.transformations[0].type == Transformation.ROTATION && Math.abs(this.transformations[0].angle) < MIN_ROTATION_ANGLE) this.transformations.shift(); }, startTransformation: function(startX, startY, type, undoable) { if (type == Transformation.TRANSLATION) this.transformations.push({ startX, startY, type, undoable, slideX: 0, slideY: 0 }); else if (type == Transformation.ROTATION) this.transformations.push({ startX, startY, type, undoable, angle: 0 }); else if (type == Transformation.SCALE_PRESERVE || type == Transformation.STRETCH) this.transformations.push({ startX, startY, type, undoable, scaleX: 1, scaleY: 1, angle: 0 }); else if (type == Transformation.REFLECTION) this.transformations.push({ startX, startY, endX: startX, endY: startY, type, undoable, scaleX: 1, scaleY: 1, slideX: 0, slideY: 0, angle: 0 }); else if (type == Transformation.INVERSION) this.transformations.push({ startX, startY, endX: startX, endY: startY, type, undoable, scaleX: -1, scaleY: -1, slideX: startX, slideY: startY, angle: Math.PI + Math.atan(startY / (startX || 1)) }); if (type == Transformation.REFLECTION || type == Transformation.INVERSION) this.showSymmetryElement = true; else if (type == Transformation.ROTATION) this.showRotationCenter = true; else if (type == Transformation.STRETCH) this.showStretchAxes = true; }, updateTransformation: function(x, y) { let transformation = this.lastTransformation; if (transformation.type == Transformation.TRANSLATION) { transformation.slideX = x - transformation.startX; transformation.slideY = y - transformation.startY; } else if (transformation.type == Transformation.ROTATION) { let center = this._getTransformedCenter(transformation); transformation.angle = getAngle(center[0], center[1], transformation.startX, transformation.startY, x, y); } else if (transformation.type == Transformation.SCALE_PRESERVE) { let center = this._getTransformedCenter(transformation); let scale = Math.hypot(x - center[0], y - center[1]) / Math.hypot(transformation.startX - center[0], transformation.startY - center[1]) || 1; [transformation.scaleX, transformation.scaleY] = [scale, scale]; } else if (transformation.type == Transformation.STRETCH) { let center = this._getTransformedCenter(transformation); let startAngle = getAngle(center[0], center[1], center[0] + 1, center[1], transformation.startX, transformation.startY); let vertical = Math.abs(Math.sin(startAngle)) >= Math.sin(Math.PI / 2 - STRETCH_TOLERANCE); let horizontal = Math.abs(Math.cos(startAngle)) >= Math.cos(STRETCH_TOLERANCE); let scale = Math.hypot(x - center[0], y - center[1]) / Math.hypot(transformation.startX - center[0], transformation.startY - center[1]) || 1; transformation.scaleX = vertical ? 1 : scale; transformation.scaleY = !vertical ? 1 : scale; transformation.angle = vertical || horizontal ? 0 : getAngle(center[0], center[1], center[0] + 1, center[1], x, y); } else if (transformation.type == Transformation.REFLECTION) { [transformation.endX, transformation.endY] = [x, y]; if (getNearness([transformation.startX, transformation.startY], [x, y], MIN_REFLECTION_LINE_LENGTH)) { // do nothing to avoid jumps (no transformation at starting and locked transformation after) } else if (Math.abs(y - transformation.startY) <= REFLECTION_TOLERANCE && Math.abs(x - transformation.startX) > REFLECTION_TOLERANCE) { [transformation.scaleX, transformation.scaleY] = [1, -1]; [transformation.slideX, transformation.slideY] = [0, transformation.startY]; transformation.angle = Math.PI; } else if (Math.abs(x - transformation.startX) <= REFLECTION_TOLERANCE && Math.abs(y - transformation.startY) > REFLECTION_TOLERANCE) { [transformation.scaleX, transformation.scaleY] = [-1, 1]; [transformation.slideX, transformation.slideY] = [transformation.startX, 0]; transformation.angle = Math.PI; } else if (x != transformation.startX) { let tan = (y - transformation.startY) / (x - transformation.startX); [transformation.scaleX, transformation.scaleY] = [1, -1]; [transformation.slideX, transformation.slideY] = [0, transformation.startY - transformation.startX * tan]; transformation.angle = Math.PI + Math.atan(tan); } else if (y != transformation.startY) { let tan = (x - transformation.startX) / (y - transformation.startY); [transformation.scaleX, transformation.scaleY] = [-1, 1]; [transformation.slideX, transformation.slideY] = [transformation.startX - transformation.startY * tan, 0]; transformation.angle = Math.PI - Math.atan(tan); } } else if (transformation.type == Transformation.INVERSION) { [transformation.endX, transformation.endY] = [x, y]; [transformation.scaleX, transformation.scaleY] = [-1, -1]; [transformation.slideX, transformation.slideY] = [x, y]; transformation.angle = Math.PI + Math.atan(y / (x || 1)); } }, stopTransformation: function() { this.showSymmetryElement = false; this.showRotationCenter = false; this.showStretchAxes = false; // Clean transformations let transformation = this.lastTransformation; if (!transformation) return; if (transformation.type == Transformation.REFLECTION && getNearness([transformation.startX, transformation.startY], [transformation.endX, transformation.endY], MIN_REFLECTION_LINE_LENGTH) || transformation.type == Transformation.TRANSLATION && Math.hypot(transformation.slideX, transformation.slideY) < MIN_TRANSLATION_DISTANCE || transformation.type == Transformation.ROTATION && Math.abs(transformation.angle) < MIN_ROTATION_ANGLE) { this.transformations.pop(); } else { delete transformation.startX; delete transformation.startY; delete transformation.endX; delete transformation.endY; } }, undoTransformation: function() { if (this.transformations && this.transformations.length) { // Do not undo initial transformations (transformations made during the drawing step). if (!this.lastTransformation.undoable) return false; if (!this._undoneTransformations) this._undoneTransformations = []; let transformation = this.transformations.pop(); if (transformation.type == Transformation.SMOOTH) transformation.undo(); this._undoneTransformations.push(transformation); return true; } return false; }, redoTransformation: function() { if (this._undoneTransformations && this._undoneTransformations.length) { if (!this.transformations) this.transformations = []; let transformation = this._undoneTransformations.pop(); if (transformation.type == Transformation.SMOOTH) transformation.redo(); this.transformations.push(transformation); return true; } return false; }, resetUndoneTransformations: function() { delete this._undoneTransformations; }, get canUndo() { return this._undoneTransformations && this._undoneTransformations.length ? true : false; }, // The figure rotation center before transformations (original). _getOriginalCenter: function() { if (!this._originalCenter) { let points = this.points; this._originalCenter = this.shape == Shape.ELLIPSE ? [points[0][0], points[0][1]] : this.shape == Shape.LINE && points.length == 4 ? getCurveCenter(points[0], points[1], points[2], points[3]) : this.shape == Shape.LINE && points.length == 3 ? getCurveCenter(points[0], points[0], points[1], points[2]) : points.length >= 3 ? getCentroid(points) : getNaiveCenter(points); } return this._originalCenter; }, // The figure rotation center, whose position is affected by all transformations done before 'transformation'. _getTransformedCenter: function(transformation) { if (!transformation.elementTransformedCenter) { let matrix = new Pango.Matrix({ xx: 1, xy: 0, yx: 0, yy: 1, x0: 0, y0: 0 }); // Apply transformations to the matrice in reverse order // because Pango multiply matrices by the left when applying a transformation this.transformations.slice(0, this.transformations.indexOf(transformation)).reverse().forEach(transformation => { if (transformation.type == Transformation.TRANSLATION) { matrix.translate(transformation.slideX, transformation.slideY); } else if (transformation.type == Transformation.ROTATION) { // nothing, the center position is preserved. } else if (transformation.type == Transformation.SCALE_PRESERVE || transformation.type == Transformation.STRETCH) { // nothing, the center position is preserved. } else if (transformation.type == Transformation.REFLECTION || transformation.type == Transformation.INVERSION) { matrix.translate(transformation.slideX, transformation.slideY); matrix.rotate(-transformation.angle * RADIAN); matrix.scale(transformation.scaleX, transformation.scaleY); matrix.rotate(transformation.angle * RADIAN); matrix.translate(-transformation.slideX, -transformation.slideY); } }); let originalCenter = this._getOriginalCenter(); transformation.elementTransformedCenter = matrix.transform_point(originalCenter[0], originalCenter[1]); } return transformation.elementTransformedCenter; }, _smooth: function(i) { if (i < 2) return; this.points[i-1] = [(this.points[i-2][0] + this.points[i][0]) / 2, (this.points[i-2][1] + this.points[i][1]) / 2]; } }); const TextElement = new Lang.Class({ Name: `${UUID}-TextElement`, Extends: _DrawingElement, toJSON: function() { // The font size is useless because it is always computed from the points during cairo/svg building. this.font.unset_fields(Pango.FontMask.SIZE); return { shape: this.shape, color: this.color, eraser: this.eraser, transformations: this.transformations, text: this.text, textAlignment: this.textAlignment, font: this.font.to_string(), points: this.points.map((point) => [Math.round(point[0]*100)/100, Math.round(point[1]*100)/100]) }; }, get x() { // this.textWidth is computed during Cairo building. let offset = this.textAlignment == TextAlignment.RIGHT ? this.textWidth : this.textAlignment == TextAlignment.CENTER ? this.textWidth / 2 : 0; return this.points[1][0] - offset; }, get y() { return Math.max(this.points[0][1], this.points[1][1]); }, get height() { return Math.abs(this.points[1][1] - this.points[0][1]); }, // this.lineWidths is computed during Cairo building. _getLineX: function(index) { let offset = this.textAlignment == TextAlignment.RIGHT && this.lineWidths && this.lineWidths[index] ? this.lineWidths[index] : this.textAlignment == TextAlignment.CENTER && this.lineWidths && this.lineWidths[index] ? this.lineWidths[index] / 2 : 0; return this.points[1][0] - offset; }, _drawCairo: function(cr, params) { if (this.points.length != 2) return; let layout = PangoCairo.create_layout(cr); let fontSize = this.height * Pango.SCALE; this.font.set_absolute_size(fontSize); layout.set_font_description(this.font); layout.set_text(this.text, -1); this.textWidth = layout.get_pixel_size()[0]; this.lineWidths = layout.get_lines_readonly().map(layoutLine => layoutLine.get_pixel_extents()[1].width); layout.get_lines_readonly().forEach((layoutLine, index) => { cr.moveTo(this._getLineX(index), this.y + this.height * index); PangoCairo.show_layout_line(cr, layoutLine); }); // Cannot use 'layout.index_to_line_x(cursorPosition, 0)' because character position != byte index if (params.showTextCursor) { let layoutCopy = layout.copy(); if (this.cursorPosition != -1) layoutCopy.set_text(this.text.slice(0, this.cursorPosition), -1); let cursorLineIndex = layoutCopy.get_line_count() - 1; let cursorX = this._getLineX(cursorLineIndex) + layoutCopy.get_line_readonly(cursorLineIndex).get_pixel_extents()[1].width; let cursorY = this.y + this.height * cursorLineIndex; cr.rectangle(cursorX, cursorY, this.height / 25, - this.height); cr.fill(); } if (params.showElementBounds) { cr.rectangle(this.x, this.y - this.height, this.textWidth, this.height * layout.get_line_count()); setDummyStroke(cr); } else if (params.drawElementBounds) { cr.rectangle(this.x, this.y - this.height, this.textWidth, this.height * layout.get_line_count()); // Only draw the rectangle to find the element, not to show it. cr.setLineWidth(0); } }, getContainsPoint: function(cr, x, y) { return cr.inFill(x, y); }, _drawSvg: function(transAttribute, bgcolorString) { if (this.points.length != 2) return ""; let row = ""; let height = Math.round(this.height * 100) / 100; let color = this.eraser ? bgcolorString : this.color.toJSON(); let attributes = this.eraser ? `class="eraser" ` : ''; attributes += `fill="${color}" ` + `font-size="${height}" ` + `font-family="${this.font.get_family()}"`; // this.font.to_string() is not valid to fill the svg 'font' shorthand property. // Each property must be filled separately. ['Stretch', 'Style', 'Variant'].forEach(attribute => { let lower = attribute.toLowerCase(); if (this.font[`get_${lower}`]() != Pango[attribute].NORMAL) { let font = new Pango.FontDescription(); font[`set_${lower}`](this.font[`get_${lower}`]()); attributes += ` font-${lower}="${font.to_string()}"`; } }); if (this.font.get_weight() != Pango.Weight.NORMAL) attributes += ` font-weight="${this.font.get_weight()}"`; // It is a fallback for thumbnails. The following layout is not the same than the Cairo one and // layoutLine.get_pixel_extents does not return the correct value with non-latin fonts. // An alternative would be to store this.lineWidths in the json. if (this.textAlignment != TextAlignment.LEFT && !this.lineWidths) { let clutterText = new Clutter.Text({ text: this.text }); let layout = clutterText.get_layout(); let fontSize = height * Pango.SCALE; this.font.set_absolute_size(fontSize); layout.set_font_description(this.font); this.lineWidths = layout.get_lines_readonly().map(layoutLine => layoutLine.get_pixel_extents()[1].width); } this.text.split(/\r\n|\r|\n/).forEach((text, index) => { let x = Math.round(this._getLineX(index) * 100) / 100; let y = Math.round((this.y + this.height * index) * 100) / 100; row += `\n ${text}`; }); return row; }, updateDrawing: function(x, y, transform) { let points = this.points; if (x == points[points.length - 1][0] && y == points[points.length - 1][1]) return; transform = transform || this.transformations.length >= 1; if (transform) { if (points.length < 2) return; let [slideX, slideY] = [x - points[1][0], y - points[1][1]]; points[0] = [points[0][0] + slideX, points[0][1] + slideY]; points[1] = [x, y]; } else { points[1] = [x, y]; } }, _getOriginalCenter: function() { if (!this._originalCenter) { let points = this.points; this._originalCenter = points.length == 2 ? [points[1][0], Math.max(points[0][1], points[1][1])] : points.length >= 3 ? getCentroid(points) : getNaiveCenter(points); } return this._originalCenter; }, }); const ImageElement = new Lang.Class({ Name: `${UUID}-ImageElement`, Extends: _DrawingElement, toJSON: function() { return { shape: this.shape, color: this.color, colored: this.colored, transformations: this.transformations, image: this.image, preserveAspectRatio: this.preserveAspectRatio, points: this.points.map((point) => [Math.round(point[0]*100)/100, Math.round(point[1]*100)/100]) }; }, _drawCairo: function(cr, params) { if (this.points.length < 2) return; let points = this.points; let [x, y] = [Math.min(points[0][0], points[1][0]), Math.min(points[0][1], points[1][1])]; let [width, height] = [Math.abs(points[1][0] - points[0][0]), Math.abs(points[1][1] - points[0][1])]; if (width < 1 || height < 1) return; cr.save(); this.image.setCairoSource(cr, x, y, width, height, this.preserveAspectRatio, this.colored ? this.color.toJSON() : null); cr.rectangle(x, y, width, height); cr.fill(); cr.restore(); if (params.showElementBounds) { cr.rectangle(x, y, width, height); setDummyStroke(cr); } else if (params.drawElementBounds) { cr.rectangle(x, y, width, height); // Only draw the rectangle to find the element, not to show it. cr.setLineWidth(0); } }, getContainsPoint: function(cr, x, y) { return cr.inFill(x, y); }, _drawSvg: function(transAttribute) { let points = this.points; let row = "\n "; let attributes = ''; if (points.length == 2) { attributes += `fill="none"`; let base64 = this.image.getBase64ForColor(this.colored ? this.color.toJSON() : null); row += ``; } return row; }, updateDrawing: function(x, y, transform) { let points = this.points; if (x == points[0][0] || y == points[0][1]) return; points[1] = [x, y]; this.preserveAspectRatio = !transform; } }); const setDummyStroke = function(cr) { cr.setLineWidth(2); cr.setLineCap(0); cr.setLineJoin(0); cr.setDash([1, 2], 0); }; const getNearness = function(pointA, pointB, distance) { return Math.hypot(pointB[0] - pointA[0], pointB[1] - pointA[1]) < distance; }; // mean of the vertices, ok for regular polygons const getNaiveCenter = function(points) { return points.reduce((accumulator, point) => accumulator = [accumulator[0] + point[0], accumulator[1] + point[1]]) .map(coord => coord / points.length); }; // https://en.wikipedia.org/wiki/Centroid#Of_a_polygon const getCentroid = function(points) { let n = points.length; points.push(points[0]); let [sA, sX, sY] = [0, 0, 0]; for (let i = 0; i <= n-1; i++) { let a = points[i][0]*points[i+1][1] - points[i+1][0]*points[i][1]; sA += a; sX += (points[i][0] + points[i+1][0]) * a; sY += (points[i][1] + points[i+1][1]) * a; } points.pop(); if (sA == 0) return getNaiveCenter(points); return [sX / (3 * sA), sY / (3 * sA)]; }; /* Cubic Bézier: [0, 1] -> ℝ², P(t) = (1-t)³P₀ + 3t(1-t)²P₁ + 3t²(1-t)P₂ + t³P₃ general case: const cubicBezierCoord = function(x0, x1, x2, x3, t) { return (1-t)**3*x0 + 3*t*(1-t)**2*x1 + 3*t**2*(1-t)*x2 + t**3*x3; } const cubicBezierPoint = function(p0, p1, p2, p3, t) { return [cubicBezier(p0[0], p1[0], p2[0], p3[0], t), cubicBezier(p0[1], p1[1], p2[1], p3[1], t)]; } Approximatively: control point: p0 ---- p1 ---- p2 ---- p3 (p2 is not on the curve) t: 0 ---- 1/3 ---- 2/3 ---- 1 */ // If the curve has a symmetry axis, it is truly a center (the intersection of the curve and the axis). // In other cases, it is not a notable point, just a visual approximation. const getCurveCenter = function(p0, p1, p2, p3) { if (p0[0] == p1[0] && p0[1] == p1[1]) // p0 = p1, t = 2/3 return [(p1[0] + 6*p1[0] + 12*p2[0] + 8*p3[0]) / 27, (p1[1] + 6*p1[1] + 12*p2[1] + 8*p3[1]) / 27]; else // t = 1/2 return [(p0[0] + 3*p1[0] + 3*p2[0] + p3[0]) / 8, (p0[1] + 3*p1[1] + 3*p2[1] + p3[1]) / 8]; }; const getAngle = function(xO, yO, xA, yA, xB, yB) { // calculate angle of rotation in absolute value // cos(AOB) = (OA.OB)/(||OA||*||OB||) where OA.OB = (xA-xO)*(xB-xO) + (yA-yO)*(yB-yO) let cos = ((xA - xO)*(xB - xO) + (yA - yO)*(yB - yO)) / (Math.hypot(xA - xO, yA - yO) * Math.hypot(xB - xO, yB - yO)); // acos is defined on [-1, 1] but // with A == B and imperfect computer calculations, cos may be equal to 1.00000001. cos = Math.min(Math.max(-1, cos), 1); let angle = Math.acos( cos ); // determine the sign of the angle if (xA == xO) { if (xB > xO) angle = -angle; } else { // equation of OA: y = ax + b let a = (yA - yO) / (xA - xO); let b = yA - a*xA; if (yB < a*xB + b) angle = - angle; if (xA < xO) angle = - angle; } return angle; };