From 9146c5ba25dd5893286da7d24b75b6c94ca93d67 Mon Sep 17 00:00:00 2001 From: nagy nabil Date: Sat, 2 Sep 2023 22:23:33 +0300 Subject: [PATCH 1/7] init exporting items - change loaded image representation to dataUrl - clean up the items before exporting them --- src/actions/copySelected.ts | 107 +++++++++++++++++++++++- src/actions/cutSelected.ts | 4 +- src/components/ToolbarElementConfig.tsx | 12 ++- src/types/general.ts | 14 +++- src/utils/canvas/generateElement.ts | 24 ++++-- 5 files changed, 146 insertions(+), 15 deletions(-) diff --git a/src/actions/copySelected.ts b/src/actions/copySelected.ts index 96b2e25..8290fe4 100644 --- a/src/actions/copySelected.ts +++ b/src/actions/copySelected.ts @@ -1,11 +1,112 @@ import { Command } from "./types"; +import { + type ZagyCanvasElement, + isRect, + isImage, + ZagyCanvasImageElement, + ZagyCanvasRectElement, + isLine, + ZagyCanvasLineElement, + CleanedElement, + ZagyPortableT, +} from "@/types/general"; import { useStore } from "@/store/index"; +import { isText } from "@/types/general"; +import { ZagyCanvasTextElement } from "@/types/general"; +import { isHanddrawn } from "@/types/general"; +import { ZagyCanvasHandDrawnElement } from "@/types/general"; + +export const DestOpts = { + CLIPBOARD: 0, + JSON: 1, +} as const; +export type DestOpts = (typeof DestOpts)[keyof typeof DestOpts]; + +/** + * clean up the elements state from unnecessary props like (cache and so on...) then copy them async to the clipboard in portable format + * + * @param dest DestOpts where to copy the data [clipboard, json file] + * @param onlySelected boolean only export selected elements or all the scene, default `false` + */ +export class ActionExportScene extends Command { + constructor(private dest: DestOpts, private onlySelected = false) { + super(); + } + + /** + * elements in the store contains props that is needed when exporting them, or when the user import them again so we clean those props up to propably make the exported size small + */ + private static cleanupItem(el: T): CleanedElement { + const baseTemp: CleanedElement = { + id: el.id, + shape: el.shape, + x: el.x, + y: el.y, + endX: el.endX, + endY: el.endY, + options: el.options, + }; + if (isImage(el)) { + return { + ...baseTemp, + shape: el.shape, + image: el.image, + } satisfies CleanedElement as unknown as CleanedElement; + } else if (isRect(el)) { + return { + ...baseTemp, + shape: el.shape, + options: el.options, + } satisfies CleanedElement as unknown as CleanedElement; + } else if (isLine(el)) { + return { + ...baseTemp, + shape: el.shape, + point1: el.point1, + point2: el.point2, + options: el.options, + } satisfies CleanedElement as unknown as CleanedElement; + } else if (isText(el)) { + return { + ...baseTemp, + shape: el.shape, + text: el.text, + options: el.options, + } satisfies CleanedElement as unknown as CleanedElement; + } else if (isHanddrawn(el)) { + return { + ...baseTemp, + shape: el.shape, + path2D: el.path2D, + paths: el.paths, + options: el.options, + } satisfies CleanedElement as unknown as CleanedElement; + } else { + throw new Error("EXPORT SCENE: cannot export unknown item"); + } + } -export class ActionCopySelected extends Command { public async execute() { - const { selectedElements } = useStore.getState(); + const { selectedElements, elements } = useStore.getState(); try { - await navigator.clipboard.writeText(JSON.stringify(selectedElements)); + // clean up the items + const portable: ZagyPortableT = { + name: "ZagyPortableContent", + elements: [], + }; + if (this.onlySelected) { + selectedElements.forEach((el) => + portable.elements.push(ActionExportScene.cleanupItem(el)) + ); + } else { + elements.forEach((el) => portable.elements.push(ActionExportScene.cleanupItem(el))); + } + + if (this.dest === DestOpts.CLIPBOARD) { + await navigator.clipboard.writeText(JSON.stringify(portable)); + } else if (this.dest === DestOpts.JSON) { + throw new Error("EXPORT SCENE: TODO export as json file"); + } } catch (e) { console.log("🪵 [copySelected.ts:15] ~ token ~ \x1b[0;32me\x1b[0m = ", e); } diff --git a/src/actions/cutSelected.ts b/src/actions/cutSelected.ts index e2998f4..efe9ebc 100644 --- a/src/actions/cutSelected.ts +++ b/src/actions/cutSelected.ts @@ -1,5 +1,5 @@ import { ActionDeleteSelected } from "./deleteSelected"; -import { ActionCopySelected } from "./copySelected"; +import { ActionExportScene, DestOpts } from "./copySelected"; import { UndoableCommand } from "./types"; export class ActionCutSelected extends UndoableCommand { @@ -7,7 +7,7 @@ export class ActionCutSelected extends UndoableCommand { public execute() { // the cut action can be seen as copy action then delete action - new ActionCopySelected().execute(); + new ActionExportScene(DestOpts.CLIPBOARD, true).execute(); this.#deleteAction = new ActionDeleteSelected(); this.#deleteAction.execute(); return; diff --git a/src/components/ToolbarElementConfig.tsx b/src/components/ToolbarElementConfig.tsx index 6326b2e..a4cc33d 100644 --- a/src/components/ToolbarElementConfig.tsx +++ b/src/components/ToolbarElementConfig.tsx @@ -29,7 +29,7 @@ import { } from "@/utils/canvas/generateElement"; import { commandManager } from "@/actions/commandManager"; import { ActionDeleteSelected } from "@/actions"; -import { ActionCopySelected } from "@/actions/copySelected"; +import { ActionExportScene, DestOpts } from "@/actions/copySelected"; import useKeyboardShortcut from "@/hooks/useShortcut"; //import { BsTextCenter, BsTextLeft, BsTextRight } from "react-icons/bs"; @@ -174,6 +174,14 @@ export default function ToolbarLeft() { }, "Delete" ); + useKeyboardShortcut( + { + onShortcut: () => + commandManager.executeCommand(new ActionExportScene(DestOpts.CLIPBOARD, true)), + }, + "ControlLeft", + "c" + ); const commonConf = useMemo(() => { if (selectedElements.length === 0) return null; @@ -186,7 +194,7 @@ export default function ToolbarLeft() { const handleCopyOnClick: React.MouseEventHandler = async () => { try { - commandManager.executeCommand(new ActionCopySelected()); + commandManager.executeCommand(new ActionExportScene(DestOpts.CLIPBOARD, true)); } catch (e) { //todo } diff --git a/src/types/general.ts b/src/types/general.ts index 6922aa7..f7621d9 100644 --- a/src/types/general.ts +++ b/src/types/general.ts @@ -94,7 +94,7 @@ interface ZagyCanvasImageElement extends ZagyCanvasElement { /** * A string containing an object URL that can be used to reference the contents of the specified source object(URL.createObjectURL) */ - image: string; + image: string | null; // TODO: change to use the cache prop already defined om ZagyElement imgRef: Promise | HTMLImageElement; } @@ -142,6 +142,16 @@ function isImage(el: ZagyCanvasElement): el is ZagyCanvasImageElement { return el.shape === "image"; } +type CleanedElement = Omit< + T, + "cache" | "cacheCtx" | "zoom" | "willDelete" | "roughElement" | "imgRef" +>; + +type ZagyPortableT = { + name: "ZagyPortableContent"; + elements: CleanedElement[]; +}; + //function to check if elements extends CachableElement export type { @@ -164,5 +174,7 @@ export type { HanddrawnOptions, ZagyCanvasImageElement, ImageOptions, + CleanedElement, + ZagyPortableT, }; export { CursorFn, FontTypeOptions, isLine, isRect, isText, isHanddrawn, isImage }; diff --git a/src/utils/canvas/generateElement.ts b/src/utils/canvas/generateElement.ts index 9b15efa..bae4491 100644 --- a/src/utils/canvas/generateElement.ts +++ b/src/utils/canvas/generateElement.ts @@ -328,21 +328,31 @@ const generateTextElement = ( }; }; +function CreateDataUrl(file: Blob) { + return new Promise((resolve) => { + const reader = new FileReader(); + reader.onloadend = () => { + resolve(reader.result as string); + // Logs data:;base64,wL2dvYWwgbW9yZ... + }; + reader.readAsDataURL(file); + }); +} /** * create new image instance append it to ZagyImageElement * filter the store from the element with placeholder image, and append the new one with the loaded image */ -async function loadImage(data: string, id: string) { +async function loadImage(file: Blob, id: string) { + const dataUrl = await CreateDataUrl(file); const img = new Image(); const promise = new Promise((resolve) => { img.onload = () => { resolve(img); }; }); - img.src = data; + + img.src = dataUrl; const loadedImage = await promise; - // TODO, delete this only for testing the preview - await sleep(5000); const { setElements, elements } = useStore.getState(); // this suppose to prevent adding loaded image to the store after the user delete the preview const oldEl = elements.find((el) => el.id === id); @@ -353,6 +363,7 @@ async function loadImage(data: string, id: string) { ...(oldEl as ZagyCanvasImageElement), endX: oldEl.x + loadedImage.width, endY: oldEl.y + loadedImage.height, + image: dataUrl, imgRef: loadedImage, } satisfies ZagyCanvasImageElement, ]); @@ -365,7 +376,6 @@ function generateImageElement( ): ZagyCanvasImageElement { // TODO hand drawn options is the same as image options so i use it, but it's better to create a separate function so they won't be coupled together const normalizedOptions = normalizeHanddrawnOptions(options); - const data = URL.createObjectURL(blob); const id = options.id || nanoid(); const el: ZagyCanvasImageElement = { id, @@ -375,8 +385,8 @@ function generateImageElement( y: startPos[1], endX: startPos[0] + PREVIEW_IMAGE_WIDTH, endY: startPos[1] + PREVIEW_IMAGE_HEIGHT, - image: data, - imgRef: loadImage(data, id), + image: null, + imgRef: loadImage(blob, id), options: { ...normalizedOptions, }, From 372c4a57f974c3fd4a8fc649f45156dceef5778a Mon Sep 17 00:00:00 2001 From: nagy nabil Date: Mon, 4 Sep 2023 21:47:23 +0300 Subject: [PATCH 2/7] feat: paste element from scene to another --- src/actions/ExportScene.ts | 119 ++++++++++++++++++++++++ src/actions/copySelected.ts | 3 +- src/actions/importElements.ts | 111 +++++++++++++++++++++- src/components/ToolbarElementConfig.tsx | 2 +- src/components/ZagyDraw.tsx | 10 +- src/types/general.ts | 16 +++- src/utils/canvas/generateElement.ts | 1 - src/utils/index.ts | 2 +- 8 files changed, 249 insertions(+), 15 deletions(-) create mode 100644 src/actions/ExportScene.ts diff --git a/src/actions/ExportScene.ts b/src/actions/ExportScene.ts new file mode 100644 index 0000000..46ee0b3 --- /dev/null +++ b/src/actions/ExportScene.ts @@ -0,0 +1,119 @@ +import { Command } from "./types"; +import { + type ZagyCanvasElement, + isRect, + isImage, + ZagyCanvasImageElement, + ZagyCanvasRectElement, + isLine, + ZagyCanvasLineElement, + CleanedElement, + ZagyPortableT, +} from "@/types/general"; +import { useStore } from "@/store/index"; +import { isText } from "@/types/general"; +import { ZagyCanvasTextElement } from "@/types/general"; +import { isHanddrawn } from "@/types/general"; +import { ZagyCanvasHandDrawnElement } from "@/types/general"; +import { getBoundingRect } from "@/utils"; + +export const DestOpts = { + CLIPBOARD: 0, + JSON: 1, +} as const; +export type DestOpts = (typeof DestOpts)[keyof typeof DestOpts]; + +/** + * clean up the elements state from unnecessary props like (cache and so on...) then copy them async to the clipboard in portable format + * + * @param dest DestOpts where to copy the data [clipboard, json file] + * @param onlySelected boolean only export selected elements or all the scene, default `false` + */ +export class ActionExportScene extends Command { + constructor(private dest: DestOpts, private onlySelected = false) { + super(); + } + + /** + * elements in the store contains props that is needed when exporting them, or when the user import them again so we clean those props up to propably make the exported size small + */ + private static cleanupItem(el: T): CleanedElement { + const baseTemp: CleanedElement = { + id: el.id, + shape: el.shape, + x: el.x, + y: el.y, + endX: el.endX, + endY: el.endY, + options: el.options, + }; + if (isImage(el)) { + return { + ...baseTemp, + shape: el.shape, + image: el.image, + } satisfies CleanedElement as unknown as CleanedElement; + } else if (isRect(el)) { + return { + ...baseTemp, + shape: el.shape, + options: el.options, + } satisfies CleanedElement as unknown as CleanedElement; + } else if (isLine(el)) { + return { + ...baseTemp, + shape: el.shape, + point1: el.point1, + point2: el.point2, + options: el.options, + } satisfies CleanedElement as unknown as CleanedElement; + } else if (isText(el)) { + return { + ...baseTemp, + shape: el.shape, + text: el.text, + options: el.options, + } satisfies CleanedElement as unknown as CleanedElement; + } else if (isHanddrawn(el)) { + return { + ...baseTemp, + shape: el.shape, + path2D: el.path2D, + paths: el.paths, + options: el.options, + } satisfies CleanedElement as unknown as CleanedElement; + } else { + throw new Error("EXPORT SCENE: cannot export unknown item"); + } + } + + public async execute() { + const { selectedElements, elements } = useStore.getState(); + try { + // clean up the items + const portable: ZagyPortableT = { + type: "ZagyPortableContent", + elements: [], + }; + // choose which items to clean up + if (this.onlySelected) { + selectedElements.forEach((el) => + portable.elements.push(ActionExportScene.cleanupItem(el)) + ); + } else { + elements.forEach((el) => portable.elements.push(ActionExportScene.cleanupItem(el))); + } + // don't copy dump text into the user clipboard if there's no data to copy + if (portable.elements.length === 0) return; + console.log("copied bounding rect", getBoundingRect(...portable.elements)); + // choose export mechanism + if (this.dest === DestOpts.CLIPBOARD) { + await navigator.clipboard.writeText(JSON.stringify(portable)); + } else if (this.dest === DestOpts.JSON) { + throw new Error("EXPORT SCENE: TODO export as json file"); + } + } catch (e) { + console.log("🪵 [copySelected.ts:15] ~ token ~ \x1b[0;32me\x1b[0m = ", e); + } + } +} diff --git a/src/actions/copySelected.ts b/src/actions/copySelected.ts index 8290fe4..f8816f8 100644 --- a/src/actions/copySelected.ts +++ b/src/actions/copySelected.ts @@ -91,7 +91,7 @@ export class ActionExportScene extends Command { try { // clean up the items const portable: ZagyPortableT = { - name: "ZagyPortableContent", + type: "ZagyPortableContent", elements: [], }; if (this.onlySelected) { @@ -101,7 +101,6 @@ export class ActionExportScene extends Command { } else { elements.forEach((el) => portable.elements.push(ActionExportScene.cleanupItem(el))); } - if (this.dest === DestOpts.CLIPBOARD) { await navigator.clipboard.writeText(JSON.stringify(portable)); } else if (this.dest === DestOpts.JSON) { diff --git a/src/actions/importElements.ts b/src/actions/importElements.ts index 3e514b2..3f19c18 100644 --- a/src/actions/importElements.ts +++ b/src/actions/importElements.ts @@ -1,29 +1,132 @@ +import { RoughGenerator } from "roughjs/bin/generator"; import { UndoableCommand } from "./types"; import { useStore } from "@/store/index"; -import { generateImageElement } from "@/utils/canvas/generateElement"; -import { Point, normalizeToGrid } from "@/utils"; +import { + generateHandDrawnElement, + generateImageElement, + generateLineElement, + generateRectElement, + generateTextElement, +} from "@/utils/canvas/generateElement"; +import { Point, getBoundingRect, normalizeToGrid } from "@/utils"; +import { + ZagyCanvasElement, + ZagyPortableT, + isHanddrawn, + isImage, + isLine, + isRect, + isText, + isZagyPortable, +} from "@/types/general"; export class ActionImportElements extends UndoableCommand { #importedIds: Set; - constructor(private dataTransfer: DataTransfer, private mouseCoords: Point) { + constructor( + private dataTransfer: DataTransfer, + private mouseCoords: Point, + private canvas: HTMLCanvasElement + ) { super(); this.#importedIds = new Set(); } + /** + * update elements coords inplace + * + * we need to keep same structure and order of the copied elements relative to the bounding rect that they were copied from + * to do so i will create bounding rect between pasted elements to know each point difference from the original bounding rect + * create new bounding rect on the current mouse position, then calc each element new position, element x = newBounding.x + (oldBounding.x - el.x); + */ + private updateElementsCoords(els: ZagyPortableT["elements"]): void { + const oldBounding = getBoundingRect(...els); + console.log("old bounding rect", oldBounding); + for (const el of els) { + el.x = this.mouseCoords[0] + (el.x - oldBounding[0][0]); + el.y = this.mouseCoords[1] + (el.y - oldBounding[0][1]); + el.endX = this.mouseCoords[0] + (el.endX - oldBounding[1][0]); + el.endY = this.mouseCoords[1] + (el.endY - oldBounding[1][1]); + console.log(el); + } + } + public execute() { const { setElements, getPosition } = useStore.getState(); for (const item of this.dataTransfer.items) { // if the content is text then we need to check to its structure if we can create ZagyElement from it or not, if not fallback to normal text // ignore any files that is not image // TODO branch to every possible item we can create from clipboard - if (item.type.indexOf("image") !== -1) { + if (item.kind === "file" && item.type.indexOf("image") !== -1) { const blob = item.getAsFile(); if (!blob) return; const norm = normalizeToGrid(getPosition(), this.mouseCoords); const el = generateImageElement(blob, norm); this.#importedIds.add(el.id); setElements((prev) => [...prev, el]); + } else { + // handle string case, which could be plain text need to be inserted as ZagyText, or json that could be parsed to form ZagyPortableContent + item.getAsString((pasted) => { + // JSON.PARSE could throw, so i depened on the try catch block to move between normal text insert or ZagyElements insert + try { + const items = JSON.parse(pasted); + isZagyPortable(items); + const roughGenerator = new RoughGenerator(); + const ctx = this.canvas.getContext("2d"); + if (!ctx) return; + // we need to keep same structure and order of the copied elements relative to the bounding rect that they were copied from + // to do so i will create bounding rect between pasted elements to know each point difference from the original bounding rect + // create new bounding rect on the current mouse position, then calc each element new position, element x = newBounding.x + (oldBounding.x - el.x); + console.log("found elements", typeof items, items); + const oldBounding = getBoundingRect(...items.elements); + console.log("old bounding rect", oldBounding); + this.updateElementsCoords(items.elements); + console.log("after update", items.elements); + const elsToPush: ZagyCanvasElement[] = []; + for (const el of items.elements) { + if (isRect(el)) { + elsToPush.push( + generateRectElement( + roughGenerator, + [el.x, el.y], + [el.endX, el.endY], + el.options + ) + ); + } else if (isLine(el)) { + elsToPush.push( + generateLineElement( + roughGenerator, + [el.x, el.y], + [el.endX, el.endY], + el.options + ) + ); + } else if (isText(el)) { + elsToPush.push( + generateTextElement( + ctx, + el.text.join("\n"), + [el.x, el.y], + el.options + ) + ); + } else if (isHanddrawn(el)) { + elsToPush.push(generateHandDrawnElement(el.paths, el.options)); + } else if (isImage(el)) { + // generateImageElement(); + } + } + + setElements((prev) => [...prev, ...elsToPush]); + } catch { + console.log("will create text element from", pasted); + const ctx = this.canvas.getContext("2d"); + if (!ctx) return; + const textEl = generateTextElement(ctx, pasted, this.mouseCoords); + setElements((prev) => [...prev, textEl]); + } + }); } } return; diff --git a/src/components/ToolbarElementConfig.tsx b/src/components/ToolbarElementConfig.tsx index a4cc33d..0fb932a 100644 --- a/src/components/ToolbarElementConfig.tsx +++ b/src/components/ToolbarElementConfig.tsx @@ -29,7 +29,7 @@ import { } from "@/utils/canvas/generateElement"; import { commandManager } from "@/actions/commandManager"; import { ActionDeleteSelected } from "@/actions"; -import { ActionExportScene, DestOpts } from "@/actions/copySelected"; +import { ActionExportScene, DestOpts } from "@/actions/ExportScene"; import useKeyboardShortcut from "@/hooks/useShortcut"; //import { BsTextCenter, BsTextLeft, BsTextRight } from "react-icons/bs"; diff --git a/src/components/ZagyDraw.tsx b/src/components/ZagyDraw.tsx index 6d905ef..db84090 100644 --- a/src/components/ZagyDraw.tsx +++ b/src/components/ZagyDraw.tsx @@ -173,11 +173,13 @@ function ZagyDraw() { useGlobalEvent("paste", (event) => { event.preventDefault(); if (!event.clipboardData) return; + if (!canvas.current) return; commandManager.executeCommand( - new ActionImportElements(event.clipboardData, [ - mouseCoords.current[0] / zoomLevel, - mouseCoords.current[1] / zoomLevel, - ]) + new ActionImportElements( + event.clipboardData, + [mouseCoords.current[0] / zoomLevel, mouseCoords.current[1] / zoomLevel], + canvas.current + ) ); }); diff --git a/src/types/general.ts b/src/types/general.ts index f7621d9..876d714 100644 --- a/src/types/general.ts +++ b/src/types/general.ts @@ -148,10 +148,22 @@ type CleanedElement = Omit< >; type ZagyPortableT = { - name: "ZagyPortableContent"; + type: "ZagyPortableContent"; elements: CleanedElement[]; }; +// mockup structure type guard +function isZagyPortable(test: unknown): asserts test is ZagyPortableT { + if ( + typeof test === "object" && + test !== null && + "type" in test && + test.type === "ZagyPortableContent" && + "elements" in test + ) + return; + throw new Error("notZagyPortable"); +} //function to check if elements extends CachableElement export type { @@ -177,4 +189,4 @@ export type { CleanedElement, ZagyPortableT, }; -export { CursorFn, FontTypeOptions, isLine, isRect, isText, isHanddrawn, isImage }; +export { CursorFn, FontTypeOptions, isLine, isRect, isText, isHanddrawn, isImage, isZagyPortable }; diff --git a/src/utils/canvas/generateElement.ts b/src/utils/canvas/generateElement.ts index bae4491..2117721 100644 --- a/src/utils/canvas/generateElement.ts +++ b/src/utils/canvas/generateElement.ts @@ -26,7 +26,6 @@ import { getGlobalMinMax, getSvgPathFromStroke, normalizeRectCoords, - sleep, } from "@/utils"; import { useStore } from "@/store"; import { diff --git a/src/utils/index.ts b/src/utils/index.ts index 8f68775..f5dad86 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -179,7 +179,7 @@ export function getBoundingRect(...elements: ZagyCanvasElement[]) { y: elementStartY, endX: elementEndX, endY: elementEndY, - } = element as ZagyCanvasRectElement; + } = element; x = Math.min(x, elementStartX); y = Math.min(y, elementStartY); endX = Math.max(endX, elementEndX); From 65d961bdfe11caf7da09ec39c2003f60f3fa153a Mon Sep 17 00:00:00 2001 From: nagy nabil Date: Thu, 7 Sep 2023 21:19:05 +0300 Subject: [PATCH 3/7] fix: save pasted elements structure --- .editorconfig | 2 +- .prettierrc | 2 +- index.html | 3 + src/actions/ExportScene.ts | 5 +- src/actions/copySelected.ts | 113 ------------------------ src/actions/createText.ts | 4 +- src/actions/cutSelected.ts | 2 +- src/actions/delete.ts | 4 +- src/actions/deleteSelected.ts | 2 +- src/actions/draw.ts | 10 +-- src/actions/importElements.ts | 73 +++++++++------ src/actions/moveElement.ts | 2 + src/actions/multiselect.ts | 2 +- src/components/ExportModal.tsx | 6 +- src/components/ToolbarElementConfig.tsx | 32 ++++--- src/components/ToolbarUpper.tsx | 28 +++--- src/components/Undo.tsx | 2 +- src/components/ZagyDraw.tsx | 56 ++++++------ src/components/ui/alert-dialog.tsx | 4 +- src/components/ui/button.tsx | 4 +- src/components/ui/dialog.tsx | 4 +- src/components/ui/dropdown-menu.tsx | 12 +-- src/components/ui/input.tsx | 4 +- src/components/ui/label.tsx | 2 +- src/components/ui/switch.tsx | 4 +- src/hooks/useCursor.ts | 2 +- src/hooks/useEvent.ts | 2 +- src/hooks/useGlobalEvent.ts | 2 +- src/hooks/useMultiPhaseEvent.ts | 14 +-- src/hooks/usePreventDefaultZoom.ts | 2 +- src/hooks/useShortcut.ts | 4 +- src/tests/actions/delete.test.tsx | 2 +- src/tests/testUtils.ts | 14 +-- src/types/general.ts | 2 +- src/utils/canvas/generateElement.ts | 34 +++---- src/utils/canvas/renderBoundingRect.ts | 4 +- src/utils/canvas/renderElements.ts | 15 ++-- src/utils/canvas/renderGrid.ts | 2 +- src/utils/canvas/renderScene.ts | 4 +- src/utils/index.ts | 10 +-- 40 files changed, 207 insertions(+), 288 deletions(-) delete mode 100644 src/actions/copySelected.ts diff --git a/.editorconfig b/.editorconfig index e282169..c6c8b36 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,7 +1,7 @@ root = true [*] -indent_style = spaces +indent_style = space indent_size = 2 end_of_line = lf charset = utf-8 diff --git a/.prettierrc b/.prettierrc index 11a11a5..9e3a80b 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,5 +1,5 @@ { - "trailingComma": "es5", + "trailingComma": "all", "semi": true, "singleQuote": false, "tabWidth": 4, diff --git a/index.html b/index.html index 745a6f6..10eede3 100644 --- a/index.html +++ b/index.html @@ -5,6 +5,9 @@ Zagydraw | The whiteboarding app + + +
diff --git a/src/actions/ExportScene.ts b/src/actions/ExportScene.ts index 46ee0b3..728d154 100644 --- a/src/actions/ExportScene.ts +++ b/src/actions/ExportScene.ts @@ -15,7 +15,6 @@ import { isText } from "@/types/general"; import { ZagyCanvasTextElement } from "@/types/general"; import { isHanddrawn } from "@/types/general"; import { ZagyCanvasHandDrawnElement } from "@/types/general"; -import { getBoundingRect } from "@/utils"; export const DestOpts = { CLIPBOARD: 0, @@ -78,7 +77,6 @@ export class ActionExportScene extends Command { return { ...baseTemp, shape: el.shape, - path2D: el.path2D, paths: el.paths, options: el.options, } satisfies CleanedElement as unknown as CleanedElement; @@ -98,14 +96,13 @@ export class ActionExportScene extends Command { // choose which items to clean up if (this.onlySelected) { selectedElements.forEach((el) => - portable.elements.push(ActionExportScene.cleanupItem(el)) + portable.elements.push(ActionExportScene.cleanupItem(el)), ); } else { elements.forEach((el) => portable.elements.push(ActionExportScene.cleanupItem(el))); } // don't copy dump text into the user clipboard if there's no data to copy if (portable.elements.length === 0) return; - console.log("copied bounding rect", getBoundingRect(...portable.elements)); // choose export mechanism if (this.dest === DestOpts.CLIPBOARD) { await navigator.clipboard.writeText(JSON.stringify(portable)); diff --git a/src/actions/copySelected.ts b/src/actions/copySelected.ts deleted file mode 100644 index f8816f8..0000000 --- a/src/actions/copySelected.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { Command } from "./types"; -import { - type ZagyCanvasElement, - isRect, - isImage, - ZagyCanvasImageElement, - ZagyCanvasRectElement, - isLine, - ZagyCanvasLineElement, - CleanedElement, - ZagyPortableT, -} from "@/types/general"; -import { useStore } from "@/store/index"; -import { isText } from "@/types/general"; -import { ZagyCanvasTextElement } from "@/types/general"; -import { isHanddrawn } from "@/types/general"; -import { ZagyCanvasHandDrawnElement } from "@/types/general"; - -export const DestOpts = { - CLIPBOARD: 0, - JSON: 1, -} as const; -export type DestOpts = (typeof DestOpts)[keyof typeof DestOpts]; - -/** - * clean up the elements state from unnecessary props like (cache and so on...) then copy them async to the clipboard in portable format - * - * @param dest DestOpts where to copy the data [clipboard, json file] - * @param onlySelected boolean only export selected elements or all the scene, default `false` - */ -export class ActionExportScene extends Command { - constructor(private dest: DestOpts, private onlySelected = false) { - super(); - } - - /** - * elements in the store contains props that is needed when exporting them, or when the user import them again so we clean those props up to propably make the exported size small - */ - private static cleanupItem(el: T): CleanedElement { - const baseTemp: CleanedElement = { - id: el.id, - shape: el.shape, - x: el.x, - y: el.y, - endX: el.endX, - endY: el.endY, - options: el.options, - }; - if (isImage(el)) { - return { - ...baseTemp, - shape: el.shape, - image: el.image, - } satisfies CleanedElement as unknown as CleanedElement; - } else if (isRect(el)) { - return { - ...baseTemp, - shape: el.shape, - options: el.options, - } satisfies CleanedElement as unknown as CleanedElement; - } else if (isLine(el)) { - return { - ...baseTemp, - shape: el.shape, - point1: el.point1, - point2: el.point2, - options: el.options, - } satisfies CleanedElement as unknown as CleanedElement; - } else if (isText(el)) { - return { - ...baseTemp, - shape: el.shape, - text: el.text, - options: el.options, - } satisfies CleanedElement as unknown as CleanedElement; - } else if (isHanddrawn(el)) { - return { - ...baseTemp, - shape: el.shape, - path2D: el.path2D, - paths: el.paths, - options: el.options, - } satisfies CleanedElement as unknown as CleanedElement; - } else { - throw new Error("EXPORT SCENE: cannot export unknown item"); - } - } - - public async execute() { - const { selectedElements, elements } = useStore.getState(); - try { - // clean up the items - const portable: ZagyPortableT = { - type: "ZagyPortableContent", - elements: [], - }; - if (this.onlySelected) { - selectedElements.forEach((el) => - portable.elements.push(ActionExportScene.cleanupItem(el)) - ); - } else { - elements.forEach((el) => portable.elements.push(ActionExportScene.cleanupItem(el))); - } - if (this.dest === DestOpts.CLIPBOARD) { - await navigator.clipboard.writeText(JSON.stringify(portable)); - } else if (this.dest === DestOpts.JSON) { - throw new Error("EXPORT SCENE: TODO export as json file"); - } - } catch (e) { - console.log("🪵 [copySelected.ts:15] ~ token ~ \x1b[0;32me\x1b[0m = ", e); - } - } -} diff --git a/src/actions/createText.ts b/src/actions/createText.ts index 129338f..47446fc 100644 --- a/src/actions/createText.ts +++ b/src/actions/createText.ts @@ -21,7 +21,7 @@ class TextAction { } private static _inProgress( textAreaWrapper: HTMLDivElement | null, - textArea: HTMLTextAreaElement | null + textArea: HTMLTextAreaElement | null, ) { const { cursorFn, zoomLevel } = useStore.getState(); if (!textAreaWrapper || !textArea || cursorFn !== CursorFn.Text) return; @@ -58,7 +58,7 @@ class TextAction { public static end( canvas: HTMLCanvasElement | null, - textArea: HTMLTextAreaElement | null + textArea: HTMLTextAreaElement | null, ): UndoableCommand | null { let element: ZagyCanvasElement | null = null; const { cursorFn } = useStore.getState(); diff --git a/src/actions/cutSelected.ts b/src/actions/cutSelected.ts index efe9ebc..8ee8935 100644 --- a/src/actions/cutSelected.ts +++ b/src/actions/cutSelected.ts @@ -1,5 +1,5 @@ import { ActionDeleteSelected } from "./deleteSelected"; -import { ActionExportScene, DestOpts } from "./copySelected"; +import { ActionExportScene, DestOpts } from "./ExportScene"; import { UndoableCommand } from "./types"; export class ActionCutSelected extends UndoableCommand { diff --git a/src/actions/delete.ts b/src/actions/delete.ts index 6dbf91a..ef3bd8b 100644 --- a/src/actions/delete.ts +++ b/src/actions/delete.ts @@ -18,7 +18,7 @@ class DeleteAction { if (el !== null) { this.willDelete = true; setElements((prev) => - prev.map((val) => (val.id === el.id ? { ...val, willDelete: true } : val)) + prev.map((val) => (val.id === el.id ? { ...val, willDelete: true } : val)), ); } } @@ -42,7 +42,7 @@ class DeleteAction { !( val.shape === "image" && (val as ZagyCanvasImageElement).imgRef instanceof Promise - ) + ), ) .map((val) => ({ ...val, willDelete: false })); if (deletedElements.length === 0) return null; diff --git a/src/actions/deleteSelected.ts b/src/actions/deleteSelected.ts index 4d7d1d8..ba23a0d 100644 --- a/src/actions/deleteSelected.ts +++ b/src/actions/deleteSelected.ts @@ -14,7 +14,7 @@ export class ActionDeleteSelected extends UndoableCommand { !( itm.shape === "image" && (itm as ZagyCanvasImageElement).imgRef instanceof Promise - ) + ), ); // remove any elements that exist on selected elements array // create hash of ids for easy check while filtring the elements diff --git a/src/actions/draw.ts b/src/actions/draw.ts index ec815e9..7d0dcdf 100644 --- a/src/actions/draw.ts +++ b/src/actions/draw.ts @@ -52,7 +52,7 @@ class DrawAction { this.roughGenerator, this.lastMouseDownPosition, this.lastMouseUpPosition, - { seed: this.currentSeed } + { seed: this.currentSeed }, ); setPreviewElement(rect); } else if (cursorFn === CursorFn.Line) { @@ -60,7 +60,7 @@ class DrawAction { this.roughGenerator, this.lastMouseDownPosition, this.lastMouseUpPosition, - { seed: this.currentSeed } + { seed: this.currentSeed }, ); setPreviewElement(line); } else if (cursorFn === CursorFn.FreeDraw) { @@ -113,7 +113,7 @@ class DrawAction { this.lastMouseDownPosition, this.lastMouseUpPosition, zoomLevel, - { seed: this.currentSeed } + { seed: this.currentSeed }, ); el = line; this.currentSeed = randomSeed(); @@ -123,7 +123,7 @@ class DrawAction { this.lastMouseDownPosition, this.lastMouseUpPosition, zoomLevel, - { seed: this.currentSeed } + { seed: this.currentSeed }, ); if (rect.endX - rect.x < 10 || rect.endY - rect.y < 10) return; @@ -132,7 +132,7 @@ class DrawAction { } else if (cursorFn === CursorFn.FreeDraw) { const handDrawnElement = generateCachedHandDrawnElement( this.currentlyDrawnFreeHand, - zoomLevel + zoomLevel, ); el = handDrawnElement; this.currentlyDrawnFreeHand = []; diff --git a/src/actions/importElements.ts b/src/actions/importElements.ts index 3f19c18..c089d3b 100644 --- a/src/actions/importElements.ts +++ b/src/actions/importElements.ts @@ -2,10 +2,10 @@ import { RoughGenerator } from "roughjs/bin/generator"; import { UndoableCommand } from "./types"; import { useStore } from "@/store/index"; import { - generateHandDrawnElement, + generateCacheLineElement, + generateCacheRectElement, + generateCachedHandDrawnElement, generateImageElement, - generateLineElement, - generateRectElement, generateTextElement, } from "@/utils/canvas/generateElement"; import { Point, getBoundingRect, normalizeToGrid } from "@/utils"; @@ -26,7 +26,7 @@ export class ActionImportElements extends UndoableCommand { constructor( private dataTransfer: DataTransfer, private mouseCoords: Point, - private canvas: HTMLCanvasElement + private canvas: HTMLCanvasElement, ) { super(); this.#importedIds = new Set(); @@ -40,19 +40,36 @@ export class ActionImportElements extends UndoableCommand { * create new bounding rect on the current mouse position, then calc each element new position, element x = newBounding.x + (oldBounding.x - el.x); */ private updateElementsCoords(els: ZagyPortableT["elements"]): void { + const { getPosition } = useStore.getState(); + const newBoundingStart = normalizeToGrid(getPosition(), this.mouseCoords); const oldBounding = getBoundingRect(...els); - console.log("old bounding rect", oldBounding); for (const el of els) { - el.x = this.mouseCoords[0] + (el.x - oldBounding[0][0]); - el.y = this.mouseCoords[1] + (el.y - oldBounding[0][1]); - el.endX = this.mouseCoords[0] + (el.endX - oldBounding[1][0]); - el.endY = this.mouseCoords[1] + (el.endY - oldBounding[1][1]); - console.log(el); + const xDiff = el.endX - el.x; + const yDiff = el.endY - el.y; + el.x = newBoundingStart[0] + (el.x - oldBounding[0][0]); + el.y = newBoundingStart[1] + (el.y - oldBounding[0][1]); + el.endX = el.x + xDiff; + el.endY = el.y + yDiff; + if (isLine(el)) { + el.point1 = [ + newBoundingStart[0] + (el.point1[0] - oldBounding[0][0]), + newBoundingStart[1] + (el.point1[1] - oldBounding[0][1]), + ]; + el.point2 = [ + newBoundingStart[0] + (el.point2[0] - oldBounding[0][0]), + newBoundingStart[1] + (el.point2[1] - oldBounding[0][1]), + ]; + } else if (isHanddrawn(el)) { + el.paths = el.paths.map((path) => [ + newBoundingStart[0] + (path[0] - oldBounding[0][0]), + newBoundingStart[1] + (path[1] - oldBounding[0][1]), + ]); + } } } public execute() { - const { setElements, getPosition } = useStore.getState(); + const { setElements, getPosition, zoomLevel } = useStore.getState(); for (const item of this.dataTransfer.items) { // if the content is text then we need to check to its structure if we can create ZagyElement from it or not, if not fallback to normal text // ignore any files that is not image @@ -77,30 +94,28 @@ export class ActionImportElements extends UndoableCommand { // we need to keep same structure and order of the copied elements relative to the bounding rect that they were copied from // to do so i will create bounding rect between pasted elements to know each point difference from the original bounding rect // create new bounding rect on the current mouse position, then calc each element new position, element x = newBounding.x + (oldBounding.x - el.x); - console.log("found elements", typeof items, items); - const oldBounding = getBoundingRect(...items.elements); - console.log("old bounding rect", oldBounding); this.updateElementsCoords(items.elements); - console.log("after update", items.elements); const elsToPush: ZagyCanvasElement[] = []; for (const el of items.elements) { if (isRect(el)) { elsToPush.push( - generateRectElement( + generateCacheRectElement( roughGenerator, [el.x, el.y], [el.endX, el.endY], - el.options - ) + zoomLevel, + el.options, + ), ); } else if (isLine(el)) { elsToPush.push( - generateLineElement( + generateCacheLineElement( roughGenerator, - [el.x, el.y], - [el.endX, el.endY], - el.options - ) + el.point1, + el.point2, + zoomLevel, + el.options, + ), ); } else if (isText(el)) { elsToPush.push( @@ -108,22 +123,26 @@ export class ActionImportElements extends UndoableCommand { ctx, el.text.join("\n"), [el.x, el.y], - el.options - ) + el.options, + ), ); } else if (isHanddrawn(el)) { - elsToPush.push(generateHandDrawnElement(el.paths, el.options)); + elsToPush.push( + generateCachedHandDrawnElement(el.paths, zoomLevel, el.options), + ); } else if (isImage(el)) { // generateImageElement(); } } + // append elements to be deleted from the history stack + elsToPush.forEach((el) => this.#importedIds.add(el.id)); setElements((prev) => [...prev, ...elsToPush]); } catch { - console.log("will create text element from", pasted); const ctx = this.canvas.getContext("2d"); if (!ctx) return; const textEl = generateTextElement(ctx, pasted, this.mouseCoords); + console.log(textEl); setElements((prev) => [...prev, textEl]); } }); diff --git a/src/actions/moveElement.ts b/src/actions/moveElement.ts index 8b2bd24..b8e6709 100644 --- a/src/actions/moveElement.ts +++ b/src/actions/moveElement.ts @@ -96,6 +96,8 @@ class MoveElementAction { this.oldPositionEnd[0] - this.position.x, this.oldPositionEnd[1] - this.position.y, ]; + // prevent adding move to the history stack if the element only got selected but not moved + if (!this.isDragging) return null; this.isDragging = false; if (el === null) return null; return { diff --git a/src/actions/multiselect.ts b/src/actions/multiselect.ts index ba44977..52fbb2c 100644 --- a/src/actions/multiselect.ts +++ b/src/actions/multiselect.ts @@ -23,7 +23,7 @@ class MultiSelectAction { const rect: ZagyCanvasRectElement = generateSelectRectElement( this.roughGenerator, this.lastMouseDownPosition!, - this.lastMouseUpPosition + this.lastMouseUpPosition, ); setMultiSelectRect(rect); } diff --git a/src/components/ExportModal.tsx b/src/components/ExportModal.tsx index df43b3f..305f7e9 100644 --- a/src/components/ExportModal.tsx +++ b/src/components/ExportModal.tsx @@ -31,7 +31,7 @@ export function ExportModal() { }, "ControlLeft", "ShiftLeft", - "e" + "e", ); useEffect(() => { @@ -102,7 +102,7 @@ export function ExportModal() { +
+ +
+
+ Z+ +
+

Save To Z+

+ Save On The Cloud + +
+ + + + ); +} + +function OpenSceneDialog() { + return ( + + {/*FIX: open dialog/alert dialog inside dropdown or context menu + @url https://github.com/radix-ui/primitives/issues/1836#issuecomment-1674338372 + */} + + e.preventDefault()}> + + Open + + + + + Load From File + +
+
+

+ +

+

+ Loading from a file will{" "} + replace your existing content. + You can back up your drawing first using one of the options below. +

+ +
+ + {/* EXPORT OPTIONS */} +
+

Save To Disk

+ + Export the scene data to a file from which you can import later. + + +
+ +
+

Save To Z+

+ Save On The Cloud + +
+
+
+
+ ); +} + export function DropDown() { return (
@@ -73,6 +195,8 @@ export function DropDown() { Export Image */} + + From d861d46f958be498e3e213fcac00c778004722c4 Mon Sep 17 00:00:00 2001 From: nagy nabil Date: Fri, 8 Sep 2023 02:38:14 +0300 Subject: [PATCH 5/7] add shortcuts modal unified place where the user can know what shorcuts we support --- src/components/DropDown.tsx | 42 ++++++++-- src/components/ExportModal.tsx | 2 +- src/components/ToolbarElementConfig.tsx | 4 +- src/constants/index.ts | 101 ++++++++++++++++++++++++ src/hooks/useShortcut.ts | 76 +----------------- 5 files changed, 142 insertions(+), 83 deletions(-) diff --git a/src/components/DropDown.tsx b/src/components/DropDown.tsx index 0a75675..cebfa3f 100644 --- a/src/components/DropDown.tsx +++ b/src/components/DropDown.tsx @@ -34,6 +34,7 @@ import { DialogTrigger, } from "@/components/ui/dialog"; import { ActionExportScene, DestOpts } from "@/actions/ExportScene"; +import { SHORTCUTS } from "@/constants"; function ResetCanvasAlert() { return ( @@ -176,6 +177,41 @@ function OpenSceneDialog() { ); } +function ShortCutsModal() { + return ( + + {/*FIX: open dialog/alert dialog inside dropdown or context menu + @url https://github.com/radix-ui/primitives/issues/1836#issuecomment-1674338372 + */} + + e.preventDefault()}> + + Keyboard shortcuts + ⌘K + + + + + Keyboard Shortcuts + + {Object.keys(SHORTCUTS).map((group) => ( + <> +

+ {group} +

+ {Object.keys(SHORTCUTS[group]).map((key) => ( +

+ {SHORTCUTS[group][key]["description"]} + {" : "} + {SHORTCUTS[group][key]["keys"].join(" + ")} +

+ ))} + + ))} +
+
+ ); +} export function DropDown() { return (
@@ -201,11 +237,7 @@ export function DropDown() { - - - Keyboard shortcuts - ⌘K - + diff --git a/src/components/ExportModal.tsx b/src/components/ExportModal.tsx index 305f7e9..53eccfe 100644 --- a/src/components/ExportModal.tsx +++ b/src/components/ExportModal.tsx @@ -1,5 +1,5 @@ import { Image } from "lucide-react"; -import React, { useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { BsDownload } from "react-icons/bs"; import { FiCopy } from "react-icons/fi"; // import { Switch } from "./form/switch"; diff --git a/src/components/ToolbarElementConfig.tsx b/src/components/ToolbarElementConfig.tsx index a74f7e2..50a8f8e 100644 --- a/src/components/ToolbarElementConfig.tsx +++ b/src/components/ToolbarElementConfig.tsx @@ -31,6 +31,7 @@ import { commandManager } from "@/actions/commandManager"; import { ActionDeleteSelected } from "@/actions"; import { ActionExportScene, DestOpts } from "@/actions/ExportScene"; import useKeyboardShortcut from "@/hooks/useShortcut"; +import { SHORTCUTS } from "@/constants"; //import { BsTextCenter, BsTextLeft, BsTextRight } from "react-icons/bs"; const gen = rough.generator(); @@ -180,8 +181,7 @@ export default function ToolbarLeft() { commandManager.executeCommand(new ActionExportScene(DestOpts.CLIPBOARD, true)), orderMatters: true, }, - "ControlLeft", - "c", + ...SHORTCUTS["editor"]["copy"]["keys"], ); useKeyboardShortcut( { diff --git a/src/constants/index.ts b/src/constants/index.ts index ab5e969..bbb5a6d 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -5,3 +5,104 @@ const PREVIEW_IMAGE_WIDTH = 200; const PREVIEW_IMAGE_HEIGHT = 200; export { CELL_SIZE, CACHE_CANVAS_SIZE_THRESHOLD, PREVIEW_IMAGE_WIDTH, PREVIEW_IMAGE_HEIGHT }; + +export const keysToCodes = { + Enter: "Enter", + Escape: "Escape", + ArrowUp: "ArrowUp", + ArrowDown: "ArrowDown", + ArrowLeft: "ArrowLeft", + ArrowRight: "ArrowRight", + Tab: "Tab", + Home: "Home", + End: "End", + PageUp: "PageUp", + PageDown: "PageDown", + Backspace: "Backspace", + Delete: "Delete", + Space: "Space", + ShiftLeft: "ShiftLeft", + ShiftRight: "ShiftRight", + ControlLeft: "ControlLeft", + ControlRight: "ControlRight", + AltLeft: "AltLeft", + AltRight: "AltRight", + Meta: "Meta", + a: "KeyA", + b: "KeyB", + c: "KeyC", + d: "KeyD", + e: "KeyE", + f: "KeyF", + g: "KeyG", + h: "KeyH", + i: "KeyI", + j: "KeyJ", + k: "KeyK", + l: "KeyL", + m: "KeyM", + n: "KeyN", + o: "KeyO", + p: "KeyP", + q: "KeyQ", + r: "KeyR", + s: "KeyS", + t: "KeyT", + u: "KeyU", + v: "KeyV", + w: "KeyW", + x: "KeyX", + y: "KeyY", + z: "KeyZ", + "0": "Digit0", + "1": "Digit1", + "2": "Digit2", + "3": "Digit3", + "4": "Digit4", + "5": "Digit5", + "6": "Digit6", + "7": "Digit7", + "8": "Digit8", + "9": "Digit9", + "`": "Backquote", + NumpadSubtract: "NumpadSubtract", + "-": "Minus", + "=": "Equal", + "[": "BracketLeft", + "]": "BracketRight", + "\\": "Backslash", + "'": "Quote", + ",": "Comma", + ".": "Period", + "/": "Slash", + ";": "Semicolon", + NumpadMultiply: "NumpadMultiply", + NumpadAdd: "NumpadAdd", +} as const; +export type Keys = keyof typeof keysToCodes; +export type KeyCodes = (typeof keysToCodes)[keyof typeof keysToCodes]; + +/** + * { + * group: {name: {description, keys}} + * } + */ +export const SHORTCUTS = { + editor: { + copy: { description: "copy selected", keys: ["ControlLeft", "c"] }, + paste: { description: "paste into the canvas", keys: ["ControlLeft", "v"] }, + undo: { description: "undo", keys: ["ControlLeft", "z"] }, + delete: { description: "delete selected", keys: ["Delete"] }, + }, + tools: { + select: { description: "select", keys: ["1"] }, + hand: { description: "hand(move in the canvas)", keys: ["2"] }, + pen: { description: "create free hand draw", keys: ["3"] }, + rect: { description: "create rect", keys: ["4"] }, + line: { description: "create line", keys: ["5"] }, + text: { description: "create text", keys: ["6"] }, + erase: { description: "erase", keys: ["7"] }, + }, +} as const satisfies { + [g: string]: { [k: string]: { description: string; keys: readonly [Keys, ...Keys[]] } }; +}; diff --git a/src/hooks/useShortcut.ts b/src/hooks/useShortcut.ts index 212ad43..24c634d 100644 --- a/src/hooks/useShortcut.ts +++ b/src/hooks/useShortcut.ts @@ -1,80 +1,6 @@ import { useEffect, useMemo, useRef } from "react"; +import { keysToCodes, Keys, KeyCodes } from "@/constants/index"; -const keysToCodes = { - Enter: "Enter", - Escape: "Escape", - ArrowUp: "ArrowUp", - ArrowDown: "ArrowDown", - ArrowLeft: "ArrowLeft", - ArrowRight: "ArrowRight", - Tab: "Tab", - Home: "Home", - End: "End", - PageUp: "PageUp", - PageDown: "PageDown", - Backspace: "Backspace", - Delete: "Delete", - Space: "Space", - ShiftLeft: "ShiftLeft", - ShiftRight: "ShiftRight", - ControlLeft: "ControlLeft", - ControlRight: "ControlRight", - AltLeft: "AltLeft", - AltRight: "AltRight", - Meta: "Meta", - a: "KeyA", - b: "KeyB", - c: "KeyC", - d: "KeyD", - e: "KeyE", - f: "KeyF", - g: "KeyG", - h: "KeyH", - i: "KeyI", - j: "KeyJ", - k: "KeyK", - l: "KeyL", - m: "KeyM", - n: "KeyN", - o: "KeyO", - p: "KeyP", - q: "KeyQ", - r: "KeyR", - s: "KeyS", - t: "KeyT", - u: "KeyU", - v: "KeyV", - w: "KeyW", - x: "KeyX", - y: "KeyY", - z: "KeyZ", - "0": "Digit0", - "1": "Digit1", - "2": "Digit2", - "3": "Digit3", - "4": "Digit4", - "5": "Digit5", - "6": "Digit6", - "7": "Digit7", - "8": "Digit8", - "9": "Digit9", - "`": "Backquote", - NumpadSubtract: "NumpadSubtract", - "-": "Minus", - "=": "Equal", - "[": "BracketLeft", - "]": "BracketRight", - "\\": "Backslash", - "'": "Quote", - ",": "Comma", - ".": "Period", - "/": "Slash", - ";": "Semicolon", - NumpadMultiply: "NumpadMultiply", - NumpadAdd: "NumpadAdd", -} as const; -type Keys = keyof typeof keysToCodes; -type KeyCodes = (typeof keysToCodes)[keyof typeof keysToCodes]; export default function useKeyboardShortcut( options: { element?: HTMLElement | Window; From 866511e17cf8c3a0b232f7d7c549923c16f00b72 Mon Sep 17 00:00:00 2001 From: nagy nabil Date: Fri, 8 Sep 2023 06:21:42 +0300 Subject: [PATCH 6/7] feat: use browser fs to import/export whole scene --- package.json | 1 + pnpm-lock.yaml | 7 +++ src/actions/ExportScene.ts | 29 +++------ src/actions/createText.ts | 2 +- src/actions/importElements.ts | 14 +---- src/actions/openScene.ts | 84 +++++++++++++++++++++++++ src/components/DropDown.tsx | 39 +++++++++--- src/components/ToolbarElementConfig.tsx | 2 +- src/components/ZagyDraw.tsx | 9 ++- src/constants/index.ts | 3 + src/utils/canvas/generateElement.ts | 6 +- src/utils/canvas/renderElements.ts | 1 - 12 files changed, 147 insertions(+), 50 deletions(-) create mode 100644 src/actions/openScene.ts diff --git a/package.json b/package.json index 2be5e80..69ed9bd 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "@radix-ui/react-switch": "^1.0.3", "@testing-library/user-event": "^14.4.3", "@types/throttle-debounce": "^5.0.0", + "browser-fs-access": "^0.34.1", "class-variance-authority": "^0.7.0", "clsx": "^1.2.1", "lucide-react": "^0.268.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 886995e..c15e0af 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ dependencies: '@types/throttle-debounce': specifier: ^5.0.0 version: 5.0.0 + browser-fs-access: + specifier: ^0.34.1 + version: 0.34.1 class-variance-authority: specifier: ^0.7.0 version: 0.7.0 @@ -2991,6 +2994,10 @@ packages: dependencies: fill-range: 7.0.1 + /browser-fs-access@0.34.1: + resolution: {integrity: sha512-HPaRf2yimp8kWSuWJXc8Mi78dPbDzfduA+Gyq14H4jlMvd6XNfIRm36Y2yRLaa4x0gwcGuepj4zf14oiTlxrxQ==} + dev: false + /browserslist@4.21.5: resolution: {integrity: sha512-tUkiguQGW7S3IhB7N+c2MV/HZPSCPAAiYBZXLsBhFB/PCy6ZKKsZrmBayHV9fdGV/ARIfJ14NkxKzRDjvp7L6w==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} diff --git a/src/actions/ExportScene.ts b/src/actions/ExportScene.ts index fc8ec31..c90eee3 100644 --- a/src/actions/ExportScene.ts +++ b/src/actions/ExportScene.ts @@ -1,3 +1,4 @@ +import { fileSave } from "browser-fs-access"; import { Command } from "./types"; import { type ZagyCanvasElement, @@ -11,9 +12,8 @@ import { ZagyPortableT, } from "@/types/general"; import { useStore } from "@/store/index"; -import { isText } from "@/types/general"; +import { isText, isHanddrawn } from "@/types/general"; import { ZagyCanvasTextElement } from "@/types/general"; -import { isHanddrawn } from "@/types/general"; import { ZagyCanvasHandDrawnElement } from "@/types/general"; export const DestOpts = { @@ -103,29 +103,18 @@ export class ActionExportScene extends Command { } // don't copy dump text into the user clipboard if there's no data to copy if (portable.elements.length === 0) return; + // choose export mechanism if (this.dest === DestOpts.CLIPBOARD) { await navigator.clipboard.writeText(JSON.stringify(portable)); } else if (this.dest === DestOpts.JSON) { - // Create a Blob from the JSON string const blob = new Blob([JSON.stringify(portable)], { type: "application/zagydraw" }); - // Create a Blob URL - const blobUrl = URL.createObjectURL(blob); - - // Create an element - const downloadLink = document.createElement("a"); - - // Set the href attribute to the Blob URL - downloadLink.href = blobUrl; - - // Set the download attribute to specify the filename - downloadLink.download = "zagy.zagydraw"; - - // Trigger a click event to open the file save dialog - downloadLink.click(); - - // Clean up by revoking the Blob URL - URL.revokeObjectURL(blobUrl); + await fileSave(blob, { + fileName: "zagy.zagydraw", + description: "Zagy Portable", + extensions: [".zagydraw"], + id: "zagydraw", + }); } } catch (e) { console.log("🪵 [copySelected.ts:15] ~ token ~ \x1b[0;32me\x1b[0m = ", e); diff --git a/src/actions/createText.ts b/src/actions/createText.ts index 47446fc..ffe8d2a 100644 --- a/src/actions/createText.ts +++ b/src/actions/createText.ts @@ -73,7 +73,7 @@ class TextAction { if (ctx === null) return; const { currentText, position } = useStore.getState(); const normalizedPosition = normalizePos(position, this.lastMouseDownPosition); - element = generateTextElement(ctx, currentText, normalizedPosition, {}); + element = generateTextElement(currentText, normalizedPosition, {}); const { setCurrentText, setIsWriting } = useStore.getState(); this.isAlreadyElement = false; diff --git a/src/actions/importElements.ts b/src/actions/importElements.ts index c089d3b..9a49329 100644 --- a/src/actions/importElements.ts +++ b/src/actions/importElements.ts @@ -23,11 +23,7 @@ import { export class ActionImportElements extends UndoableCommand { #importedIds: Set; - constructor( - private dataTransfer: DataTransfer, - private mouseCoords: Point, - private canvas: HTMLCanvasElement, - ) { + constructor(private dataTransfer: DataTransfer, private mouseCoords: Point) { super(); this.#importedIds = new Set(); } @@ -89,8 +85,6 @@ export class ActionImportElements extends UndoableCommand { const items = JSON.parse(pasted); isZagyPortable(items); const roughGenerator = new RoughGenerator(); - const ctx = this.canvas.getContext("2d"); - if (!ctx) return; // we need to keep same structure and order of the copied elements relative to the bounding rect that they were copied from // to do so i will create bounding rect between pasted elements to know each point difference from the original bounding rect // create new bounding rect on the current mouse position, then calc each element new position, element x = newBounding.x + (oldBounding.x - el.x); @@ -120,7 +114,6 @@ export class ActionImportElements extends UndoableCommand { } else if (isText(el)) { elsToPush.push( generateTextElement( - ctx, el.text.join("\n"), [el.x, el.y], el.options, @@ -139,10 +132,7 @@ export class ActionImportElements extends UndoableCommand { elsToPush.forEach((el) => this.#importedIds.add(el.id)); setElements((prev) => [...prev, ...elsToPush]); } catch { - const ctx = this.canvas.getContext("2d"); - if (!ctx) return; - const textEl = generateTextElement(ctx, pasted, this.mouseCoords); - console.log(textEl); + const textEl = generateTextElement(pasted, this.mouseCoords); setElements((prev) => [...prev, textEl]); } }); diff --git a/src/actions/openScene.ts b/src/actions/openScene.ts new file mode 100644 index 0000000..1305998 --- /dev/null +++ b/src/actions/openScene.ts @@ -0,0 +1,84 @@ +import { RoughGenerator } from "roughjs/bin/generator"; +import { fileOpen } from "browser-fs-access"; +import { Command } from "./types"; +import { + ZagyCanvasElement, + isZagyPortable, + isLine, + isRect, + isText, + isImage, + isHanddrawn, +} from "@/types/general"; +import { useStore } from "@/store/index"; +import { + generateCacheLineElement, + generateCacheRectElement, + generateCachedHandDrawnElement, + generateTextElement, +} from "@/utils/canvas/generateElement"; + +/** + * future version should support loading scene from Z+ as well + */ +export class ActionOpenScene extends Command { + public async execute() { + const { setSelectedElements, setElements, setPosition, zoomLevel } = useStore.getState(); + try { + const blob = await fileOpen({ + // List of allowed file extensions (with leading '.'), defaults to `''`. + extensions: [".zagydraw"], + // Textual description for file dialog , defaults to `''`. + description: "Open Zagydraw", + // By specifying an ID, the user agent can remember different directories for different IDs. + id: "zagydraw", + }); + const text = await (await blob.handle?.getFile())?.text(); + if (text) { + const portable = JSON.parse(text); + isZagyPortable(portable); + const roughGenerator = new RoughGenerator(); + const elsToPush: ZagyCanvasElement[] = []; + for (const el of portable.elements) { + if (isRect(el)) { + elsToPush.push( + generateCacheRectElement( + roughGenerator, + [el.x, el.y], + [el.endX, el.endY], + zoomLevel, + el.options, + ), + ); + } else if (isLine(el)) { + elsToPush.push( + generateCacheLineElement( + roughGenerator, + el.point1, + el.point2, + zoomLevel, + el.options, + ), + ); + } else if (isText(el)) { + elsToPush.push( + generateTextElement(el.text.join("\n"), [el.x, el.y], el.options), + ); + } else if (isHanddrawn(el)) { + elsToPush.push( + generateCachedHandDrawnElement(el.paths, zoomLevel, el.options), + ); + } else if (isImage(el)) { + // generateImageElement(); + } + + setPosition({ x: 0, y: 0 }); + setSelectedElements(() => []); + setElements(() => [...elsToPush]); + } + } + } catch (e) { + console.log("🪵 [copySelected.ts:15] ~ token ~ \x1b[0;32me\x1b[0m = ", e); + } + } +} diff --git a/src/components/DropDown.tsx b/src/components/DropDown.tsx index cebfa3f..5032694 100644 --- a/src/components/DropDown.tsx +++ b/src/components/DropDown.tsx @@ -1,5 +1,6 @@ import { Trash2, Github, Keyboard, Menu, Save, File, AlertTriangle } from "lucide-react"; +import { useState } from "react"; import { ModeToggle } from "./mode-toggle"; import { commandManager } from "@/actions/commandManager"; import { ActionClearCanvas } from "@/actions/resetCanvas"; @@ -35,6 +36,8 @@ import { } from "@/components/ui/dialog"; import { ActionExportScene, DestOpts } from "@/actions/ExportScene"; import { SHORTCUTS } from "@/constants"; +import { ActionOpenScene } from "@/actions/openScene"; +import useKeyboardShortcut from "@/hooks/useShortcut"; function ResetCanvasAlert() { return ( @@ -117,8 +120,24 @@ function SaveToDialog() { } function OpenSceneDialog() { + const [isOpen, setIsOpen] = useState(false); + useKeyboardShortcut( + { + onShortcut: (e) => { + e.preventDefault(); + setIsOpen(true); + }, + orderMatters: true, + }, + ...SHORTCUTS["scene"]["open"].keys, + ); + return ( - + { + setIsOpen(open); + }}> {/*FIX: open dialog/alert dialog inside dropdown or context menu @url https://github.com/radix-ui/primitives/issues/1836#issuecomment-1674338372 */} @@ -133,7 +152,7 @@ function OpenSceneDialog() { Load From File
-
+

@@ -142,7 +161,13 @@ function OpenSceneDialog() { replace your existing content. You can back up your drawing first using one of the options below.

-
@@ -195,18 +220,18 @@ function ShortCutsModal() { Keyboard Shortcuts {Object.keys(SHORTCUTS).map((group) => ( - <> +

{group}

- {Object.keys(SHORTCUTS[group]).map((key) => ( -

+ {Object.keys(SHORTCUTS[group as keyof typeof SHORTCUTS]).map((key) => ( +

{SHORTCUTS[group][key]["description"]} {" : "} {SHORTCUTS[group][key]["keys"].join(" + ")}

))} - +
))}
diff --git a/src/components/ToolbarElementConfig.tsx b/src/components/ToolbarElementConfig.tsx index 50a8f8e..4010e69 100644 --- a/src/components/ToolbarElementConfig.tsx +++ b/src/components/ToolbarElementConfig.tsx @@ -239,7 +239,7 @@ export default function ToolbarLeft() { ); } else if (isText(el)) { els.push( - generateTextElement(ctx, el.text.join("\n"), [el.x, el.y], { + generateTextElement(el.text.join("\n"), [el.x, el.y], { ...el.options, [k]: value, }), diff --git a/src/components/ZagyDraw.tsx b/src/components/ZagyDraw.tsx index ddc69e2..d3c6f4d 100644 --- a/src/components/ZagyDraw.tsx +++ b/src/components/ZagyDraw.tsx @@ -175,11 +175,10 @@ function ZagyDraw() { if (!event.clipboardData) return; if (!canvas.current) return; commandManager.executeCommand( - new ActionImportElements( - event.clipboardData, - [mouseCoords.current[0] / zoomLevel, mouseCoords.current[1] / zoomLevel], - canvas.current, - ), + new ActionImportElements(event.clipboardData, [ + mouseCoords.current[0] / zoomLevel, + mouseCoords.current[1] / zoomLevel, + ]), ); }); diff --git a/src/constants/index.ts b/src/constants/index.ts index bbb5a6d..d67ef65 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -103,6 +103,9 @@ export const SHORTCUTS = { text: { description: "create text", keys: ["6"] }, erase: { description: "erase", keys: ["7"] }, }, + scene: { + open: { description: "open from file", keys: ["ControlLeft", "o"] }, + }, } as const satisfies { [g: string]: { [k: string]: { description: string; keys: readonly [Keys, ...Keys[]] } }; }; diff --git a/src/utils/canvas/generateElement.ts b/src/utils/canvas/generateElement.ts index b0007b1..f342f23 100644 --- a/src/utils/canvas/generateElement.ts +++ b/src/utils/canvas/generateElement.ts @@ -265,12 +265,14 @@ const generateCacheLineElement = ( * return text as lines, and calc text element position(width/height) from text string */ function textElementHelper( - ctx: CanvasRenderingContext2D, text: string, startPos: Point, fontSize: number, font: TextOptions["font"], ): { text: string[]; startPos: Point; endPos: Point } { + const tempCanvas = document.createElement("canvas"); + const ctx = tempCanvas.getContext("2d"); + if (!ctx) throw new Error("GENERATE TEXT: must have ctx to be able to create new text"); const lines = text.split("\n"); // text element width is the largest line width let largestLineIndex = 0; @@ -298,14 +300,12 @@ function textElementHelper( } const generateTextElement = ( - ctx: CanvasRenderingContext2D, text: string, startPos: [number, number], options: Partial = {}, ): ZagyCanvasTextElement => { const normalizedOptions = normalizeTextOptions(options); const norm = textElementHelper( - ctx, text, startPos, normalizedOptions.fontSize, diff --git a/src/utils/canvas/renderElements.ts b/src/utils/canvas/renderElements.ts index 017a0f7..71565e7 100644 --- a/src/utils/canvas/renderElements.ts +++ b/src/utils/canvas/renderElements.ts @@ -57,7 +57,6 @@ function renderTextElement(el: ZagyCanvasTextElement, ctx: CanvasRenderingContex ctx.save(); ctx.font = `${el.options.fontSize}px ` + FontTypeOptions[el.options.font]; - console.log("re", `${el.options.fontSize}px ` + FontTypeOptions[el.options.font]); ctx.fillStyle = el.options.stroke; ctx.textBaseline = "top"; el.text.forEach((val, i) => ctx.fillText(val, el.x, el.y + i * el.options.fontSize)); From 69077f802ce94f075b28dde3646d3b63394bd518 Mon Sep 17 00:00:00 2001 From: nagy nabil Date: Fri, 8 Sep 2023 06:24:06 +0300 Subject: [PATCH 7/7] ts go easy --- src/components/DropDown.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/DropDown.tsx b/src/components/DropDown.tsx index 5032694..3e46bc5 100644 --- a/src/components/DropDown.tsx +++ b/src/components/DropDown.tsx @@ -226,8 +226,10 @@ function ShortCutsModal() { {Object.keys(SHORTCUTS[group as keyof typeof SHORTCUTS]).map((key) => (

+ {/* @ts-ignore*/} {SHORTCUTS[group][key]["description"]} {" : "} + {/* @ts-ignore*/} {SHORTCUTS[group][key]["keys"].join(" + ")}

))}