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/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 new file mode 100644 index 0000000..c90eee3 --- /dev/null +++ b/src/actions/ExportScene.ts @@ -0,0 +1,123 @@ +import { fileSave } from "browser-fs-access"; +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, isHanddrawn } from "@/types/general"; +import { ZagyCanvasTextElement } 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, + 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; + + // choose export mechanism + if (this.dest === DestOpts.CLIPBOARD) { + await navigator.clipboard.writeText(JSON.stringify(portable)); + } else if (this.dest === DestOpts.JSON) { + const blob = new Blob([JSON.stringify(portable)], { type: "application/zagydraw" }); + 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/copySelected.ts b/src/actions/copySelected.ts deleted file mode 100644 index 96b2e25..0000000 --- a/src/actions/copySelected.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Command } from "./types"; -import { useStore } from "@/store/index"; - -export class ActionCopySelected extends Command { - public async execute() { - const { selectedElements } = useStore.getState(); - try { - await navigator.clipboard.writeText(JSON.stringify(selectedElements)); - } 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..ffe8d2a 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(); @@ -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/cutSelected.ts b/src/actions/cutSelected.ts index e2998f4..8ee8935 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 "./ExportScene"; 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/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 3e514b2..9a49329 100644 --- a/src/actions/importElements.ts +++ b/src/actions/importElements.ts @@ -1,7 +1,24 @@ +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 { + generateCacheLineElement, + generateCacheRectElement, + generateCachedHandDrawnElement, + generateImageElement, + 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; @@ -11,19 +28,114 @@ export class ActionImportElements extends UndoableCommand { 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 { getPosition } = useStore.getState(); + const newBoundingStart = normalizeToGrid(getPosition(), this.mouseCoords); + const oldBounding = getBoundingRect(...els); + for (const el of els) { + 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 // 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(); + // 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); + this.updateElementsCoords(items.elements); + const elsToPush: ZagyCanvasElement[] = []; + for (const el of items.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(); + } + } + + // append elements to be deleted from the history stack + elsToPush.forEach((el) => this.#importedIds.add(el.id)); + setElements((prev) => [...prev, ...elsToPush]); + } catch { + const textEl = generateTextElement(pasted, this.mouseCoords); + setElements((prev) => [...prev, textEl]); + } + }); } } return; 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/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 1e88d9d..3e46bc5 100644 --- a/src/components/DropDown.tsx +++ b/src/components/DropDown.tsx @@ -1,5 +1,6 @@ -import { Trash2, Github, Keyboard, Menu } from "lucide-react"; +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"; @@ -26,6 +27,18 @@ import { AlertDialogTrigger, } from "@/components/ui/alert-dialog"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} 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 ( @@ -54,6 +67,178 @@ function ResetCanvasAlert() { ); } +function SaveToDialog() { + return ( + + {/*FIX: open dialog/alert dialog inside dropdown or context menu + @url https://github.com/radix-ui/primitives/issues/1836#issuecomment-1674338372 + */} + + e.preventDefault()}> + + Save To + + + + + Save To + +
+
+
+ +
+

Save To Disk

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

Save To Z+

+ Save On The Cloud + +
+
+
+
+ ); +} + +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 + */} + + 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 + +
+
+
+
+ ); +} + +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 as keyof typeof SHORTCUTS]).map((key) => ( +

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

+ ))} +
+ ))} +
+
+ ); +} export function DropDown() { return (
@@ -73,15 +258,13 @@ export function DropDown() { Export Image */} + + - - - Keyboard shortcuts - ⌘K - + diff --git a/src/components/ExportModal.tsx b/src/components/ExportModal.tsx index df43b3f..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"; @@ -31,7 +31,7 @@ export function ExportModal() { }, "ControlLeft", "ShiftLeft", - "e" + "e", ); useEffect(() => { @@ -102,7 +102,7 @@ export function ExportModal() {