UNPKG

js-draw

Version:

Draw pictures using a pen, touchscreen, or mouse! JS-draw is a drawing library for JavaScript and TypeScript.

325 lines (324 loc) 13.7 kB
import { EditorImageEventType } from '../image/EditorImage.mjs'; import { Rect2, Color4, toRoundedString, Path, PathCommandType, Vec2, } from '@js-draw/math'; import AbstractComponent, { ComponentSizingMode } from './AbstractComponent.mjs'; import { createRestyleComponentCommand, } from './RestylableComponent.mjs'; import Viewport from '../Viewport.mjs'; import { pathToRenderable } from '../rendering/RenderablePathSpec.mjs'; export var BackgroundType; (function (BackgroundType) { BackgroundType[BackgroundType["SolidColor"] = 0] = "SolidColor"; BackgroundType[BackgroundType["Grid"] = 1] = "Grid"; BackgroundType[BackgroundType["None"] = 2] = "None"; })(BackgroundType || (BackgroundType = {})); export const imageBackgroundCSSClassName = 'js-draw-image-background'; // Class name prefix indicating the size of the background's grid cells (if present). export const imageBackgroundGridSizeCSSPrefix = 'js-draw-image-background-grid-'; // Flag included in rendered SVGs (etc) that indicates that the secondary color of the // background has been manually set. export const imageBackgroundNonAutomaticSecondaryColorCSSClassName = 'js-draw-image-background-non-automatic-secondary-color'; export const backgroundTypeToClassNameMap = { [BackgroundType.Grid]: 'js-draw-image-background-grid', [BackgroundType.SolidColor]: imageBackgroundCSSClassName, [BackgroundType.None]: '', }; // Represents the background of the editor's canvas. export default class BackgroundComponent extends AbstractComponent { constructor(backgroundType, mainColor) { super('image-background', 0); this.backgroundType = backgroundType; this.mainColor = mainColor; this.viewportSizeChangeListener = null; this.autoresizeChangedListener = null; // Whether the background should grow/shrink to match the screen size, // rather than being clipped to the image boundaries. this.fillsScreen = false; this.gridSize = Viewport.getGridSize(2); this.gridStrokeWidth = 0.7; this.secondaryColor = null; // eslint-disable-next-line @typescript-eslint/prefer-as-const this.isRestylableComponent = true; this.contentBBox = Rect2.empty; } static ofGrid(backgroundColor, gridSize, gridColor, gridStrokeWidth) { const background = new BackgroundComponent(BackgroundType.Grid, backgroundColor); if (gridSize !== undefined) { background.gridSize = gridSize; } if (gridColor !== undefined) { background.secondaryColor = gridColor; } if (gridStrokeWidth !== undefined) { background.gridStrokeWidth = gridStrokeWidth; } return background; } getBackgroundType() { return this.backgroundType; } // @internal getMainColor() { return this.mainColor; } // @internal getSecondaryColor() { return this.secondaryColor; } // @internal getGridSize() { return this.gridSize; } getStyle() { let color = this.mainColor; if (this.backgroundType === BackgroundType.None) { color = undefined; } return { color, }; } updateStyle(style) { return createRestyleComponentCommand(this.getStyle(), style, this); } // @internal forceStyle(style, editor) { const fill = style.color; if (!fill) { return; } this.mainColor = fill; // A solid background and transparent fill is equivalent to no background. if (fill.eq(Color4.transparent) && this.backgroundType === BackgroundType.SolidColor) { this.backgroundType = BackgroundType.None; } else if (this.backgroundType === BackgroundType.None) { this.backgroundType = BackgroundType.SolidColor; } if (editor) { editor.image.queueRerenderOf(this); editor.queueRerender(); } } onAddToImage(image) { if (this.viewportSizeChangeListener) { console.warn('onAddToImage called when background is already in an image'); this.onRemoveFromImage(); } this.viewportSizeChangeListener = image.notifier.on(EditorImageEventType.ExportViewportChanged, () => { this.recomputeBBox(image); }); this.autoresizeChangedListener = image.notifier.on(EditorImageEventType.AutoresizeModeChanged, () => { this.recomputeBBox(image); }); this.recomputeBBox(image); } onRemoveFromImage() { this.viewportSizeChangeListener?.remove(); this.autoresizeChangedListener?.remove(); this.viewportSizeChangeListener = null; this.autoresizeChangedListener = null; } recomputeBBox(image) { const importExportRect = image.getImportExportViewport().visibleRect; let needsRerender = false; if (!this.contentBBox.eq(importExportRect)) { this.contentBBox = importExportRect; // If the box already fills the screen, rerendering it will have // no visual effect. // // TODO: This decision should be made by queueRerenderOf and not here. // needsRerender ||= !this.fillsScreen; } const imageAutoresizes = image.getAutoresizeEnabled(); if (imageAutoresizes !== this.fillsScreen) { this.fillsScreen = imageAutoresizes; needsRerender = true; } if (needsRerender) { // Re-renders this if already added to the EditorImage. image.queueRerenderOf(this); } } generateGridPath(visibleRect) { const contentBBox = this.getFullBoundingBox(visibleRect); // .grownBy acts on all sides, so we need only grow by strokeWidth / 2 (1 * the stroke radius) const targetRect = (visibleRect?.intersection(contentBBox) ?? contentBBox).grownBy(this.gridStrokeWidth / 2); const roundDownToGrid = (coord) => Math.floor(coord / this.gridSize) * this.gridSize; const roundUpToGrid = (coord) => Math.ceil(coord / this.gridSize) * this.gridSize; const startY = roundUpToGrid(targetRect.y); const endY = roundDownToGrid(targetRect.y + targetRect.h); const startX = roundUpToGrid(targetRect.x); const endX = roundDownToGrid(targetRect.x + targetRect.w); const result = []; // Don't generate grids with a huge number of rows/columns -- such grids // take a long time to render and are likely invisible due to the number of // cells. const rowCount = (endY - startY) / this.gridSize; const colCount = (endX - startX) / this.gridSize; const maxGridCols = 1000; const maxGridRows = 1000; if (rowCount > maxGridRows || colCount > maxGridCols) { return Path.empty; } const startPoint = Vec2.of(targetRect.x, startY); for (let y = startY; y <= endY; y += this.gridSize) { result.push({ kind: PathCommandType.MoveTo, point: Vec2.of(targetRect.x, y), }); result.push({ kind: PathCommandType.LineTo, point: Vec2.of(targetRect.x + targetRect.w, y), }); } for (let x = startX; x <= endX; x += this.gridSize) { result.push({ kind: PathCommandType.MoveTo, point: Vec2.of(x, targetRect.y), }); result.push({ kind: PathCommandType.LineTo, point: Vec2.of(x, targetRect.y + targetRect.h), }); } return new Path(startPoint, result); } /** * @returns this background's bounding box if the screen size is taken into * account (which may be necessary if this component is configured to fill the * entire screen). */ getFullBoundingBox(visibleRect) { return (this.fillsScreen ? visibleRect : this.contentBBox) ?? this.contentBBox; } render(canvas, visibleRect) { if (this.backgroundType === BackgroundType.None) { return; } // If visibleRect is null, components should render everything. // In that case, a full render is being done. const mustRender = !visibleRect; // If this.fillsScreen, the visibleRect needs to be known. // Use the screen rect. if (this.fillsScreen) { visibleRect ??= canvas.getVisibleRect(); } const clip = this.backgroundType === BackgroundType.Grid; const contentBBox = this.getFullBoundingBox(visibleRect); canvas.startObject(contentBBox, clip); if (this.backgroundType === BackgroundType.SolidColor || this.backgroundType === BackgroundType.Grid) { // If the rectangle for this region contains the visible rect, // we can fill the entire visible rectangle (which may be more efficient than // filling the entire region for this.) const intersection = visibleRect?.intersection(contentBBox); if (intersection) { canvas.fillRect(intersection, this.mainColor); } else if (mustRender) { canvas.fillRect(contentBBox, this.mainColor); } } if (this.backgroundType === BackgroundType.Grid) { let gridColor = this.secondaryColor; gridColor ??= Color4.ofRGBA(1 - this.mainColor.r, 1 - this.mainColor.g, 1 - this.mainColor.b, 0.2); // If the background fill is completely transparent, ensure visibility on otherwise light // and dark backgrounds. if (this.mainColor.a === 0) { gridColor = Color4.ofRGBA(0.5, 0.5, 0.5, 0.2); } const style = { fill: Color4.transparent, stroke: { width: this.gridStrokeWidth, color: gridColor }, }; canvas.drawPath(pathToRenderable(this.generateGridPath(visibleRect), style)); } const backgroundTypeCSSClass = backgroundTypeToClassNameMap[this.backgroundType]; const classNames = [imageBackgroundCSSClassName]; if (backgroundTypeCSSClass !== imageBackgroundCSSClassName) { classNames.push(backgroundTypeCSSClass); const gridSizeStr = toRoundedString(this.gridSize).replace(/[.]/g, 'p'); classNames.push(imageBackgroundGridSizeCSSPrefix + gridSizeStr); } if (this.secondaryColor !== null) { classNames.push(imageBackgroundNonAutomaticSecondaryColorCSSClassName); } canvas.endObject(this.getLoadSaveData(), classNames); } intersects(lineSegment) { return this.contentBBox.getEdges().some((edge) => edge.intersects(lineSegment)); } isSelectable() { return false; } isBackground() { return true; } getSizingMode() { return this.fillsScreen ? ComponentSizingMode.FillScreen : ComponentSizingMode.BoundingBox; } serializeToJSON() { return { mainColor: this.mainColor.toHexString(), secondaryColor: this.secondaryColor?.toHexString(), backgroundType: this.backgroundType, gridSize: this.gridSize, gridStrokeWidth: this.gridStrokeWidth, }; } applyTransformation(_affineTransfm) { // Do nothing — it doesn't make sense to transform the background. } description(localizationTable) { if (this.backgroundType === BackgroundType.SolidColor) { return localizationTable.filledBackgroundWithColor(this.mainColor.toString()); } else if (this.backgroundType === BackgroundType.None) { return localizationTable.emptyBackground; } else if (this.backgroundType === BackgroundType.Grid) { return localizationTable.gridBackground; } else { const exhaustivenessCheck = this.backgroundType; return exhaustivenessCheck; } } createClone() { return new BackgroundComponent(this.backgroundType, this.mainColor); } // @internal static deserializeFromJSON(json) { if (typeof json === 'string') { json = JSON.parse(json); } if (typeof json.mainColor !== 'string') { throw new Error('Error deserializing — mainColor must be of type string.'); } let backgroundType; const jsonBackgroundType = json.backgroundType; if (jsonBackgroundType === BackgroundType.None || jsonBackgroundType === BackgroundType.Grid || jsonBackgroundType === BackgroundType.SolidColor) { backgroundType = jsonBackgroundType; } else { const exhaustivenessCheck = jsonBackgroundType; return exhaustivenessCheck; } const mainColor = Color4.fromHex(json.mainColor); const secondaryColor = json.secondaryColor ? Color4.fromHex(json.secondaryColor) : null; const gridSize = json.gridSize ?? undefined; const gridStrokeWidth = json.gridStrokeWidth ?? undefined; const result = new BackgroundComponent(backgroundType, mainColor); result.secondaryColor = secondaryColor; if (gridSize) { result.gridSize = gridSize; } if (gridStrokeWidth) { result.gridStrokeWidth = gridStrokeWidth; } return result; } } AbstractComponent.registerComponent('image-background', BackgroundComponent.deserializeFromJSON);