From 3c83a322b6d938f1e17a7ce36eaf28fc8dade182 Mon Sep 17 00:00:00 2001 From: dwelle Date: Mon, 20 Dec 2021 21:50:16 +0100 Subject: [PATCH] feat: support image background editor [wip] --- src/actions/actionFinalize.tsx | 53 +++++++++++++- src/actions/actionImageEditing.tsx | 75 +++++++++++++++++++ src/actions/index.ts | 1 + src/actions/types.ts | 3 +- src/appState.ts | 2 + src/components/Actions.tsx | 8 +++ src/components/App.tsx | 46 +++++++++++- src/components/icons.tsx | 8 +++ src/element/image.ts | 13 ++++ src/element/imageEditor.ts | 112 +++++++++++++++++++++++++++++ src/renderer/renderElement.ts | 56 +++++++++------ src/scene/Scene.ts | 6 +- src/scene/export.ts | 1 + src/scene/types.ts | 1 + src/types.ts | 5 ++ 15 files changed, 361 insertions(+), 29 deletions(-) create mode 100644 src/actions/actionImageEditing.tsx create mode 100644 src/element/imageEditor.ts diff --git a/src/actions/actionFinalize.tsx b/src/actions/actionFinalize.tsx index e89112af7f..4109b7f8cc 100644 --- a/src/actions/actionFinalize.tsx +++ b/src/actions/actionFinalize.tsx @@ -14,10 +14,60 @@ import { bindOrUnbindLinearElement, } from "../element/binding"; import { isBindingElement } from "../element/typeChecks"; +import { ExcalidrawImageElement } from "../element/types"; +import { imageFromImageData } from "../element/image"; export const actionFinalize = register({ name: "finalize", - perform: (elements, appState, _, { canvas, focusContainer }) => { + perform: ( + elements, + appState, + _, + { canvas, focusContainer, imageCache, addFiles }, + ) => { + if (appState.editingImageElement) { + const { elementId, imageData } = appState.editingImageElement; + const editingImageElement = elements.find((el) => el.id === elementId) as + | ExcalidrawImageElement + | undefined; + if (editingImageElement?.fileId) { + const cachedImageData = imageCache.get(editingImageElement.fileId); + if (cachedImageData) { + const { image, dataURL } = imageFromImageData(imageData); + + imageCache.set(editingImageElement.fileId, { + ...cachedImageData, + image, + }); + + addFiles([ + { + id: editingImageElement.fileId, + dataURL, + mimeType: cachedImageData.mimeType, + created: Date.now(), + }, + ]); + + return { + appState: { + ...appState, + editingImageElement: null, + }, + commitToHistory: false, + }; + } + } + + return { + appState: { + ...appState, + editingImageElement: null, + }, + commitToHistory: false, + }; + } + if (appState.editingLinearElement) { const { elementId, startBindingElement, endBindingElement } = appState.editingLinearElement; @@ -162,6 +212,7 @@ export const actionFinalize = register({ keyTest: (event, appState) => (event.key === KEYS.ESCAPE && (appState.editingLinearElement !== null || + appState.editingImageElement !== null || (!appState.draggingElement && appState.multiElement === null))) || ((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) && appState.multiElement !== null), diff --git a/src/actions/actionImageEditing.tsx b/src/actions/actionImageEditing.tsx new file mode 100644 index 0000000000..b162e7e075 --- /dev/null +++ b/src/actions/actionImageEditing.tsx @@ -0,0 +1,75 @@ +import { getSelectedElements, isSomeElementSelected } from "../scene"; +import { ToolButton } from "../components/ToolButton"; +import { backgroundIcon } from "../components/icons"; +import { register } from "./register"; +import { getNonDeletedElements } from "../element"; +import { isInitializedImageElement } from "../element/typeChecks"; +import Scene from "../scene/Scene"; + +export const actionEditImageAlpha = register({ + name: "editImageAlpha", + perform: async (elements, appState, _, app) => { + if (appState.editingImageElement) { + return { + appState: { + ...appState, + editingImageElement: null, + }, + commitToHistory: false, + }; + } + + const selectedElements = getSelectedElements(elements, appState); + const selectedElement = selectedElements[0]; + if ( + selectedElements.length === 1 && + isInitializedImageElement(selectedElement) + ) { + const imgData = app.imageCache.get(selectedElement.fileId); + if (!imgData) { + return false; + } + + const image = await imgData.image; + const { width, height } = image; + + const canvas = document.createElement("canvas"); + canvas.height = height; + canvas.width = width; + const context = canvas.getContext("2d")!; + + context.drawImage(image, 0, 0, width, height); + + const imageData = context.getImageData(0, 0, width, height); + + Scene.mapElementToScene(selectedElement.id, app.scene); + + return { + appState: { + ...appState, + editingImageElement: { + editorType: "alpha", + elementId: selectedElement.id, + origImageData: imageData, + imageData, + pointerDownState: { screenX: 0, screenY: 0, sampledPixel: null }, + }, + }, + commitToHistory: false, + }; + } + return false; + }, + PanelComponent: ({ elements, appState, updateData }) => ( + updateData(null)} + visible={isSomeElementSelected(getNonDeletedElements(elements), appState)} + /> + ), +}); diff --git a/src/actions/index.ts b/src/actions/index.ts index f37c784229..ad240d0904 100644 --- a/src/actions/index.ts +++ b/src/actions/index.ts @@ -80,3 +80,4 @@ export { actionToggleGridMode } from "./actionToggleGridMode"; export { actionToggleZenMode } from "./actionToggleZenMode"; export { actionToggleStats } from "./actionToggleStats"; +export { actionEditImageAlpha } from "./actionImageEditing"; diff --git a/src/actions/types.ts b/src/actions/types.ts index 672fb91bb7..74e8ab8446 100644 --- a/src/actions/types.ts +++ b/src/actions/types.ts @@ -101,7 +101,8 @@ export type ActionName = | "flipVertical" | "viewMode" | "exportWithDarkMode" - | "toggleTheme"; + | "toggleTheme" + | "editImageAlpha"; export type PanelComponentProps = { elements: readonly ExcalidrawElement[]; diff --git a/src/appState.ts b/src/appState.ts index a15a4e9558..d82dbc3a74 100644 --- a/src/appState.ts +++ b/src/appState.ts @@ -41,6 +41,7 @@ export const getDefaultAppState = (): Omit< editingElement: null, editingGroupId: null, editingLinearElement: null, + editingImageElement: null, elementLocked: false, elementType: "selection", errorMessage: null, @@ -125,6 +126,7 @@ const APP_STATE_STORAGE_CONF = (< editingElement: { browser: false, export: false, server: false }, editingGroupId: { browser: true, export: false, server: false }, editingLinearElement: { browser: false, export: false, server: false }, + editingImageElement: { browser: false, export: false, server: false }, elementLocked: { browser: true, export: false, server: false }, elementType: { browser: true, export: false, server: false }, errorMessage: { browser: false, export: false, server: false }, diff --git a/src/components/Actions.tsx b/src/components/Actions.tsx index cc291eace9..4c64709b09 100644 --- a/src/components/Actions.tsx +++ b/src/components/Actions.tsx @@ -19,6 +19,7 @@ import { capitalizeString, isTransparent, setCursorForShape } from "../utils"; import Stack from "./Stack"; import { ToolButton } from "./ToolButton"; import { hasStrokeColor } from "../scene/comparisons"; +import { isImageElement } from "../element/typeChecks"; export const SelectedShapeActions = ({ appState, @@ -105,6 +106,13 @@ export const SelectedShapeActions = ({ <>{renderAction("changeArrowhead")} )} +
+
+ {targetElements.some((element) => isImageElement(element)) && + renderAction("editImageAlpha")} +
+
+ {renderAction("changeOpacity")}
diff --git a/src/components/App.tsx b/src/components/App.tsx index 6814d273ad..6f084eb9fd 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -237,6 +237,7 @@ import { getBoundTextElementId, } from "../element/textElement"; import { isHittingElementNotConsideringBoundingBox } from "../element/collision"; +import { ImageEditor } from "../element/imageEditor"; const IsMobileContext = React.createContext(false); export const useIsMobile = () => useContext(IsMobileContext); @@ -281,7 +282,7 @@ class App extends React.Component { UIOptions: DEFAULT_UI_OPTIONS, }; - private scene: Scene; + public scene: Scene; private resizeObserver: ResizeObserver | undefined; private nearestScrollableContainer: HTMLElement | Document | undefined; public library: AppClassProperties["library"]; @@ -1031,8 +1032,14 @@ class App extends React.Component { ); if ( - this.state.editingLinearElement && - !this.state.selectedElementIds[this.state.editingLinearElement.elementId] + (this.state.editingLinearElement && + !this.state.selectedElementIds[ + this.state.editingLinearElement.elementId + ]) || + (this.state.editingImageElement && + !this.state.selectedElementIds[ + this.state.editingImageElement.elementId + ]) ) { // defer so that the commitToHistory flag isn't reset via current update setTimeout(() => { @@ -1135,6 +1142,7 @@ class App extends React.Component { imageCache: this.imageCache, isExporting: false, renderScrollbars: !this.isMobile, + editingImageElement: this.state.editingImageElement, }, ); if (scrollBars) { @@ -2330,6 +2338,10 @@ class App extends React.Component { const scenePointer = viewportCoordsToSceneCoords(event, this.state); const { x: scenePointerX, y: scenePointerY } = scenePointer; + if (this.state.editingImageElement) { + return; + } + if ( this.state.editingLinearElement && !this.state.editingLinearElement.isDragging @@ -2920,6 +2932,14 @@ class App extends React.Component { pointerDownState: PointerDownState, ): boolean => { if (this.state.elementType === "selection") { + if (this.state.editingImageElement) { + ImageEditor.handlePointerDown( + this.state.editingImageElement, + pointerDownState.origin, + ); + return false; + } + const elements = this.scene.getElements(); const selectedElements = getSelectedElements(elements, this.state); if (selectedElements.length === 1 && !this.state.editingLinearElement) { @@ -3480,6 +3500,22 @@ class App extends React.Component { } } + if (this.state.editingImageElement) { + const newImageData = ImageEditor.handlePointerMove( + this.state.editingImageElement, + pointerCoords, + ); + if (newImageData) { + this.setState({ + editingImageElement: { + ...this.state.editingImageElement, + imageData: newImageData, + }, + }); + } + return; + } + if (this.state.editingLinearElement) { const didDrag = LinearElementEditor.handlePointDragging( this.state, @@ -3802,6 +3838,10 @@ class App extends React.Component { this.savePointer(childEvent.clientX, childEvent.clientY, "up"); + if (this.state.editingImageElement) { + ImageEditor.handlePointerUp(this.state.editingImageElement); + } + // Handle end of dragging a point of a linear element, might close a loop // and sets binding element if (this.state.editingLinearElement) { diff --git a/src/components/icons.tsx b/src/components/icons.tsx index 1fa207275f..3e67b3c0d0 100644 --- a/src/components/icons.tsx +++ b/src/components/icons.tsx @@ -89,6 +89,14 @@ export const trash = createIcon( { width: 448, height: 512 }, ); +export const backgroundIcon = createIcon( + , + + { width: 576, height: 512 }, +); export const palette = createIcon( "M204.3 5C104.9 24.4 24.8 104.3 5.2 203.4c-37 187 131.7 326.4 258.8 306.7 41.2-6.4 61.4-54.6 42.5-91.7-23.1-45.4 9.9-98.4 60.9-98.4h79.7c35.8 0 64.8-29.6 64.9-65.3C511.5 97.1 368.1-26.9 204.3 5zM96 320c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32zm32-128c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32zm128-64c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32zm128 64c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32z", diff --git a/src/element/image.ts b/src/element/image.ts index 17ba245e10..d01f1fa024 100644 --- a/src/element/image.ts +++ b/src/element/image.ts @@ -109,3 +109,16 @@ export const normalizeSVG = async (SVGString: string) => { return svg.outerHTML; } }; + +export const imageFromImageData = (imagedata: ImageData) => { + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d")!; + canvas.width = imagedata.width; + canvas.height = imagedata.height; + ctx.putImageData(imagedata, 0, 0); + + const image = new Image(); + const dataURL = canvas.toDataURL() as DataURL; + image.src = dataURL; + return { image, dataURL }; +}; diff --git a/src/element/imageEditor.ts b/src/element/imageEditor.ts new file mode 100644 index 0000000000..b43acb11f0 --- /dev/null +++ b/src/element/imageEditor.ts @@ -0,0 +1,112 @@ +import { distance2d } from "../math"; +import Scene from "../scene/Scene"; +import { + ExcalidrawImageElement, + InitializedExcalidrawImageElement, +} from "./types"; + +export type EditingImageElement = { + editorType: "alpha"; + elementId: ExcalidrawImageElement["id"]; + origImageData: Readonly; + imageData: ImageData; + pointerDownState: { + screenX: number; + screenY: number; + sampledPixel: readonly [number, number, number, number] | null; + }; +}; + +const getElement = (id: EditingImageElement["elementId"]) => { + const element = Scene.getScene(id)?.getNonDeletedElement(id); + if (element) { + return element as InitializedExcalidrawImageElement; + } + return null; +}; + +export class ImageEditor { + static handlePointerDown( + editingElement: EditingImageElement, + scenePointer: { x: number; y: number }, + ) { + const imageElement = getElement(editingElement.elementId); + + if (imageElement) { + if ( + scenePointer.x >= imageElement.x && + scenePointer.x <= imageElement.x + imageElement.width && + scenePointer.y >= imageElement.y && + scenePointer.y <= imageElement.y + imageElement.height + ) { + editingElement.pointerDownState.screenX = scenePointer.x; + editingElement.pointerDownState.screenY = scenePointer.y; + + const { width, height, data } = editingElement.origImageData; + + const imageOffsetX = Math.round( + (scenePointer.x - imageElement.x) * (width / imageElement.width), + ); + const imageOffsetY = Math.round( + (scenePointer.y - imageElement.y) * (height / imageElement.height), + ); + + const sampledPixel = [ + data[(imageOffsetY * width + imageOffsetX) * 4 + 0], + data[(imageOffsetY * width + imageOffsetX) * 4 + 1], + data[(imageOffsetY * width + imageOffsetX) * 4 + 2], + data[(imageOffsetY * width + imageOffsetX) * 4 + 3], + ] as const; + + editingElement.pointerDownState.sampledPixel = sampledPixel; + } + } + } + + static handlePointerMove( + editingElement: EditingImageElement, + scenePointer: { x: number; y: number }, + ) { + const { sampledPixel } = editingElement.pointerDownState; + if (sampledPixel) { + const { screenX, screenY } = editingElement.pointerDownState; + const distance = distance2d( + scenePointer.x, + scenePointer.y, + screenX, + screenY, + ); + + const { width, height, data } = editingElement.origImageData; + const newImageData = new ImageData(width, height); + + for (let x = 0; x < width; ++x) { + for (let y = 0; y < height; ++y) { + if ( + Math.abs(sampledPixel[0] - data[(y * width + x) * 4 + 0]) + + Math.abs(sampledPixel[1] - data[(y * width + x) * 4 + 1]) + + Math.abs(sampledPixel[2] - data[(y * width + x) * 4 + 2]) < + distance + ) { + newImageData.data[(y * width + x) * 4 + 0] = 0; + newImageData.data[(y * width + x) * 4 + 1] = 255; + newImageData.data[(y * width + x) * 4 + 2] = 0; + newImageData.data[(y * width + x) * 4 + 3] = 0; + } else { + for (let p = 0; p < 4; ++p) { + newImageData.data[(y * width + x) * 4 + p] = + data[(y * width + x) * 4 + p]; + } + } + } + } + + return newImageData; + } + } + + static handlePointerUp(editingElement: EditingImageElement) { + editingElement.pointerDownState.sampledPixel = null; + editingElement.origImageData = editingElement.imageData; + } +} diff --git a/src/renderer/renderElement.ts b/src/renderer/renderElement.ts index 63f164ed79..d7f17febfa 100644 --- a/src/renderer/renderElement.ts +++ b/src/renderer/renderElement.ts @@ -12,6 +12,7 @@ import { isLinearElement, isFreeDrawElement, isInitializedImageElement, + isImageElement, } from "../element/typeChecks"; import { getDiamondPoints, @@ -221,19 +222,31 @@ const drawElementOnCanvas = ( break; } case "image": { - const img = isInitializedImageElement(element) - ? renderConfig.imageCache.get(element.fileId)?.image - : undefined; - if (img != null && !(img instanceof Promise)) { - context.drawImage( - img, - 0 /* hardcoded for the selection box*/, - 0, - element.width, - element.height, - ); + if (renderConfig.editingImageElement) { + const { imageData } = renderConfig.editingImageElement; + + const imgCanvas = document.createElement("canvas"); + imgCanvas.width = imageData.width; + imgCanvas.height = imageData.height; + const imgContext = imgCanvas.getContext("2d")!; + imgContext.putImageData(imageData, 0, 0); + + context.drawImage(imgCanvas, 0, 0, element.width, element.height); } else { - drawImagePlaceholder(element, context, renderConfig.zoom.value); + const img = isInitializedImageElement(element) + ? renderConfig.imageCache.get(element.fileId)?.image + : undefined; + if (img != null && !(img instanceof Promise)) { + context.drawImage( + img, + 0 /* hardcoded for the selection box*/, + 0, + element.width, + element.height, + ); + } else { + drawImagePlaceholder(element, context, renderConfig.zoom.value); + } } break; } @@ -410,23 +423,23 @@ const generateElementShape = ( topY + (rightY - topY) * 0.25 } L ${rightX - (rightX - topX) * 0.25} ${ rightY - (rightY - topY) * 0.25 - } + } C ${rightX} ${rightY}, ${rightX} ${rightY}, ${ rightX - (rightX - bottomX) * 0.25 - } ${rightY + (bottomY - rightY) * 0.25} + } ${rightY + (bottomY - rightY) * 0.25} L ${bottomX + (rightX - bottomX) * 0.25} ${ bottomY - (bottomY - rightY) * 0.25 - } + } C ${bottomX} ${bottomY}, ${bottomX} ${bottomY}, ${ bottomX - (bottomX - leftX) * 0.25 - } ${bottomY - (bottomY - leftY) * 0.25} + } ${bottomY - (bottomY - leftY) * 0.25} L ${leftX + (bottomX - leftX) * 0.25} ${ leftY + (bottomY - leftY) * 0.25 - } + } C ${leftX} ${leftY}, ${leftX} ${leftY}, ${ leftX + (topX - leftX) * 0.25 - } ${leftY - (leftY - topY) * 0.25} - L ${topX - (topX - leftX) * 0.25} ${topY + (leftY - topY) * 0.25} + } ${leftY - (leftY - topY) * 0.25} + L ${topX - (topX - leftX) * 0.25} ${topY + (leftY - topY) * 0.25} C ${topX} ${topY}, ${topX} ${topY}, ${ topX + (rightX - topX) * 0.25 } ${topY + (rightY - topY) * 0.25}`, @@ -608,7 +621,10 @@ const generateElementWithCanvas = ( if ( !prevElementWithCanvas || shouldRegenerateBecauseZoom || - prevElementWithCanvas.theme !== renderConfig.theme + prevElementWithCanvas.theme !== renderConfig.theme || + (renderConfig.editingImageElement && + isImageElement(element) && + element.id === renderConfig.editingImageElement.elementId) ) { const elementWithCanvas = generateElementCanvas( element, diff --git a/src/scene/Scene.ts b/src/scene/Scene.ts index 5d981c3f3d..e948a3cd23 100644 --- a/src/scene/Scene.ts +++ b/src/scene/Scene.ts @@ -4,15 +4,13 @@ import { NonDeleted, } from "../element/types"; import { getNonDeletedElements, isNonDeletedElement } from "../element"; -import { LinearElementEditor } from "../element/linearElementEditor"; -type ElementIdKey = InstanceType["elementId"]; -type ElementKey = ExcalidrawElement | ElementIdKey; +type ElementKey = ExcalidrawElement | ExcalidrawElement["id"]; type SceneStateCallback = () => void; type SceneStateCallbackRemover = () => void; -const isIdKey = (elementKey: ElementKey): elementKey is ElementIdKey => { +const isIdKey = (elementKey: ElementKey): elementKey is string => { if (typeof elementKey === "string") { return true; } diff --git a/src/scene/export.ts b/src/scene/export.ts index 1f46bd9ff1..80ba068eff 100644 --- a/src/scene/export.ts +++ b/src/scene/export.ts @@ -67,6 +67,7 @@ export const exportToCanvas = async ( renderSelection: false, renderGrid: false, isExporting: true, + editingImageElement: null, }); return canvas; diff --git a/src/scene/types.ts b/src/scene/types.ts index be05ddfde9..84cf8bfaef 100644 --- a/src/scene/types.ts +++ b/src/scene/types.ts @@ -11,6 +11,7 @@ export type RenderConfig = { zoom: AppState["zoom"]; shouldCacheIgnoreZoom: AppState["shouldCacheIgnoreZoom"]; theme: AppState["theme"]; + editingImageElement: AppState["editingImageElement"]; // collab-related state // --------------------------------------------------------------------------- remotePointerViewportCoords: { [id: string]: { x: number; y: number } }; diff --git a/src/types.ts b/src/types.ts index 712728a4f2..969f0942fe 100644 --- a/src/types.ts +++ b/src/types.ts @@ -29,6 +29,8 @@ import { MaybeTransformHandleType } from "./element/transformHandles"; import Library from "./data/library"; import type { FileSystemHandle } from "./data/filesystem"; import type { ALLOWED_IMAGE_MIME_TYPES, MIME_TYPES } from "./constants"; +import { EditingImageElement } from "./element/imageEditor"; +import Scene from "./scene/Scene"; export type Point = Readonly; @@ -77,6 +79,7 @@ export type AppState = { // (e.g. text element when typing into the input) editingElement: NonDeletedExcalidrawElement | null; editingLinearElement: LinearElementEditor | null; + editingImageElement: EditingImageElement | null; elementType: typeof SHAPES[number]["value"]; elementLocked: boolean; exportBackground: boolean; @@ -316,6 +319,8 @@ export type AppClassProperties = { } >; files: BinaryFiles; + scene: Scene; + addFiles: App["addFiles"]; }; export type PointerDownState = Readonly<{