diff --git a/src/fontra/client/core/font-controller.js b/src/fontra/client/core/font-controller.js index ada7f5051..5c7e30ceb 100644 --- a/src/fontra/client/core/font-controller.js +++ b/src/fontra/client/core/font-controller.js @@ -137,7 +137,9 @@ export class FontController { const imagePromise = this._getBackgroundImage(imageIdentifier); cacheEntry = { imagePromise, image: null }; this._backgroundImageCache.put(imageIdentifier, cacheEntry); - imagePromise.then((image) => (cacheEntry.image = image)); + imagePromise.then( + (image) => ((image.src = white2transparent(image)), (cacheEntry.image = image)) + ); } return cacheEntry.imagePromise; } @@ -1053,3 +1055,35 @@ class InstanceRequestQueue { } } } + +// Reference: https://stackoverflow.com/questions/6755314/canvas-imagedata-remove-white-pixels +function white2transparent(img) { + var c = document.createElement("canvas"); + + var w = img.width, + h = img.height; + + c.width = w; + c.height = h; + + var ctx = c.getContext("2d"); + + ctx.drawImage(img, 0, 0, w, h); + var imageData = ctx.getImageData(0, 0, w, h); + var pixel = imageData.data; + + var r = 0, + g = 1, + b = 2, + a = 3; + for (var p = 0; p < pixel.length; p += 4) { + if (pixel[p + r] == 255 && pixel[p + g] == 255 && pixel[p + b] == 255) { + // if white then change alpha to 0 + pixel[p + a] = 0; + } + } + + ctx.putImageData(imageData, 0, 0); + + return c.toDataURL("image/png"); +} diff --git a/src/fontra/client/core/ui-utils.js b/src/fontra/client/core/ui-utils.js index 7edad168a..0903c5e2e 100644 --- a/src/fontra/client/core/ui-utils.js +++ b/src/fontra/client/core/ui-utils.js @@ -171,6 +171,75 @@ export function labeledTextInput(label, controller, key, options) { return items; } +export function labeledColorInput(label, controller, key, options) { + const inputElement = colorInput(controller, key, options); + const items = [labelForElement(label, inputElement), inputElement]; + + return items; +} + +export function colorInput(controller, key, options) { + const inputID = options?.id || `color-input-${uniqueID()}-${key}`; + const style = + options?.style || + ` + margin: 0; + padding: 0; + outline: none; + border: none; + border-color: transparent; + border-radius: 0.25em; + width: 100%;`; + const inputElement = html.input({ type: "color", id: inputID, style: style }); + inputElement.value = controller.model[key]; + + inputElement.onchange = () => { + controller.model[key] = inputElement.value; + }; + + controller.addKeyListener(key, (event) => { + inputElement.value = event.newValue; + }); + + return inputElement; +} + +export function labeledSliderInput(label, controller, key, options) { + const inputElement = sliderInput(controller, key, options); + const items = [labelForElement(label, inputElement), inputElement]; + + return items; +} + +// for now keep it as is, but maybe better use: return html.createDomElement("range-slider", parms); +export function sliderInput(controller, key, options) { + const inputID = options?.id || `range-input-${uniqueID()}-${key}`; + const min = options?.min || 0; + const max = options?.max || 100; + const value = options?.value || 0; + const step = options?.step || 1; + + const inputElement = html.input({ + type: "range", + id: inputID, + min: min, + max: max, + step: step, + value: value, + }); + inputElement.value = controller.model[key]; + + inputElement.onchange = () => { + controller.model[key] = inputElement.value; + }; + + controller.addKeyListener(key, (event) => { + inputElement.value = event.newValue; + }); + + return inputElement; +} + export const DefaultFormatter = { toString: (value) => (value !== undefined && value !== null ? value.toString() : ""), fromString: (value) => { diff --git a/src/fontra/client/lang/de.js b/src/fontra/client/lang/de.js index 24e15d3e4..995cad531 100644 --- a/src/fontra/client/lang/de.js +++ b/src/fontra/client/lang/de.js @@ -25,6 +25,7 @@ export const strings = { "action.delete-glyph": "Glyph entfernen", "action.delete-selection": "Auswahl entfernen", "action.edit-anchor": "Anker bearbeiten", + "action.edit-background-image": "Hintergrund-Bild bearbeiten", "action.edit-guideline": "Hilfslinie bearbeiten", "action.export-as.designspace": "Designspace + UFO (*.designspace)", "action.export-as.fontra": "Fontra (*.fontra)", @@ -94,6 +95,8 @@ export const strings = { "axes.undo.add": "Achse %0 hinzufügen", "axes.undo.delete": "Achse %0 entfernen", "axes.undo.edit": "Achse %0 bearbeiten", + "backgroundImage.labels.color": "Farbe", + "backgroundImage.labels.opacity": "Opacity", "canvas.clean-view-and-hand-tool": "Ungehinderte Sicht und Hand Werkzeug", "cross-axis-mapping.axis-participates": "Wenn markiert, dann ist diese Achse teil des Mappings", diff --git a/src/fontra/client/lang/en.js b/src/fontra/client/lang/en.js index 9df6f5ddd..daf39a83a 100644 --- a/src/fontra/client/lang/en.js +++ b/src/fontra/client/lang/en.js @@ -25,6 +25,7 @@ export const strings = { "action.delete-glyph": "Delete Glyph", "action.delete-selection": "Delete Selection", "action.edit-anchor": "Edit Anchor", + "action.edit-background-image": "Edit Background Image", "action.edit-guideline": "Edit Guideline", "action.export-as.designspace": "Designspace + UFO (*.designspace)", "action.export-as.fontra": "Fontra (*.fontra)", @@ -94,6 +95,8 @@ export const strings = { "axes.undo.add": "add axis %0", "axes.undo.delete": "delete axis %0", "axes.undo.edit": "edit axis %0", + "backgroundImage.labels.color": "Color", + "backgroundImage.labels.opacity": "Opacity", "canvas.clean-view-and-hand-tool": "Clean View and Hand Tool", "cross-axis-mapping.axis-participates": "When checked, this axis participates in the mapping", diff --git a/src/fontra/client/lang/fr.js b/src/fontra/client/lang/fr.js index e40a7af02..db9462bc7 100644 --- a/src/fontra/client/lang/fr.js +++ b/src/fontra/client/lang/fr.js @@ -25,6 +25,7 @@ export const strings = { "action.delete-glyph": "Supprimer le glyphe", "action.delete-selection": "Supprimer la sélection", "action.edit-anchor": "Edit Anchor", + "action.edit-background-image": "Edit Background Image", "action.edit-guideline": "Edit Guideline", "action.export-as.designspace": "Designspace + UFO (*.designspace)", "action.export-as.fontra": "Fontra (*.fontra)", @@ -94,6 +95,8 @@ export const strings = { "axes.undo.add": "add axis %0", "axes.undo.delete": "delete axis %0", "axes.undo.edit": "edit axis %0", + "backgroundImage.labels.color": "Color", + "backgroundImage.labels.opacity": "Opacity", "canvas.clean-view-and-hand-tool": "Prévisualisation et outil de déplacement", "cross-axis-mapping.axis-participates": "When checked, this axis participates in the mapping", diff --git a/src/fontra/client/lang/ja.js b/src/fontra/client/lang/ja.js index ffb978860..24f97fbb1 100644 --- a/src/fontra/client/lang/ja.js +++ b/src/fontra/client/lang/ja.js @@ -25,6 +25,7 @@ export const strings = { "action.delete-glyph": "グリフを削除", "action.delete-selection": "選択範囲を削除", "action.edit-anchor": "アンカーを編集", + "action.edit-background-image": "Edit Background Image", "action.edit-guideline": "ガイドラインを編集", "action.export-as.designspace": "Designspace + UFO (*.designspace)", "action.export-as.fontra": "Fontra (*.fontra)", @@ -94,6 +95,8 @@ export const strings = { "axes.undo.add": "補完軸%0を追加", "axes.undo.delete": "補完軸%0を削除", "axes.undo.edit": "補完軸%0を編集", + "backgroundImage.labels.color": "Color", + "backgroundImage.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 0be20fafc..db4bec55e 100644 --- a/src/fontra/client/lang/nl.js +++ b/src/fontra/client/lang/nl.js @@ -25,6 +25,7 @@ export const strings = { "action.delete-glyph": "Verwijder glyph", "action.delete-selection": "Verwijder selectie", "action.edit-anchor": "Edit Anchor", + "action.edit-background-image": "Edit Background Image", "action.edit-guideline": "Edit Guideline", "action.export-as.designspace": "Designspace + UFO (*.designspace)", "action.export-as.fontra": "Fontra (*.fontra)", @@ -94,6 +95,8 @@ export const strings = { "axes.undo.add": "add axis %0", "axes.undo.delete": "delete axis %0", "axes.undo.edit": "edit axis %0", + "backgroundImage.labels.color": "Color", + "backgroundImage.labels.opacity": "Opacity", "canvas.clean-view-and-hand-tool": "Schone weergave en Hand gereedschap", "cross-axis-mapping.axis-participates": "When checked, this axis participates in the mapping", diff --git a/src/fontra/client/lang/zh-CN.js b/src/fontra/client/lang/zh-CN.js index ca8571dbb..9ed381a1f 100644 --- a/src/fontra/client/lang/zh-CN.js +++ b/src/fontra/client/lang/zh-CN.js @@ -25,6 +25,7 @@ export const strings = { "action.delete-glyph": "删除字形", "action.delete-selection": "删除选中", "action.edit-anchor": "编辑锚点", + "action.edit-background-image": "Edit Background Image", "action.edit-guideline": "编辑参考线", "action.export-as.designspace": "Designspace + UFO (*.designspace)", "action.export-as.fontra": "Fontra (*.fontra)", @@ -94,6 +95,8 @@ export const strings = { "axes.undo.add": "添加参数轴 %0", "axes.undo.delete": "删除参数轴 %0", "axes.undo.edit": "编辑参数轴 %0", + "backgroundImage.labels.color": "Color", + "backgroundImage.labels.opacity": "Opacity", "canvas.clean-view-and-hand-tool": "预览与拖拽工具", "cross-axis-mapping.axis-participates": "选中后,该参数轴参与映射", "cross-axis-mapping.delete": "删除跨轴映射", diff --git a/src/fontra/views/editor/edit-tools-pointer.js b/src/fontra/views/editor/edit-tools-pointer.js index 5de89ec11..2f53e49b4 100644 --- a/src/fontra/views/editor/edit-tools-pointer.js +++ b/src/fontra/views/editor/edit-tools-pointer.js @@ -236,6 +236,7 @@ export class PointerTool extends BaseTool { guideline: guidelineIndices, // TODO: Font Guidelines // fontGuideline: fontGuidelineIndices, + backgroundImage: backgroundImageIndices, } = parseSelection(sceneController.selection); if (componentIndices?.length && !pointIndices?.length && !anchorIndices?.length) { componentIndices.sort(); @@ -257,6 +258,13 @@ export class PointerTool extends BaseTool { guidelineIndices.sort(); sceneController.doubleClickedGuidelineIndices = guidelineIndices; sceneController._dispatchEvent("doubleClickedGuidelines"); + } else if ( + backgroundImageIndices?.length && + !guidelineIndices?.length && + !pointIndices?.length && + !componentIndices?.length + ) { + sceneController._dispatchEvent("doubleClickedBackgroundImage"); } else if (pointIndices?.length && !sceneController.hoverPathHit) { await this.handlePointsDoubleClick(pointIndices); } else if (sceneController.hoverPathHit) { diff --git a/src/fontra/views/editor/editor.js b/src/fontra/views/editor/editor.js index 7921cf03b..188b201c2 100644 --- a/src/fontra/views/editor/editor.js +++ b/src/fontra/views/editor/editor.js @@ -30,12 +30,18 @@ import { getRemoteProxy } from "../core/remote.js"; import { SceneView } from "../core/scene-view.js"; import { parseClipboard } from "../core/server-utils.js"; import { isSuperset } from "../core/set-ops.js"; -import { labeledCheckbox, labeledTextInput } from "../core/ui-utils.js"; +import { + labeledCheckbox, + labeledColorInput, + labeledSliderInput, + labeledTextInput, +} from "../core/ui-utils.js"; import { commandKeyProperty, dumpURLFragment, enumerate, fetchJSON, + hexToRgba, hyphenatedToCamelCase, hyphenatedToLabel, isActiveElementTypeable, @@ -47,6 +53,7 @@ import { range, readFromClipboard, reversed, + rgbaToHex, scheduleCalls, writeToClipboard, } from "../core/utils.js"; @@ -215,6 +222,13 @@ export class EditorController { // this.doubleClickedFontGuidelinesCallback(event); // }); + this.sceneController.addEventListener( + "doubleClickedBackgroundImage", + async (event) => { + this.doubleClickedBackgroundImageCallback(event); + } + ); + this.sceneController.addEventListener("glyphEditCannotEditReadOnly", async () => { this.showDialogGlyphEditCannotEditReadOnly(); }); @@ -1526,6 +1540,38 @@ export class EditorController { }); } + async doubleClickedBackgroundImageCallback(event) { + const glyphController = await this.sceneModel.getSelectedStaticGlyphController(); + if (!glyphController.canEdit) { + this.sceneController._dispatchEvent("glyphEditLocationNotAtSource"); + return; + } + + const instance = glyphController.instance; + const backgroundImage = instance.backgroundImage; + if (!backgroundImage) { + return; + } + + const { backgroundImageColor: newBackgroundImageColor } = + await this.doEditBackgroundImageDialog(backgroundImage.color); + if (!newBackgroundImageColor) { + return; + } + await this.sceneController.editLayersAndRecordChanges((layerGlyphs) => { + for (const layerGlyph of Object.values(layerGlyphs)) { + const oldBackgroundImage = layerGlyph.backgroundImage; + if (!oldBackgroundImage) { + // skip: if no background image, create a new one + continue; + } + layerGlyph.backgroundImage.color = newBackgroundImageColor; + } + this.sceneController.selection = new Set([`backgroundImage/0`]); + return translate("action.edit-background-image"); + }); + } + initContextMenuItems() { this.basicContextMenuItems = []; this.basicContextMenuItems.push({ @@ -2752,6 +2798,86 @@ export class EditorController { return { contentElement, warningElement }; } + async doEditBackgroundImageDialog(backgroundImageColor) { + const titleDialog = translate("action.edit-background-image"); + const defaultButton = translate("dialog.edit"); + + const backgroundImageController = new ObservableController({ + backgroundImageColor: rgbaToHex([ + backgroundImageColor.red, + backgroundImageColor.green, + backgroundImageColor.blue, + ]), + backgroundImageOpacity: backgroundImageColor.alpha, + }); + + // even though we don't need warning for color, + // I keep it for consistency + if we want to add transformation later + const { contentElement, warningElement } = + this._backgroundImagePropertiesContentElement(backgroundImageController); + const dialog = await dialogSetup(titleDialog, null, [ + { title: translate("dialog.cancel"), isCancelButton: true }, + { title: defaultButton, isDefaultButton: true }, + ]); + + dialog.setContent(contentElement); + + if (!(await dialog.run())) { + // User cancelled + return {}; + } + + // The color picker only returns rgb, no alpha. This is why the following is only rgb, + // even though hexToRgba would be possible to return alpha as well. + // But we get the alpha from the slider via backgroundImageOpacity. + const rgb = hexToRgba(backgroundImageController.model.backgroundImageColor); + const newNackgroundImageColor = { + red: rgb[0], + green: rgb[1], + blue: rgb[2], + alpha: parseFloat(backgroundImageController.model.backgroundImageOpacity), + }; + + return { backgroundImageColor: newNackgroundImageColor }; + } + + _backgroundImagePropertiesContentElement(controller) { + const warningElement = html.div({ + id: "warning-text-guideline-name", + style: `grid-column: 1 / -1; min-height: 1.5em;`, + }); + const contentElement = html.div( + { + style: `overflow: hidden; + white-space: nowrap; + display: grid; + gap: 0.5em; + grid-template-columns: auto auto; + align-items: center; + height: 100%; + min-height: 0; + `, + }, + [ + ...labeledColorInput( + translate("backgroundImage.labels.color"), + controller, + "backgroundImageColor", + {} + ), + ...labeledSliderInput( + translate("backgroundImage.labels.opacity"), + controller, + "backgroundImageOpacity", + { min: 0, max: 1, step: 0.001 } + ), + html.br(), + // warningElement, + ] + ); + return { contentElement, warningElement }; + } + doSelectAllNone(selectNone) { const positionedGlyph = this.sceneModel.getSelectedPositionedGlyph(); diff --git a/src/fontra/views/editor/visualization-layer-definitions.js b/src/fontra/views/editor/visualization-layer-definitions.js index 26fe4130e..1b9dd49f7 100644 --- a/src/fontra/views/editor/visualization-layer-definitions.js +++ b/src/fontra/views/editor/visualization-layer-definitions.js @@ -10,6 +10,7 @@ import { makeUPlusStringFromCodePoint, parseSelection, rgbaToCSS, + rgbaToHex, round, unionIndexSets, withSavedState, @@ -435,7 +436,6 @@ registerVisualizationLayerDefinition({ if (!image) { return; } - const affine = decomposedToTransform(backgroundImage.transformation) .translate(0, image.height) .scale(1, -1); @@ -450,9 +450,18 @@ registerVisualizationLayerDefinition({ affine.dy ); if (backgroundImage.color) { - // TODO: solve colorizing with backgroundImage.color - // For now: apply alpha context.globalAlpha = backgroundImage.color.alpha; + const hexColor = rgbaToHex([ + backgroundImage.color.red, + backgroundImage.color.green, + backgroundImage.color.blue, + backgroundImage.color.alpha, + ]); + context.fillStyle = hexColor; + // The following code does do the color change, of the background is transparent. + // Reference: https://stackoverflow.com/questions/45706829/change-color-image-in-canvas + context.fillRect(0, 0, image.width, image.height); + context.globalCompositeOperation = "destination-in"; } context.drawImage(image, 0, 0, image.width, image.height); });