import {Pattern, Line, Defs, Rect, G, Text, Tspan} from '@svgdotjs/svg.js'; import {radToDegree} from '../shapes/helpers.js'; import opentype from 'opentype.js'; import fs from 'fs'; /** * Represents a basic Tldraw shape on the whiteboard. * * @class Shape * @typedef {Object} ColorTypes * @property {'shape'} ShapeColor - Color for shape outlines or borders. * @property {'fill'} FillColor - Solid fill color inside the shape. * @property {'semi'} SemiFillColor - Semi fill shape color. * @property {'sticky'} StickyColor - Color for sticky notes. */ export class Shape { /** * Creates an instance of Shape. * @constructor * @param {Object} params - The shape's parameters. * @param {String} params.id - The the shape ID. * @param {Number} params.x - The shape's x-coordinate. * @param {Number} params.y - The shape's y-coordinate. * @param {Number} params.rotation - The shape's rotation angle in radians. * @param {Number} params.opacity - The shape's opacity. * @param {Object} params.props - Shape-specific properties. */ constructor({ id, x, y, rotation, opacity, props, }) { this.id = id; this.x = x; this.y = y; this.rotation = rotation; this.opacity = opacity; this.props = props; this.size = this.props?.size; this.color = this.props?.color; this.dash = this.props?.dash; this.fill = this.props?.fill; this.text = this.props?.text; // Derived SVG properties this.thickness = Shape.getStrokeWidth(this.size); this.dasharray = Shape.determineDasharray(this.dash, this.size); this.shapeColor = Shape.colorToHex(this.color, ColorTypes.ShapeColor); // SVG representation this.shapeGroup = new G({ transform: this.getTransform(), opacity: this.opacity, }); } /** * Generates an SVG element with a pattern for filling the shape. * * @method getFillPattern * @param {String} shapeColor - The color to use for the pattern lines. * @return {Defs} An SVG element containing the pattern. */ getFillPattern(shapeColor) { const defs = new Defs(); const pattern = new Pattern({ id: `hash_pattern-${this.id}`, width: 8, height: 8, patternUnits: 'userSpaceOnUse', patternTransform: 'rotate(45 0 0)', }); pattern.add(new Rect({width: 8, height: 8, fill: 'white'})); pattern.add(new Line({'x1': 0, 'y1': 0, 'x2': 0, 'y2': 8, 'stroke': shapeColor, 'stroke-width': 3.5, 'stroke-dasharray': '4, 4'})); defs.add(pattern); return defs; } /** * Applies the appropriate fill style to the given SVG shape element based on * the object's `fill` property. It supports 'solid', 'semi', 'pattern', and * 'none' as fill options. * @param {SVGElement} shape - The element to be filled */ setFill(shape) { switch (this.fill) { case 'solid': const fillColor = Shape.colorToHex(this.color, ColorTypes.FillColor); shape.attr('fill', fillColor); break; case 'semi': const semiColor = Shape.colorToHex(this.fill, ColorTypes.SemiFillColor); shape.attr('fill', semiColor); break; case 'pattern': const shapeColor = Shape.colorToHex(this.color, ColorTypes.ShapeColor); const pattern = this.getFillPattern(shapeColor); this.shapeGroup.add(pattern); shape.attr('fill', `url(#hash_pattern-${this.id})`); break; default: shape.attr('fill', 'none'); break; } } /** * Generates a transformation string for SVG elements based on the object's * rotation and position properties. The transformation includes translation, * rotation, and setting the transform origin to the center. * * @return {string} The SVG transform attribute value. */ getTransform() { const x = this.x.toFixed(2); const y = this.y.toFixed(2); const rotation = radToDegree(this.rotation); const translate = `translate(${x} ${y})`; const transformOrigin = 'transform-origin: center'; const rotate = `rotate(${rotation})`; const transform = `${translate}; ${transformOrigin}; ${rotate}`; return transform; } /** * Converts a tldraw color name to its corresponding HEX code. * * @param {string} color - The name of the color (e.g., 'blue', 'red'). * @param {string} colorType - Context to select the appropriate mapping. * Valid values are 'shape', 'fill', * 'semi', and 'sticky'. * * @return {string} The HEX code for the given color and color type. * Returns '#0d0d0d' if not found. */ static colorToHex(color, colorType) { const colorMap = { 'black': '#161616', 'grey': '#9EA6B0', 'light-violet': '#DD80F5', 'violet': '#9C1FBE', 'blue': '#3348E5', 'light-blue': '#4099F5', 'yellow': '#FDB365', 'orange': '#F3500B', 'green': '#148355', 'light-green': '#38B845', 'light-red': '#FC7075', 'red': '#D61A25', }; const fillMap = { 'black': '#E2E2E2', 'grey': '#E7EAEC', 'light-violet': '#F2E5F9', 'violet': '#E7D3EF', 'blue': '#D4D8F6', 'light-blue': '#D6E8F9', 'yellow': '#F8ECE0', 'orange': '#F5DBCA', 'green': '#CAE5DC', 'light-green': '#D4EED9', 'light-red': '#F0D1D3', 'red': '#F0D1D3', }; const stickyMap = { 'black': '#FEC78C', 'grey': '#B6BDC3', 'light-violet': '#E4A1F7', 'violet': '#B65ACF', 'blue': '#6476EC', 'light-blue': '#6FB3F6', 'yellow': '#FEC78C', 'orange': '#F57D48', 'green': '#47A37F', 'light-green': '#64C46F', 'light-red': '#FC9598', 'red': '#E05458', }; const semiFillMap = { 'semi': '#F5F9F7', }; const colors = { shape: colorMap, fill: fillMap, semi: semiFillMap, sticky: stickyMap, }; return colors[colorType][color] || '#0d0d0d'; } /** * Determines SVG style attributes based on the dash type. * * @param {string} dash - The type of dash ('dashed', 'dotted'). * @param {string} size - The size ('s', 'm', 'l', 'xl'). * * @return {string} A string representing the SVG attributes * for the given dash and gap. */ static determineDasharray(dash, size) { const gapSettings = { 'dashed': { 's': '4.37 4.91', 'm': '8.16 10.21', 'l': '11.85 14.81', 'xl': '21.41 32.12', 'default': '8 8', }, 'dotted': { 's': '0.02 4', 'm': '0.03 8', 'l': '0.05 12', 'xl': '0.12 16', 'default': '0.03 8', }, }; const gap = gapSettings[dash]?.[size] || gapSettings[dash]?.['default'] || '0'; const dashSettings = { 'dashed': `stroke-linecap:butt;stroke-dasharray:${gap};`, 'dotted': `stroke-linecap:round;stroke-dasharray:${gap};`, }; return dashSettings[dash] || 'stroke-linejoin:round;stroke-linecap:round;'; } /** * Get the stroke width based on the size. * * @param {string} size - The size of the stroke ('s', 'm', 'l', 'xl'). * @return {number} - The corresponding stroke width. */ static getStrokeWidth(size) { const strokeWidths = { 's': 2, 'm': 3.5, 'l': 5, 'xl': 7.5, }; return strokeWidths[size] || 1; } /** * Get the font size in pixels. * * @param {string} size - The size of the font ('s', 'm', 'l', 'xl'). * @return {number} - The corresponding font size, in pixels. */ static determineFontSize(size) { const fontSizes = { 's': 26, 'm': 36, 'l': 54, 'xl': 64, }; return fontSizes[size] || 16; } /** * Aligns horizontally based on the given alignment type. * * @param {string} align - One of ('start', 'middle', 'end'). * @param {number} width - The width of the container. * @return {string} The calculated horizontal position as a string with * two decimal places. Coordinates are relative to the container. * @static */ static alignHorizontally(align, width) { switch (align) { case 'middle': return (width / 2).toFixed(2); case 'end': return (width).toFixed(2); default: return '0'; } } /** * Aligns vertically based on the given alignment type. * * @param {string} align - One of ('start', 'middle', 'end'). * @param {number} height - The height of the container. * @return {string} The calculated vertical position as a string with * two decimal places. Coordinates are relative to the container. * @static */ static alignVertically(align, height) { switch (align) { case 'middle': return (height / 2).toFixed(2); case 'end': return height.toFixed(2); default: return '0'; } } /** * Determines the font to use based on the specified font family. * Supported families are 'draw', 'sans', 'serif', and 'mono'. Any other input * defaults to the Caveat Brush font. * * @param {string} family The name of the font family. * @return {string} The font that corresponds to the given family. * @static */ static determineFontFromFamily(family) { switch (family) { case 'sans': return 'Source Sans Pro'; case 'serif': return 'Crimson Pro'; case 'mono': return 'Source Code Pro'; case 'draw': default: return 'Caveat Brush'; } } /** * Measures the width of a given text string using font metrics. * @param {string} text - The text to measure. * @param {opentype.Font} font - The loaded font object. * @param {number} fontSize - The size of the font. * @return {number} The width of the text. */ measureTextWidth(text, font, fontSize) { const scale = 1 / font.unitsPerEm * fontSize; const glyphs = font.stringToGlyphs(text); let width = 0; glyphs.forEach((glyph) => { if (glyph.advanceWidth) { width += glyph.advanceWidth * scale; } }); return width; } /** * Wraps text to fit within a specified width and height. * @param {string} text - The text to wrap. * @param {number} width - The width of the bounding box. * @return {string[]} An array of strings, each being a line. */ wrapText(text, width) { const config = JSON.parse( fs.readFileSync( './config/settings.json', 'utf8')); const font = this.props?.font || 'draw'; const fontPath = config.fonts[font]; const words = text.split(' '); let line = ''; const lines = []; // Read the font file into a Buffer const fontBuffer = fs.readFileSync(fontPath); // Convert the Buffer to an ArrayBuffer const arrayBuffer = fontBuffer.buffer.slice( fontBuffer.byteOffset, fontBuffer.byteOffset + fontBuffer.byteLength); // Parse the font using the ArrayBuffer const parsedFont = opentype.parse(arrayBuffer); const fontSize = Shape.determineFontSize(this.size); for (const word of words) { const testLine = line + word + ' '; const testWidth = this.measureTextWidth( testLine, parsedFont, fontSize); if (testWidth > width) { if (line !== '') { lines.push(line); } line = word + ' '; } else { line = testLine; } } if (line !== '') { lines.push(line.trim()); } // Split newlines into separate lines const brokenLines = lines .map((line) => line.split('\n')) .flat(); return brokenLines; } /** * Draws label text on the SVG canvas. * @param {SVGG} group The SVG group element to add the label to. */ drawLabel(group) { // Do nothing if there is no text if (!this.text) return; // Sticky notes have a width and height of 200 and can't be resized, // unless the text becomes too long. if (!this.w) { this.w = 200; } if (!this.h) { this.h = 200; } if (!this.growY) { this.growY = 0; } const width = this.w; const height = this.h + this.growY; const x = Shape.alignHorizontally(this.align, width); let y = Shape.alignVertically(this.verticalAlign, height); const lineHeight = Shape.determineFontSize(this.size); const fontFamily = Shape.determineFontFromFamily(this.props?.font); if (this.verticalAlign === 'end' || this.verticalAlign === 'middle') { y -= (lineHeight / 2); } // Create a new SVG text element // Text is escaped by SVG.js const textElement = new Text() .move(x, y) .font({ 'family': fontFamily, 'size': lineHeight, 'anchor': this.align, 'alignment-baseline': 'baseline', }); const lines = this.wrapText(this.text, width); lines.forEach((line) => { const tspan = new Tspan() .text(line) .attr({ x: x, dy: lineHeight, }); textElement.add(tspan); }); // Set the fill color for the text textElement.fill(this.labelColor || 'black'); // If there's a URL, make the text clickable if (this.url) { textElement.linkTo(this.url); } group.add(textElement); } /** * Placeholder method for drawing the shape. * Intended to be overridden by subclasses. * * @method draw * @return {G} An empty SVG group element. */ draw() { return new G(); } } /** * An object representing various types of colors used in shapes. * This object is frozen to prevent modifications. * * @const {ColorTypes} */ export const ColorTypes = Object.freeze({ ShapeColor: 'shape', FillColor: 'fill', SemiFillColor: 'semi', StickyColor: 'sticky', });