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
JavaScript
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);