js-draw
Version:
Draw pictures using a pen, touchscreen, or mouse! JS-draw is a drawing library for JavaScript and TypeScript.
358 lines (357 loc) • 16.1 kB
JavaScript
import { makeArrowBuilder } from '../../components/builders/ArrowBuilder.mjs';
import { makeFreehandLineBuilder } from '../../components/builders/FreehandLineBuilder.mjs';
import { makePressureSensitiveFreehandLineBuilder } from '../../components/builders/PressureSensitiveFreehandLineBuilder.mjs';
import { makeLineBuilder } from '../../components/builders/LineBuilder.mjs';
import { makeFilledRectangleBuilder, makeOutlinedRectangleBuilder, } from '../../components/builders/RectangleBuilder.mjs';
import { makeOutlinedCircleBuilder } from '../../components/builders/CircleBuilder.mjs';
import { EditorEventType } from '../../types.mjs';
import makeColorInput from './components/makeColorInput.mjs';
import BaseToolWidget from './BaseToolWidget.mjs';
import { Color4 } from '@js-draw/math';
import { selectStrokeTypeKeyboardShortcutIds } from './keybindings.mjs';
import { toolbarCSSPrefix } from '../constants.mjs';
import makeThicknessSlider from './components/makeThicknessSlider.mjs';
import makeGridSelector from './components/makeGridSelector.mjs';
import { makePolylineBuilder } from '../../components/builders/PolylineBuilder.mjs';
/**
* This toolbar widget allows a user to control a single {@link Pen} tool.
*
* See also {@link AbstractToolbar.addDefaultToolWidgets}.
*/
class PenToolWidget extends BaseToolWidget {
constructor(editor, tool, localization) {
super(editor, tool, 'pen', localization);
this.tool = tool;
this.updateInputs = () => { };
// Pen types that correspond to
this.shapelikeIDs = ['pressure-sensitive-pen', 'freehand-pen'];
// Additional client-specified pens.
const additionalPens = editor.getCurrentSettings().pens?.additionalPenTypes ?? [];
const filterPens = editor.getCurrentSettings().pens?.filterPenTypes ?? (() => true);
// Default pen types
this.penTypes = [
// Non-shape pens
{
name: this.localizationTable.flatTipPen,
id: 'pressure-sensitive-pen',
factory: makePressureSensitiveFreehandLineBuilder,
},
{
name: this.localizationTable.roundedTipPen,
id: 'freehand-pen',
factory: makeFreehandLineBuilder,
},
{
name: this.localizationTable.roundedTipPen2,
id: 'polyline-pen',
factory: makePolylineBuilder,
},
...additionalPens.filter((pen) => !pen.isShapeBuilder),
// Shape pens
{
name: this.localizationTable.arrowPen,
id: 'arrow',
isShapeBuilder: true,
factory: makeArrowBuilder,
},
{
name: this.localizationTable.linePen,
id: 'line',
isShapeBuilder: true,
factory: makeLineBuilder,
},
{
name: this.localizationTable.filledRectanglePen,
id: 'filled-rectangle',
isShapeBuilder: true,
factory: makeFilledRectangleBuilder,
},
{
name: this.localizationTable.outlinedRectanglePen,
id: 'outlined-rectangle',
isShapeBuilder: true,
factory: makeOutlinedRectangleBuilder,
},
{
name: this.localizationTable.outlinedCirclePen,
id: 'outlined-circle',
isShapeBuilder: true,
factory: makeOutlinedCircleBuilder,
},
...additionalPens.filter((pen) => pen.isShapeBuilder),
].filter(filterPens);
this.editor.notifier.on(EditorEventType.ToolUpdated, (toolEvt) => {
if (toolEvt.kind !== EditorEventType.ToolUpdated) {
throw new Error('Invalid event type!');
}
// The button icon may depend on tool properties.
if (toolEvt.tool === this.tool) {
this.updateIcon();
this.updateInputs();
}
});
}
getTitle() {
return this.targetTool.description;
}
// Return the index of this tool's stroke factory in the list of
// all stroke factories.
//
// Returns -1 if the stroke factory is not in the list of all stroke factories.
getCurrentPenTypeIdx() {
const currentFactory = this.tool.getStrokeFactory();
for (let i = 0; i < this.penTypes.length; i++) {
if (this.penTypes[i].factory === currentFactory) {
return i;
}
}
return -1;
}
getCurrentPenType() {
for (const penType of this.penTypes) {
if (penType.factory === this.tool.getStrokeFactory()) {
return penType;
}
}
return null;
}
createIconForRecord(record) {
const style = {
...this.tool.getStyleValue().get(),
};
if (record?.factory) {
style.factory = record.factory;
}
const strokeFactory = record?.factory;
if (!strokeFactory ||
strokeFactory === makeFreehandLineBuilder ||
strokeFactory === makePressureSensitiveFreehandLineBuilder ||
strokeFactory === makePolylineBuilder) {
return this.editor.icons.makePenIcon(style);
}
else {
return this.editor.icons.makeIconFromFactory(style);
}
}
createIcon() {
return this.createIconForRecord(this.getCurrentPenType());
}
// Creates a widget that allows selecting different pen types
createPenTypeSelector(helpOverlay) {
const allChoices = this.penTypes.map((penType, index) => {
return {
id: index,
makeIcon: () => this.createIconForRecord(penType),
title: penType.name,
isShapeBuilder: penType.isShapeBuilder ?? false,
};
});
const penItems = allChoices.filter((choice) => !choice.isShapeBuilder);
const penSelector = makeGridSelector(this.localizationTable.selectPenType, this.getCurrentPenTypeIdx(), penItems);
const shapeItems = allChoices.filter((choice) => choice.isShapeBuilder);
const shapeSelector = makeGridSelector(this.localizationTable.selectShape, this.getCurrentPenTypeIdx(), shapeItems);
const onSelectorUpdate = (newPenTypeIndex) => {
this.tool.setStrokeFactory(this.penTypes[newPenTypeIndex].factory);
};
penSelector.value.onUpdate(onSelectorUpdate);
shapeSelector.value.onUpdate(onSelectorUpdate);
helpOverlay?.registerTextHelpForElements([penSelector.getRootElement(), shapeSelector.getRootElement()], this.localizationTable.penDropdown__penTypeHelpText);
return {
setValue: (penTypeIndex) => {
penSelector.value.set(penTypeIndex);
shapeSelector.value.set(penTypeIndex);
},
updateIcons: () => {
penSelector.updateIcons();
shapeSelector.updateIcons();
},
addTo: (parent) => {
if (penItems.length) {
penSelector.addTo(parent);
}
if (shapeItems.length) {
shapeSelector.addTo(parent);
}
},
};
}
createStrokeCorrectionOptions(helpOverlay) {
const container = document.createElement('div');
container.classList.add('action-button-row', `${toolbarCSSPrefix}-pen-tool-toggle-buttons`);
const addToggleButton = (labelText, icon) => {
const button = document.createElement('button');
button.classList.add(`${toolbarCSSPrefix}-toggle-button`);
const iconElement = icon.cloneNode(true);
iconElement.classList.add('icon');
const label = document.createElement('span');
label.innerText = labelText;
button.replaceChildren(iconElement, label);
button.setAttribute('role', 'switch');
container.appendChild(button);
let checked = false;
let onChangeListener = (_checked) => { };
const result = {
setChecked(newChecked) {
checked = newChecked;
button.setAttribute('aria-checked', `${checked}`);
onChangeListener(checked);
},
setOnInputListener(listener) {
onChangeListener = listener;
},
addHelpText(text) {
helpOverlay?.registerTextHelpForElement(button, text);
},
};
button.onclick = () => {
result.setChecked(!checked);
};
return result;
};
const stabilizationOption = addToggleButton(this.localizationTable.inputStabilization, this.editor.icons.makeStrokeSmoothingIcon());
stabilizationOption.setOnInputListener((enabled) => {
this.tool.setHasStabilization(enabled);
});
const autocorrectOption = addToggleButton(this.localizationTable.strokeAutocorrect, this.editor.icons.makeShapeAutocorrectIcon());
autocorrectOption.setOnInputListener((enabled) => {
this.tool.setStrokeAutocorrectEnabled(enabled);
});
const pressureSensitivityOption = addToggleButton(this.localizationTable.pressureSensitivity, this.editor.icons.makePressureSensitivityIcon());
pressureSensitivityOption.setOnInputListener((enabled) => {
this.tool.setPressureSensitivityEnabled(enabled);
});
// Help text
autocorrectOption.addHelpText(this.localizationTable.penDropdown__autocorrectHelpText);
stabilizationOption.addHelpText(this.localizationTable.penDropdown__stabilizationHelpText);
pressureSensitivityOption.addHelpText(this.localizationTable.penDropdown__pressureSensitivityHelpText);
return {
update: () => {
stabilizationOption.setChecked(!!this.tool.getInputMapper());
autocorrectOption.setChecked(this.tool.getStrokeAutocorrectionEnabled());
pressureSensitivityOption.setChecked(this.tool.getPressureSensitivityEnabled());
},
addTo: (parent) => {
parent.appendChild(container);
},
};
}
getHelpText() {
return this.localizationTable.penDropdown__baseHelpText;
}
fillDropdown(dropdown, helpDisplay) {
const container = document.createElement('div');
container.classList.add(`${toolbarCSSPrefix}spacedList`, `${toolbarCSSPrefix}nonbutton-controls-main-list`);
// Thickness: Value of the input is squared to allow for finer control/larger values.
const { container: thicknessRow, setValue: setThickness } = makeThicknessSlider(this.editor, (thickness) => {
this.tool.setThickness(thickness);
});
const colorRow = document.createElement('div');
const colorLabel = document.createElement('label');
const colorInputControl = makeColorInput(this.editor, (color) => {
this.tool.setColor(color);
});
const { input: colorInput, container: colorInputContainer } = colorInputControl;
colorInput.id = `${toolbarCSSPrefix}colorInput${PenToolWidget.idCounter++}`;
colorLabel.innerText = this.localizationTable.colorLabel;
colorLabel.setAttribute('for', colorInput.id);
colorRow.appendChild(colorLabel);
colorRow.appendChild(colorInputContainer);
// Autocorrect and stabilization options
const toggleButtonRow = this.createStrokeCorrectionOptions(helpDisplay);
const penTypeSelect = this.createPenTypeSelector(helpDisplay);
// Add help text for color and thickness last, as these are likely to be
// features users are least interested in.
helpDisplay?.registerTextHelpForElement(colorRow, this.localizationTable.penDropdown__colorHelpText);
if (helpDisplay) {
colorInputControl.registerWithHelpTextDisplay(helpDisplay);
}
helpDisplay?.registerTextHelpForElement(thicknessRow, this.localizationTable.penDropdown__thicknessHelpText);
this.updateInputs = () => {
colorInputControl.setValue(this.tool.getColor());
setThickness(this.tool.getThickness());
penTypeSelect.updateIcons();
// Update the selected stroke factory.
penTypeSelect.setValue(this.getCurrentPenTypeIdx());
toggleButtonRow.update();
};
this.updateInputs();
container.replaceChildren(colorRow, thicknessRow);
penTypeSelect.addTo(container);
dropdown.replaceChildren(container);
// Add the toggle button row *outside* of the main content (use different
// spacing with respect to the sides of the container).
toggleButtonRow.addTo(dropdown);
return true;
}
onKeyPress(event) {
if (!this.isSelected()) {
return false;
}
for (let i = 0; i < selectStrokeTypeKeyboardShortcutIds.length; i++) {
const shortcut = selectStrokeTypeKeyboardShortcutIds[i];
if (this.editor.shortcuts.matchesShortcut(shortcut, event)) {
const penTypeIdx = i;
if (penTypeIdx < this.penTypes.length) {
this.tool.setStrokeFactory(this.penTypes[penTypeIdx].factory);
return true;
}
}
}
// Run any default actions registered by the parent class.
if (super.onKeyPress(event)) {
return true;
}
return false;
}
serializeState() {
return {
...super.serializeState(),
color: this.tool.getColor().toHexString(),
thickness: this.tool.getThickness(),
strokeFactoryId: this.getCurrentPenType()?.id,
inputStabilization: !!this.tool.getInputMapper(),
strokeAutocorrect: this.tool.getStrokeAutocorrectionEnabled(),
pressureSensitivity: this.tool.getPressureSensitivityEnabled(),
};
}
deserializeFrom(state) {
super.deserializeFrom(state);
const verifyPropertyType = (propertyName, expectedType) => {
const actualType = typeof state[propertyName];
if (actualType !== expectedType) {
throw new Error(`Deserializing property ${propertyName}: Invalid type. Expected ${expectedType},` +
` was ${actualType}.`);
}
};
if (state.color) {
verifyPropertyType('color', 'string');
this.tool.setColor(Color4.fromHex(state.color));
}
if (state.thickness) {
verifyPropertyType('thickness', 'number');
this.tool.setThickness(state.thickness);
}
if (state.strokeFactoryId) {
verifyPropertyType('strokeFactoryId', 'string');
const factoryId = state.strokeFactoryId;
for (const penType of this.penTypes) {
if (factoryId === penType.id) {
this.tool.setStrokeFactory(penType.factory);
break;
}
}
}
if (state.inputStabilization !== undefined) {
this.tool.setHasStabilization(!!state.inputStabilization);
}
if (state.strokeAutocorrect !== undefined) {
this.tool.setStrokeAutocorrectEnabled(!!state.strokeAutocorrect);
}
if (state.pressureSensitivity !== undefined) {
this.tool.setPressureSensitivityEnabled(!!state.pressureSensitivity);
}
}
}
// A counter variable that ensures different HTML elements are given unique names/ids.
PenToolWidget.idCounter = 0;
export default PenToolWidget;