diff --git a/editor-packages/editor-canvas/canvas-event-target/canvas-event-target.tsx b/editor-packages/editor-canvas/canvas-event-target/canvas-event-target.tsx index 6a26f436..d3b7e3e0 100644 --- a/editor-packages/editor-canvas/canvas-event-target/canvas-event-target.tsx +++ b/editor-packages/editor-canvas/canvas-event-target/canvas-event-target.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useLayoutEffect, useRef, useState } from "react"; +import React, { useEffect, useRef, useState } from "react"; import { useGesture } from "@use-gesture/react"; import type { Handler, @@ -39,24 +39,28 @@ export function CanvasEventTarget({ onDrag, onDragStart, onDragEnd, + cursor, children, -}: { - onZoomToFit?: () => void; - onPanning: OnPanningHandler; - onPanningStart: OnPanningHandler; - onPanningEnd: OnPanningHandler; - onZooming: OnZoomingHandler; - onZoomingStart: OnZoomingHandler; - onZoomingEnd: OnZoomingHandler; - onPointerMove: OnPointerMoveHandler; - onPointerMoveStart: OnPointerMoveHandler; - onPointerMoveEnd: OnPointerMoveHandler; - onPointerDown: OnPointerDownHandler; - onDrag: OnDragHandler; - onDragStart: OnDragHandler; - onDragEnd: OnDragHandler; - children?: React.ReactNode; -}) { +}: React.PropsWithChildren< + { + onZoomToFit?: () => void; + onPanning: OnPanningHandler; + onPanningStart: OnPanningHandler; + onPanningEnd: OnPanningHandler; + onZooming: OnZoomingHandler; + onZoomingStart: OnZoomingHandler; + onZoomingEnd: OnZoomingHandler; + onPointerMove: OnPointerMoveHandler; + onPointerMoveStart: OnPointerMoveHandler; + onPointerMoveEnd: OnPointerMoveHandler; + onPointerDown: OnPointerDownHandler; + onDrag: OnDragHandler; + onDragStart: OnDragHandler; + onDragEnd: OnDragHandler; + } & { + cursor?: React.CSSProperties["cursor"]; + } +>) { const interactionEventTargetRef = useRef(); const [isSpacebarPressed, setIsSpacebarPressed] = useState(false); @@ -210,7 +214,7 @@ export function CanvasEventTarget({ inset: 0, overflow: "hidden", touchAction: "none", - cursor: isSpacebarPressed ? "grab" : "default", + cursor: isSpacebarPressed ? "grab" : cursor, userSelect: "none", WebkitUserSelect: "none", }} diff --git a/editor-packages/editor-canvas/canvas/canvas.tsx b/editor-packages/editor-canvas/canvas/canvas.tsx index 7fcb82fb..e670fbf2 100644 --- a/editor-packages/editor-canvas/canvas/canvas.tsx +++ b/editor-packages/editor-canvas/canvas/canvas.tsx @@ -81,6 +81,10 @@ interface GroupingOptions { disabled?: boolean; } +interface CanvasCursorOptions { + cursor?: React.CSSProperties["cursor"]; +} + const default_canvas_preferences: CanvsPreferences = { can_highlight_selected_layer: false, marquee: { @@ -91,6 +95,18 @@ const default_canvas_preferences: CanvsPreferences = { }, }; +type CanvasProps = CanvasCursorOptions & { + viewbound: Box; + onSelectNode?: (...node: ReflectSceneNode[]) => void; + onMoveNodeStart?: (...node: string[]) => void; + onMoveNode?: (delta: XY, ...node: string[]) => void; + onMoveNodeEnd?: (delta: XY, ...node: string[]) => void; + onClearSelection?: () => void; +} & CanvasCustomRenderers & + CanvasState & { + config?: CanvsPreferences; + }; + interface HovringNode { node: ReflectSceneNode; reason: "frame-title" | "raycast" | "external"; @@ -113,18 +129,9 @@ export function Canvas({ readonly = true, config = default_canvas_preferences, backgroundColor, + cursor, ...props -}: { - viewbound: Box; - onSelectNode?: (...node: ReflectSceneNode[]) => void; - onMoveNodeStart?: (...node: string[]) => void; - onMoveNode?: (delta: XY, ...node: string[]) => void; - onMoveNodeEnd?: (delta: XY, ...node: string[]) => void; - onClearSelection?: () => void; -} & CanvasCustomRenderers & - CanvasState & { - config?: CanvsPreferences; - }) { +}: CanvasProps) { useEffect(() => { if (transformIntitialized) { return; @@ -430,8 +437,9 @@ export function Canvas({ <> ` - width: ${(p) => p.width}px; - height: ${(p) => p.height}px; +const Container = styled.div` + min-width: 240px; + min-height: 240px; `; +/* width: ${(p) => p.width}px; */ +/* height: ${(p) => p.height}px; */ /** * 1. container positioning guide (static per selection) diff --git a/editor-packages/editor-canvas/hud/hud-surface.tsx b/editor-packages/editor-canvas/hud/hud-surface.tsx index 78ac51e4..5f92aecc 100644 --- a/editor-packages/editor-canvas/hud/hud-surface.tsx +++ b/editor-packages/editor-canvas/hud/hud-surface.tsx @@ -168,8 +168,8 @@ function PositionGuides({ }) { return ( <> - {guides.map((guide) => { - return ; + {guides.map((guide, i) => { + return ; })} ); diff --git a/editor-packages/editor-canvas/overlay/guide-positioning/index.tsx b/editor-packages/editor-canvas/overlay/guide-positioning/index.tsx index dd0e424e..aafb0110 100644 --- a/editor-packages/editor-canvas/overlay/guide-positioning/index.tsx +++ b/editor-packages/editor-canvas/overlay/guide-positioning/index.tsx @@ -135,9 +135,10 @@ function SpacingMeterLabel({ return ( ); } @@ -257,7 +258,7 @@ function AuxiliaryLine({ direction={rotation} width={1} dashed - color={"orange"} + color={"darkorange"} /> ); } diff --git a/editor-packages/editor-canvas/overlay/handle.tsx b/editor-packages/editor-canvas/overlay/handle.tsx index 32cf1574..dc7fe48a 100644 --- a/editor-packages/editor-canvas/overlay/handle.tsx +++ b/editor-packages/editor-canvas/overlay/handle.tsx @@ -1,30 +1,33 @@ import React, { forwardRef } from "react"; import * as k from "./k"; -export const Handle = forwardRef(function ({ - color, - anchor, - box, - outlineWidth = 1, - outlineColor = "transparent", - size = 4, - borderRadius = 0, - cursor, - readonly, -}: { - color: string; - /** - * the width of the outline - */ - outlineWidth?: number; - outlineColor?: string; - size: number; - anchor: "nw" | "ne" | "sw" | "se"; - box: [number, number, number, number]; - borderRadius?: React.CSSProperties["borderRadius"]; - cursor?: React.CSSProperties["cursor"]; - readonly?: boolean; -}) { +export const Handle = forwardRef(function ( + { + color, + anchor, + box, + outlineWidth = 1, + outlineColor = "transparent", + size = 4, + borderRadius = 0, + cursor, + readonly, + }: { + color: string; + /** + * the width of the outline + */ + outlineWidth?: number; + outlineColor?: string; + size: number; + anchor: "nw" | "ne" | "sw" | "se"; + box: [number, number, number, number]; + borderRadius?: React.CSSProperties["borderRadius"]; + cursor?: React.CSSProperties["cursor"]; + readonly?: boolean; + }, + ref +) { let dx = 0; let dy = 0; switch (anchor) { diff --git a/editor-packages/editor-canvas/overlay/meter-label.tsx b/editor-packages/editor-canvas/overlay/meter-label.tsx index ca6eed2f..f6ec714f 100644 --- a/editor-packages/editor-canvas/overlay/meter-label.tsx +++ b/editor-packages/editor-canvas/overlay/meter-label.tsx @@ -45,9 +45,11 @@ export function MeterLabel({ zoom, margin = 0, zIndex = k.Z_INDEX_GUIDE_LABEL, + weight = 500, }: { x: number; y: number; + weight?: React.CSSProperties["fontWeight"]; background?: React.CSSProperties["background"]; label: string; anchor: "w" | "n" | "s" | "e"; @@ -98,7 +100,7 @@ export function MeterLabel({ color: "white", fontSize: font_size, fontFamily: "Inter, sans-serif", - fontWeight: 500, + fontWeight: weight, textAlign: "center", zIndex: zIndex, }} diff --git a/editor-packages/editor-figma-file/index.ts b/editor-packages/editor-figma-file/index.ts new file mode 100644 index 00000000..aa1695c1 --- /dev/null +++ b/editor-packages/editor-figma-file/index.ts @@ -0,0 +1,2 @@ +export * from "./stores"; +export * from "./repository"; diff --git a/editor-packages/editor-figma-file/package.json b/editor-packages/editor-figma-file/package.json new file mode 100644 index 00000000..2cf42c5b --- /dev/null +++ b/editor-packages/editor-figma-file/package.json @@ -0,0 +1,4 @@ +{ + "name": "@editor/figma-file", + "version": "0.0.0" +} \ No newline at end of file diff --git a/editor/repository/figma-design-repository/index.ts b/editor-packages/editor-figma-file/repository.ts similarity index 69% rename from editor/repository/figma-design-repository/index.ts rename to editor-packages/editor-figma-file/repository.ts index ead1055a..f20212a1 100644 --- a/editor/repository/figma-design-repository/index.ts +++ b/editor-packages/editor-figma-file/repository.ts @@ -1,6 +1,6 @@ import { fetch } from "@design-sdk/figma-remote"; -import { FileResponse } from "@design-sdk/figma-remote-types"; -import { FigmaFileStore, FileResponseRecord } from "@editor/figma-file-store"; +import { FigmaFileStore, FigmaFileMetaStore } from "./stores"; +import type { FileResponseRecord } from "./stores"; export type TFetchFileForApp = ( | fetch.FetchFileGeneratorReturnType @@ -20,23 +20,29 @@ export class FigmaDesignRepository { ) {} static async fetchCachedFile(fileId: string) { + const metastore = new FigmaFileMetaStore(); const store = new FigmaFileStore(fileId); const existing = await store.get(); if (existing) { + // everytime the file is consumed consider it as used, we upsert the file so that the lastUsed can be updated. + metastore.upsert(existing.key, existing); return { ...existing, __initial: false } as TFetchFileForApp; } else { throw new Error("file not found"); } } - async *fetchFile(fileId: string) { - const store = new FigmaFileStore(fileId); + async *fetchFile(filekey: string) { + const metastore = new FigmaFileMetaStore(); + const store = new FigmaFileStore(filekey); const existing = await store.get(); if (existing) { + // everytime the file is consumed consider it as used, we upsert the file so that the lastUsed can be updated. + metastore.upsert(existing.key, existing); yield { ...existing, __initial: false } as TFetchFileForApp; } - const _iter = fetch.fetchFile({ file: fileId, auth: this.auth }); + const _iter = fetch.fetchFile({ file: filekey, auth: this.auth }); let next: IteratorResult; while ((next = await _iter.next()).done === false) { switch (next.value.__response_type) { @@ -48,6 +54,7 @@ export class FigmaDesignRepository { __type: "file-fetched-for-app", } as TFetchFileForApp; store.upsert(next.value); + metastore.upsert(filekey, next.value); } break; case "roots": @@ -58,6 +65,7 @@ export class FigmaDesignRepository { __type: "file-fetched-for-app", } as TFetchFileForApp; store.upsert(next.value); + metastore.upsert(filekey, next.value); } break; case "whole": @@ -67,6 +75,7 @@ export class FigmaDesignRepository { __type: "file-fetched-for-app", } as TFetchFileForApp; store.upsert(next.value); + metastore.upsert(filekey, next.value); break; } } diff --git a/editor-packages/editor-figma-file/stores/figma-file-meta-store.ts b/editor-packages/editor-figma-file/stores/figma-file-meta-store.ts new file mode 100644 index 00000000..5d0cb667 --- /dev/null +++ b/editor-packages/editor-figma-file/stores/figma-file-meta-store.ts @@ -0,0 +1,118 @@ +/// +/// the file can be very large, which even simply loading the objects from indexed db will take long time (longer than 1 second) +/// for the solution (while using indexed db), we save meta datas on a different strore for listing the files on the ui. +/// the record's meta should match the full record on 'files' table. +/// + +import { openDB, IDBPDatabase } from "idb"; +import type { FileResponse, Canvas, Color } from "@design-sdk/figma-remote-api"; +import * as k from "./k"; + +// #region global db initialization +const __db_pref = { name: "fimga-file-meta-store", version: k.DB_VER }; +const __table = "files-meta"; + +export type FileMetaRecord = { + readonly key: string; + readonly lastUsed: Date; + readonly components: FileResponse["components"]; + readonly styles: FileResponse["styles"]; + readonly lastModified: string; + readonly document: { + readonly type: "DOCUMENT"; + readonly children: Array<{ + readonly type: "CANVAS"; + readonly backgroundColor: Color; + readonly id: string; + readonly children: Array<{ + id: string; + }>; + }>; + }; + readonly name: string; + readonly schemaVersion: number; + readonly thumbnailUrl: string; + readonly version: string; +}; + +const db: Promise> = new Promise((resolve) => { + // disable on ssr + if (typeof window === "undefined") { + return; + } + + openDB(__db_pref.name, __db_pref.version, { + upgrade(db) { + if (!db.objectStoreNames.contains(__table)) { + db.createObjectStore(__table, { + keyPath: "key", + }); + } + }, + }).then((_db) => { + resolve(_db); + }); +}); +// #endregion + +export class FigmaFileMetaStore { + constructor() {} + + async all() { + return await (await db).getAll(__table); + } + + async upsert(key: string, file: FileResponse) { + try { + await ( + await db + ).put(__table, { + ...minimize(file), + key: key, + lastUsed: new Date(), + }); + } catch (e) {} + } + + async get(key: string): Promise { + return await (await db).get(__table, key); + } + + async clear() { + (await db).clear(__table); + } +} + +/** + * minimizes the full api response optimized (minimized) for meta storage + * @returns + */ +function minimize( + full: FileResponse +): Omit { + return { + components: full.components, + styles: full.styles, + + lastModified: full.lastModified, + document: { + type: "DOCUMENT", + children: (full.document.children as Canvas[]).map((canvas) => { + return { + type: "CANVAS", + backgroundColor: canvas.backgroundColor, + id: canvas.id, + children: canvas.children.map((child) => { + return { + id: child.id, + }; + }), + }; + }), + }, + name: full.name, + schemaVersion: full.schemaVersion, + thumbnailUrl: full.thumbnailUrl, + version: full.version, + }; +} diff --git a/editor-packages/editor-store-figma-file/figma-file-store.ts b/editor-packages/editor-figma-file/stores/figma-file-store.ts similarity index 83% rename from editor-packages/editor-store-figma-file/figma-file-store.ts rename to editor-packages/editor-figma-file/stores/figma-file-store.ts index ce603552..91875bc7 100644 --- a/editor-packages/editor-store-figma-file/figma-file-store.ts +++ b/editor-packages/editor-figma-file/stores/figma-file-store.ts @@ -1,22 +1,29 @@ import { openDB, IDBPDatabase } from "idb"; import type { FileResponse } from "@design-sdk/figma-remote-api"; +import * as k from "./k"; // #region global db initialization -const __db_pref = { name: "fimga-file-store", version: 1 }; +const __db_pref = { name: "fimga-file-store", version: k.DB_VER }; const __pk = "key"; const __table = "files"; export type FileResponseRecord = FileResponse & { [__pk]: string; - lastUsed: Date; }; const db: Promise> = new Promise( (resolve) => { + // disable on ssr + if (typeof window === "undefined") { + return; + } + openDB(__db_pref.name, __db_pref.version, { upgrade(db) { - db.createObjectStore(__table, { - keyPath: __pk, - }); + if (!db.objectStoreNames.contains(__table)) { + db.createObjectStore(__table, { + keyPath: __pk, + }); + } }, }).then((_db) => { resolve(_db); @@ -48,9 +55,8 @@ export class FigmaFileStore { await ( await db ).put(__table, { - ...minimizeFileResponse(file), + ...optimize(file), [__pk]: this.filekey, - lastUsed: new Date(), }); } catch (e) { if (process.env.NODE_ENV === "development") { @@ -60,7 +66,9 @@ export class FigmaFileStore { } } - async get(options?: { nounwrap?: boolean }): Promise { + async get(options?: { + nounwrap?: boolean; + }): Promise { const rec = await (await db).get(__table, this.filekey); if (options?.nounwrap) { return rec; @@ -79,13 +87,10 @@ type StorableFileResponse = { readonly schemaVersion: number; readonly thumbnailUrl: string; readonly version: string; - readonly lastUsed: Date; }; type JSONString = string; -function minimizeFileResponse( - file: FileResponse -): Omit { +function optimize(file: FileResponse): StorableFileResponse { return { components: JSON.stringify(file.components), styles: JSON.stringify(file.styles), @@ -101,8 +106,8 @@ function minimizeFileResponse( function unwrapStorableFileResponse( stored: StorableFileResponse | undefined | null -): FileResponseRecord { - if (!stored) return; +): FileResponseRecord | null { + if (!stored) return null; const parsesafe = (s: string) => { try { return JSON.parse(s); @@ -124,6 +129,5 @@ function unwrapStorableFileResponse( schemaVersion: stored.schemaVersion, thumbnailUrl: stored.thumbnailUrl, version: stored.version, - lastUsed: stored.lastUsed, }; } diff --git a/editor-packages/editor-figma-file/stores/index.ts b/editor-packages/editor-figma-file/stores/index.ts new file mode 100644 index 00000000..98117776 --- /dev/null +++ b/editor-packages/editor-figma-file/stores/index.ts @@ -0,0 +1,4 @@ +export { FigmaFileStore, FigmaFilesStore } from "./figma-file-store"; +export type { FileResponseRecord } from "./figma-file-store"; +export { FigmaFileMetaStore } from "./figma-file-meta-store"; +export type { FileMetaRecord } from "./figma-file-meta-store"; diff --git a/editor-packages/editor-figma-file/stores/k.ts b/editor-packages/editor-figma-file/stores/k.ts new file mode 100644 index 00000000..d8338258 --- /dev/null +++ b/editor-packages/editor-figma-file/stores/k.ts @@ -0,0 +1 @@ +export const DB_VER = 5; diff --git a/editor-packages/editor-preview-vanilla/package.json b/editor-packages/editor-preview-vanilla/package.json index cb5e9fe9..75ba61d8 100644 --- a/editor-packages/editor-preview-vanilla/package.json +++ b/editor-packages/editor-preview-vanilla/package.json @@ -17,7 +17,7 @@ ], "peerDependencies": { "@emotion/styled": "^11.3.0", - "react": "^18.0.0" + "react": "^18.2.0" }, "devDependencies": { "@babel/core": "^7.16.7", diff --git a/editor-packages/editor-property/color-chip.tsx b/editor-packages/editor-property/color-chip.tsx new file mode 100644 index 00000000..c063593f --- /dev/null +++ b/editor-packages/editor-property/color-chip.tsx @@ -0,0 +1,86 @@ +import React from "react"; +import styled from "@emotion/styled"; +import { HoverCard, HoverCardTrigger, HoverCardContent } from "./hover-card"; +import * as k from "./k"; +import * as css from "@web-builder/styles"; + +const rd = (d) => Math.round((d + Number.EPSILON) * 100) / 100; + +type Color = { r: number; g: number; b: number; o: number }; + +export function ColorChip({ + onClick, + color, + snippet, + size = k.chip_size, + outline = false, +}: { + onClick?: ({ color, text }: { color: Color; text: string }) => void; + color: Color; + snippet?: string; + size?: number; + outline?: boolean; +}) { + const csscolor = css.color({ + r: color.r * 255, + g: color.g * 255, + b: color.b * 255, + a: color.o, + }); + + const text = snippet || csscolor; + + return ( + + { + onClick?.({ + color, + text, + }); + }} + > + + + + + + + + + + + ); +} + +const CardBody = styled.div` + padding: 8px; + label { + font-size: 10px; + color: black; + transition: width 0.2s ease; + } +`; + +const ChipContainer = styled.div` + cursor: pointer; + background: transparent; + display: flex; + flex-direction: row; + gap: 4px; + align-items: center; + transition: all 0.2s ease; + + &:hover { + transform: scale(1.1); + } +`; diff --git a/editor-packages/editor-property/gradient-chip.tsx b/editor-packages/editor-property/gradient-chip.tsx new file mode 100644 index 00000000..5c54716e --- /dev/null +++ b/editor-packages/editor-property/gradient-chip.tsx @@ -0,0 +1,88 @@ +import React from "react"; +import styled from "@emotion/styled"; +import { HoverCard, HoverCardTrigger, HoverCardContent } from "./hover-card"; +import type { GradientPaint } from "@design-sdk/figma-types"; +import { LinearGradient, Alignment } from "@reflect-ui/core"; +import * as k from "./k"; +import * as css from "@web-builder/styles"; + +export function GradientChip({ + onClick, + gradient, + snippet, + size = k.chip_size, + outline = false, +}: { + onClick?: ({ + gradient, + text, + }: { + gradient: GradientPaint; + text: string; + }) => void; + gradient: GradientPaint; + snippet?: string; + size?: number; + outline?: boolean; +}) { + const { gradientStops: stops, gradientTransform: transform, type } = gradient; + + const gradientcss = css.linearGradient( + new LinearGradient({ + begin: Alignment.topLeft, + end: Alignment.bottomRight, + stops: stops.map((s) => { + return s.position; + }), + colors: stops.map((s) => { + return s.color; + }), + }) + ); + + return ( + + + + + + + + + + + + + ); +} + +const CardBody = styled.div` + padding: 8px; + label { + font-size: 10px; + color: black; + transition: width 0.2s ease; + } +`; + +const ChipContainer = styled.div` + cursor: pointer; + background: transparent; + display: flex; + flex-direction: row; + gap: 4px; + align-items: center; + transition: all 0.2s ease; + + &:hover { + transform: scale(1.1); + } +`; diff --git a/editor-packages/editor-property/hover-card.tsx b/editor-packages/editor-property/hover-card.tsx new file mode 100644 index 00000000..0efe2bc5 --- /dev/null +++ b/editor-packages/editor-property/hover-card.tsx @@ -0,0 +1,64 @@ +import React from "react"; +import { styled, keyframes } from "@stitches/react"; +import { mauve } from "@radix-ui/colors"; +import * as HoverCardPrimitive from "@radix-ui/react-hover-card"; + +const slideUpAndFade = keyframes({ + "0%": { opacity: 0, transform: "translateY(2px)" }, + "100%": { opacity: 1, transform: "translateY(0)" }, +}); + +const slideRightAndFade = keyframes({ + "0%": { opacity: 0, transform: "translateX(-2px)" }, + "100%": { opacity: 1, transform: "translateX(0)" }, +}); + +const slideDownAndFade = keyframes({ + "0%": { opacity: 0, transform: "translateY(-2px)" }, + "100%": { opacity: 1, transform: "translateY(0)" }, +}); + +const slideLeftAndFade = keyframes({ + "0%": { opacity: 0, transform: "translateX(2px)" }, + "100%": { opacity: 1, transform: "translateX(0)" }, +}); + +const StyledContent = styled(HoverCardPrimitive.Content, { + zIndex: 99, + borderRadius: 4, + padding: 4, + backgroundColor: "white", + boxShadow: + "hsl(206 22% 7% / 35%) 0px 10px 38px -10px, hsl(206 22% 7% / 20%) 0px 10px 20px -15px", + "@media (prefers-reduced-motion: no-preference)": { + animationDuration: "400ms", + animationTimingFunction: "cubic-bezier(0.16, 1, 0.3, 1)", + willChange: "transform, opacity", + '&[data-state="open"]': { + '&[data-side="top"]': { animationName: slideDownAndFade }, + '&[data-side="right"]': { animationName: slideLeftAndFade }, + '&[data-side="bottom"]': { animationName: slideUpAndFade }, + '&[data-side="left"]': { animationName: slideRightAndFade }, + }, + }, +}); + +const StyledArrow = styled(HoverCardPrimitive.Arrow, { + fill: "white", +}); + +function Content({ children, ...props }) { + return ( + + + {children} + + + + ); +} + +// Exports +export const HoverCard = HoverCardPrimitive.Root; +export const HoverCardTrigger = HoverCardPrimitive.Trigger; +export const HoverCardContent = Content; diff --git a/editor-packages/editor-property/index.ts b/editor-packages/editor-property/index.ts new file mode 100644 index 00000000..b9714b6b --- /dev/null +++ b/editor-packages/editor-property/index.ts @@ -0,0 +1,2 @@ +export { ColorChip } from "./color-chip"; +export { GradientChip } from "./gradient-chip"; diff --git a/editor-packages/editor-property/k.ts b/editor-packages/editor-property/k.ts new file mode 100644 index 00000000..b5f8f605 --- /dev/null +++ b/editor-packages/editor-property/k.ts @@ -0,0 +1 @@ +export const chip_size = 20; diff --git a/editor-packages/editor-property/package.json b/editor-packages/editor-property/package.json new file mode 100644 index 00000000..9fe8fadc --- /dev/null +++ b/editor-packages/editor-property/package.json @@ -0,0 +1,16 @@ +{ + "name": "@code-editor/property", + "version": "0.0.0", + "dependencies": { + "@radix-ui/react-hover-card": "^1.0.2" + }, + "peerDependencies": { + "react": "^18.2.0" + }, + "devDependencies": { + "@types/node": "^18.11.9", + "@types/react": "^18.0.24", + "@types/react-dom": "^18.0.8", + "typescript": "^4.8.4" + } +} \ No newline at end of file diff --git a/editor-packages/editor-property/tsconfig.json b/editor-packages/editor-property/tsconfig.json new file mode 100644 index 00000000..35f5aaab --- /dev/null +++ b/editor-packages/editor-property/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "es2015", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": false, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "baseUrl": "." + }, + "include": ["**/*.ts", "**/*.tsx"], + "exclude": ["node_modules"] +} diff --git a/editor-packages/editor-store-figma-file/index.ts b/editor-packages/editor-store-figma-file/index.ts deleted file mode 100644 index 8aa6d22b..00000000 --- a/editor-packages/editor-store-figma-file/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./figma-file-store"; diff --git a/editor-packages/editor-store-figma-file/package.json b/editor-packages/editor-store-figma-file/package.json deleted file mode 100644 index 299c259c..00000000 --- a/editor-packages/editor-store-figma-file/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "@editor/figma-file-store", - "version": "0.0.0" -} \ No newline at end of file diff --git a/editor/components/code-editor/monaco.tsx b/editor/components/code-editor/monaco.tsx index 45cb5e74..708046b0 100644 --- a/editor/components/code-editor/monaco.tsx +++ b/editor/components/code-editor/monaco.tsx @@ -20,6 +20,7 @@ export interface MonacoEditorProps { height?: number | string; options?: Options; readonly?: boolean; + fold_comments_on_load?: boolean; } export function MonacoEditor(props: MonacoEditorProps) { @@ -30,6 +31,8 @@ export function MonacoEditor(props: MonacoEditorProps) { const onMount: OnMount = (editor, monaco) => { const format = editor.getAction("editor.action.formatDocument"); const rename = editor.getAction("editor.action.rename"); + // fold all comments + const fold_comments = editor.getAction("editor.foldAllBlockComments"); instance.current = { editor, format }; @@ -66,9 +69,17 @@ export function MonacoEditor(props: MonacoEditorProps) { }, }); - editor.onDidChangeModelContent(() => - debounce(() => editor.saveViewState(), 200) - ); + if (props.fold_comments_on_load) { + fold_comments.run(); + } + + editor.onDidChangeModelContent(() => { + debounce(() => editor.saveViewState(), 200); + + if (props.fold_comments_on_load) { + fold_comments.run(); + } + }); editor.onDidDispose(() => { dispose(); diff --git a/editor/components/editor-progress-indicator/editor-progress-indicator-popover-content.tsx b/editor/components/editor-progress-indicator/editor-progress-indicator-popover-content.tsx index 47075257..f84dd0c5 100644 --- a/editor/components/editor-progress-indicator/editor-progress-indicator-popover-content.tsx +++ b/editor/components/editor-progress-indicator/editor-progress-indicator-popover-content.tsx @@ -15,12 +15,16 @@ const Container = styled.div` flex-direction: column; align-items: center; flex: none; - gap: 32px; box-shadow: 0px 16px 24px 0px rgba(0, 0, 0, 0.25); border: solid 1px rgb(74, 73, 77); border-radius: 12px; background-color: rgba(30, 30, 30, 0.8); box-sizing: border-box; - padding: 24px; + padding: 4px; + gap: 4px; backdrop-filter: blur(32px); + min-width: 200px; + min-height: 80px; + max-height: 400px; + overflow-y: scroll; `; diff --git a/editor/components/editor-progress-indicator/editor-progress-indicator-trigger-button.tsx b/editor/components/editor-progress-indicator/editor-progress-indicator-trigger-button.tsx index 00bfca80..99abc782 100644 --- a/editor/components/editor-progress-indicator/editor-progress-indicator-trigger-button.tsx +++ b/editor/components/editor-progress-indicator/editor-progress-indicator-trigger-button.tsx @@ -1,6 +1,7 @@ import React from "react"; import styled from "@emotion/styled"; - +import { LinearProgress } from "@mui/material"; +import { DownloadIcon } from "@radix-ui/react-icons"; export function EditorProgressIndicatorButton({ isBusy = false, }: { @@ -8,23 +9,20 @@ export function EditorProgressIndicatorButton({ }) { return ( - - - + {isBusy && } ); } +function IndicatorLine() { + return ( + + + + ); +} + const Container = styled.div` display: flex; justify-content: flex-start; @@ -35,9 +33,14 @@ const Container = styled.div` box-sizing: border-box; `; -const IndicatorLine = styled.div` +const IndicatorLineContainer = styled.div` width: 20px; height: 4px; background-color: rgb(37, 98, 255); + overflow: hidden; border-radius: 7px; `; + +const ColoredLinearProgress = styled(LinearProgress)` + background-color: rgb(37, 98, 255); +`; diff --git a/editor/components/editor-progress-indicator/editor-progress-indicator.tsx b/editor/components/editor-progress-indicator/editor-progress-indicator.tsx index 791f91d3..7ba40b76 100644 --- a/editor/components/editor-progress-indicator/editor-progress-indicator.tsx +++ b/editor/components/editor-progress-indicator/editor-progress-indicator.tsx @@ -4,35 +4,46 @@ import { EditorTaskItem } from "./editor-task-item"; import { EditorProgressIndicatorButton } from "./editor-progress-indicator-trigger-button"; import { EditorProgressIndicatorPopoverContent } from "./editor-progress-indicator-popover-content"; import * as Popover from "@radix-ui/react-popover"; +import { EditorTaskQueue } from "core/states"; +import { styled as s, keyframes } from "@stitches/react"; + +export function EditorProgressIndicator({ isBusy, tasks }: EditorTaskQueue) { + const TasksBody = () => { + if (tasks.length === 0) { + return No background tasks; + } + return ( + <> + {tasks.map((task) => ( + + ))} + + ); + }; -export function EditorProgressIndicator({ - isBusy, - tasks, -}: { - isBusy: boolean; - tasks: any[]; -}) { return ( - - {tasks.map((task, index) => ( - - ))} + - + ); } @@ -42,3 +53,21 @@ const StyledTrigger = styled(Popover.Trigger)` border: none; background: none; `; + +const scaleIn = keyframes({ + "0%": { opacity: 0 }, + "100%": { opacity: 1 }, +}); + +const StyledContent = s(Popover.Content, { + animation: `${scaleIn} 0.1s ease-out`, +}); + +const Empty = styled.div` + display: flex; + justify-content: center; + align-items: center; + height: 100%; + color: rgba(255, 255, 255, 0.5); + font-size: 12px; +`; diff --git a/editor/components/editor-progress-indicator/editor-task-item.tsx b/editor/components/editor-progress-indicator/editor-task-item.tsx index e1702c6a..14a43dc2 100644 --- a/editor/components/editor-progress-indicator/editor-task-item.tsx +++ b/editor/components/editor-progress-indicator/editor-task-item.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useEffect, useState } from "react"; import styled from "@emotion/styled"; import LinearProgress from "@mui/material/LinearProgress"; @@ -6,10 +6,12 @@ export function EditorTaskItem({ label, description, progress, + createdAt, }: { label: string; description?: string; progress: number | null; + createdAt?: Date; }) { return ( @@ -17,19 +19,77 @@ export function EditorTaskItem({ {label} - {description} + + {description} + {createdAt && } + ); } +function EllapsedTime({ from }: { from: Date }) { + const [time, settime] = useState(new Date().getTime() - from.getTime()); + + useEffect(() => { + const interval = setInterval(() => { + settime(new Date().getTime() - from.getTime()); + }, 1000); + + return () => { + clearInterval(interval); + }; + }, []); + + const formatted = formatTime(time); + + return {formatted}; +} + +/** + * 00:00 or 00:00:00 + * + * @examples + * - 00:00 + * - 00:01 + * - 00:50 + * - 01:01 + * - 59:59 + * - 01:00:00 + * @param time + */ +function formatTime(time: number) { + const seconds = Math.floor((time / 1000) % 60); + const minutes = Math.floor((time / (1000 * 60)) % 60); + const hours = Math.floor((time / (1000 * 60 * 60)) % 24); + + const s = seconds < 10 ? "0" + seconds : seconds; + const m = minutes < 10 ? "0" + minutes : minutes; + const h = hours < 10 ? "0" + hours : hours; + + if (hours > 0) { + return h + ":" + m + ":" + s; + } else { + return m + ":" + s; + } +} + const RootWrapperProgressingItemReadonly = styled.div` + cursor: default; + padding: 20px; display: flex; justify-content: center; flex-direction: column; align-items: flex-start; flex: none; gap: 4px; + border-radius: 8px; box-sizing: border-box; + background: transparent; + &:hover { + background: rgba(0, 0, 0, 0.4); + } + + transition: all 0.1s ease-in-out; `; const TitleAndValueContainer = styled.div` @@ -60,12 +120,28 @@ const ColoredLinearProgress = styled(LinearProgress)` background-color: rgb(37, 98, 255); `; +const FooterContainer = styled.footer` + display: flex; + justify-content: space-between; + flex-direction: row; + align-items: center; + gap: 4px; + padding: 0px; + margin: 0px; + align-self: stretch; +`; + const ThisDescription = styled.span` color: rgba(255, 255, 255, 0.5); text-overflow: ellipsis; font-size: 10px; font-weight: 400; text-align: left; - align-self: stretch; + flex: 1; flex-shrink: 0; `; + +const EllapsedTimeLabel = styled.label` + color: rgba(255, 255, 255, 0.5); + font-size: 10px; +`; diff --git a/editor/components/editor/editor-appbar/editor-appbar-fragment-for-code-editor.tsx b/editor/components/editor/editor-appbar/editor-appbar-fragment-for-code-editor.tsx index 78a794ea..84c7a75e 100644 --- a/editor/components/editor/editor-appbar/editor-appbar-fragment-for-code-editor.tsx +++ b/editor/components/editor/editor-appbar/editor-appbar-fragment-for-code-editor.tsx @@ -6,15 +6,18 @@ import { EditorFrameworkConfigOnAppbar } from "../editor-framework-config-on-app import { EditorProgressIndicator } from "scaffolds/editor-progress-indicator"; import { colors } from "theme"; -export function AppbarFragmentForCodeEditor({ +export function AppbarFragmentForRightSidebar({ background = false, + flex = 1, }: { background?: boolean; + flex?: number; }) { const hasNotification = false; return ( {/* disable temporarily */} @@ -41,13 +44,14 @@ export function AppbarFragmentForCodeEditor({ const RootWrapperAppbarFragmentForCodeEditor = styled.div<{ background: React.CSSProperties["background"]; + flex: number; }>` z-index: 10; display: flex; - justify-content: center; + justify-content: end; flex-direction: row; align-items: center; - flex: 1; + flex: ${(props) => props.flex}; gap: 10px; align-self: stretch; box-sizing: border-box; diff --git a/editor/components/editor/editor-appbar/editor-appbar.tsx b/editor/components/editor/editor-appbar/editor-appbar.tsx index 9c3331a8..525b5742 100644 --- a/editor/components/editor/editor-appbar/editor-appbar.tsx +++ b/editor/components/editor/editor-appbar/editor-appbar.tsx @@ -2,14 +2,14 @@ import React from "react"; import styled from "@emotion/styled"; import { AppbarFragmentForSidebar } from "./editor-appbar-fragment-for-sidebar"; import { AppbarFragmentForCanvas } from "./editor-appbar-fragment-for-canvas"; -import { AppbarFragmentForCodeEditor } from "./editor-appbar-fragment-for-code-editor"; +import { AppbarFragmentForRightSidebar } from "./editor-appbar-fragment-for-code-editor"; export function Appbar() { return ( - + ); } diff --git a/editor/components/editor/editor-appbar/index.ts b/editor/components/editor/editor-appbar/index.ts index 48c0a0c0..4bfb3f4f 100644 --- a/editor/components/editor/editor-appbar/index.ts +++ b/editor/components/editor/editor-appbar/index.ts @@ -2,10 +2,10 @@ export { Appbar as EditorAppbar } from "./editor-appbar"; import { AppbarFragmentForSidebar } from "./editor-appbar-fragment-for-sidebar"; import { AppbarFragmentForCanvas } from "./editor-appbar-fragment-for-canvas"; -import { AppbarFragmentForCodeEditor } from "./editor-appbar-fragment-for-code-editor"; +import { AppbarFragmentForRightSidebar } from "./editor-appbar-fragment-for-code-editor"; export const EditorAppbarFragments = { Sidebar: AppbarFragmentForSidebar, Canvas: AppbarFragmentForCanvas, - CodeEditor: AppbarFragmentForCodeEditor, + RightSidebar: AppbarFragmentForRightSidebar, }; diff --git a/editor/components/editor/editor-browser-meta-head/index.tsx b/editor/components/editor/editor-browser-meta-head/index.tsx index e0c3104e..d172f56d 100644 --- a/editor/components/editor/editor-browser-meta-head/index.tsx +++ b/editor/components/editor/editor-browser-meta-head/index.tsx @@ -4,16 +4,16 @@ import { useEditorState } from "core/states"; export function EditorBrowserMetaHead({ children, -}: { - children: React.ReactChild; -}) { +}: React.PropsWithChildren<{}>) { const [state] = useEditorState(); return ( <> - {state?.design?.name ? `Grida | ${state?.design?.name}` : "Loading.."} + {state?.design?.name + ? `Grida | ${state?.design?.name}` + : "Grida Code"} {children} diff --git a/editor/components/editor/editor-layer-hierarchy/editor-layer-hierarchy-item.tsx b/editor/components/editor/editor-layer-hierarchy/editor-layer-hierarchy-item.tsx index b7ca68e4..f76e347d 100644 --- a/editor/components/editor/editor-layer-hierarchy/editor-layer-hierarchy-item.tsx +++ b/editor/components/editor/editor-layer-hierarchy/editor-layer-hierarchy-item.tsx @@ -1,10 +1,4 @@ -import React, { - forwardRef, - memo, - useCallback, - useState, - ReactNode, -} from "react"; +import React, { forwardRef, memo, useCallback, ReactNode } from "react"; import { TreeView } from "@editor-ui/hierarchy"; import { Spacer } from "@editor-ui/spacer"; import { withSeparatorElements } from "@editor-ui/utils"; @@ -12,18 +6,8 @@ import styled from "@emotion/styled"; import { useTheme } from "@emotion/react"; import "@editor-ui/theme"; import { ReflectSceneNodeType } from "@design-sdk/figma-node"; -import { - FrameIcon, - DotsHorizontalIcon, - FileIcon, - TextIcon, - GroupIcon, - ComponentInstanceIcon, - Component1Icon, - BoxIcon, - CircleIcon, - ImageIcon, -} from "@radix-ui/react-icons"; +import { DotsHorizontalIcon } from "@radix-ui/react-icons"; +import { SceneNodeIcon } from "components/icons"; export const IconContainer = styled.span(({ theme }) => ({ color: theme.colors.mask, @@ -37,7 +21,7 @@ export const LayerIcon = memo(function LayerIcon({ selected, variant, }: { - type: string; + type: ReflectSceneNodeType; selected?: boolean; variant?: "primary"; }) { @@ -50,42 +34,7 @@ export const LayerIcon = memo(function LayerIcon({ ? colors.iconSelected : colors.icon; - switch (type as ReflectSceneNodeType) { - case ReflectSceneNodeType.group: - return ; - case ReflectSceneNodeType.component: - return ; - case ReflectSceneNodeType.instance: - return ; - case ReflectSceneNodeType.text: - return ; - case ReflectSceneNodeType.frame: - return ; - case ReflectSceneNodeType.ellipse: - return ; - case ReflectSceneNodeType.rectangle: - return ; - case ReflectSceneNodeType.variant_set: - return <>; - case ReflectSceneNodeType.constraint: - return <>; - case ReflectSceneNodeType.line: - return <>; - case ReflectSceneNodeType.vector: - return <>; - case ReflectSceneNodeType.star: - return <>; - case ReflectSceneNodeType.poligon: - return <>; - case ReflectSceneNodeType.boolean_operation: - return <>; - case ReflectSceneNodeType.image: - return ; - case ReflectSceneNodeType.unknown: - return <>; - default: - return <>; - } + return ; }); export const LayerRow = memo( @@ -108,7 +57,7 @@ export const LayerRow = memo( onMenuClick: () => void; children?: ReactNode; }, - forwardedRef: any + ref: any ) { const handleHoverChange = useCallback( (hovered: boolean) => { @@ -120,7 +69,7 @@ export const LayerRow = memo( return ( // @ts-ignore - ref={forwardedRef} + ref={ref} onHoverChange={handleHoverChange} hovered={hovered} selected={selected} diff --git a/editor/components/editor/editor-layer-hierarchy/editor-layer-hierarchy-tree.tsx b/editor/components/editor/editor-layer-hierarchy/editor-layer-hierarchy-tree.tsx index 97716208..0d496148 100644 --- a/editor/components/editor/editor-layer-hierarchy/editor-layer-hierarchy-tree.tsx +++ b/editor/components/editor/editor-layer-hierarchy/editor-layer-hierarchy-tree.tsx @@ -1,5 +1,4 @@ -import React, { memo, useCallback, useMemo, useReducer, useState } from "react"; -import styled from "@emotion/styled"; +import React, { useCallback, useMemo, useState } from "react"; import { TreeView } from "@editor-ui/editor"; import { LayerRow, @@ -16,9 +15,21 @@ import { // TODO: // - add navigate context menu // - add go to main component -// - add reveal on select +// - add reveal and focus to selected layers export function EditorLayerHierarchy() { + const [state] = useEditorState(); + const { selectedPage } = state; + + switch (selectedPage) { + case "home": + return <>; + default: + return ; + } +} + +function CanvasLayerHierarchy() { const [state] = useEditorState(); const { highlightLayer, highlightedLayer } = useWorkspace(); const dispatch = useDispatch(); @@ -95,4 +106,5 @@ export function EditorLayerHierarchy() { renderItem={renderItem} /> ); + // } diff --git a/editor/components/editor/editor-pages-list/editor-page-item.tsx b/editor/components/editor/editor-pages-list/editor-page-item.tsx index a3c9c919..50e69cac 100644 --- a/editor/components/editor/editor-pages-list/editor-page-item.tsx +++ b/editor/components/editor/editor-pages-list/editor-page-item.tsx @@ -1,23 +1,36 @@ -import React from "react"; -import { FileIcon } from "@radix-ui/react-icons"; +import React, { useCallback } from "react"; +import { FileIcon, HomeIcon } from "@radix-ui/react-icons"; import styled from "@emotion/styled"; import { useTheme } from "@emotion/react"; import { TreeView } from "@editor-ui/hierarchy"; +import type { PageInfo } from "./editor-pages-list"; export function EditorPageItem({ id, name, + type, selected, onPress, -}: { - id: string; - name: string; +}: PageInfo & { selected: boolean; onPress: () => void; }) { const { icon: iconColor, iconSelected: iconSelectedColor } = useTheme().colors; + const iconRenderer = (type: PageInfo["type"]) => { + switch (type) { + case "home": + return ; + case "assets": + case "canvas": + case "components": + case "styles": + default: + return ; + } + }; + return ( {}} - icon={} + icon={iconRenderer(type)} > {name} diff --git a/editor/components/editor/editor-pages-list/editor-pages-list.tsx b/editor/components/editor/editor-pages-list/editor-pages-list.tsx index 4e2c844e..8281b3a5 100644 --- a/editor/components/editor/editor-pages-list/editor-pages-list.tsx +++ b/editor/components/editor/editor-pages-list/editor-pages-list.tsx @@ -11,16 +11,21 @@ const Container = styled.div<{ expanded: boolean }>(({ theme, expanded }) => ({ flexDirection: "column", })); -type PageInfo = { +export type PageInfo = { id: string; name: string; - type: "design" | "components" | "styles" | "assets"; + type: "home" | "canvas" | "components" | "styles" | "assets"; }; export function EditorPagesList() { const [state] = useEditorState(); const dispatch = useDispatch(); - const pages = state.design?.pages ?? []; + const pages = [ + // default pages + { id: "home", name: "Dashboard", type: "home" }, + // design canvas pages + ...(state.design?.pages ?? []), + ]; return ( @@ -35,6 +40,7 @@ export function EditorPagesList() { return ( void; children?: ReactNode; }, - forwardedRef: any + ref: any ) { const [hovered, setHovered] = useState(false); @@ -80,7 +80,7 @@ export const PageRow = memo( return ( // @ts-ignore - ref={forwardedRef} + ref={ref} onHoverChange={handleHoverChange} selected={selected} disabled={false} diff --git a/editor/components/icons/index.ts b/editor/components/icons/index.ts new file mode 100644 index 00000000..014c265f --- /dev/null +++ b/editor/components/icons/index.ts @@ -0,0 +1 @@ +export * from "./scene-node-icon"; diff --git a/editor/components/icons/scene-node-icon.tsx b/editor/components/icons/scene-node-icon.tsx new file mode 100644 index 00000000..ffbc2ede --- /dev/null +++ b/editor/components/icons/scene-node-icon.tsx @@ -0,0 +1,59 @@ +import React from "react"; +import { + FrameIcon, + TextIcon, + GroupIcon, + ComponentInstanceIcon, + Component1Icon, + BoxIcon, + CircleIcon, + GridIcon, + StarIcon, + ImageIcon, +} from "@radix-ui/react-icons"; +import { ReflectSceneNodeType } from "@design-sdk/figma-node"; + +export function SceneNodeIcon({ + type, + color, +}: { + type: ReflectSceneNodeType; + color?: string; +}) { + switch (type as ReflectSceneNodeType) { + case ReflectSceneNodeType.group: + return ; + case ReflectSceneNodeType.component: + return ; + case ReflectSceneNodeType.instance: + return ; + case ReflectSceneNodeType.text: + return ; + case ReflectSceneNodeType.frame: + return ; + case ReflectSceneNodeType.ellipse: + return ; + case ReflectSceneNodeType.rectangle: + return ; + case ReflectSceneNodeType.variant_set: + return ; + case ReflectSceneNodeType.constraint: + return <>; + case ReflectSceneNodeType.line: + return <>; + case ReflectSceneNodeType.vector: + return <>; + case ReflectSceneNodeType.star: + return ; + case ReflectSceneNodeType.poligon: + return <>; + case ReflectSceneNodeType.boolean_operation: + return <>; + case ReflectSceneNodeType.image: + return ; + case ReflectSceneNodeType.unknown: + return <>; + default: + return <>; + } +} diff --git a/editor/components/prompt-banner-signin-to-continue/index.tsx b/editor/components/prompt-banner-signin-to-continue/index.tsx index c362e8a0..2a0e5728 100644 --- a/editor/components/prompt-banner-signin-to-continue/index.tsx +++ b/editor/components/prompt-banner-signin-to-continue/index.tsx @@ -5,7 +5,7 @@ import { useRouter } from "next/router"; const __is_dev = process.env.NODE_ENV == "development"; -export function SigninToContinueBannerPrmoptProvider({ +export function SigninToContinuePrmoptProvider({ children, }: { children: React.ReactNode; diff --git a/editor/core/actions/index.ts b/editor/core/actions/index.ts index 854cbd95..4d44c689 100644 --- a/editor/core/actions/index.ts +++ b/editor/core/actions/index.ts @@ -8,9 +8,14 @@ import type { export type WorkspaceAction = | HistoryAction - | HighlightLayerAction + | HighlightNodeAction | EditorModeAction; +/** + * actions that can be executed while workspace is being warmed up. + */ +export type WorkspaceWarmupAction = SetFigmaAuthAction | SetFigmaUserAction; + export type HistoryAction = // | { type: "undo" } @@ -19,9 +24,12 @@ export type HistoryAction = | Action; export type Action = + | SetFigmaAuthAction + | SetFigmaUserAction | PageAction | SelectNodeAction - | HighlightLayerAction + | LocateNodeAction + | HighlightNodeAction | CanvasEditAction | CanvasModeAction | PreviewAction @@ -32,18 +40,42 @@ export type Action = export type ActionType = Action["type"]; +export type SetFigmaAuthAction = { + type: "set-figma-auth"; + authentication: { + personalAccessToken?: string; + accessToken?: string; + }; +}; + +export type SetFigmaUserAction = { + type: "set-figma-user"; + user: { + id: string; + name: string; + profile: string; + }; +}; + export type EditorModeAction = EditorModeSwitchAction; export type EditorModeSwitchAction = { type: "mode"; mode: EditorState["mode"]; }; -export type HierarchyAction = SelectNodeAction; export interface SelectNodeAction { type: "select-node"; node: string | string[]; } +/** + * Select and move to the node. + */ +export interface LocateNodeAction { + type: "locate-node"; + node: string; +} + export type CanvasEditAction = TranslateNodeAction; export interface TranslateNodeAction { @@ -59,8 +91,8 @@ export interface SelectPageAction { page: string; } -export interface HighlightLayerAction { - type: "highlight-layer"; +export interface HighlightNodeAction { + type: "highlight-node"; id: string; } diff --git a/editor/core/reducers/editor-reducer.ts b/editor/core/reducers/editor-reducer.ts index 1594fd3f..b6e8d366 100644 --- a/editor/core/reducers/editor-reducer.ts +++ b/editor/core/reducers/editor-reducer.ts @@ -15,12 +15,14 @@ import type { BackgroundTaskPopAction, BackgroundTaskUpdateProgressAction, EditorModeSwitchAction, + LocateNodeAction, } from "core/actions"; import { EditorState } from "core/states"; -import { useRouter } from "next/router"; +import { NextRouter, useRouter } from "next/router"; import { CanvasStateStore } from "@code-editor/canvas/stores"; import q from "@design-sdk/query"; import assert from "assert"; +import { getPageNode } from "utils/get-target-node"; const _editor_path_name = "/files/[key]/"; @@ -45,86 +47,66 @@ export function editorReducer(state: EditorState, action: Action): EditorState { draft.mode = mode; }); } + case "select-node": { const { node } = action; - const ids = Array.isArray(node) ? node : [node]; - const current_node = state.selectedNodes; + console.clear(); + console.info("cleard console by editorReducer#select-node"); - if ( - ids.length <= 1 && - current_node.length <= 1 && - ids[0] === current_node[0] - ) { - // same selection (no selection or same 1 selection) - return produce(state, (draft) => {}); - } + // update router + update_route(router, { + node: Array.isArray(node) ? node[0] : node ?? state.selectedPage, + }); - if (ids.length > 1 && ids.length === current_node.length) { - // the selection event is always triggered by user, which means selecting same amount of nodes (greater thatn 1, and having a different node array is impossible.) - return produce(state, (draft) => {}); - } + return reducers["select-node"](state, action); + } - console.clear(); - console.info("cleard console by editorReducer#select-node"); + case "locate-node": { + const { node } = action; - const primary = ids?.[0]; + update_route(router, { node }); - // update router - router.push( - { - pathname: _editor_path_name, - query: { ...router.query, node: primary ?? state.selectedPage }, - }, - undefined, - { shallow: true } - ); + // 1. select node + // 2. select page containing the node + // 3. move canvas to the node (if page is canvas) return produce(state, (draft) => { - const _canvas_state_store = new CanvasStateStore( - filekey, - state.selectedPage - ); + const page = getPageNode(node, state); + const _1_select_node = reducers["select-node"](draft, { + node: node, + }); + const _2_select_page = reducers["select-page"](_1_select_node, { + page: page.id, + }); - const new_selections = ids.filter(Boolean); - _canvas_state_store.saveLastSelection(...new_selections); + // TODO: move canvas to the node - // assign new nodes set to the state. - draft.selectedNodes = new_selections; - - // remove the initial selection after the first interaction. - draft.selectedNodesInitial = null; + return { ..._1_select_node, ..._2_select_page }; }); } + case "select-page": { const { page } = action; console.clear(); console.info("cleard console by editorReducer#select-page"); - // update router - router.push( - { - pathname: _editor_path_name, - query: { ...router.query, node: page }, - }, - undefined, - { shallow: true } - ); + switch (page) { + case "home": { + // update router + update_route(router, { node: undefined }); + } - return produce(state, (draft) => { - const _canvas_state_store = new CanvasStateStore(filekey, page); + default: { + // update router + update_route(router, { node: page }); + } + } - const last_known_selections_of_this_page = - _canvas_state_store.getLastSelection() ?? []; - console.log( - "last_known_selections_of_this_page", - last_known_selections_of_this_page - ); - draft.selectedPage = page; - draft.selectedNodes = last_known_selections_of_this_page; - }); + return reducers["select-page"](state, action); } + case "node-transform-translate": { const { translate, node } = action; @@ -152,14 +134,11 @@ export function editorReducer(state: EditorState, action: Action): EditorState { }); // } + case "canvas-mode-switch": { const { mode } = action; - router.push({ - pathname: _editor_path_name, - query: { ...router.query, mode: mode }, - // no need to set shallow here. - }); + update_route(router, { mode: mode }, false); // shallow false return produce(state, (draft) => { if (mode === "isolated-view") { @@ -179,11 +158,7 @@ export function editorReducer(state: EditorState, action: Action): EditorState { const dest = state.canvasMode_previous ?? fallback; - router.push({ - pathname: _editor_path_name, - query: { ...router.query, mode: dest }, - // no need to set shallow here. - }); + update_route(router, { mode: dest }, false); // shallow false return produce(state, (draft) => { assert( @@ -194,6 +169,7 @@ export function editorReducer(state: EditorState, action: Action): EditorState { draft.canvasMode = dest; // previous or fallback }); } + case "preview-update-building-state": { const { isBuilding } = action; return produce(state, (draft) => { @@ -215,12 +191,14 @@ export function editorReducer(state: EditorState, action: Action): EditorState { } }); } + case "preview-set": { const { data } = action; return produce(state, (draft) => { draft.currentPreview = data; // set }); } + case "devtools-console": { const { log } = action; return produce(state, (draft) => { @@ -235,6 +213,7 @@ export function editorReducer(state: EditorState, action: Action): EditorState { }); break; } + case "devtools-console-clear": { const {} = action; return produce(state, (draft) => { @@ -250,16 +229,30 @@ export function editorReducer(state: EditorState, action: Action): EditorState { }); break; } + case "editor-task-push": { const { task } = action; const { id } = task; - // TODO: check id duplication return produce(state, (draft) => { - draft.editorTaskQueue.tasks.push(task); + // todo: + // 1. handle debounce. + + // check id duplication + const exists = draft.editorTaskQueue.tasks.find((t) => t.id === id); + if (exists) { + // pass + } else { + if (!task.createdAt) { + task.createdAt = new Date(); + } + draft.editorTaskQueue.tasks.push(task); + draft.editorTaskQueue.isBusy = true; + } }); break; } + case "editor-task-pop": { const { task } = action; const { id } = task; @@ -268,10 +261,14 @@ export function editorReducer(state: EditorState, action: Action): EditorState { draft.editorTaskQueue.tasks = draft.editorTaskQueue.tasks.filter( (i) => i.id !== id ); - // TODO: handle isBusy property by the task + + if (draft.editorTaskQueue.tasks.length === 0) { + draft.editorTaskQueue.isBusy = false; + } }); break; } + case "editor-task-update-progress": { const { id, progress } = action; return produce(state, (draft) => { @@ -280,8 +277,105 @@ export function editorReducer(state: EditorState, action: Action): EditorState { }); break; } + default: throw new Error(`Unhandled action type: ${action["type"]}`); } + return state; } + +function update_route( + router: NextRouter, + { node, mode }: { node?: string; mode?: string }, + shallow = true +) { + const q = { + node: node, + mode: mode, + }; + + // remove undefined fields + Object.keys(q).forEach((k) => q[k] === undefined && delete q[k]); + + router.push( + { + pathname: _editor_path_name, + query: { ...router.query, ...q }, + }, + undefined, + { shallow: shallow } + ); + + // router.push({ + // pathname: _editor_path_name, + // query: { ...router.query, mode: dest }, + // // no need to set shallow here. + // }); + + // router.push({ + // pathname: _editor_path_name, + // query: { ...router.query, mode: mode }, + // // no need to set shallow here. + // }); +} + +const reducers = { + "select-node": ( + state: EditorState, + action: Omit + ) => { + return produce(state, (draft) => { + const filekey = state.design.key; + + const { node } = action; + const ids = Array.isArray(node) ? node : [node]; + + const current_node = state.selectedNodes; + + if ( + ids.length <= 1 && + current_node.length <= 1 && + ids[0] === current_node[0] + ) { + // same selection (no selection or same 1 selection) + return produce(state, (draft) => {}); + } + + if (ids.length > 1 && ids.length === current_node.length) { + // the selection event is always triggered by user, which means selecting same amount of nodes (greater thatn 1, and having a different node array is impossible.) + return produce(state, (draft) => {}); + } + + const _canvas_state_store = new CanvasStateStore( + filekey, + state.selectedPage + ); + + const new_selections = ids.filter(Boolean); + _canvas_state_store.saveLastSelection(...new_selections); + + // assign new nodes set to the state. + draft.selectedNodes = new_selections; + + // remove the initial selection after the first interaction. + draft.selectedNodesInitial = null; + }); + }, + "select-page": ( + state: EditorState, + action: Omit + ) => { + return produce(state, (draft) => { + const { page } = action; + const filekey = state.design.key; + const _canvas_state_store = new CanvasStateStore(filekey, page); + + const last_known_selections_of_this_page = + _canvas_state_store.getLastSelection() ?? []; + + draft.selectedPage = page; + draft.selectedNodes = last_known_selections_of_this_page; + }); + }, +}; diff --git a/editor/core/reducers/workspace-reducer.ts b/editor/core/reducers/workspace-reducer.ts index e1765ea8..39c4db7c 100644 --- a/editor/core/reducers/workspace-reducer.ts +++ b/editor/core/reducers/workspace-reducer.ts @@ -1,6 +1,6 @@ import { WorkspaceState } from "core/states"; import produce from "immer"; -import { WorkspaceAction } from "../actions"; +import { WorkspaceAction, WorkspaceWarmupAction } from "../actions"; import { historyReducer } from "./history"; export function workspaceReducer( @@ -8,7 +8,7 @@ export function workspaceReducer( action: WorkspaceAction ): WorkspaceState { switch (action.type) { - case "highlight-layer": { + case "highlight-node": { return produce(state, (draft) => { draft.highlightedLayer = action.id; }); @@ -26,3 +26,27 @@ export function workspaceReducer( } } } + +/** + * workspace reducer that is safe to use on pending state. + * @param state + * @param action + * @returns + */ +export function workspaceWarmupReducer>( + state: T, + action: WorkspaceWarmupAction +): T { + switch (action.type) { + case "set-figma-auth": { + return produce(state, (draft) => { + draft.figmaAuthentication = action.authentication; + }); + } + case "set-figma-user": { + return produce(state, (draft) => { + draft.figmaUser = action.user; + }); + } + } +} diff --git a/editor/core/states/editor-state.ts b/editor/core/states/editor-state.ts index 8cd81a25..c9149dd6 100644 --- a/editor/core/states/editor-state.ts +++ b/editor/core/states/editor-state.ts @@ -17,10 +17,10 @@ type TCanvasMode = "free" | "isolated-view" | "fullscreen-preview"; * - code - with coding editor * - inspect - with inspector */ -type TUserTaskMode = "view" | "code" | "inspect"; +type TUserTaskMode = "view" | "comment" | "code" | "inspect"; export interface EditorState { - selectedPage: string; + selectedPage: string | "home"; selectedNodes: string[]; selectedLayersOnPreview: string[]; /** @@ -171,4 +171,5 @@ export interface EditorTask { * 0-1, if null, it is indeterminate */ progress: number | null; + createdAt?: Date; } diff --git a/editor/core/states/index.tsx b/editor/core/states/index.tsx index 32df8980..c1cd700a 100644 --- a/editor/core/states/index.tsx +++ b/editor/core/states/index.tsx @@ -1,5 +1,5 @@ export * from "./editor-state"; -export * from "./editor-context"; +export * from "./workspace-context"; export * from "./state-provider"; export * from "./workspace-state"; export * from "./history-state"; diff --git a/editor/core/states/state-provider.tsx b/editor/core/states/state-provider.tsx index c4359ff7..fa58c5fc 100644 --- a/editor/core/states/state-provider.tsx +++ b/editor/core/states/state-provider.tsx @@ -1,6 +1,6 @@ import React, { memo, ReactNode } from "react"; import { Dispatcher, DispatchContext } from "../dispatch"; -import { StateContext } from "./editor-context"; +import { StateContext } from "./workspace-context"; import { WorkspaceState } from "./workspace-state"; export const StateProvider = memo(function StateProvider({ diff --git a/editor/core/states/use-workspace-state.ts b/editor/core/states/use-workspace-state.ts index 3ba80646..6a707e47 100644 --- a/editor/core/states/use-workspace-state.ts +++ b/editor/core/states/use-workspace-state.ts @@ -1,5 +1,5 @@ import { useContext } from "react"; -import { StateContext } from "./editor-context"; +import { StateContext } from "./workspace-context"; import { WorkspaceState } from "./workspace-state"; export const useWorkspaceState = (): WorkspaceState => { diff --git a/editor/core/states/use-workspace.ts b/editor/core/states/use-workspace.ts index e99e4b85..2f0eb792 100644 --- a/editor/core/states/use-workspace.ts +++ b/editor/core/states/use-workspace.ts @@ -9,8 +9,7 @@ export function useWorkspace() { const { highlightedLayer, preferences } = state; const highlightLayer = useCallback( - (highlight?: string) => - dispatch({ type: "highlight-layer", id: highlight }), + (highlight?: string) => dispatch({ type: "highlight-node", id: highlight }), [dispatch] ); diff --git a/editor/core/states/editor-context.ts b/editor/core/states/workspace-context.ts similarity index 100% rename from editor/core/states/editor-context.ts rename to editor/core/states/workspace-context.ts diff --git a/editor/core/states/workspace-initial-state.ts b/editor/core/states/workspace-initial-state.ts index 7ea2bf63..c075f9d3 100644 --- a/editor/core/states/workspace-initial-state.ts +++ b/editor/core/states/workspace-initial-state.ts @@ -19,8 +19,12 @@ const _IS_DEV = process.env.NODE_ENV === "development"; const _ENABLE_PREVIEW_FEATURE_COMPONENTS_SUPPORT = process.env.NEXT_PUBLIC_ENABLE_PREVIEW_FEATURE_COMPONENTS_SUPPORT === "true"; -export function createInitialWorkspaceState( - editor: EditorSnapshot +/** + * this gets called when the editor snapshot is ready, returns the initial workspace state merged with editor snapshot's value. + */ +export function merge_initial_workspace_state_with_editor_snapshot( + base: Partial, + snapshot: EditorSnapshot ): WorkspaceState { const pref_store = new WorkspacePreferenceStore(); const saved_pref = pref_store.load(); @@ -34,12 +38,14 @@ export function createInitialWorkspaceState( if (!saved_pref) pref_store.set(default_pref); return { - history: createInitialHistoryState(editor), + ...base, + // below fields will be overwritten irrelevent to the existing base. + history: createInitialHistoryState(snapshot), preferences: saved_pref ?? default_pref, }; } -export function createPendingWorkspaceState(): WorkspaceState { +export function create_initial_pending_workspace_state(): WorkspaceState { return { history: createPendingHistoryState(), preferences: { diff --git a/editor/core/states/workspace-state.ts b/editor/core/states/workspace-state.ts index 7670bf27..2e65656d 100644 --- a/editor/core/states/workspace-state.ts +++ b/editor/core/states/workspace-state.ts @@ -11,13 +11,23 @@ export interface WorkspaceState { /** * figma authentication data store state - * @deprecated - not implemented */ - authenticationFigma?: { - name?: string; + figmaAuthentication?: { accessToken?: string; personalAccessToken?: string; }; + + /** + * figma user data + */ + figmaUser?: { + /** Unique stable id of the user */ + id: string; + /** Name of the user */ + name: string; + /** URL link to the user's profile image */ + profile: string; + }; } export interface WorkspacePreferences { diff --git a/editor/core/utility-types/pending-state.ts b/editor/core/utility-types/pending-state.ts index 3943d965..c74d34c1 100644 --- a/editor/core/utility-types/pending-state.ts +++ b/editor/core/utility-types/pending-state.ts @@ -1,12 +1,19 @@ +export type PendingState_Pending = { + type: "pending"; + value?: Partial; +}; + +export type PendingState_Success = { + type: "success"; + value: T; +}; + +export type PendingState_Failure = { + type: "failure"; + value?: Error; +}; + export type PendingState = - | { - type: "pending"; - } - | { - type: "success"; - value: T; - } - | { - type: "failure"; - value: Error; - }; + | PendingState_Pending + | PendingState_Success + | PendingState_Failure; diff --git a/editor/cursors/index.ts b/editor/cursors/index.ts new file mode 100644 index 00000000..13260afe --- /dev/null +++ b/editor/cursors/index.ts @@ -0,0 +1,3 @@ +export const cursors = { + comment: 'url("/cursors/comment.cur") 4 18, auto', +}; diff --git a/editor/hooks/index.ts b/editor/hooks/index.ts index 4c9f6eba..8c1b01f7 100644 --- a/editor/hooks/index.ts +++ b/editor/hooks/index.ts @@ -1,6 +1,5 @@ export * from "./use-design"; export * from "./use-async-effect"; export * from "./use-auth-state"; -export * from "./use-figma-access-token"; export * from "./use-target-node"; export * from "./use-window-size"; diff --git a/editor/hooks/use-design.ts b/editor/hooks/use-design.ts index ff2efa10..9f2bc466 100644 --- a/editor/hooks/use-design.ts +++ b/editor/hooks/use-design.ts @@ -13,11 +13,8 @@ import { FigmaRemoteErrors } from "@design-sdk/figma-remote"; import { RemoteDesignSessionCacheStore } from "../store"; import { convert } from "@design-sdk/figma-node-conversion"; import { mapper } from "@design-sdk/figma-remote"; -import { useFigmaAccessToken } from "."; -import { - FigmaDesignRepository, - TFetchFileForApp, -} from "repository/figma-design-repository"; +import { useFigmaAuth } from "scaffolds/workspace/figma-auth"; +import { FigmaDesignRepository, TFetchFileForApp } from "@editor/figma-file"; // globally configure auth credentials for interacting with `@design-sdk/figma-remote` configure_auth_credentials({ @@ -60,7 +57,7 @@ export function useDesign({ ...props }: UseDesignProp) { const [design, setDesign] = useState(null); - const fat = useFigmaAccessToken(); + const fat = useFigmaAuth(); const router = (type === "use-router" && props["router"]) ?? useRouter(); useEffect(() => { @@ -188,7 +185,7 @@ export function useDesignFile({ file }: { file: string }) { const [designfile, setDesignFile] = useState({ __type: "loading", }); - const fat = useFigmaAccessToken(); + const fat = useFigmaAuth(); useEffect(() => { if (file) { if (fat.personalAccessToken || fat.accessToken.token) { diff --git a/editor/next.config.js b/editor/next.config.js index 268b1c13..5d5b54e7 100644 --- a/editor/next.config.js +++ b/editor/next.config.js @@ -5,6 +5,7 @@ const withTM = require("next-transpile-modules")([ "@code-editor/preview-pip", // TODO: remove me. this is for development. for production, use npm ver instead. "@code-editor/devtools", "@code-editor/canvas", + "@code-editor/property", // ----------------------------- // region @designto-code diff --git a/editor/package.json b/editor/package.json index f952bb0d..562f487e 100644 --- a/editor/package.json +++ b/editor/package.json @@ -37,6 +37,7 @@ "codesandbox": "^2.2.3", "cuid": "^2.1.8", "dart-services": "^0.2.5", + "dayjs": "^1.11.6", "firebase": "^9.6.0", "framer-motion": "^5.3.1", "idb": "^6.1.2", @@ -45,13 +46,15 @@ "next": "^12.1.4", "pouchdb-adapter-idb": "^7.2.2", "re-resizable": "^6.9.1", - "react": "^18.0.0", - "react-dom": "^18.0.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-highlight-words": "^0.18.0", "react-hot-toast": "^2.4.0", "react-hotkeys-hook": "^3.4.4", "react-json-tree": "^0.15.0", "react-resizable": "^3.0.1", "react-spring": "^9.3.2", + "react-textarea-autosize": "^8.3.4", "react-use-measure": "^2.1.1", "recoil": "^0.2.0", "rxdb": "^10.5.4", @@ -63,6 +66,7 @@ "@emotion/babel-preset-css-prop": "^11.2.0", "@types/node": "^14.14.37", "@types/react": "^17.0.3", + "@types/react-highlight-words": "^0.16.4", "@types/react-resizable": "^1.7.2", "@zeit/next-css": "^1.0.1", "babel-plugin-module-resolver": "^4.1.0", @@ -71,4 +75,4 @@ "raw-loader": "^4.0.2", "typescript": "^4.2.3" } -} +} \ No newline at end of file diff --git a/editor/pages/files/[key]/index.tsx b/editor/pages/files/[key]/index.tsx index 3aa9f4d5..a6f01dfe 100644 --- a/editor/pages/files/[key]/index.tsx +++ b/editor/pages/files/[key]/index.tsx @@ -1,261 +1,45 @@ -import React, { useEffect, useCallback, useReducer, useState } from "react"; -import { useRouter, NextRouter } from "next/router"; -import { SigninToContinueBannerPrmoptProvider } from "components/prompt-banner-signin-to-continue"; -import { Editor, EditorDefaultProviders } from "scaffolds/editor"; -import { EditorSnapshot, StateProvider } from "core/states"; -import { WorkspaceAction } from "core/actions"; -import { useDesignFile, TUseDesignFile } from "hooks"; -import { warmup } from "scaffolds/editor"; -import { EditorBrowserMetaHead } from "components/editor"; -import type { FileResponse } from "@design-sdk/figma-remote-types"; +import React, { useEffect, useState } from "react"; +import { useRouter } from "next/router"; +import { SigninToContinuePrmoptProvider } from "components/prompt-banner-signin-to-continue"; +import { Editor, SetupEditor } from "scaffolds/editor"; +import { Workspace } from "scaffolds/workspace/workspace"; export default function FileEntryEditor() { const router = useRouter(); + const nodeid = useNodeID(); const { key } = router.query; - const [nodeid, setNodeid] = useState(); const filekey = key as string; - // const nodeid = node as string; - // background whole file fetching - const file = useDesignFile({ file: filekey }); + return ( + + + + + + + + ); +} +/** + * use target node id from query params. + */ +function useNodeID() { + const router = useRouter(); + const [nodeid, setNodeid] = useState(); useEffect(() => { if (!router.isReady) return; if (!nodeid) { // set nodeid only first time setNodeid(router.query.node as string); - console.log("nodeid set", router.query.node); } }, [router.isReady]); - - return ( - - - - ); + return nodeid; } - -const action_fetchfile_id = "fetchfile"; - -function SetupEditor({ - filekey, - nodeid, - router, - file, -}: { - nodeid: string; - filekey: string; - router: NextRouter; - file: TUseDesignFile; -}) { - const [loading, setLoading] = useState(true); - - const [initialState, initialDispatcher] = useReducer(warmup.initialReducer, { - type: "pending", - }); - - const handleDispatch = useCallback((action: WorkspaceAction) => { - initialDispatcher({ type: "update", value: action }); - }, []); - - const initialCanvasMode = q_map_canvas_mode_from_query( - router.query.mode as string - ); // TODO: change this to reflect the nodeid input - - const initWith = (file: FileResponse) => { - const prevstate = - initialState.type == "success" && initialState.value.history.present; - - let val: EditorSnapshot; - - // TODO: seed this as well - // ->> file.styles; - - const components = warmup.componentsFrom(file); - const pages = warmup.pagesFrom(filekey, file); - - if (prevstate) { - val = { - ...prevstate, - design: { - ...prevstate.design, - pages: pages, - }, - selectedPage: warmup.selectedPage( - prevstate, - pages, - prevstate.selectedNodes - ), - }; - } else { - const initialSelections = - // set selected nodes initially only if the nodeid is the id of non-page node - pages.some((p) => p.id === nodeid) ? [] : nodeid ? [nodeid] : []; - - val = { - selectedNodes: initialSelections, - selectedNodesInitial: initialSelections, - selectedPage: warmup.selectedPage(prevstate, pages, nodeid && [nodeid]), - selectedLayersOnPreview: [], - design: { - name: file.name, - input: null, - components: components, - // styles: null, - key: filekey, - pages: pages, - }, - canvasMode: initialCanvasMode, - editorTaskQueue: { - isBusy: true, - tasks: [ - { - id: action_fetchfile_id, - name: "Figma File", - description: "Refreshing remote file", - progress: null, - }, - ], - }, - }; - } - - initialDispatcher({ - type: "set", - value: val, - }); - }; - - useEffect(() => { - if (!loading) { - return; - } - - if (file.__type === "loading") { - return; - } - - if (file.__type === "error") { - // handle error by reason - switch (file.reason) { - case "unauthorized": - case "no-auth": { - if (file.cached) { - initWith(file.cached); - setLoading(false); - alert( - "You will now see the cached version of this file. To view the latest version, setup your personall access token." - ); - // TODO: show signin prompt - window.open("/preferences/access-tokens", "_blank"); - } else { - router.push("/preferences/access-tokens"); - } - break; - } - case "no-file": { - // ignore. might still be fetching file from query param. - break; - } - } - return; - } - - if (!file.__initial) { - // when full file is loaded, allow editor with user interaction. - setLoading(false); - } - - initWith(file); - }, [ - filekey, - file, - file.__type == "file-fetched-for-app" ? file.document?.children : null, - ]); - - const safe_value = warmup.safestate(initialState); - - return ( - - - - - - - - ); -} - -/** - * TODO: support single design fetching - // if target node is provided from query, use it. - const design = useDesign({ - type: "use-file-node-id", - file: filekey, - node: nodeid, - }); - - - useEffect(() => { - if (!loading) { - // if already loaded, ignore target node change. - return; - } - if (design) { - if (initialState.type === "success") return; - initialDispatcher({ - type: "set", - value: warmup.initializeDesign(design), - }); - } - }, [design, router.query, loading]); - - // under main hook - if (design) { - const initialState = warmup.initializeDesign(design); - val = { - ...initialState, - design: { - ...initialState.design, - pages: pages, - }, - selectedPage: warmup.selectedPage( - prevstate, - pages, - initialState.selectedNodes - ), - }; - } else { - - } - */ - -const q_map_canvas_mode_from_query = ( - mode: string -): EditorSnapshot["canvasMode"] => { - switch (mode) { - case "free": - case "isolated-view": - case "fullscreen-preview": - return mode; - - // ------------------------- - // legacy query param key - case "full": - return "free"; - case "isolate": - return "isolated-view"; - // ------------------------- - - default: - return "free"; - } -}; diff --git a/editor/pages/files/index.tsx b/editor/pages/files/index.tsx index 4ccae5ee..e806557c 100644 --- a/editor/pages/files/index.tsx +++ b/editor/pages/files/index.tsx @@ -8,7 +8,7 @@ import { HomeSidebar, } from "components/home"; import { WorkspaceRepository } from "repository"; -import { FileResponseRecord } from "@editor/figma-file-store"; +import { FileResponseRecord } from "@editor/figma-file"; import { colors } from "theme"; export default function FilesPage() { diff --git a/editor/public/cursors/comment.cur b/editor/public/cursors/comment.cur new file mode 100644 index 00000000..942d329e Binary files /dev/null and b/editor/public/cursors/comment.cur differ diff --git a/editor/repository/workspace-repository/index.ts b/editor/repository/workspace-repository/index.ts index 98bb2503..2ac02673 100644 --- a/editor/repository/workspace-repository/index.ts +++ b/editor/repository/workspace-repository/index.ts @@ -1,11 +1,10 @@ -import { FigmaFilesStore, FileResponseRecord } from "@editor/figma-file-store"; +import { FigmaFileMetaStore, FileMetaRecord } from "@editor/figma-file"; -export type LastUsedFileDisplay = FileResponseRecord & { type: "file" } & { - lastUsed: Date; -}; +export type LastUsedFileDisplay = FileMetaRecord & { type: "file" }; export type LastusedDisplayType = LastUsedFileDisplay; export class WorkspaceRepository { + private metastore = new FigmaFileMetaStore(); constructor() {} async getRecents({ count = 4 }: { count?: number }) { @@ -30,7 +29,7 @@ export class WorkspaceRepository { } async getFiles() { - return FigmaFilesStore.all(); + return this.metastore.all(); } async getRecentScenes() { diff --git a/editor/scaffolds/appbar/index.tsx b/editor/scaffolds/appbar/index.tsx index ef012673..54c660df 100644 --- a/editor/scaffolds/appbar/index.tsx +++ b/editor/scaffolds/appbar/index.tsx @@ -16,7 +16,7 @@ export function Appbar() { flexDirection: "row", }} > - + ); } diff --git a/editor/scaffolds/canvas/canvas.tsx b/editor/scaffolds/canvas/canvas.tsx index dbe0cf29..b8e5613e 100644 --- a/editor/scaffolds/canvas/canvas.tsx +++ b/editor/scaffolds/canvas/canvas.tsx @@ -3,8 +3,8 @@ import styled from "@emotion/styled"; import { Canvas } from "@code-editor/canvas"; import { useEditorState, useWorkspace } from "core/states"; import { - WebWorkerD2CVanillaPreview, D2CVanillaPreview, + OptimizedPreviewCanvas, } from "scaffolds/preview-canvas"; import useMeasure from "react-use-measure"; import { useDispatch } from "core/dispatch"; @@ -12,6 +12,7 @@ import { FrameTitleRenderer } from "./render/frame-title"; import { IsolateModeCanvas } from "./isolate-mode"; import { Dialog } from "@mui/material"; import { FullScreenPreview } from "scaffolds/preview-full-screen"; +import { cursors } from "cursors"; /** * Statefull canvas segment that contains canvas as a child, with state-data connected. @@ -78,6 +79,8 @@ export function VisualContentArea() { thisPage.backgroundColor.g * 255 }, ${thisPage.backgroundColor.b * 255}, ${thisPage.backgroundColor.a})`; + const cursor = state.mode === "comment" ? cursors.comment : "default"; + return ( {/* */} @@ -144,7 +147,12 @@ export function VisualContentArea() { // target={p.node} // {...p} // /> - + // + ); }} // readonly={false} @@ -158,6 +166,7 @@ export function VisualContentArea() { disabled: false, }, }} + cursor={cursor} renderFrameTitle={(p) => ( ((resolve) => { + bus.once("initialized", () => { + resolve(); + }); +}); + +export function initialize( + { filekey, authentication }: { filekey: string; authentication }, + onReady: () => void +) { + console.info("initializing.. code ww"); + + const __onready = () => { + bus.emit("initialized"); + onReady(); + }; + + if (!previewworker) { + // initialize the worker and set the preferences. + const { worker } = createWorkerQueue( + new Worker(new URL("./workers/code.worker.js", import.meta.url)) + ); + + previewworker = worker; + } + + previewworker.postMessage({ + $type: "initialize", + filekey, + authentication, + }); + + previewworker.addEventListener("message", (e) => { + if (e.data.$type === "data-ready") { + __onready(); + } + }); + + return () => { + if (previewworker) { + previewworker.terminate(); + } + }; +} + +export function preview( + { target, page }: { target: string; page: string }, + onResult: (result: Result) => void, + onError?: (error: Error) => void +) { + previewworker.postMessage({ + $type: "preview", + page, + target, + }); + + const handler = (e) => { + const id = e.data.id; + if (target === id) { + switch (e.data.$type) { + case "result": + onResult(e.data); + break; + case "error": + onError(new Error(e.data.message)); + break; + } + } + }; + + previewworker.addEventListener("message", handler); + + return () => { + previewworker.removeEventListener("message", handler); + }; +} + +export async function code({ + target, + framework, +}: { + target: string; + framework: config.FrameworkConfig; +}): Promise { + return new Promise((resolve, reject) => { + const handler = (e) => { + const id = e.data.id; + if (target === id) { + switch (e.data.$type) { + case "result": + resolve(e.data); + break; + case "error": + reject(new Error(e.data.message)); + break; + } + previewworker.removeEventListener("message", handler); + } + }; + + previewworker.addEventListener("message", handler); + + initialized.then(() => { + previewworker.postMessage({ + $type: "code", + target, + framework, + }); + }); + }); +} diff --git a/editor/scaffolds/preview-canvas/workers/canvas-preview.worker.js b/editor/scaffolds/code/workers/code.worker.js similarity index 57% rename from editor/scaffolds/preview-canvas/workers/canvas-preview.worker.js rename to editor/scaffolds/code/workers/code.worker.js index 46157bc1..f12db0ec 100644 --- a/editor/scaffolds/preview-canvas/workers/canvas-preview.worker.js +++ b/editor/scaffolds/code/workers/code.worker.js @@ -6,14 +6,17 @@ import { import { RemoteImageRepositories } from "@design-sdk/figma-remote/asset-repository"; import { config } from "@grida/builder-config"; import { preview_presets } from "@grida/builder-config-preset"; -import { FigmaFileStore } from "@editor/figma-file-store"; +import { FigmaFileStore } from "@editor/figma-file"; import { convert } from "@design-sdk/figma-node-conversion"; import { mapper } from "@design-sdk/figma-remote"; +import q from "@design-sdk/query"; const placeholderimg = "https://bridged-service-static.s3.us-west-1.amazonaws.com/placeholder-images/image-placeholder-bw-tile-100.png"; -// : config.BuildConfiguration +/** + * @type {config.BuildConfiguration} + */ const build_config = { ...config.default_build_configuration, disable_components: true, @@ -21,29 +24,10 @@ const build_config = { disable_flags_support: true, }; -// : config.VanillaPreviewFrameworkConfig -const framework_config = { - ...preview_presets.default, - additional_css_declaration: { - declarations: [ - { - key: { - name: "body", - selector: "tag", - }, - style: { - contain: "layout style paint", - }, - }, - ], - }, -}; - let initialized = false; let pages = []; function initialize({ filekey, authentication }) { - console.info("initializing.. wwpreview"); // ------- setup image repo with auth + filekey ------- MainImageRepository.instance = new RemoteImageRepositories(filekey, { authentication: authentication, @@ -83,12 +67,13 @@ function pagesFrom(file) { }), flowStartingPoints: page.flowStartingPoints, backgroundColor: page.backgroundColor, - type: "design", + type: "canvas", })); } addEventListener("message", async (event) => { function respond(data) { + data = serialize(data); setTimeout(() => { postMessage({ $type: "result", ...data }); }, 0); @@ -112,20 +97,61 @@ addEventListener("message", async (event) => { break; } case "preview": { - try { - // requires all 2 data for faster query - const { page, target } = event.data; - const node = pages - .find((p) => p.id === page) - .children.find((c) => c.id === target); - - const _input = { - id: node.id, - name: node.name, - entry: node, - }; - - const result = await designToCode({ + respond(await handle_preview(event, "without-asset")); + respond(await handle_preview(event, "with-asset")); + break; + } + case "code": { + handle_code(event, "without-asset").then(respond); + // handle_code(event, "with-asset").then(respond); + break; + } + } +}); + +function serialize(result) { + // the widget is a class with a recursive-referencing property, so it shall be removed before posting back to the client. + delete result["widget"]; + return result; +} + +async function handle_preview(event, type) { + /** + * @type {config.VanillaPreviewFrameworkConfig} + */ + const framework_config = { + ...preview_presets.default, + additional_css_declaration: { + declarations: [ + { + key: { + name: "body", + selector: "tag", + }, + style: { + contain: "layout style paint", + }, + }, + ], + }, + }; + + try { + // requires all 2 data for faster query + const { page, target } = event.data; + const node = pages + .find((p) => p.id === page) + .children.find((c) => c.id === target); + + const _input = { + id: node.id, + name: node.name, + entry: node, + }; + + switch (type) { + case "without-asset": { + return await designToCode({ input: _input, build_config: build_config, framework: framework_config, @@ -138,10 +164,9 @@ addEventListener("message", async (event) => { }, }, }); - - respond(result); - - const result_w_img = await designToCode({ + } + case "with-asset": { + return await designToCode({ input: _input, build_config: build_config, framework: framework_config, @@ -149,13 +174,50 @@ addEventListener("message", async (event) => { asset_repository: MainImageRepository.instance, }, }); - - respond(result_w_img); - } catch (error) { - // respond({ error }); } + } + } catch (error) { + // respond({ error }); + } +} - break; +async function handle_code(event, type) { + const { framework: framework_config } = event.data; + + // requires all 2 data for faster query + const { target } = event.data; + const node = q.getNodeByIdFrom(target, pages); + + if (!node) { + throw new Error("node not found"); + } + + const _input = { + id: node.id, + name: node.name, + entry: node, + }; + + switch (type) { + case "without-asset": { + return await designToCode({ + input: _input, + build_config: build_config, + framework: framework_config, + asset_config: { + skip_asset_replacement: true, + }, + }); + } + case "with-asset": { + return await designToCode({ + input: _input, + build_config: build_config, + framework: framework_config, + asset_config: { + asset_repository: MainImageRepository.instance, + }, + }); } } -}); +} diff --git a/editor/scaffolds/conversations/comment-reaction.tsx b/editor/scaffolds/conversations/comment-reaction.tsx new file mode 100644 index 00000000..10376e18 --- /dev/null +++ b/editor/scaffolds/conversations/comment-reaction.tsx @@ -0,0 +1,49 @@ +import React from "react"; +import styled from "@emotion/styled"; +import type { User } from "@design-sdk/figma-remote-api"; +import type { ReactionEmoji } from "services/figma-comments-service"; +import { emojimap } from "./k"; + +export function Reaction({ + users, + emoji, + selected, + onClick, +}: { + selected?: boolean; + users: Array; + emoji: ReactionEmoji; + onClick?: () => void; +}) { + return ( + + {emojimap[emoji]} + + + ); +} + +const Emoji = styled.span` + cursor: default; + font-size: 16px; + border-radius: 16px; + padding: 4px 6px; + height: 21px; + background: rgba(255, 255, 255, 0.1); + text-align: center; + display: flex; + align-items: center; + justify-content: center; + + &[data-selected="true"] { + background: rgba(0, 0, 0, 0.8); + outline: 1px solid rgba(255, 255, 255, 0.5); + } + + label { + opacity: 0.8; + font-size: 10px; + color: white; + margin-left: 4px; + } +`; diff --git a/editor/scaffolds/conversations/comment.tsx b/editor/scaffolds/conversations/comment.tsx new file mode 100644 index 00000000..6b75f33b --- /dev/null +++ b/editor/scaffolds/conversations/comment.tsx @@ -0,0 +1,449 @@ +import React from "react"; +import styled from "@emotion/styled"; +import type { User } from "@design-sdk/figma-remote-api"; +import type { + Comment, + Reply, + ReactionEmoji, + Reactions, +} from "services/figma-comments-service"; +import dayjs from "dayjs"; +import relativeTime from "dayjs/plugin/relativeTime"; +import { MessageInput } from "./message-input"; +import { DotsVerticalIcon } from "@radix-ui/react-icons"; +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuContent, +} from "@editor-ui/dropdown-menu"; +import { Reaction } from "./comment-reaction"; +import { emojimap } from "./k"; + +dayjs.extend(relativeTime); + +type CommentProps = { + onDelete?: (id: string) => void; + onReaction?: (id: string, emoji: ReactionEmoji) => void; + onCopyLink?: (id: string) => void; +}; + +type ThreadProps = CommentProps & { + onReply?: (reply: string) => void; +}; + +type MeProps = { + me?: { id: string; name: string; profile: string }; +}; + +export function TopLevelComment({ + me, + user, + message, + client_meta, + order_id, + created_at, + resolved_at, + replies, + reactions, + id, + readonly, + file_key, + parent_id, + ...props +}: Comment & { readonly?: boolean } & ThreadProps & MeProps) { + const ReplyMessageBox = () => { + if (readonly === false) { + return ( + { + (props as ThreadProps).onReply(text); + }} + /> + ); + } + return <>; + }; + + const ReadonlyFalseCommentMenus = () => { + if (readonly === false) { + const { onCopyLink, onDelete, onReaction } = props as ThreadProps; + return ( +
+ { + onCopyLink(id); + }} + onDeleteClick={() => { + onDelete(id); + }} + onReactionClick={(emoji) => { + onReaction(id, emoji); + }} + /> +
+ ); + } + return <>; + }; + + const reactions_by_emoji = map_reactions_for_display(reactions, me.id); + + return ( + + + #{order_id} + + +
+ {user.handle} + + {dayjs(created_at).fromNow()} +
+
+ {message} + + {reactions_by_emoji.map((r, i) => { + return ( + { + props.onReaction(id, r.emoji); + }} + /> + ); + })} + + + {replies.map((r, i) => { + return ( + + ); + })} + + +
+ ); +} + +/** + * map array of reactions by same emojis and count. + */ +const map_reactions_for_display = ( + reactions: Reactions, + me: string +): Array<{ emoji: ReactionEmoji; users: User[]; selected: boolean }> => { + return reactions.reduce((acc, r) => { + const { user, emoji } = r; + const i = acc.findIndex((r) => r.emoji === emoji); + + if (i > -1) { + acc[i].users.push(user); + acc[i].selected = acc[i].selected || user.id === me; + } else { + acc.push({ + emoji: emoji, + users: [user], + selected: user.id === me, + }); + } + return acc; + }, []); +}; + +const ThreadUserDisplay = styled.div` + margin-top: 8px; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; +`; + +const TopLevelCommentContainer = styled.div` + position: relative; + display: flex; + flex-direction: column; + padding: 16px; + border-radius: 4px; + + &:hover { + background: rgba(0, 0, 0, 0.2); + } + + border-top: 1px solid rgba(255, 255, 255, 0.1); + + .hover-menus { + pointer-events: none; + position: absolute; + opacity: 0; + margin: -12px 12px 0 0; + top: 0; + right: 0; + } + + &:hover { + .hover-menus { + pointer-events: auto; + opacity: 1; + } + } +`; + +const quick_reactions = [":eyes:", ":+1:", ":fire:"] as const; + +function CommentMenus({ + disableDelete, + onReactionClick, + onDeleteClick, + onCopyLinkClick, +}: { + disableDelete: boolean; + onCopyLinkClick: () => void; + onDeleteClick: () => void; + onReactionClick: (emoji: ReactionEmoji) => void; +}) { + return ( + + {quick_reactions.map((r, i) => { + return ( + { + onReactionClick?.(r); + }} + > + {emojimap[r]} + + ); + })} + + + + + + + {!disableDelete && ( + + + Delete message + + + )} + + Copy link + + + + + + ); +} + +const MenusContainer = styled.div` + display: flex; + gap: 4px; + padding: 2px; + background: rgba(0, 0, 0, 0.8); + border-radius: 4px; + outline: 1px solid rgba(255, 255, 255, 0.1); +`; + +const MenuItem = styled.span` + cursor: pointer; + display: flex; + font-size: 12px; + width: 16px; + height: 16px; + align-items: center; + justify-content: center; + padding: 4px; + :hover { + background: rgba(255, 255, 255, 0.1); + } + + button { + background: transparent; + outline: none; + border: none; + } +`; + +const UserLabel = styled.label` + font-size: 12px; + font-weight: 500; + color: rgba(255, 255, 255, 0.5); +`; + +const DateMeta = styled.label` + font-size: 12px; + font-weight: 400; + color: rgba(255, 255, 255, 0.5); +`; + +const ThreadNumber = styled.label` + font-size: 14px; + color: rgba(255, 255, 255, 0.8); +`; + +const Message = styled.p` + font-size: 12px; + color: rgba(255, 255, 255, 0.8); +`; + +const RepliesContainer = styled.div` + display: flex; + flex-direction: column; + align-items: stretch; + gap: 4px; +`; + +function UserAvatar({ + handle, + img_url, + from, + id, + size, +}: User & { + size?: number; + from: "figma" | "grida"; +}) { + return ( + + + + ); +} + +const AvatarContainer = styled.div` + border-radius: 50%; + overflow: hidden; + width: 32px; + height: 32px; + background-color: grey; + + img { + width: 100%; + height: 100%; + } +`; + +const ReactionsContainer = styled.div` + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + margin-top: 4px; + margin-bottom: 16px; +`; + +function ThreadReplyComment({ + id, + me, + user, + message, + created_at, + reactions, + readonly, + order_id, + ...props +}: Reply & CommentProps & { readonly?: boolean } & MeProps) { + const NoReadonlyMenus = () => { + if (!readonly) { + const { onReaction, onDelete, onCopyLink } = props as CommentProps; + return ( +
+ { + onCopyLink(id); + }} + onDeleteClick={() => { + onDelete(id); + }} + onReactionClick={(emoji: ReactionEmoji) => { + onReaction(id, emoji); + }} + /> +
+ ); + } + return <>; + }; + + const reactions_by_emoji = map_reactions_for_display(reactions, me.id); + + return ( + + + + + {user.handle} + + {dayjs(created_at).fromNow()} + + {message} + + {reactions_by_emoji.map((r, i) => { + return ( + { + props.onReaction(id, r.emoji); + }} + /> + ); + })} + + + ); +} + +const ReplyCommentContainer = styled.div` + position: relative; + border-radius: 2px; + padding: 8px; + &:hover { + background: rgba(0, 0, 0, 0.2); + } + + .reply-hover-menus { + pointer-events: none; + position: absolute; + opacity: 0; + top: 0; + right: 0; + margin: -12px -6px 0 0; + } + + &:hover { + .reply-hover-menus { + pointer-events: auto; + opacity: 1; + } + } +`; + +const ReplyUserDisplay = styled.div` + display: flex; + flex-direction: row; + align-items: center; + gap: 4px; +`; diff --git a/editor/scaffolds/conversations/conversations.tsx b/editor/scaffolds/conversations/conversations.tsx new file mode 100644 index 00000000..8d62b0aa --- /dev/null +++ b/editor/scaffolds/conversations/conversations.tsx @@ -0,0 +1,68 @@ +import React from "react"; + +import { useEditorState, useWorkspaceState } from "core/states"; +import styled from "@emotion/styled"; + +import { useFigmaComments } from "services/figma-comments-service"; +import { TopLevelComment } from "./comment"; +import { copy } from "utils/clipboard"; + +export function Conversations() { + const wssate = useWorkspaceState(); + const [state] = useEditorState(); + const filekey = state.design?.key; + + const [comments, dispatch] = useFigmaComments( + filekey, + wssate.figmaAuthentication + ); + + const me = wssate.figmaUser; + + return ( + <> + + {comments.map((c) => { + return ( + { + dispatch({ + type: "post", + message, + comment_id: c.id, + me: me.id, + }); + }} + onCopyLink={(id) => { + const url = `https://www.figma.com/file/${filekey}?#${id}`; + copy(url, { notify: true }); + }} + onDelete={(id) => { + dispatch({ type: "delete", comment_id: id, me: me.id }); + }} + onReaction={(id, emoji) => { + dispatch({ type: "react", comment_id: id, emoji, me: me.id }); + }} + /> + ); + })} + + + ); +} + +const CommentsListContainer = styled.div` + margin-top: 24px; + display: flex; + flex-direction: column; + align-items: stretch; + width: 100%; + + div:first-of-type { + border-top: none; + } +`; diff --git a/editor/scaffolds/conversations/index.ts b/editor/scaffolds/conversations/index.ts new file mode 100644 index 00000000..16cca6d1 --- /dev/null +++ b/editor/scaffolds/conversations/index.ts @@ -0,0 +1 @@ +export { Conversations } from "./conversations"; diff --git a/editor/scaffolds/conversations/k.ts b/editor/scaffolds/conversations/k.ts new file mode 100644 index 00000000..4ce911e0 --- /dev/null +++ b/editor/scaffolds/conversations/k.ts @@ -0,0 +1,9 @@ +export const emojimap = { + ":eyes:": "👀", + ":heart_eyes:": "😍", + ":heavy_plus_sign:": "➕", + ":+1:": "👍", + ":-1:": "👎", + ":joy:": "😂", + ":fire:": "🔥", +} as const; diff --git a/editor/scaffolds/conversations/message-input.tsx b/editor/scaffolds/conversations/message-input.tsx new file mode 100644 index 00000000..bd8c9ace --- /dev/null +++ b/editor/scaffolds/conversations/message-input.tsx @@ -0,0 +1,95 @@ +import React from "react"; +import styled from "@emotion/styled"; +import { Send } from "@mui/icons-material"; +import TextareaAutosize from "react-textarea-autosize"; + +export function MessageInput({ + placeholder, + onSend, +}: { + placeholder?: string; + onSend: (text: string) => void; +}) { + const [text, setText] = React.useState(""); + + const onkeydown = (e: React.KeyboardEvent) => { + // ctr + enter or cmd + enter + if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { + onSend(text); + setText(""); + } + }; + + return ( + + { + setText(e.target.value); + }} + /> + { + onSend(text); + setText(""); + }} + > + + + + ); +} + +const MessageInputContainer = styled.div` + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; +`; + +const MessageInputField = styled(TextareaAutosize)` + flex: 1; + border: none; + outline: none; + border-radius: 2px; + color: white; + font-size: 12px; + padding: 12px; + background-color: transparent; + resize: none; + font-family: sans-serif; + + &:hover { + background-color: rgba(255, 255, 255, 0.1); + } + &:focus { + outline: 1px solid rgba(255, 255, 255, 0.2); + background-color: rgba(255, 255, 255, 0.1); + } +`; + +const MessageInputButton = styled.button` + border: none; + outline: none; + background-color: transparent; + color: white; + padding: 8px; + cursor: pointer; + + svg { + color: rgba(255, 255, 255, 0.5); + } + &:hover { + svg { + color: rgba(255, 255, 255, 0.8); + } + } +`; diff --git a/editor/scaffolds/conversations/readme.md b/editor/scaffolds/conversations/readme.md new file mode 100644 index 00000000..dc385a55 --- /dev/null +++ b/editor/scaffolds/conversations/readme.md @@ -0,0 +1,6 @@ +# Conversations + +- comments +- issues +- threads +- messages diff --git a/editor/scaffolds/editor-home/editor-home-header.tsx b/editor/scaffolds/editor-home/editor-home-header.tsx new file mode 100644 index 00000000..bee374f8 --- /dev/null +++ b/editor/scaffolds/editor-home/editor-home-header.tsx @@ -0,0 +1,58 @@ +import styled from "@emotion/styled"; +import React from "react"; + +export function EditorHomeHeader({ + onQueryChange, +}: { + onQueryChange?: (query: string) => void; +}) { + return ( + + + { + onQueryChange?.(e.target.value); + }} + /> + + + ); +} + +const EditorHomeHeaderWrapper = styled.div` + position: fixed; + display: flex; + flex-direction: column; + flex: 1; + width: 100%; + overflow: hidden; + padding: 24px 40px; + z-index: 9; + flex: 1; + background-color: rgba(30, 30, 30, 0.8); + backdrop-filter: blur(40px); +`; + +const SearchBar = styled.div` + display: flex; + flex-direction: row; + + input { + background: transparent; + color: rgba(255, 255, 255, 0.8); + width: 620px; + border: 1px solid #eaeaea; + border-radius: 4px; + padding: 8px; + font-size: 14px; + outline: none; + border: 1px solid rgba(255, 255, 255, 0.05); + + &:focus, + &:hover { + border: 1px solid rgba(255, 255, 255, 0.4); + } + } +`; diff --git a/editor/scaffolds/editor-home/editor-home.tsx b/editor/scaffolds/editor-home/editor-home.tsx new file mode 100644 index 00000000..20c67fa1 --- /dev/null +++ b/editor/scaffolds/editor-home/editor-home.tsx @@ -0,0 +1,134 @@ +import React, { useState } from "react"; +import styled from "@emotion/styled"; +import type { ReflectSceneNode } from "@design-sdk/figma-node"; +import { useEditorState } from "core/states"; +import { useDispatch } from "core/dispatch"; +import { SceneCard } from "./scene-card"; +import { EditorHomeHeader } from "./editor-home-header"; + +export function EditorHomePageView() { + const [state] = useEditorState(); + const { design, selectedNodes } = state; + const dispatch = useDispatch(); + const [query, setQuery] = useState(null); + + const scenes: ReadonlyArray = design.pages + .reduce((acc, page) => { + return acc.concat(page.children); + }, []) + .filter(Boolean) + // query by name first, since it's more efficient + .filter((s) => s.name.toLowerCase().includes(query?.toLowerCase() || "")) + .filter( + (s: ReflectSceneNode) => + (s.origin === "FRAME" || + s.origin === "COMPONENT" || + s.origin === "COMPONENT_SET") && + s.visible && + s.children.length > 0 + ); + + const components = Object.values(design.components) + .filter(Boolean) + // query by name first, since it's more efficient + .filter((s) => s.name.toLowerCase().includes(query?.toLowerCase() || "")); + + const handleQuery = (query: string) => { + setQuery(query); + }; + + return ( + <> + +
+ Scenes + { + dispatch({ + type: "select-node", + node: null, + }); + }} + > + {scenes.map((s) => { + return ( + { + dispatch({ + type: "select-node", + node: s.id, + }); + e.stopPropagation(); + }} + onDoubleClick={() => { + dispatch({ + type: "locate-node", + node: s.id, + }); + dispatch({ + type: "mode", + mode: "code", + }); + }} + /> + ); + })} + + Components + + {components.map((cmp) => ( + { + dispatch({ + type: "select-node", + node: cmp.id, + }); + e.stopPropagation(); + }} + onDoubleClick={() => { + dispatch({ + type: "locate-node", + node: cmp.id, + }); + dispatch({ + type: "mode", + mode: "code", + }); + }} + /> + ))} + +
+ + ); +} + +const SceneGrid = styled.div` + display: flex; + flex-direction: row; + align-items: flex-start; + flex-wrap: wrap; + gap: 40px; +`; + +const SectionLabel = styled.label` + display: inline-block; + color: white; + font-size: 14px; + font-weight: 500; + margin-bottom: 16px; +`; diff --git a/editor/scaffolds/editor-home/index.ts b/editor/scaffolds/editor-home/index.ts new file mode 100644 index 00000000..6565f215 --- /dev/null +++ b/editor/scaffolds/editor-home/index.ts @@ -0,0 +1 @@ +export { EditorHomePageView as EditorHome } from "./editor-home"; diff --git a/editor/scaffolds/editor-home/scene-card.tsx b/editor/scaffolds/editor-home/scene-card.tsx new file mode 100644 index 00000000..a9d2885e --- /dev/null +++ b/editor/scaffolds/editor-home/scene-card.tsx @@ -0,0 +1,121 @@ +import React from "react"; +import styled from "@emotion/styled"; +import type { ReflectSceneNode } from "@design-sdk/figma-node"; +import { FigmaStaticImageFrameView } from "scaffolds/preview-canvas"; +import { SceneNodeIcon } from "components/icons"; +import Highlighter from "react-highlight-words"; + +export function SceneCard({ + scene, + selected, + onClick, + onDoubleClick, + q, +}: { + scene: ReflectSceneNode; + selected?: boolean; + onClick?: (e) => void; + onDoubleClick?: () => void; + q?: string; +}) { + const maxwidth = 300; + // max allowed zoom = 1 + const scale = Math.min(maxwidth / scene.width, 1); + const { height, type } = scene; + return ( +
+ {/* sizer */} + + + {/* transformer */} +
+ +
+
+
+ +
+
+ ); +} + +const Preview = styled.div` + outline: 1px solid rgba(0, 0, 0, 0.1); + border-radius: 2px; + overflow: hidden; + overflow: hidden; + box-sizing: border-box; + + #overlay { + display: none; + z-index: 99; + position: absolute; + width: inherit; + height: inherit; + background: rgba(0, 0, 255, 0.1); + } + + &[data-selected="true"] { + outline: 4px solid rgb(0, 179, 255); + + #overlay { + display: block; + } + } +`; + +const Label = styled.span` + padding: 16px 0; + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + + .name { + font-size: 12px; + font-weight: 500; + color: white; + mark { + background: white; + color: black; + } + } +`; diff --git a/editor/scaffolds/editor/_providers.tsx b/editor/scaffolds/editor/_providers.tsx index 76c5a41d..bff336e0 100644 --- a/editor/scaffolds/editor/_providers.tsx +++ b/editor/scaffolds/editor/_providers.tsx @@ -1,55 +1,26 @@ import React from "react"; -import { useHotkeys } from "react-hotkeys-hook"; +import { EditorShortcutsProvider } from "./editor-shortcuts-provider"; import { EditorImageRepositoryProvider } from "./editor-image-repository-provider"; import { EditorPreviewDataProvider } from "./editor-preview-provider"; -import { EditorCanvasPreviewProvider } from "scaffolds/preview-canvas/editor-canvas-preview-provider"; +import { EditorCodeWebworkerProvider } from "scaffolds/editor/editor-code-webworker-provider"; import { ToastProvider } from "./editor-toast-provider"; +import { FigmaImageServiceProvider } from "./editor-figma-image-service-provider"; +import { useEditorState } from "core/states"; export function EditorDefaultProviders(props: { children: React.ReactNode }) { + const [state] = useEditorState(); + return ( - - - - - {props.children} - - - - + + + + + + {props.children} + + + + + ); } - -function ShortcutsProvider(props: { children: React.ReactNode }) { - const noop = (e) => { - e.preventDefault(); - e.stopPropagation(); - }; - - const _save = keymap("ctrl-cmd", "s"); - const _preferences = keymap("ctrl-cmd", ","); - - useHotkeys(_save.universal, noop); - useHotkeys(_preferences.universal, noop); - - return <>{props.children}; -} - -const keymap = ( - ...c: ("ctrl" | "cmd" | "ctrl-cmd" | "shift" | "a" | "p" | "s" | ",")[] -) => { - const magic_replacer = (s: string, os: "win" | "mac") => { - return replaceAll(s, "ctrl-cmd", os === "win" ? "ctrl" : "cmd"); - }; - - const win = magic_replacer(c.join("+"), "win"); - const mac = magic_replacer(c.join("+"), "mac"); - const universal = [win, mac].join(", "); - return { win, mac, universal }; -}; - -function _escapeRegExp(string) { - return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // $& means the whole matched string -} -function replaceAll(str, match, replacement) { - return str.replace(new RegExp(_escapeRegExp(match), "g"), () => replacement); -} diff --git a/editor/scaffolds/editor/editor-code-webworker-provider.tsx b/editor/scaffolds/editor/editor-code-webworker-provider.tsx new file mode 100644 index 00000000..0343bd94 --- /dev/null +++ b/editor/scaffolds/editor/editor-code-webworker-provider.tsx @@ -0,0 +1,32 @@ +import React, { useEffect } from "react"; +import { useEditorState, useWorkspaceState } from "core/states"; +import { initialize } from "../code/code-worker-messenger"; + +/** + * d2c codegen with webworker provider + * @returns + */ +export function EditorCodeWebworkerProvider({ + children, +}: { + children?: React.ReactNode; +}) { + const [state] = useEditorState(); + const wssate = useWorkspaceState(); + + useEffect(() => { + if (state.design?.key && wssate.figmaAuthentication) { + initialize( + { + filekey: state.design.key, + authentication: wssate.figmaAuthentication, + }, + () => { + // + } + ); + } + }, [state.design?.key, wssate.figmaAuthentication]); + + return <>{children}; +} diff --git a/editor/scaffolds/editor/editor-figma-image-service-provider.tsx b/editor/scaffolds/editor/editor-figma-image-service-provider.tsx new file mode 100644 index 00000000..1c65239d --- /dev/null +++ b/editor/scaffolds/editor/editor-figma-image-service-provider.tsx @@ -0,0 +1,82 @@ +import { useDispatch } from "core/dispatch"; +import { useWorkspaceState } from "core/states"; +import React, { useCallback, useEffect, useMemo } from "react"; +import { FigmaImageService } from "services"; + +type Fetcher = { fetch: FigmaImageService["fetch"] }; +type FetcherParams = Parameters; + +export const FigmaImageServiceContext = React.createContext(null); + +export function FigmaImageServiceProvider({ + filekey, + children, +}: React.PropsWithChildren<{ + filekey: string; +}>) { + const wssate = useWorkspaceState(); + const dispatch = useDispatch(); + + const service = useMemo(() => { + if (!filekey || !wssate.figmaAuthentication) return; + + return new FigmaImageService(filekey, wssate.figmaAuthentication, null, 24); + }, [filekey, wssate.figmaAuthentication]); + + const pushTask = useCallback( + (key: string | string[]) => + dispatch({ + type: "editor-task-push", + task: { + id: `services.figma.fetch-image.${key}`, + debounce: 1000, + name: `Fetch image`, + description: `Fetching image of ${ + Array.isArray(key) ? key.join(", ") : key + }`, + progress: null, + }, + }), + [dispatch] + ); + + const popTask = useCallback( + (key: string | string[]) => + dispatch({ + type: "editor-task-pop", + task: { + id: `services.figma.fetch-image.${key}`, + }, + }), + [dispatch] + ); + + const fetcher = useMemo(() => { + return { + fetch: (...p: FetcherParams) => { + const task = service.fetch(...p); + pushTask(p[0]); + task.finally(() => { + popTask(p[0]); + }); + return task; + }, + }; + }, [service]); + + useEffect(() => { + if (service) { + service.warmup(); + } + }, [service]); + + return ( + + {children} + + ); +} + +export function useFigmaImageService() { + return React.useContext(FigmaImageServiceContext); +} diff --git a/editor/scaffolds/editor/editor-image-repository-provider.tsx b/editor/scaffolds/editor/editor-image-repository-provider.tsx index 2cfd61d5..baebba0a 100644 --- a/editor/scaffolds/editor/editor-image-repository-provider.tsx +++ b/editor/scaffolds/editor/editor-image-repository-provider.tsx @@ -4,8 +4,7 @@ import { ImageRepository, MainImageRepository, } from "@design-sdk/asset-repository"; -import { useEditorState } from "core/states"; -import { useFigmaAccessToken } from "hooks"; +import { useEditorState, useWorkspaceState } from "core/states"; /** * This is a queue handler of d2c requests. @@ -17,6 +16,7 @@ export function EditorImageRepositoryProvider({ }: { children: React.ReactNode; }) { + const wssate = useWorkspaceState(); const [state] = useEditorState(); // listen to requests @@ -24,20 +24,15 @@ export function EditorImageRepositoryProvider({ // handle requests, dispatch with results // - const fat = useFigmaAccessToken(); - useEffect(() => { // ------------------------------------------------------------ // other platforms are not supported yet // set image repo for figma platform - if (state.design) { + if (state.design && wssate.figmaAuthentication) { MainImageRepository.instance = new RemoteImageRepositories( state.design.key, { - authentication: { - personalAccessToken: fat.personalAccessToken, - accessToken: fat.accessToken.token, - }, + authentication: wssate.figmaAuthentication, } ); MainImageRepository.instance.register( @@ -48,7 +43,7 @@ export function EditorImageRepositoryProvider({ ); } // ------------------------------------------------------------ - }, [state.design?.key, fat.accessToken]); + }, [state.design?.key, wssate.figmaAuthentication]); return <>{children}; } diff --git a/editor/scaffolds/editor/editor-shortcuts-provider.tsx b/editor/scaffolds/editor/editor-shortcuts-provider.tsx new file mode 100644 index 00000000..b5d9ab5e --- /dev/null +++ b/editor/scaffolds/editor/editor-shortcuts-provider.tsx @@ -0,0 +1,84 @@ +import { useDispatch } from "core/dispatch"; +import { EditorState } from "core/states"; +import React, { useCallback } from "react"; +import { useHotkeys } from "react-hotkeys-hook"; + +const noop = (e) => { + e.preventDefault(); + e.stopPropagation(); + console.log(e.key); +}; + +export function EditorShortcutsProvider({ + children, +}: React.PropsWithChildren<{}>) { + const dispatch = useDispatch(); + + const setMode = useCallback( + (mode: EditorState["mode"]) => { + dispatch({ type: "mode", mode: mode }); + }, + [dispatch] + ); + + const _save = keymap("ctrl-cmd", "s"); + const _preferences = keymap("ctrl-cmd", ","); + const _toggle_comments = keymap("c"); + const _toggle_view = keymap("v"); + const _escape = keymap("esc"); + + useHotkeys(_save.universal, () => { + // dispatch({ type: "editor-save" }); + }); + + useHotkeys(_preferences.universal, () => { + // dispatch({ type: "editor-open-preference" }); + }); + + useHotkeys(_toggle_comments.universal, () => { + setMode("comment"); + }); + + useHotkeys(_toggle_view.universal, () => { + setMode("view"); + }); + + useHotkeys(_escape.universal, () => { + setMode("view"); + }); + + return <>{children}; +} + +const keymap = ( + ...c: ( + | "esc" + | "ctrl" + | "cmd" + | "ctrl-cmd" + | "shift" + | "a" + | "c" + | "p" + | "s" + | "v" + | "," + )[] +) => { + const magic_replacer = (s: string, os: "win" | "mac") => { + return replaceAll(s, "ctrl-cmd", os === "win" ? "ctrl" : "cmd"); + }; + + const win = magic_replacer(c.join("+"), "win"); + const mac = magic_replacer(c.join("+"), "mac"); + const universal = [win, mac].join(", "); + return { win, mac, universal }; +}; + +function _escapeRegExp(string) { + return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // $& means the whole matched string +} + +function replaceAll(str, match, replacement) { + return str.replace(new RegExp(_escapeRegExp(match), "g"), () => replacement); +} diff --git a/editor/scaffolds/editor/editor.tsx b/editor/scaffolds/editor/editor.tsx index 4d1df928..bdce00f1 100644 --- a/editor/scaffolds/editor/editor.tsx +++ b/editor/scaffolds/editor/editor.tsx @@ -4,23 +4,19 @@ import { WorkspaceContentPanel, WorkspaceContentPanelGridLayout, } from "layouts/panel"; -import { EditorSidebar } from "components/editor"; +import { EditorAppbar, EditorSidebar } from "components/editor"; import { useEditorState } from "core/states"; import { Canvas } from "scaffolds/canvas"; import { Code } from "scaffolds/code"; import { Inspector } from "scaffolds/inspector"; +import { EditorHome } from "scaffolds/editor-home"; import { EditorSkeleton } from "./skeleton"; import { colors } from "theme"; +import { useEditorSetupContext } from "./setup"; -export function Editor({ - loading = false, -}: { - /** - * explicitly set loading to block uesr interaction. - */ - loading?: boolean; -}) { +export function Editor() { const [state] = useEditorState(); + const { loading } = useEditorSetupContext(); const _initially_loaded = state.design?.pages?.length > 0; const _initial_load_progress = @@ -41,6 +37,7 @@ export function Editor({ } leftbar={{ _type: "resizable", minWidth: 240, @@ -50,12 +47,11 @@ export function Editor({ > - +