diff --git a/src/fontra/client/core/font-controller.js b/src/fontra/client/core/font-controller.js index f49ad22e9..fa67618e2 100644 --- a/src/fontra/client/core/font-controller.js +++ b/src/fontra/client/core/font-controller.js @@ -16,6 +16,7 @@ import { TaskPool } from "./task-pool.js"; import { assert, chain, + colorizeImage, getCharFromCodePoint, mapObjectValues, throttleCalls, @@ -133,11 +134,40 @@ export class FontController { getBackgroundImage(imageIdentifier) { // This returns a promise for the requested background image + const cacheEntry = this._getBackgroundImageCacheEntry(imageIdentifier); + return cacheEntry.imagePromise; + } + + getBackgroundImageColorized(imageIdentifier, color) { + // This returns a promise for the requested colorized background image + if (!color) { + return this.getBackgroundImage(imageIdentifier); + } + const cacheEntry = this._getBackgroundImageCacheEntry(imageIdentifier); + if (cacheEntry.color !== color) { + cacheEntry.color = color; + cacheEntry.imageColorizedPromise = new Promise((resolve, reject) => { + cacheEntry.imagePromise.then((image) => { + if (image) { + colorizeImage(image, color).then((image) => { + cacheEntry.imageColorized = image; + resolve(image); + }); + } else { + resolve(null); + } + }); + }); + } + return cacheEntry.imageColorizedPromise; + } + + _getBackgroundImageCacheEntry(imageIdentifier) { let cacheEntry = this._backgroundImageCache.get(imageIdentifier); if (!cacheEntry) { cacheEntry = this._cacheBackgroundImageFromIdentifier(imageIdentifier); } - return cacheEntry.imagePromise; + return cacheEntry; } getBackgroundImageCached(imageIdentifier, onLoad = null) { @@ -156,6 +186,19 @@ export class FontController { return cacheEntry?.image; } + getBackgroundImageColorizedCached(imageIdentifier, color, onLoad = null) { + if (!color) { + return this.getBackgroundImageCached(imageIdentifier, onLoad); + } + const cacheEntry = this._backgroundImageCache.get(imageIdentifier); + if ((!cacheEntry?.imageColorizedPromise || cacheEntry.color !== color) && onLoad) { + this.getBackgroundImageColorized(imageIdentifier, color).then((image) => + onLoad(image) + ); + } + return cacheEntry?.imageColorized; + } + _cacheBackgroundImageFromIdentifier(imageIdentifier) { return this._cacheBackgroundImageFromDataURLPromise( imageIdentifier, diff --git a/src/fontra/client/core/utils.js b/src/fontra/client/core/utils.js index b308688be..9a8d96b87 100644 --- a/src/fontra/client/core/utils.js +++ b/src/fontra/client/core/utils.js @@ -63,6 +63,7 @@ export function scheduleCalls(func, timeout = 0) { timeoutID = null; func(...args); }, timeout); + return timeoutID; }; } @@ -635,3 +636,37 @@ export function readFileOrBlobAsDataURL(fileOrBlob) { reader.readAsDataURL(fileOrBlob); }); } + +export function colorizeImage(inputImage, color) { + const w = inputImage.naturalWidth; + const h = inputImage.naturalHeight; + const canvas = document.createElement("canvas"); + canvas.width = w; + canvas.height = h; + const context = canvas.getContext("2d"); + + // First step, draw the image + context.drawImage(inputImage, 0, 0, w, h); + // Second step, reduce saturation to zero (making the image grayscale) + context.fillStyle = "black"; + context.globalCompositeOperation = "saturation"; + context.fillRect(0, 0, w, h); + // Last step, colorize the image, using screen (inverse multiply) + context.fillStyle = color; + context.globalCompositeOperation = "screen"; + context.fillRect(0, 0, w, h); + + return new Promise((resolve, reject) => { + canvas.toBlob((blob) => { + const outputImage = new Image(); + outputImage.width = inputImage.width; + outputImage.height = inputImage.height; + const url = URL.createObjectURL(blob); + outputImage.onload = () => { + URL.revokeObjectURL(url); + resolve(outputImage); + }; + outputImage.src = url; + }); + }); +} diff --git a/src/fontra/client/lang/de.js b/src/fontra/client/lang/de.js index 367b79022..c7116337c 100644 --- a/src/fontra/client/lang/de.js +++ b/src/fontra/client/lang/de.js @@ -101,6 +101,7 @@ export const strings = { "axes.undo.add": "Achse %0 hinzufügen", "axes.undo.delete": "Achse %0 entfernen", "axes.undo.edit": "Achse %0 bearbeiten", + "background-image.labels.colorize": "Colorize", "background-image.labels.opacity": "Transparenz", "canvas.clean-view-and-hand-tool": "Ungehinderte Sicht und Hand Werkzeug", "cross-axis-mapping.axis-participates": diff --git a/src/fontra/client/lang/en.js b/src/fontra/client/lang/en.js index 23abb9c54..332e45392 100644 --- a/src/fontra/client/lang/en.js +++ b/src/fontra/client/lang/en.js @@ -101,6 +101,7 @@ export const strings = { "axes.undo.add": "add axis %0", "axes.undo.delete": "delete axis %0", "axes.undo.edit": "edit axis %0", + "background-image.labels.colorize": "Colorize", "background-image.labels.opacity": "Opacity", "canvas.clean-view-and-hand-tool": "Clean View and Hand Tool", "cross-axis-mapping.axis-participates": diff --git a/src/fontra/client/lang/fr.js b/src/fontra/client/lang/fr.js index 3717b9a7b..3bbce8312 100644 --- a/src/fontra/client/lang/fr.js +++ b/src/fontra/client/lang/fr.js @@ -101,6 +101,7 @@ export const strings = { "axes.undo.add": "add axis %0", "axes.undo.delete": "delete axis %0", "axes.undo.edit": "edit axis %0", + "background-image.labels.colorize": "Colorize", "background-image.labels.opacity": "Opacity", "canvas.clean-view-and-hand-tool": "Prévisualisation et outil de déplacement", "cross-axis-mapping.axis-participates": diff --git a/src/fontra/client/lang/ja.js b/src/fontra/client/lang/ja.js index 3405b3550..a882704c3 100644 --- a/src/fontra/client/lang/ja.js +++ b/src/fontra/client/lang/ja.js @@ -101,6 +101,7 @@ export const strings = { "axes.undo.add": "補完軸%0を追加", "axes.undo.delete": "補完軸%0を削除", "axes.undo.edit": "補完軸%0を編集", + "background-image.labels.colorize": "Colorize", "background-image.labels.opacity": "Opacity", "canvas.clean-view-and-hand-tool": "塗りのプレビューと手のひらツール", "cross-axis-mapping.axis-participates": diff --git a/src/fontra/client/lang/nl.js b/src/fontra/client/lang/nl.js index baf544225..ba86c2da9 100644 --- a/src/fontra/client/lang/nl.js +++ b/src/fontra/client/lang/nl.js @@ -101,6 +101,7 @@ export const strings = { "axes.undo.add": "add axis %0", "axes.undo.delete": "delete axis %0", "axes.undo.edit": "edit axis %0", + "background-image.labels.colorize": "Colorize", "background-image.labels.opacity": "Opacity", "canvas.clean-view-and-hand-tool": "Schone weergave en Hand gereedschap", "cross-axis-mapping.axis-participates": diff --git a/src/fontra/client/lang/zh-CN.js b/src/fontra/client/lang/zh-CN.js index 9637becbe..c7b93362b 100644 --- a/src/fontra/client/lang/zh-CN.js +++ b/src/fontra/client/lang/zh-CN.js @@ -101,6 +101,7 @@ export const strings = { "axes.undo.add": "添加参数轴 %0", "axes.undo.delete": "删除参数轴 %0", "axes.undo.edit": "编辑参数轴 %0", + "background-image.labels.colorize": "Colorize", "background-image.labels.opacity": "透明度", "canvas.clean-view-and-hand-tool": "预览与拖拽工具", "cross-axis-mapping.axis-participates": "选中后,该参数轴参与映射", diff --git a/src/fontra/client/web-components/ui-form.js b/src/fontra/client/web-components/ui-form.js index d03b362d8..bdea2d094 100644 --- a/src/fontra/client/web-components/ui-form.js +++ b/src/fontra/client/web-components/ui-form.js @@ -1,7 +1,12 @@ import * as html from "../core/html-utils.js"; import { SimpleElement } from "../core/html-utils.js"; import { QueueIterator } from "../core/queue-iterator.js"; -import { enumerate, hyphenatedToCamelCase, round } from "../core/utils.js"; +import { + enumerate, + hyphenatedToCamelCase, + round, + scheduleCalls, +} from "../core/utils.js"; import { RangeSlider } from "/web-components/range-slider.js"; import "/web-components/rotary-control.js"; @@ -79,6 +84,16 @@ export class Form extends SimpleElement { height: 1.6em; } + .ui-form-value input[type="checkbox"] { + width: initial; + height: initial; + } + + .ui-form-value input[type="color"] { + height: 2em; + width: 4em; + } + .ui-form-value input[type="text"] { width: 100%; } @@ -404,6 +419,78 @@ export class Form extends SimpleElement { valueElement.appendChild(rangeElement); } + _addColorPicker(valueElement, fieldItem) { + const parseColor = fieldItem.parseColor || ((v) => v); + const formatColor = fieldItem.formatColor || ((v) => v); + + let checkboxElement; + const colorInputElement = html.input({ type: "color" }); + colorInputElement.value = formatColor(fieldItem.value); + + { + // color picker change closure + let valueStream = undefined; + + const oninputFunc = scheduleCalls((event) => { + if (checkboxElement) { + checkboxElement.checked = true; + } + const value = parseColor(colorInputElement.value); + if (!valueStream) { + valueStream = new QueueIterator(5, true); + this._fieldChanging(fieldItem, value, valueStream); + } + + if (valueStream) { + valueStream.put(value); + this._dispatchEvent("doChange", { key: fieldItem.key, value: value }); + } else { + this._fieldChanging(fieldItem, value, undefined); + } + }, fieldItem.continuousDelay || 0); + + let oninputTimer; + + colorInputElement.oninput = (event) => { + oninputTimer = oninputFunc(event); + }; + + colorInputElement.onchange = (event) => { + if (checkboxElement) { + checkboxElement.checked = true; + } + if (valueStream) { + valueStream.done(); + valueStream = undefined; + if (oninputTimer) { + clearTimeout(oninputTimer); + oninputTimer = undefined; + } + this._dispatchEvent("endChange", { key: fieldItem.key }); + } else { + this._dispatchEvent("doChange", { key: fieldItem.key, value: value }); + } + }; + } + + valueElement.appendChild(colorInputElement); + + if (fieldItem.allowNoColor) { + checkboxElement = html.input({ + type: "checkbox", + checked: !!fieldItem.value, + onchange: (event) => { + this._fieldChanging( + fieldItem, + checkboxElement.checked ? parseColor(colorInputElement.value) : undefined, + undefined + ); + }, + }); + valueElement.appendChild(checkboxElement); + } + } + addEventListener(eventName, handler, options) { this.contentElement.addEventListener(eventName, handler, options); } diff --git a/src/fontra/views/editor/panel-selection-info.js b/src/fontra/views/editor/panel-selection-info.js index 7b1c66afd..b736ab9b6 100644 --- a/src/fontra/views/editor/panel-selection-info.js +++ b/src/fontra/views/editor/panel-selection-info.js @@ -11,6 +11,7 @@ import { makeUPlusStringFromCodePoint, parseSelection, range, + rgbaToHex, round, splitGlyphNameExtension, throttleCalls, @@ -292,6 +293,22 @@ export default class SelectionInfoPanel extends Panel { }), }); + formContents.push({ + type: "color-picker", + key: backgroundImageKey("color"), + label: translate("background-image.labels.colorize"), + continuousDelay: 150, + allowNoColor: true, + value: backgroundImage.color, + parseColor: (value) => { + const matches = value.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i); + const channels = matches.slice(1, 4).map((ch) => parseInt(ch, 16) / 255); + return { red: channels[0], green: channels[1], blue: channels[2] }; + }, + formatColor: (value) => + value ? rgbaToHex([value.red, value.green, value.blue]) : "#000000", + }); + formContents.push({ type: "edit-number-slider", key: backgroundImageKey("opacity"), diff --git a/src/fontra/views/editor/visualization-layer-definitions.js b/src/fontra/views/editor/visualization-layer-definitions.js index 5b25b9d3c..6414b2696 100644 --- a/src/fontra/views/editor/visualization-layer-definitions.js +++ b/src/fontra/views/editor/visualization-layer-definitions.js @@ -427,8 +427,15 @@ registerVisualizationLayerDefinition({ return; } - const image = model.fontController.getBackgroundImageCached( + const image = model.fontController.getBackgroundImageColorizedCached( backgroundImage.identifier, + backgroundImage.color + ? rgbaToCSS([ + backgroundImage.color.red, + backgroundImage.color.green, + backgroundImage.color.blue, + ]) + : null, () => controller.requestUpdate() );