From 872c41ef19cc0f3f5684368c78181397473c79cb Mon Sep 17 00:00:00 2001 From: Rizky Date: Sat, 6 Jan 2024 22:33:56 +0700 Subject: [PATCH] wip add side style --- app/web/src/nova/ed/panel/side/side-style.tsx | 2 + .../ed/panel/side/style/panel/auto-layout.tsx | 654 ++++++++++++++++++ .../src/nova/ed/panel/side/style/side-all.tsx | 3 + .../panel/side/style/tools/dynamic-import.ts | 40 ++ .../nova/ed/panel/side/style/tools/fill-id.ts | 41 ++ .../ed/panel/side/style/tools/flat-tree.ts | 32 + .../panel/side/style/tools/responsive-val.ts | 23 + .../ed/panel/side/style/tools/yjs-tools.ts | 51 ++ .../nova/ed/panel/side/style/ui/BoxSep.tsx | 16 + .../nova/ed/panel/side/style/ui/Button.tsx | 34 + .../ed/panel/side/style/ui/FieldBtnRadio.tsx | 39 ++ .../ed/panel/side/style/ui/FieldColor.tsx | 73 ++ .../panel/side/style/ui/FieldColorPicker.tsx | 200 ++++++ .../panel/side/style/ui/FieldColorPopover.tsx | 68 ++ .../ed/panel/side/style/ui/FieldNumUnit.tsx | 182 +++++ .../ed/panel/side/style/ui/LayoutIcon.tsx | 40 ++ .../ed/panel/side/style/ui/LayoutPacked.tsx | 111 +++ .../ed/panel/side/style/ui/LayoutSpaced.tsx | 261 +++++++ .../nova/ed/panel/side/style/ui/SideBox.tsx | 5 + .../nova/ed/panel/side/style/ui/SideLabel.tsx | 20 + .../src/nova/ed/panel/side/style/ui/style.ts | 24 + 21 files changed, 1919 insertions(+) create mode 100644 app/web/src/nova/ed/panel/side/style/panel/auto-layout.tsx create mode 100644 app/web/src/nova/ed/panel/side/style/side-all.tsx create mode 100644 app/web/src/nova/ed/panel/side/style/tools/dynamic-import.ts create mode 100644 app/web/src/nova/ed/panel/side/style/tools/fill-id.ts create mode 100644 app/web/src/nova/ed/panel/side/style/tools/flat-tree.ts create mode 100644 app/web/src/nova/ed/panel/side/style/tools/responsive-val.ts create mode 100644 app/web/src/nova/ed/panel/side/style/tools/yjs-tools.ts create mode 100644 app/web/src/nova/ed/panel/side/style/ui/BoxSep.tsx create mode 100644 app/web/src/nova/ed/panel/side/style/ui/Button.tsx create mode 100644 app/web/src/nova/ed/panel/side/style/ui/FieldBtnRadio.tsx create mode 100644 app/web/src/nova/ed/panel/side/style/ui/FieldColor.tsx create mode 100644 app/web/src/nova/ed/panel/side/style/ui/FieldColorPicker.tsx create mode 100644 app/web/src/nova/ed/panel/side/style/ui/FieldColorPopover.tsx create mode 100644 app/web/src/nova/ed/panel/side/style/ui/FieldNumUnit.tsx create mode 100644 app/web/src/nova/ed/panel/side/style/ui/LayoutIcon.tsx create mode 100644 app/web/src/nova/ed/panel/side/style/ui/LayoutPacked.tsx create mode 100644 app/web/src/nova/ed/panel/side/style/ui/LayoutSpaced.tsx create mode 100644 app/web/src/nova/ed/panel/side/style/ui/SideBox.tsx create mode 100644 app/web/src/nova/ed/panel/side/style/ui/SideLabel.tsx create mode 100644 app/web/src/nova/ed/panel/side/style/ui/style.ts diff --git a/app/web/src/nova/ed/panel/side/side-style.tsx b/app/web/src/nova/ed/panel/side/side-style.tsx index bc68fe18..2430f135 100644 --- a/app/web/src/nova/ed/panel/side/side-style.tsx +++ b/app/web/src/nova/ed/panel/side/side-style.tsx @@ -3,6 +3,7 @@ import { EDGlobal, IMeta, active } from "../../logic/ed-global"; import { useGlobal } from "web-utils"; import { IItem } from "../../../../utils/types/item"; import { EdSidePropComp } from "./prop-master"; +import { EdStyleAll } from "./style/side-all"; export const EdSideStyle: FC<{ meta: IMeta }> = ({ meta }) => { const p = useGlobal(EDGlobal, "EDITOR"); @@ -32,6 +33,7 @@ export const EdSideStyle: FC<{ meta: IMeta }> = ({ meta }) => { )} + ); }; diff --git a/app/web/src/nova/ed/panel/side/style/panel/auto-layout.tsx b/app/web/src/nova/ed/panel/side/style/panel/auto-layout.tsx new file mode 100644 index 00000000..dc4aad4d --- /dev/null +++ b/app/web/src/nova/ed/panel/side/style/panel/auto-layout.tsx @@ -0,0 +1,654 @@ +import { FC } from "react"; +import { useLocal } from "web-utils"; +import { IItem } from "../../../../../../utils/types/item"; +import { FNLayout } from "../../../../../../utils/types/meta-fn"; +import { ISection } from "../../../../../../utils/types/section"; +import { IText } from "../../../../../../utils/types/text"; +import { Popover } from "../../../../../../utils/ui/popover"; +import { Tooltip } from "../../../../../../utils/ui/tooltip"; +import { BoxSep } from "../ui/BoxSep"; +import { Button } from "../ui/Button"; +import { FieldBtnRadio } from "../ui/FieldBtnRadio"; +import { FieldNumUnit } from "../ui/FieldNumUnit"; +import { LayoutPacked } from "../ui/LayoutPacked"; +import { LayoutSpaced } from "../ui/LayoutSpaced"; +import { responsiveVal } from "../tools/responsive-val"; + +type AutoLayoutUpdate = { + layout: FNLayout; +}; + +export const PanelAutoLayout: FC<{ + value: ISection | IItem | IText; + mode: "desktop" | "mobile"; + update: ( + key: T, + val: AutoLayoutUpdate[T] + ) => void; +}> = ({ value, update, mode }) => { + const local = useLocal({ lastGap: 0, open: false }); + + const layout = responsiveVal(value, "layout", mode, { + dir: "col", + align: "top-left", + gap: 0, + wrap: "flex-nowrap", + }); + + return ( + <> +
+
+
+
+ button { + min-width: 0px; + flex: 1; + padding: 2px 4px; + } + ` + )} + > + +
+ +
+ + ), + row: ( + +
+ +
+
+ ), + // wrap: ( + // + //
+ // + //
+ //
+ // ), + }} + value={layout.dir} + disabled={false} + update={(dir) => { + let align = layout.align; + if (layout.gap === "auto") { + if (dir.startsWith("col") && align === "top") + align = "left"; + if (dir.startsWith("col") && align === "bottom") + align = "right"; + if (dir.startsWith("row") && align === "left") + align = "top"; + if (dir.startsWith("row") && align === "right") + align = "bottom"; + } + + update("layout", { ...layout, align, dir }); + local.render(); + }} + /> +
+ { + local.open = open; + local.render(); + }} + backdrop={false} + autoFocus={false} + popoverClassName="rounded-md p-2 text-sm bg-white shadow-2xl border border-slate-300" + content={ +
+

Direction

+ button { + min-width: 0px; + flex: 1; + padding: 2px 4px; + } + ` + )} + > + +
+ +
+ + ), + "col-reverse": ( + +
+ +
+
+ ), + row: ( + +
+ +
+
+ ), + "row-reverse": ( + +
+ +
+
+ ), + }} + value={layout.dir} + disabled={false} + update={(dir) => { + let align = layout.align; + if (layout.gap === "auto") { + if (dir.startsWith("col") && align === "top") + align = "left"; + if (dir.startsWith("col") && align === "bottom") + align = "right"; + if (dir.startsWith("row") && align === "left") + align = "top"; + if (dir.startsWith("row") && align === "right") + align = "bottom"; + } + + update("layout", { ...layout, align, dir }); + local.render(); + }} + /> +
+
+ } + > +
{ + local.open = !local.open; + local.render(); + }} + className={`${ + false && "opacity-0" + } h-full px-1 flex flew-row items-center justify-center border-l border-l-slate-300 hover:bg-blue-100 bg-white other cursor-pointer`} + > + +
+
+
+ + + +
+ +
+ +
+ {layout.gap !== "auto" ? ( + } + value={layout.gap + "px"} + update={(val) => { + update("layout", { + ...layout, + gap: parseInt(val.replaceAll("px", "")), + }); + }} + /> + ) : ( + + +
+ Auto +
+
+ )} +
+
+ + + Gap Mode: +
Space Between / Packed + + } + > + +
+ + Align Items: +
Stretch / Normal + + } + > + +
+
+ + {/*
+ button { + min-width: 0px; + flex: 1; + padding: 2px 4px; + } + ` + )} + > + + + + + + ), + "Flex nowrap": ( + +
+ + + +
+
+ ), + }} + value={layout.wrap} + disabled={false} + update={(dir) => { + update("layout", { ...layout, wrap: dir as any }); + }} + /> +
+
*/} +
+ {layout.gap === "auto" ? ( + { + update("layout", { ...layout, align }); + }} + /> + ) : ( + { + update("layout", { ...layout, align }); + }} + /> + )} +
+ + ); +}; +const Down = () => ( + + + +); +const Wrap = () => ( + + + +); +const TapDown = () => ( + + + +); + +const TapRight = () => ( + + + +); + +const GapIcon: FC<{ layout: FNLayout }> = ({ layout }) => ( +
+ {layout.dir === "col" ? ( + + + + ) : ( + + + + )} +
+); diff --git a/app/web/src/nova/ed/panel/side/style/side-all.tsx b/app/web/src/nova/ed/panel/side/style/side-all.tsx new file mode 100644 index 00000000..d04fedd6 --- /dev/null +++ b/app/web/src/nova/ed/panel/side/style/side-all.tsx @@ -0,0 +1,3 @@ +export const EdStyleAll = () => { + return
; +}; diff --git a/app/web/src/nova/ed/panel/side/style/tools/dynamic-import.ts b/app/web/src/nova/ed/panel/side/style/tools/dynamic-import.ts new file mode 100644 index 00000000..dd64a44f --- /dev/null +++ b/app/web/src/nova/ed/panel/side/style/tools/dynamic-import.ts @@ -0,0 +1,40 @@ +function toAbsoluteURL(url: string) { + const a = document.createElement("a"); + a.setAttribute("href", url); // + return (a.cloneNode(false) as any).href; // -> "http://example.com/hoge.html" +} + +export function importModule(url: string) { + if (!url) return ""; + + return new Promise((resolve, reject) => { + const vector = "$importModule$" + Math.random().toString(32).slice(2); + const script = document.createElement("script"); + const destructor = () => { + delete (window as any)[vector]; + script.onerror = null; + script.onload = null; + script.remove(); + URL.revokeObjectURL(script.src); + script.src = ""; + }; + script.defer = true; + script.type = "module"; + script.onerror = () => { + reject(new Error(`Failed to import: ${url}`)); + destructor(); + }; + script.onload = () => { + resolve((window as any)[vector]); + destructor(); + }; + const absURL = toAbsoluteURL(url); + const loader = `import * as m from "${absURL}"; window.${vector} = m;`; // export Module + const blob = new Blob([loader], { type: "text/javascript" }); + script.src = URL.createObjectURL(blob); + + document.head.appendChild(script); + }); +} + +export default importModule; diff --git a/app/web/src/nova/ed/panel/side/style/tools/fill-id.ts b/app/web/src/nova/ed/panel/side/style/tools/fill-id.ts new file mode 100644 index 00000000..b3d4d6c0 --- /dev/null +++ b/app/web/src/nova/ed/panel/side/style/tools/fill-id.ts @@ -0,0 +1,41 @@ +import { createId as cuid } from "@paralleldrive/cuid2"; +import { IContent } from "../../../utils/types/general"; + +export const fillID = ( + object: IContent, + modify?: (obj: IContent) => boolean, + currentDepth?: number +) => { + const _depth = (currentDepth || 0) + 1; + + if (modify) { + if (modify(object)) { + object.id = cuid(); + } + } else { + object.id = cuid(); + } + + if ( + object.type === "item" && + object.component && + object.component.id && + object.component.props + ) { + for (const p of Object.values(object.component.props)) { + if (p.meta?.type === "content-element" && p.content) { + fillID(p.content, modify, _depth); + } + } + } + + if (object.type !== "text") { + if (object.childs && Array.isArray(object.childs)) { + for (const child of object.childs) { + fillID(child, modify, _depth); + } + } + } + + return object; +}; diff --git a/app/web/src/nova/ed/panel/side/style/tools/flat-tree.ts b/app/web/src/nova/ed/panel/side/style/tools/flat-tree.ts new file mode 100644 index 00000000..9722bd70 --- /dev/null +++ b/app/web/src/nova/ed/panel/side/style/tools/flat-tree.ts @@ -0,0 +1,32 @@ +import find from "lodash.find"; +import get from "lodash.get"; +import set from "lodash.set"; +import { deepClone } from "web-utils"; +import { IContent } from "../../../utils/types/general"; + +export const flatTree = (item: Array) => { + const children = item as Array; + let ls = deepClone(item); + let sitem: any = ls.map((v: IContent) => { + if (v.type !== "text") { + v.childs = []; + } + return { ...v }; + }); + let result = [] as any; + sitem.forEach((v: IContent) => { + let parent = children.filter((x: IContent) => + find(get(x, "childs"), (x: IContent) => x.id === v.id) + ); + if (get(parent, "length")) { + let s = sitem.find((e: any) => e.id === get(parent, "[0].id")); + let childs: any = s.childs || []; + childs = childs.filter((e: any) => get(e, "id")) || []; + let now = [v]; + set(s, "childs", childs.concat(now)); + } else { + result.push(v); + } + }); + return result; +}; diff --git a/app/web/src/nova/ed/panel/side/style/tools/responsive-val.ts b/app/web/src/nova/ed/panel/side/style/tools/responsive-val.ts new file mode 100644 index 00000000..b10f5f84 --- /dev/null +++ b/app/web/src/nova/ed/panel/side/style/tools/responsive-val.ts @@ -0,0 +1,23 @@ +export const responsiveVal = ( + item: any, + key: string, + mode: "desktop" | "mobile" | undefined, + defaultVal: T +): T => { + let value = item[key]; + + if (mode === "desktop" || !mode) { + if (!value && item.mobile && item.mobile[key]) { + value = item.mobile[key]; + } + } else { + if (item.mobile && item.mobile[key]) { + value = item.mobile[key]; + } + } + + if (!value) { + value = defaultVal; + } + return value as T; +}; diff --git a/app/web/src/nova/ed/panel/side/style/tools/yjs-tools.ts b/app/web/src/nova/ed/panel/side/style/tools/yjs-tools.ts new file mode 100644 index 00000000..e3eb39c4 --- /dev/null +++ b/app/web/src/nova/ed/panel/side/style/tools/yjs-tools.ts @@ -0,0 +1,51 @@ +import { syncronize } from "y-pojo"; +import * as Y from "yjs"; +import { TypedArray, TypedMap } from "yjs-types"; + +export const getArray = (map: TypedMap, key: string) => { + let item = map.get(key); + if (!item) { + map.set(key, new Y.Array()); + item = map.get(key); + } + + return item as TypedArray | undefined; +}; + +export const newMap = (item: any) => { + const map = new Y.Map(); + syncronize(map, item as any); + return map; +}; + +export const getMap = ( + map: TypedMap, + key: string, + fill?: any +) => { + let item = map.get(key); + if (!item) { + map.set(key, new Y.Map() as any); + item = map.get(key); + + if (fill && item) { + syncronize(item as any, fill); + } + } + + return item as T; +}; + +export const getMText = (map: TypedMap, key: string) => { + let item = map.get(key); + if (typeof item === "string") { + map.set(key, new Y.Text(item)); + item = map.get(key); + } else if (typeof item === "object" && item instanceof Y.Text) { + } else { + item = new Y.Text(); + map.set(key, item); + } + + return item as Y.Text; +}; diff --git a/app/web/src/nova/ed/panel/side/style/ui/BoxSep.tsx b/app/web/src/nova/ed/panel/side/style/ui/BoxSep.tsx new file mode 100644 index 00000000..a91fcb3e --- /dev/null +++ b/app/web/src/nova/ed/panel/side/style/ui/BoxSep.tsx @@ -0,0 +1,16 @@ +import { FC, ReactElement } from "react"; + +export const BoxSep: FC<{ + children: string | ReactElement | (ReactElement | string)[]; + className?: string; +}> = ({ children, className = "border-l" }) => { + return ( +
+ {children} +
+ ); +}; diff --git a/app/web/src/nova/ed/panel/side/style/ui/Button.tsx b/app/web/src/nova/ed/panel/side/style/ui/Button.tsx new file mode 100644 index 00000000..c22a0622 --- /dev/null +++ b/app/web/src/nova/ed/panel/side/style/ui/Button.tsx @@ -0,0 +1,34 @@ +import { FC, ReactNode } from "react"; + +type ButtonProp = { + disabled?: boolean; + className?: string; + onClick?: React.MouseEventHandler; + appearance?: "secondary" | "subtle"; + children?: ReactNode; +}; +export const Button: FC = ({ + children, + appearance, + className, + onClick, +}) => { + return ( + + ); +}; diff --git a/app/web/src/nova/ed/panel/side/style/ui/FieldBtnRadio.tsx b/app/web/src/nova/ed/panel/side/style/ui/FieldBtnRadio.tsx new file mode 100644 index 00000000..6abd896a --- /dev/null +++ b/app/web/src/nova/ed/panel/side/style/ui/FieldBtnRadio.tsx @@ -0,0 +1,39 @@ +import { FC, ReactElement } from "react"; +import { Button } from "./Button"; + +export const FieldBtnRadio: FC<{ + value: any; + update: (value: any) => void; + disabled?: boolean; + items: Record; +}> = ({ items, update, value, disabled }) => { + return ( + <> + {Object.entries(items).map(([name, content], idx) => { + return ( + + ); + })} + + ); +}; diff --git a/app/web/src/nova/ed/panel/side/style/ui/FieldColor.tsx b/app/web/src/nova/ed/panel/side/style/ui/FieldColor.tsx new file mode 100644 index 00000000..fc74573e --- /dev/null +++ b/app/web/src/nova/ed/panel/side/style/ui/FieldColor.tsx @@ -0,0 +1,73 @@ +import { FC, useEffect } from "react"; +import { useLocal } from "web-utils"; +import { FieldColorPicker } from "./FieldColorPopover"; +import { w } from "../../../../../../utils/types/general"; + +export const FieldColor: FC<{ + popupID: string; + value?: string; + update: (value: string) => void; + showHistory?: boolean; +}> = ({ value, update, showHistory = true, popupID }) => { + if (!w.openedPopupID) w.openedPopupID = {}; + + const local = useLocal({ + val: w.lastColorPicked || "", + }); + + useEffect(() => { + if (value) { + w.lastColorPicked = value; + } + local.val = value || ""; + + local.render(); + }, [value]); + + const onOpen = () => { + w.openedPopupID[popupID] = true; + local.render(); + }; + + const onClose = () => { + delete w.openedPopupID[popupID]; + w.lastColorPicked = ""; + local.render(); + }; + + if (typeof local.val === "string" && local.val.length > 10) { + update(""); + return null; + } + + return ( + update(val)} + onOpen={onOpen} + onClose={onClose} + open={w.openedPopupID[popupID]} + showHistory={showHistory} + > +
'); + `, + "cursor-pointer" + )} + > +
+
+
+ ); +}; diff --git a/app/web/src/nova/ed/panel/side/style/ui/FieldColorPicker.tsx b/app/web/src/nova/ed/panel/side/style/ui/FieldColorPicker.tsx new file mode 100644 index 00000000..5f8a83c2 --- /dev/null +++ b/app/web/src/nova/ed/panel/side/style/ui/FieldColorPicker.tsx @@ -0,0 +1,200 @@ +import { createId as cuid } from "@paralleldrive/cuid2"; +import { FC, Suspense, lazy, useEffect } from "react"; +import tinycolor from "tinycolor2"; +import { useLocal } from "web-utils"; + +const HexAlphaColorPicker = lazy(async () => { + return { default: (await import("react-colorful")).HexAlphaColorPicker }; +}); + +export const FieldPickColor: FC<{ + value?: string; + onChangePicker: (value: string) => void; + onClose?: () => void; + showHistory?: boolean; +}> = ({ value, onChangePicker, onClose, showHistory }) => { + const meta = useLocal({ + originalValue: "", + inputValue: value, + rgbValue: "", + selectedEd: "" as string, + }); + + useEffect(() => { + meta.inputValue = value || ""; + const convertColor = tinycolor(meta.inputValue); + meta.rgbValue = convertColor.toRgbString(); + meta.render(); + }, [value]); + + const colors: { id: string; value: string }[] = []; + const tin = tinycolor(meta.inputValue); + + return ( +
+
{ + e.stopPropagation(); + e.preventDefault(); + }} + > + + { + if (color) { + meta.inputValue = color; + onChangePicker(color); + const convertColor = tinycolor(meta.inputValue); + meta.rgbValue = convertColor.toRgbString(); + } + }} + /> + +
+
+
+ { + // height: "18px", + // minWidth: "0px", + // fontSize: "13px", + // meta.selectedEd = -1; + // meta.render(); + }} + spellCheck={false} + onChange={(e) => { + const color = e.currentTarget.value; + meta.inputValue = color; + + // if (meta.selectedEd >= 0) { + // ed.colors[meta.selectedEd] = color; + // } + + onChangePicker(color); + }} + /> +
+ {showHistory && + colors.map((e, key) => ( +
+
{ + meta.inputValue = e.value; + meta.selectedEd = e.id; + onChangePicker(e.value); + + const convertColor = tinycolor(meta.inputValue); + meta.rgbValue = convertColor.toRgbString(); + }} + /> + {/* { + meta.selectedEd = ""; + const index = colors.indexOf(e); + const color = colors.find((_, i) => i === index); + if (color) onChangePicker(color?.value); + if (index > -1) { + colors.splice(index, 1); + ed.render(); + } + }} + /> */} +
+ ))} + +
+ {meta.inputValue !== "" && ( + <> + {/*
{ + if (meta.inputValue) { + const id = cuid(); + const color = { id, value: meta.inputValue }; + colors.push(color); + meta.selectedEd = id; + meta.render(); + onChangePicker(meta.inputValue); + } + }} + > + + Add +
*/} +
{ + meta.inputValue = ""; + onChangePicker(""); + }} + > + Reset +
+ + )} + + {onClose && ( +
+ Close +
+ )} +
+
+
+ ); +}; diff --git a/app/web/src/nova/ed/panel/side/style/ui/FieldColorPopover.tsx b/app/web/src/nova/ed/panel/side/style/ui/FieldColorPopover.tsx new file mode 100644 index 00000000..5a716249 --- /dev/null +++ b/app/web/src/nova/ed/panel/side/style/ui/FieldColorPopover.tsx @@ -0,0 +1,68 @@ +import { FC, ReactElement, useEffect, useTransition } from "react"; +import { useLocal } from "web-utils"; +import { FieldPickColor } from "./FieldColorPicker"; +import { Popover } from "../../../../../utils/ui/popover"; + +export const FieldColorPicker: FC<{ + children: ReactElement; + value?: string; + update: (value: string) => void; + open: boolean; + onOpen?: () => void; + onClose?: () => void; + showHistory?: boolean; +}> = ({ children, value, update, open, onClose, onOpen, showHistory }) => { + const local = useLocal({ show: open || false }); + useEffect(() => { + if (value) { + local.show = open || false; + local.render(); + } + }, [value, open]); + const [_, tx] = useTransition(); + + return ( + { + local.show = open; + if (open && onOpen) { + onOpen(); + } else if (onClose) { + onClose(); + } + local.render(); + }} + backdrop={false} + popoverClassName="rounded-md p-2 text-sm bg-white shadow-2xl border border-slate-300" + content={ + { + local.show = false; + local.render(); + if (onClose) onClose(); + }} + onChangePicker={(color) => { + tx(() => { + if (color.indexOf("NaN") < 0) { + update(color); + } + }); + }} + /> + } + > +
{ + local.show = true; + local.render(); + if (onOpen) onOpen(); + }} + > + {children} +
+
+ ); +}; diff --git a/app/web/src/nova/ed/panel/side/style/ui/FieldNumUnit.tsx b/app/web/src/nova/ed/panel/side/style/ui/FieldNumUnit.tsx new file mode 100644 index 00000000..5bca4795 --- /dev/null +++ b/app/web/src/nova/ed/panel/side/style/ui/FieldNumUnit.tsx @@ -0,0 +1,182 @@ +import React, { + FC, + ReactElement, + useCallback, + useEffect, + useTransition, +} from "react"; +import { useLocal } from "web-utils"; + +export const FieldNumUnit: FC<{ + label?: string; + icon?: ReactElement; + value: string; + unit?: string; + hideUnit?: boolean; + update: (value: string, setDragVal?: (val: number) => void) => void; + width?: string; + positiveOnly?: boolean; + disabled?: boolean | string; + enableWhenDrag?: boolean; + dashIfEmpty?: boolean; +}> = ({ + icon, + value, + label, + update, + unit, + hideUnit, + width, + disabled, + positiveOnly, + enableWhenDrag, +}) => { + const local = useLocal({ + val: 0, + unit: "", + drag: { clientX: 0, old: 0 }, + dragging: false, + }); + + const parseVal = useCallback(() => { + let val = ""; + let unt = ""; + if (value.length >= 1) { + let fillMode = "val" as "val" | "unit"; + for (let idx = 0; idx < value.length; idx++) { + const c = value[idx]; + if (idx > 0 && isNaN(parseInt(c))) { + fillMode = "unit"; + } + + if (fillMode === "val") { + val += c; + } else { + unt += c || ""; + } + } + if (!parseInt(val)) unt = ""; + } + local.val = parseInt(val) || 0; + if (positiveOnly && local.val < 0) { + local.val = Math.max(0, local.val); + } + local.unit = unit || unt || "px"; + local.render(); + }, [value, unit]); + + useEffect(() => { + parseVal(); + local.render(); + }, [value, unit]); + + const [txPending, tx] = useTransition(); + + useEffect(() => { + // Only change the value if the drag was actually started. + const onUpdate = (event: any) => { + if (local.drag.clientX) { + local.val = Math.round( + local.drag.old + (event.clientX - local.drag.clientX) + ); + + if (positiveOnly && local.val < 0) { + local.val = Math.max(0, local.val); + } + + local.render(); + + tx(() => { + update(local.val + local.unit); + }); + } + }; + + // Stop the drag operation now. + const onEnd = () => { + local.drag.clientX = 0; + local.dragging = false; + local.render(); + }; + + document.addEventListener("pointermove", onUpdate); + document.addEventListener("pointerup", onEnd); + return () => { + document.removeEventListener("pointermove", onUpdate); + document.removeEventListener("pointerup", onEnd); + }; + }, [local.drag.clientX, local.drag.old, local.val]); + + const onStart = useCallback( + (event: React.MouseEvent) => { + let _disabled = disabled; + if (enableWhenDrag && _disabled) { + update(local.val + local.unit, (val) => { + local.val = val; + }); + _disabled = false; + } + if (!_disabled) { + local.dragging = true; + local.render(); + + local.drag.clientX = event.clientX; + local.drag.old = local.val; + } + }, + [local.val, disabled] + ); + + return ( + <> +
+
+ {icon && ( +
+ {icon} +
+ )} + {label && ( +
+ {label} +
+ )} +
+
+ { + local.val = parseInt(e.currentTarget.value) || 0; + update(local.val + local.unit); + }} + /> + {hideUnit !== true && ( +
+ {local.unit} +
+ )} +
+
+ {local.dragging && ( +
+ )} + + ); +}; diff --git a/app/web/src/nova/ed/panel/side/style/ui/LayoutIcon.tsx b/app/web/src/nova/ed/panel/side/style/ui/LayoutIcon.tsx new file mode 100644 index 00000000..ae7c8b86 --- /dev/null +++ b/app/web/src/nova/ed/panel/side/style/ui/LayoutIcon.tsx @@ -0,0 +1,40 @@ +import { FC } from "react"; +import { FNLayout } from "../../../../../utils/types/meta-fn"; + +export const AlignIcon: FC<{ + dir: FNLayout["dir"]; + pos: "start" | "center" | "end"; + className?: string; +}> = ({ dir, pos, className }) => { + return ( +
+
+ +
+ +
+
+ ); +}; diff --git a/app/web/src/nova/ed/panel/side/style/ui/LayoutPacked.tsx b/app/web/src/nova/ed/panel/side/style/ui/LayoutPacked.tsx new file mode 100644 index 00000000..c71f9558 --- /dev/null +++ b/app/web/src/nova/ed/panel/side/style/ui/LayoutPacked.tsx @@ -0,0 +1,111 @@ +import { FC } from "react"; +import { FNAlign, FNLayout } from "../../../../../../utils/types/meta-fn"; +import { AlignIcon } from "./LayoutIcon"; +import { Tooltip } from "../../../../../../utils/ui/tooltip"; + +export const LayoutPacked: FC<{ + dir: FNLayout["dir"]; + align: FNAlign; + onChange: (align: FNAlign) => void; +}> = ({ dir, align, onChange }) => { + return ( +
+ + + + + + + + + + + +
+ ); +}; + +const AlignItem: FC<{ + dir: FNLayout["dir"]; + align: FNAlign; + active: string; + onChange: (align: FNAlign) => void; +}> = ({ dir, align, active, onChange }) => { + let pos = "start"; + + if (dir.startsWith("col")) { + if (align.endsWith("left")) pos = "start"; + if (align.endsWith("center")) pos = "center"; + if (align.endsWith("right")) pos = "end"; + } else { + if (align.startsWith("top")) pos = "start"; + else if (align.startsWith("bottom")) pos = "end"; + else pos = "center"; + } + + return ( + +
{ + onChange(align); + }} + > + +
+
+
+ ); +}; diff --git a/app/web/src/nova/ed/panel/side/style/ui/LayoutSpaced.tsx b/app/web/src/nova/ed/panel/side/style/ui/LayoutSpaced.tsx new file mode 100644 index 00000000..5b62ccdb --- /dev/null +++ b/app/web/src/nova/ed/panel/side/style/ui/LayoutSpaced.tsx @@ -0,0 +1,261 @@ +import { FC } from "react"; +import { useLocal } from "web-utils"; +import { FNAlign, FNLayout } from "../../../../../../utils/types/meta-fn"; + +export const LayoutSpaced: FC<{ + dir: FNLayout["dir"]; + align: FNAlign; + onChange: (align: FNAlign) => void; +}> = ({ dir, align, onChange }) => { + return ( +
+ {dir === "col" && ( + <> + + + + + )} + {dir === "col-reverse" && ( + <> + + + + + )} + + {dir === "row" && ( + <> + + + + + )} + {dir === "row-reverse" && ( + <> + + + + + )} +
+ ); +}; + +const AlignItemRow: FC<{ + align: "top" | "center" | "bottom"; + active: string; + onChange: (align: FNAlign) => void; + reverse?: boolean; +}> = ({ align, active, onChange, reverse }) => { + const local = useLocal({ hover: false }); + let justify = "justify-start"; + if (align === "center") justify = `justify-center`; + if (align === "bottom") justify = `justify-end`; + + return ( +
{ + local.hover = true; + local.render(); + }} + onMouseOut={() => { + local.hover = false; + local.render(); + }} + onClick={() => { + onChange(align); + }} + > + {active === align || local.hover ? ( + <> +
+
+
+
+
+
+
+
+
+ + ) : ( + <> +
+
+
+ +
+
+
+
+
+
+ + )} +
+ ); +}; + +const AlignItemCol: FC<{ + align: "left" | "center" | "right"; + active: string; + onChange: (align: FNAlign) => void; + reverse?: boolean; +}> = ({ align, active, onChange, reverse }) => { + const local = useLocal({ hover: false }); + let justify = "justify-start"; + if (align === "center") justify = `justify-center`; + if (align === "right") justify = `justify-end`; + + return ( +
{ + local.hover = true; + local.render(); + }} + onMouseOut={() => { + local.hover = false; + local.render(); + }} + onClick={() => { + onChange(align); + }} + > + {active === align || local.hover ? ( + <> +
+
+
+
+
+
+
+
+
+ + ) : ( + <> +
+
+
+ +
+
+
+
+
+
+ + )} +
+ ); +}; diff --git a/app/web/src/nova/ed/panel/side/style/ui/SideBox.tsx b/app/web/src/nova/ed/panel/side/style/ui/SideBox.tsx new file mode 100644 index 00000000..0d422164 --- /dev/null +++ b/app/web/src/nova/ed/panel/side/style/ui/SideBox.tsx @@ -0,0 +1,5 @@ +import { FC, ReactNode } from "react"; + +export const SideBox: FC<{ children: ReactNode }> = ({ children }) => { + return
{children}
; +}; diff --git a/app/web/src/nova/ed/panel/side/style/ui/SideLabel.tsx b/app/web/src/nova/ed/panel/side/style/ui/SideLabel.tsx new file mode 100644 index 00000000..15fb6ac4 --- /dev/null +++ b/app/web/src/nova/ed/panel/side/style/ui/SideLabel.tsx @@ -0,0 +1,20 @@ +import { FC, ReactNode } from "react"; + +export const SideLabel: FC<{ children: ReactNode; sep?: "top" | "bottom" }> = ({ + children, + sep, +}) => { + return ( +
+
+ {children} +
+
+ ); +}; diff --git a/app/web/src/nova/ed/panel/side/style/ui/style.ts b/app/web/src/nova/ed/panel/side/style/ui/style.ts new file mode 100644 index 00000000..2eb4d4f2 --- /dev/null +++ b/app/web/src/nova/ed/panel/side/style/ui/style.ts @@ -0,0 +1,24 @@ +export const dropdownProp = { + className: cx( + "p-1 border border-gray-300 h-[28px]", + css` + input { + max-width: none; + width: 87px; + flex: 1; + } + ` + ), + popover: { + className: "border border-gray-300", + itemClassName: cx( + "text-sm cursor-pointer min-w-[150px] p-1 hover:bg-blue-100", + css` + &.active { + background: #3c82f6; + color: white; + } + ` + ), + }, +};