From 3fad4e4d5136a28069a48961cfa49731882fcb5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20J=C3=B3nsson?= Date: Sun, 12 Jul 2020 21:47:00 +0000 Subject: [PATCH] WIP: Color properties (#26) * work on color properties * performance optimizations * hide timeline for color properties * reduce node editor t value decimal places * color input node decimal places * remove return statement * mild refactor Co-authored-by: alexharri --- src/components/colorPicker/ColorPicker.tsx | 246 ++++++++++++++++++ src/composition/compositionTypes.ts | 35 ++- src/composition/state/compositionReducer.ts | 5 +- .../timeline/CompositionTimeline.tsx | 2 +- src/composition/timeline/compTimeContext.ts | 4 + .../{ => layer}/CompTimeLayer.style.ts | 0 .../timeline/{ => layer}/CompTimeLayer.tsx | 46 +--- .../layer/CompTimeLayerPropertyToValue.tsx | 43 +++ .../property/CompTimeColorProperty.tsx | 80 ++++++ .../{ => property}/CompTimeProperty.styles.ts | 19 +- .../{ => property}/CompTimeProperty.tsx | 52 +--- .../property/common/CompTimePropertyName.tsx | 57 ++++ .../value/CompTimePropertyColorValue.tsx | 106 ++++++++ .../value/CompTimePropertyNumberValue.tsx | 78 ++++++ .../property/value/CompTimePropertyValue.tsx | 49 ++++ .../util/compositionPropertyUtils.ts | 97 ++++++- .../workspace/CompositionWorkspaceLayer.tsx | 44 +++- .../CompositionWorkspaceViewport.tsx | 4 +- src/contextMenu/CustomContextMenu.tsx | 25 +- src/contextMenu/contextMenuTypes.ts | 2 + src/hook/useCanvasPixelSelector.ts | 77 ++++++ src/hook/useDebounce.ts | 17 ++ src/hook/useDidUpdate.ts | 13 + src/listener/requestAction.ts | 10 + src/nodeEditor/NodeEditor.tsx | 6 + .../components/NodeEditorNumberInput.tsx | 10 +- .../components/NodeEditorTValueInput.tsx | 4 +- src/nodeEditor/graph/computeLayerGraph.ts | 8 +- src/nodeEditor/graph/computeNode.ts | 51 +++- src/nodeEditor/nodeEditorIO.ts | 85 +++++- src/nodeEditor/nodes/Node.styles.ts | 18 ++ src/nodeEditor/nodes/color/ColorInputNode.tsx | 202 ++++++++++++++ src/nodeEditor/util/nodeEditorContextMenu.ts | 20 +- src/types.ts | 21 ++ src/util/color/convertColor.ts | 87 +++++++ 35 files changed, 1505 insertions(+), 118 deletions(-) create mode 100644 src/components/colorPicker/ColorPicker.tsx create mode 100644 src/composition/timeline/compTimeContext.ts rename src/composition/timeline/{ => layer}/CompTimeLayer.style.ts (100%) rename src/composition/timeline/{ => layer}/CompTimeLayer.tsx (61%) create mode 100644 src/composition/timeline/layer/CompTimeLayerPropertyToValue.tsx create mode 100644 src/composition/timeline/property/CompTimeColorProperty.tsx rename src/composition/timeline/{ => property}/CompTimeProperty.styles.ts (79%) rename src/composition/timeline/{ => property}/CompTimeProperty.tsx (76%) create mode 100644 src/composition/timeline/property/common/CompTimePropertyName.tsx create mode 100644 src/composition/timeline/property/value/CompTimePropertyColorValue.tsx create mode 100644 src/composition/timeline/property/value/CompTimePropertyNumberValue.tsx create mode 100644 src/composition/timeline/property/value/CompTimePropertyValue.tsx create mode 100644 src/hook/useCanvasPixelSelector.ts create mode 100644 src/hook/useDebounce.ts create mode 100644 src/hook/useDidUpdate.ts create mode 100644 src/nodeEditor/nodes/color/ColorInputNode.tsx create mode 100644 src/util/color/convertColor.ts diff --git a/src/components/colorPicker/ColorPicker.tsx b/src/components/colorPicker/ColorPicker.tsx new file mode 100644 index 0000000..6c83221 --- /dev/null +++ b/src/components/colorPicker/ColorPicker.tsx @@ -0,0 +1,246 @@ +import React, { useRef, useState, useLayoutEffect } from "react"; +import { compileStylesheetLabelled } from "~/util/stylesheets"; +import { RGBColor } from "~/types"; +import { rgbToHSL, hslToRGB } from "~/util/color/convertColor"; +import { useCanvasPixelSelector } from "~/hook/useCanvasPixelSelector"; +import { useDidUpdate } from "~/hook/useDidUpdate"; + +const s = compileStylesheetLabelled(({ css }) => ({ + container: css` + display: flex; + `, + + colorCursor: css` + position: absolute; + background: white; + width: 12px; + height: 12px; + border: 2px solid white; + background: black; + border-radius: 50%; + transform: translate(-50%, -50%); + pointer-events: none; + `, + + hueCursor: css` + position: absolute; + background: white; + left: -4px; + right: -4px; + height: 2px; + transform: translateY(-50%); + pointer-events: none; + `, +})); + +const WIDTH = 256; +const HEIGHT = WIDTH; +const STRIP_WIDTH = 16; + +function renderBlock(ctx: CanvasRenderingContext2D, hue: number) { + const color = hslToRGB([hue, 100, 50]); + + ctx.fillStyle = `rgb(${color.join(",")})`; + ctx.fillRect(0, 0, WIDTH, HEIGHT); + + const white = ctx.createLinearGradient(0, 0, WIDTH, 0); + white.addColorStop(0, "rgba(255,255,255,1)"); + white.addColorStop(1, "rgba(255,255,255,0)"); + + const black = ctx.createLinearGradient(0, 0, 0, HEIGHT); + black.addColorStop(0, "rgba(0,0,0,0)"); + black.addColorStop(1, "rgba(0,0,0,1)"); + + ctx.fillStyle = white; + ctx.fillRect(0, 0, WIDTH, HEIGHT); + + ctx.fillStyle = black; + ctx.fillRect(0, 0, WIDTH, HEIGHT); + + // Ensure that corners are not diluted in any way + ctx.fillStyle = "#ffffff"; + ctx.fillRect(0, 0, 1, 1); + + ctx.fillStyle = `rgb(${color.join(",")})`; + ctx.fillRect(WIDTH - 1, 0, 1, 1); +} + +const Strip: React.FC<{ hue: number; onHueChange: (hue: number) => void }> = (props) => { + const canvas = useRef(null); + const ctx = useRef(null); + const [y, setY] = useState(() => Math.round((props.hue / 360) * HEIGHT)); + + useLayoutEffect(() => { + ctx.current = canvas.current?.getContext("2d") || null; + }, [canvas.current]); + + // Render strip once on mount + useLayoutEffect(() => { + if (!ctx.current) { + return; + } + + for (let i = 0; i < HEIGHT; i += 1) { + ctx.current.fillStyle = `hsl(${(i / HEIGHT) * 360}, 100%, 50%)`; + ctx.current.fillRect(0, i, STRIP_WIDTH, 1); + } + }, [ctx.current]); + + const pixelSelector = useCanvasPixelSelector( + canvas, + { allowOutside: true }, + (rgbColor, position) => { + const [hue] = rgbToHSL(rgbColor); + props.onHueChange(hue); + setY(position.y); + }, + ); + + return ( +
+ +
+
+ ); +}; + +/** + * Returns the position of the RGB color in the context. + * + * If an exact color match is not found, the position of the + * closest color is returned. + */ +const findPositionOfColor = (ctx: CanvasRenderingContext2D, rgbColor: RGBColor): Vec2 => { + const h = ctx.canvas.height; + const w = ctx.canvas.width; + + let dist = Infinity; + let closestPos: Vec2 = Vec2.new(0, 0); + + const imageData = ctx.getImageData(0, 0, w, h).data; + + for (let i = 0; i < w; i += 1) { + for (let j = 0; j < h; j += 1) { + const r = imageData[j * w * 4 + i * 4]; + const g = imageData[j * w * 4 + i * 4 + 1]; + const b = imageData[j * w * 4 + i * 4 + 2]; + + const currColor: RGBColor = [r, g, b]; + const currDist = currColor.reduce((acc, _, i) => { + return acc + Math.abs(currColor[i] - rgbColor[i]); + }, 0); + + if (currDist === 0) { + return Vec2.new(i, j); + } + + if (currDist < dist) { + dist = currDist; + closestPos = Vec2.new(i, j); + } + } + } + + return closestPos; +}; + +const Block: React.FC<{ rgb: RGBColor; hue: number; onRgbChange: (rgb: RGBColor) => void }> = ( + props, +) => { + const canvas = useRef(null); + const ctx = useRef(null); + const [position, setPosition] = useState(null); + + useLayoutEffect(() => { + ctx.current = canvas.current?.getContext("2d") || null; + }, [canvas.current]); + + useLayoutEffect(() => { + if (!ctx.current) { + return; + } + + renderBlock(ctx.current, props.hue); + + // Get initial position of color cursor + if (!position) { + const _ctx = ctx.current; + setPosition(findPositionOfColor(_ctx, props.rgb)); + } + }, [ctx.current, props.hue]); + + const pixelSelector = useCanvasPixelSelector( + canvas, + { allowOutside: true, shiftPosition: Vec2.new(0, -5) }, + (rgbColor, position) => { + props.onRgbChange(rgbColor); + setPosition(position); + }, + ); + + return ( +
+ + {position && ( +
+ )} +
+ ); +}; + +interface Props { + rgbColor: RGBColor; + onChange: (rgbColor: RGBColor) => void; +} + +export const ColorPicker: React.FC = (props) => { + const [initialRgb] = useState(props.rgbColor); + const [rgb, setRgb] = useState(props.rgbColor); + const [hue, setHue] = useState(() => rgbToHSL(rgb)[0]); + + // Update selected color on hue change + useDidUpdate(() => { + const [, s, l] = rgbToHSL(rgb); + setRgb(hslToRGB([hue, s, l])); + }, [hue]); + + useDidUpdate(() => { + props.onChange(rgb); + }, [rgb]); + + return ( +
+ + +
+
+
+
+
+ ); +}; diff --git a/src/composition/compositionTypes.ts b/src/composition/compositionTypes.ts index 974ceb7..c359397 100644 --- a/src/composition/compositionTypes.ts +++ b/src/composition/compositionTypes.ts @@ -1,4 +1,4 @@ -import { ValueType, PropertyName, PropertyGroupName } from "~/types"; +import { ValueType, PropertyName, PropertyGroupName, ValueFormat, RGBColor } from "~/types"; export interface Composition { id: string; @@ -28,16 +28,43 @@ export interface CompositionPropertyGroup { collapsed: boolean; } -export interface CompositionProperty { +export type CompositionProperty = { type: "property"; id: string; layerId: string; compositionId: string; name: PropertyName; - valueType: ValueType; - value: number; + valueFormat?: ValueFormat; timelineId: string; color?: string; min?: number; max?: number; +} & ( + | { + valueType: ValueType.Any; + value: any; + } + | { + valueType: ValueType.Color; + value: RGBColor; + } + | { + valueType: ValueType.Number; + value: number; + } + | { + valueType: ValueType.Rect; + value: Rect; + } + | { + valueType: ValueType.Vec2; + value: Vec2; + } +); + +export interface PropertyToValueMap { + [propertyId: string]: { + rawValue: unknown; + computedValue: unknown; + }; } diff --git a/src/composition/state/compositionReducer.ts b/src/composition/state/compositionReducer.ts index 3be7a02..c35f654 100644 --- a/src/composition/state/compositionReducer.ts +++ b/src/composition/state/compositionReducer.ts @@ -12,6 +12,7 @@ import { modifyItemInMap, modifyItemInUnionMap, } from "~/util/mapUtils"; +import { RGBAColor } from "~/types"; const createLayerId = (layers: CompositionState["layers"]) => ( @@ -102,7 +103,7 @@ export const compositionActions = { }), setPropertyValue: createAction("comp/SET_PROPERTY_VALUE", (action) => { - return (propertyId: string, value: number) => action({ propertyId, value }); + return (propertyId: string, value: number | RGBAColor) => action({ propertyId, value }); }), setPropertyGroupCollapsed: createAction("comp/SET_PROP_GROUP_COLLAPSED", (action) => { @@ -174,7 +175,7 @@ export const compositionReducer = ( propertyId, (item: CompositionProperty) => ({ ...item, - value, + value: value as any, }), ), }; diff --git a/src/composition/timeline/CompositionTimeline.tsx b/src/composition/timeline/CompositionTimeline.tsx index 36b6214..3a90a60 100644 --- a/src/composition/timeline/CompositionTimeline.tsx +++ b/src/composition/timeline/CompositionTimeline.tsx @@ -11,7 +11,7 @@ import { Composition, CompositionProperty } from "~/composition/compositionTypes import { splitRect, capToRange } from "~/util/math"; import { RequestActionCallback, requestAction } from "~/listener/requestAction"; import { separateLeftRightMouse } from "~/util/mouse"; -import { CompTimeLayer } from "~/composition/timeline/CompTimeLayer"; +import { CompTimeLayer } from "~/composition/timeline/layer/CompTimeLayer"; import { ViewBounds } from "~/timeline/ViewBounds"; import { areaActions } from "~/area/state/areaActions"; import { useKeyDownEffect } from "~/hook/useKeyDown"; diff --git a/src/composition/timeline/compTimeContext.ts b/src/composition/timeline/compTimeContext.ts new file mode 100644 index 0000000..ef318b6 --- /dev/null +++ b/src/composition/timeline/compTimeContext.ts @@ -0,0 +1,4 @@ +import React from "react"; +import { PropertyToValueMap } from "~/composition/compositionTypes"; + +export const CompTimePropertyValueContext = React.createContext({}); diff --git a/src/composition/timeline/CompTimeLayer.style.ts b/src/composition/timeline/layer/CompTimeLayer.style.ts similarity index 100% rename from src/composition/timeline/CompTimeLayer.style.ts rename to src/composition/timeline/layer/CompTimeLayer.style.ts diff --git a/src/composition/timeline/CompTimeLayer.tsx b/src/composition/timeline/layer/CompTimeLayer.tsx similarity index 61% rename from src/composition/timeline/CompTimeLayer.tsx rename to src/composition/timeline/layer/CompTimeLayer.tsx index 5bb33cf..8e6c25b 100644 --- a/src/composition/timeline/CompTimeLayer.tsx +++ b/src/composition/timeline/layer/CompTimeLayer.tsx @@ -1,19 +1,15 @@ import React from "react"; import { compileStylesheetLabelled } from "~/util/stylesheets"; import { CompositionLayer } from "~/composition/compositionTypes"; -import styles from "~/composition/timeline/CompTimeLayer.style"; -import { CompTimeLayerProperty } from "~/composition/timeline/CompTimeProperty"; +import styles from "~/composition/timeline/layer/CompTimeLayer.style"; +import { CompTimeLayerProperty } from "~/composition/timeline/property/CompTimeProperty"; import { CompTimeLayerName } from "~/composition/timeline/layer/CompTimeLayerName"; import { connectActionState } from "~/state/stateUtils"; import { separateLeftRightMouse } from "~/util/mouse"; import { compTimeHandlers } from "~/composition/timeline/compTimeHandlers"; import { GraphIcon } from "~/components/icons/GraphIcon"; -import { NodeEditorGraphState } from "~/nodeEditor/nodeEditorReducers"; -import { computeLayerGraph } from "~/nodeEditor/graph/computeLayerGraph"; -import { useComputeHistory } from "~/hook/useComputeHistory"; -import { useActionState } from "~/hook/useActionState"; -import { ComputeNodeContext } from "~/nodeEditor/graph/computeNode"; import { OpenInAreaIcon } from "~/components/icons/OpenInAreaIcon"; +import { CompTimeLayerPropertyToValue } from "~/composition/timeline/layer/CompTimeLayerPropertyToValue"; const s = compileStylesheetLabelled(styles); @@ -23,30 +19,12 @@ interface OwnProps { } interface StateProps { layer: CompositionLayer; - graph?: NodeEditorGraphState; isSelected: boolean; } type Props = OwnProps & StateProps; const CompTimeLayerComponent: React.FC = (props) => { - const { layer, graph } = props; - - const { computePropertyValues } = useComputeHistory(() => { - return { computePropertyValues: computeLayerGraph(graph) }; - }); - - const propertyToValue = useActionState((actionState) => { - const context: ComputeNodeContext = { - computed: {}, - compositionId: props.compositionId, - layerId: props.id, - compositionState: actionState.compositions, - timelines: actionState.timelines, - timelineSelection: actionState.timelineSelection, - }; - - return computePropertyValues(context, graph && actionState.nodeEditor.graphs[graph.id]); - }); + const { layer } = props; return ( <> @@ -78,29 +56,31 @@ const CompTimeLayerComponent: React.FC = (props) => {
)}
- {layer.properties.map((id) => { - return ( + + {layer.properties.map((id) => ( - ); - })} + ))} + ); }; const mapStateToProps: MapActionState = ( - { nodeEditor, compositions, compositionSelection }, + { compositions, compositionSelection }, { id }, ) => { const layer = compositions.layers[id]; return { layer, - graph: layer.graphId ? nodeEditor.graphs[layer.graphId] : undefined, isSelected: !!compositionSelection.layers[id], }; }; diff --git a/src/composition/timeline/layer/CompTimeLayerPropertyToValue.tsx b/src/composition/timeline/layer/CompTimeLayerPropertyToValue.tsx new file mode 100644 index 0000000..bfd8b18 --- /dev/null +++ b/src/composition/timeline/layer/CompTimeLayerPropertyToValue.tsx @@ -0,0 +1,43 @@ +import React from "react"; +import { useComputeHistory } from "~/hook/useComputeHistory"; +import { computeLayerGraph } from "~/nodeEditor/graph/computeLayerGraph"; +import { useActionState } from "~/hook/useActionState"; +import { ComputeNodeContext } from "~/nodeEditor/graph/computeNode"; +import { CompTimePropertyValueContext } from "~/composition/timeline/compTimeContext"; + +interface OwnProps { + compositionId: string; + layerId: string; + graphId: string; +} +type Props = OwnProps; + +export const CompTimeLayerPropertyToValue: React.FC = (props) => { + const { compositionId, layerId, graphId } = props; + + const { computePropertyValues } = useComputeHistory((state) => { + const graph = state.nodeEditor.graphs[graphId]; + return { computePropertyValues: computeLayerGraph(graph) }; + }); + + const propertyToValue = useActionState((actionState) => { + const graph = actionState.nodeEditor.graphs[graphId]; + + const context: ComputeNodeContext = { + computed: {}, + compositionId, + layerId, + compositionState: actionState.compositions, + timelines: actionState.timelines, + timelineSelection: actionState.timelineSelection, + }; + + return computePropertyValues(context, graph && actionState.nodeEditor.graphs[graph.id]); + }); + + return ( + + {props.children} + + ); +}; diff --git a/src/composition/timeline/property/CompTimeColorProperty.tsx b/src/composition/timeline/property/CompTimeColorProperty.tsx new file mode 100644 index 0000000..d0608a0 --- /dev/null +++ b/src/composition/timeline/property/CompTimeColorProperty.tsx @@ -0,0 +1,80 @@ +import React from "react"; + +import { StopwatchIcon } from "~/components/icons/StopwatchIcon"; +import { compileStylesheetLabelled } from "~/util/stylesheets"; +import { + CompositionProperty, + Composition, + PropertyToValueMap, +} from "~/composition/compositionTypes"; +import { connectActionState } from "~/state/stateUtils"; +import { separateLeftRightMouse } from "~/util/mouse"; +import { Timeline } from "~/timeline/timelineTypes"; +import { compTimeHandlers } from "~/composition/timeline/compTimeHandlers"; +import { CompTimePropertyName } from "~/composition/timeline/property/common/CompTimePropertyName"; +import styles from "~/composition/timeline/property/CompTimeProperty.styles"; +import { CompTimePropertyValue } from "~/composition/timeline/property/value/CompTimePropertyValue"; + +const s = compileStylesheetLabelled(styles); + +interface OwnProps { + compositionId: string; + propertyId: string; + propertyToValue: PropertyToValueMap; + depth: number; +} +interface StateProps { + property: CompositionProperty; + isSelected: boolean; + composition: Composition; + timeline?: Timeline; +} +type Props = OwnProps & StateProps; + +const CompTimeColorPropertyComponent: React.FC = (props) => { + const { property } = props; + + return ( +
+
+
+ compTimeHandlers.onPropertyKeyframeIconMouseDown( + props.compositionId, + property.id, + property.timelineId, + ), + })} + > + +
+ + +
+
+ ); +}; + +const mapStateToProps: MapActionState = ( + { timelines, compositions, compositionSelection }, + { propertyId, compositionId }, +) => { + const composition = compositions.compositions[compositionId]; + const property = compositions.properties[propertyId] as CompositionProperty; + const isSelected = !!compositionSelection.properties[propertyId]; + + const timeline = property.timelineId ? timelines[property.timelineId] : undefined; + + return { + composition, + timeline, + isSelected, + property, + }; +}; + +export const CompTimeColorProperty = connectActionState(mapStateToProps)( + CompTimeColorPropertyComponent, +); diff --git a/src/composition/timeline/CompTimeProperty.styles.ts b/src/composition/timeline/property/CompTimeProperty.styles.ts similarity index 79% rename from src/composition/timeline/CompTimeProperty.styles.ts rename to src/composition/timeline/property/CompTimeProperty.styles.ts index 4a5c686..5aeb30b 100644 --- a/src/composition/timeline/CompTimeProperty.styles.ts +++ b/src/composition/timeline/property/CompTimeProperty.styles.ts @@ -66,7 +66,7 @@ export default ({ css }: StyleParams) => ({ `, value: css` - width: 80px; + white-space: nowrap; `, timelineIcon: css` @@ -88,4 +88,21 @@ export default ({ css }: StyleParams) => ({ } } `, + + colorValueButton: css` + border: none; + background: black; + border-radius: 3px; + margin-top: 1px; + height: 14px; + width: 32px; + `, + + colorPickerWrapper: css` + background: ${cssVariables.dark700}; + border: 1px solid ${cssVariables.gray600}; + padding: 16px; + border-radius: 4px; + border-bottom-left-radius: 0; + `, }); diff --git a/src/composition/timeline/CompTimeProperty.tsx b/src/composition/timeline/property/CompTimeProperty.tsx similarity index 76% rename from src/composition/timeline/CompTimeProperty.tsx rename to src/composition/timeline/property/CompTimeProperty.tsx index 23558e7..5ddc7e1 100644 --- a/src/composition/timeline/CompTimeProperty.tsx +++ b/src/composition/timeline/property/CompTimeProperty.tsx @@ -8,30 +8,23 @@ import { } from "~/composition/compositionTypes"; import { connectActionState } from "~/state/stateUtils"; import { separateLeftRightMouse } from "~/util/mouse"; -import { NumberInput } from "~/components/common/NumberInput"; import { Timeline } from "~/timeline/timelineTypes"; -import styles from "~/composition/timeline/CompTimeProperty.styles"; +import styles from "~/composition/timeline/property/CompTimeProperty.styles"; import { compTimeHandlers } from "~/composition/timeline/compTimeHandlers"; import { getLayerPropertyLabel, getLayerPropertyGroupLabel, } from "~/composition/util/compositionPropertyUtils"; -import { PropertyName } from "~/types"; -import { usePropertyNumberInput } from "~/composition/hook/usePropertyNumberInput"; import { requestAction } from "~/listener/requestAction"; import { compositionActions } from "~/composition/state/compositionReducer"; +import { CompTimePropertyValue } from "~/composition/timeline/property/value/CompTimePropertyValue"; +import { ValueType } from "~/types"; const s = compileStylesheetLabelled(styles); interface OwnProps { compositionId: string; id: string; - propertyToValue: { - [propertyId: string]: { - rawValue: number; - computedValue: number; - }; - }; depth: number; } interface StateProps { @@ -43,9 +36,7 @@ interface StateProps { type Props = OwnProps & StateProps; const CompTimeLayerPropertyComponent: React.FC = (props) => { - const { property, composition, timeline } = props; - - const value = props.propertyToValue[props.id]; + const { property } = props; if (property.type === "group") { const { properties } = property; @@ -93,7 +84,6 @@ const CompTimeLayerPropertyComponent: React.FC = (props) => { compositionId={props.compositionId} id={id} key={id} - propertyToValue={props.propertyToValue} depth={props.depth + 1} /> ))} @@ -101,12 +91,6 @@ const CompTimeLayerPropertyComponent: React.FC = (props) => { ); } - const [onValueChange, onValueChangeEnd] = usePropertyNumberInput( - timeline, - property, - composition, - ); - return (
@@ -120,6 +104,11 @@ const CompTimeLayerPropertyComponent: React.FC = (props) => { property.timelineId, ), })} + style={ + property.valueType === ValueType.Color + ? { pointerEvents: "none", opacity: "0" } + : {} + } >
@@ -138,28 +127,7 @@ const CompTimeLayerPropertyComponent: React.FC = (props) => { > {getLayerPropertyLabel(property.name)}
-
- -
+
); diff --git a/src/composition/timeline/property/common/CompTimePropertyName.tsx b/src/composition/timeline/property/common/CompTimePropertyName.tsx new file mode 100644 index 0000000..c985018 --- /dev/null +++ b/src/composition/timeline/property/common/CompTimePropertyName.tsx @@ -0,0 +1,57 @@ +import React from "react"; +import { separateLeftRightMouse } from "~/util/mouse"; +import { compTimeHandlers } from "~/composition/timeline/compTimeHandlers"; +import { compileStylesheetLabelled } from "~/util/stylesheets"; +import CompTimePropertyStyles from "~/composition/timeline/property/CompTimeProperty.styles"; +import { getLayerPropertyLabel } from "~/composition/util/compositionPropertyUtils"; +import { CompositionProperty } from "~/composition/compositionTypes"; +import { connectActionState } from "~/state/stateUtils"; +import { PropertyName } from "~/types"; + +const s = compileStylesheetLabelled(CompTimePropertyStyles); + +interface OwnProps { + propertyId: string; +} +interface StateProps { + compositionId: string; + selected: boolean; + name: PropertyName; +} +type Props = OwnProps & StateProps; + +const CompTimePropertyNameComponent: React.FC = (props) => { + return ( +
+ compTimeHandlers.onPropertyNameMouseDown( + e, + props.compositionId, + props.propertyId, + ), + })} + > + {getLayerPropertyLabel(props.name)} +
+ ); +}; + +const mapState: MapActionState = ( + { compositions, compositionSelection }, + { propertyId }, +) => { + const selected = !!compositionSelection.properties[propertyId]; + const { name, compositionId } = compositions.properties[propertyId] as CompositionProperty; + + return { + compositionId, + name, + selected, + }; +}; + +export const CompTimePropertyName = connectActionState(mapState)(CompTimePropertyNameComponent); diff --git a/src/composition/timeline/property/value/CompTimePropertyColorValue.tsx b/src/composition/timeline/property/value/CompTimePropertyColorValue.tsx new file mode 100644 index 0000000..1858228 --- /dev/null +++ b/src/composition/timeline/property/value/CompTimePropertyColorValue.tsx @@ -0,0 +1,106 @@ +import React, { useRef, useEffect } from "react"; +import { compositionActions } from "~/composition/state/compositionReducer"; +import { OpenCustomContextMenuOptions, ContextMenuBaseProps } from "~/contextMenu/contextMenuTypes"; +import { useRefRect, useGetRefRectFn } from "~/hook/useRefRect"; +import { ColorPicker } from "~/components/colorPicker/ColorPicker"; +import { requestAction } from "~/listener/requestAction"; +import { contextMenuActions } from "~/contextMenu/contextMenuActions"; +import { useKeyDownEffect } from "~/hook/useKeyDown"; +import { compileStylesheetLabelled } from "~/util/stylesheets"; +import styles from "~/composition/timeline/property/CompTimeProperty.styles"; +import { RGBAColor, RGBColor } from "~/types"; + +const s = compileStylesheetLabelled(styles); + +interface ColorOwnProps { + propertyId: string; + value: RGBAColor; +} +type ColorProps = ColorOwnProps; + +export const CompTimePropertyColorValue: React.FC = (props) => { + const buttonRef = useRef(null); + const getButtonRect = useGetRefRectFn(buttonRef); + + const value = props.value; + + const onClick = () => { + const [r, g, b] = value; + const rgb: RGBColor = [r, g, b]; + + requestAction({ history: true }, (params) => { + const Component: React.FC = ({ updateRect }) => { + const ref = useRef(null); + const rect = useRefRect(ref); + const latestColor = useRef(rgb); + + useEffect(() => { + updateRect(rect!); + }, [rect]); + + const onChange = (rgbColor: RGBColor) => { + latestColor.current = rgbColor; + + const rgbaColor = [...rgbColor, 1] as RGBAColor; + + params.dispatch( + compositionActions.setPropertyValue(props.propertyId, rgbaColor), + ); + }; + + // Submit on enter + useKeyDownEffect("Enter", (down) => { + if (!down) { + return; + } + + let changed = false; + + for (let i = 0; i < rgb.length; i += 1) { + if (rgb[i] !== latestColor.current[i]) { + changed = true; + break; + } + } + + if (!changed) { + params.cancelAction(); + return; + } + + params.dispatch(contextMenuActions.closeContextMenu()); + params.submitAction("Update color"); + }); + + return ( +
+ +
+ ); + }; + + const rect = getButtonRect()!; + + const options: OpenCustomContextMenuOptions = { + component: Component, + props: {}, + position: Vec2.new(rect.left + rect.width + 8, rect.top + rect.height), + alignPosition: "bottom-left", + closeMenuBuffer: Infinity, + close: () => params.cancelAction(), + }; + params.dispatch(contextMenuActions.openCustomContextMenu(options)); + }); + }; + + return ( +
+
+ ); +}; diff --git a/src/composition/timeline/property/value/CompTimePropertyNumberValue.tsx b/src/composition/timeline/property/value/CompTimePropertyNumberValue.tsx new file mode 100644 index 0000000..45afbb0 --- /dev/null +++ b/src/composition/timeline/property/value/CompTimePropertyNumberValue.tsx @@ -0,0 +1,78 @@ +import React from "react"; +import { connectActionState } from "~/state/stateUtils"; +import { CompositionProperty, Composition } from "~/composition/compositionTypes"; +import { Timeline } from "~/timeline/timelineTypes"; +import { usePropertyNumberInput } from "~/composition/hook/usePropertyNumberInput"; +import { NumberInput } from "~/components/common/NumberInput"; +import { compileStylesheetLabelled } from "~/util/stylesheets"; +import CompTimePropertyStyles from "~/composition/timeline/property/CompTimeProperty.styles"; +import { PropertyName } from "~/types"; + +const s = compileStylesheetLabelled(CompTimePropertyStyles); + +interface OwnProps { + propertyId: string; + rawValue: number; + computedValue: number; +} +interface StateProps { + property: CompositionProperty; + composition: Composition; + timeline?: Timeline; +} +type Props = OwnProps & StateProps; + +const NumberValueComponent: React.FC = (props) => { + const { composition, property, timeline } = props; + + const [onValueChange, onValueChangeEnd] = usePropertyNumberInput( + timeline, + property, + composition, + ); + + return ( +
+ +
+ ); +}; + +const mapStateToProps: MapActionState = ( + { timelines, compositions, compositionSelection }, + { propertyId }, +) => { + const property = compositions.properties[propertyId] as CompositionProperty; + const composition = compositions.compositions[property.compositionId]; + const isSelected = !!compositionSelection.properties[propertyId]; + + const timeline = property.timelineId ? timelines[property.timelineId] : undefined; + + return { + composition, + timeline, + isSelected, + property, + }; +}; + +export const CompTimePropertyNumberValue = connectActionState(mapStateToProps)( + NumberValueComponent, +); diff --git a/src/composition/timeline/property/value/CompTimePropertyValue.tsx b/src/composition/timeline/property/value/CompTimePropertyValue.tsx new file mode 100644 index 0000000..f1530f7 --- /dev/null +++ b/src/composition/timeline/property/value/CompTimePropertyValue.tsx @@ -0,0 +1,49 @@ +import React, { useContext } from "react"; + +import { RGBAColor, ValueType } from "~/types"; +import { connectActionState } from "~/state/stateUtils"; +import { CompTimePropertyValueContext } from "~/composition/timeline/compTimeContext"; +import { CompositionProperty } from "~/composition/compositionTypes"; +import { CompTimePropertyNumberValue } from "~/composition/timeline/property/value/CompTimePropertyNumberValue"; +import { CompTimePropertyColorValue } from "~/composition/timeline/property/value/CompTimePropertyColorValue"; + +interface OwnProps { + propertyId: string; +} +interface StateProps { + valueType: ValueType; +} +type Props = OwnProps & StateProps; + +const CompTimePropertyValueComponent: React.FC = (props) => { + const propertyToValue = useContext(CompTimePropertyValueContext); + + const value = propertyToValue[props.propertyId]; + + if (props.valueType === ValueType.Color) { + return ( + + ); + } + + if (props.valueType === ValueType.Number) { + return ( + + ); + } + + return null; +}; + +const mapState: MapActionState = ({ compositions }, { propertyId }) => ({ + valueType: (compositions.properties[propertyId] as CompositionProperty).valueType, +}); + +export const CompTimePropertyValue = connectActionState(mapState)(CompTimePropertyValueComponent); diff --git a/src/composition/util/compositionPropertyUtils.ts b/src/composition/util/compositionPropertyUtils.ts index 35bbded..866df9c 100644 --- a/src/composition/util/compositionPropertyUtils.ts +++ b/src/composition/util/compositionPropertyUtils.ts @@ -6,9 +6,12 @@ import { CompositionState } from "~/composition/state/compositionReducer"; const propertyGroupNameToLabel: { [key in keyof typeof PropertyGroupName]: string } = { Dimensions: "Dimensions", Transform: "Transform", + Content: "Content", }; const propertyNameToLabel: { [key in keyof typeof PropertyName]: string } = { + AnchorX: "Anchor X", + AnchorY: "Anchor Y", Scale: "Scale", Rotation: "Rotation", PositionX: "X Position", @@ -17,6 +20,10 @@ const propertyNameToLabel: { [key in keyof typeof PropertyName]: string } = { Height: "Height", Width: "Width", + Fill: "Fill", + StrokeColor: "Stroke Color", + StrokeWidth: "Stroke Width", + BorderRadius: "Border Radius", }; export const getLayerPropertyLabel = (name: PropertyName): string => { @@ -39,6 +46,28 @@ const createDefaultTransformProperties = (opts: Options): CompositionProperty[] const { compositionId, createId, layerId } = opts; return [ + { + type: "property", + id: createId(), + layerId, + compositionId, + name: PropertyName.AnchorX, + timelineId: "", + valueType: ValueType.Number, + value: 0, + color: TimelineColors.XPosition, + }, + { + type: "property", + id: createId(), + layerId, + compositionId, + name: PropertyName.AnchorY, + timelineId: "", + valueType: ValueType.Number, + value: 0, + color: TimelineColors.YPosition, + }, { type: "property", id: createId(), @@ -130,6 +159,61 @@ const createDefaultDimensionProperties = (opts: Options): CompositionProperty[] ]; }; +const createDefaultContentsProperties = (opts: Options): CompositionProperty[] => { + const { compositionId, createId, layerId } = opts; + + return [ + { + type: "property", + id: createId(), + layerId, + compositionId, + name: PropertyName.Fill, + timelineId: "", + valueType: ValueType.Color, + color: TimelineColors.Width, + value: [255, 0, 0], + min: 0, + }, + { + type: "property", + id: createId(), + layerId, + compositionId, + name: PropertyName.StrokeWidth, + timelineId: "", + valueType: ValueType.Number, + color: TimelineColors.Height, + value: 0, + min: 0, + }, + { + type: "property", + id: createId(), + layerId, + compositionId, + name: PropertyName.StrokeColor, + timelineId: "", + valueType: ValueType.Color, + color: TimelineColors.Height, + value: [0, 0, 255], + min: 0, + }, + { + type: "property", + id: createId(), + layerId, + compositionId, + name: PropertyName.BorderRadius, + timelineId: "", + valueType: ValueType.Number, + color: TimelineColors.Height, + value: 0, + min: 0, + }, + ]; +}; + export const getDefaultLayerProperties = ( opts: Options, ): { @@ -154,9 +238,18 @@ export const getDefaultLayerProperties = ( collapsed: true, }; + const contentProperties = createDefaultContentsProperties(opts); + const contentGroup: CompositionPropertyGroup = { + type: "group", + name: PropertyGroupName.Content, + id: opts.createId(), + properties: contentProperties.map((p) => p.id), + collapsed: true, + }; + return { - nestedProperties: [...transformProperties, ...dimensionProperties], - topLevelProperties: [dimensionsGroup, transformGroup], + nestedProperties: [...transformProperties, ...dimensionProperties, ...contentProperties], + topLevelProperties: [contentGroup, dimensionsGroup, transformGroup], }; }; diff --git a/src/composition/workspace/CompositionWorkspaceLayer.tsx b/src/composition/workspace/CompositionWorkspaceLayer.tsx index 27dbf14..ffff1eb 100644 --- a/src/composition/workspace/CompositionWorkspaceLayer.tsx +++ b/src/composition/workspace/CompositionWorkspaceLayer.tsx @@ -55,27 +55,49 @@ const CompositionWorkspaceLayerComponent: React.FC = (props) => { return getLayerCompositionProperties(layer.id, state.compositions); }); - const nameToProperty = properties.reduce((obj, p) => { - const value = propertyToValue[p.id] ?? p.value; - (obj as any)[PropertyName[p.name]] = value.computedValue; - return obj; - }, {} as { [key in keyof typeof PropertyName]: number }); + const nameToProperty = properties.reduce<{ [key in keyof typeof PropertyName]: any }>( + (obj, p) => { + const value = propertyToValue[p.id] ?? p.value; + (obj as any)[PropertyName[p.name]] = value.computedValue; + return obj; + }, + {} as any, + ); + + const { + Width, + Height, + PositionX, + PositionY, + Scale, + Opacity, + Rotation, + Fill, + StrokeWidth, + StrokeColor, + BorderRadius, + } = nameToProperty; - const { Width, Height, PositionX, PositionY, Scale, Opacity, Rotation } = nameToProperty; + const fillColor = `rgba(${Fill.join(",")})`; + const strokeColor = `rgba(${StrokeColor.join(",")})`; return ( -
+ /> ); }; diff --git a/src/composition/workspace/CompositionWorkspaceViewport.tsx b/src/composition/workspace/CompositionWorkspaceViewport.tsx index 450d898..0c6ce8f 100644 --- a/src/composition/workspace/CompositionWorkspaceViewport.tsx +++ b/src/composition/workspace/CompositionWorkspaceViewport.tsx @@ -27,7 +27,7 @@ const CompositionWorkspaceViewportComponent: React.FC = (props) => { const { width, height, layerIds } = props; return ( -
+ {layerIds.map((id) => ( = (props) => { layerId={id} /> ))} -
+ ); }; diff --git a/src/contextMenu/CustomContextMenu.tsx b/src/contextMenu/CustomContextMenu.tsx index ee3876a..594a06f 100644 --- a/src/contextMenu/CustomContextMenu.tsx +++ b/src/contextMenu/CustomContextMenu.tsx @@ -5,7 +5,7 @@ import { cssZIndex } from "~/cssVariables"; import { useState } from "react"; import { connectActionState } from "~/state/stateUtils"; -const CLOSE_MENU_BUFFER = 100; +const DEFAULT_CLOSE_MENU_BUFFER = 100; const s = compileStylesheetLabelled(({ css }) => ({ background: css` @@ -23,6 +23,14 @@ const s = compileStylesheetLabelled(({ css }) => ({ top: 0; left: 0; z-index: ${cssZIndex.contextMenu}; + + &--center { + transform: translate(-50%, -50%); + } + + &--bottomLeft { + transform: translate(0, -100%); + } `, })); @@ -40,6 +48,9 @@ const CustomContextMenuComponent: React.FC = (props) => { return null; } + const center = options.alignPosition === "center"; + const bottomLeft = options.alignPosition === "bottom-left"; + const onMouseMove = (e: React.MouseEvent) => { const vec = Vec2.fromEvent(e); const { x, y } = vec; @@ -48,11 +59,13 @@ const CustomContextMenuComponent: React.FC = (props) => { return; } + const closeMenuBuffer = options.closeMenuBuffer ?? DEFAULT_CLOSE_MENU_BUFFER; + if ( - x < rect.left - CLOSE_MENU_BUFFER || - x > rect.left + rect.width + CLOSE_MENU_BUFFER || - y < rect.top - CLOSE_MENU_BUFFER || - y > rect.top + rect.height + CLOSE_MENU_BUFFER + x < rect.left - closeMenuBuffer || + x > rect.left + rect.width + closeMenuBuffer || + y < rect.top - closeMenuBuffer || + y > rect.top + rect.height + closeMenuBuffer ) { options.close(); } @@ -68,7 +81,7 @@ const CustomContextMenuComponent: React.FC = (props) => { onMouseDown={() => options.close()} />
diff --git a/src/contextMenu/contextMenuTypes.ts b/src/contextMenu/contextMenuTypes.ts index 3b17c1a..8526611 100644 --- a/src/contextMenu/contextMenuTypes.ts +++ b/src/contextMenu/contextMenuTypes.ts @@ -8,5 +8,7 @@ export interface OpenCustomContextMenuOptions< component: React.ComponentType; props: Omit; position: Vec2; + alignPosition?: "top-left" | "bottom-left" | "center"; + closeMenuBuffer?: number; close: () => void; } diff --git a/src/hook/useCanvasPixelSelector.ts b/src/hook/useCanvasPixelSelector.ts new file mode 100644 index 0000000..8ba3c6c --- /dev/null +++ b/src/hook/useCanvasPixelSelector.ts @@ -0,0 +1,77 @@ +import { useRef } from "react"; +import { useRefRect } from "~/hook/useRefRect"; +import { RGBColor } from "~/types"; +import { isVecInRect } from "~/util/math"; + +export const useCanvasPixelSelector = ( + ref: React.RefObject, + options: { allowOutside: boolean; shiftPosition?: Vec2 }, + callback: (color: RGBColor, position: Vec2) => void, +) => { + const rect = useRefRect(ref); + const isDragging = useRef(false); + + const onMouseDown = (e: React.MouseEvent) => { + isDragging.current = true; + + const onMouseMoveListener = (e: MouseEvent | React.MouseEvent) => { + if (!isDragging.current) { + return; + } + + if (!rect || !ref.current) { + return; + } + + let vec = Vec2.fromEvent(e); + + if (options.shiftPosition) { + vec = vec.add(options.shiftPosition); + } + + const isInRect = isVecInRect(vec, { + ...rect, + width: rect.width - 1, + height: rect.height - 1, + }); + if (!isInRect && !options.allowOutside) { + return; + } + + if (!isInRect) { + vec.x = Math.min(rect.left + rect.width - 1, Math.max(rect.left, vec.x)); + vec.y = Math.min(rect.top + rect.height - 1, Math.max(rect.top, vec.y)); + } + + vec = vec.sub(Vec2.new(rect.left, rect.top)); + + const ctx = ref.current.getContext("2d"); + + if (!ctx) { + return; + } + + if (vec.x < 0) { + return; + } + + var [r, g, b] = ctx.getImageData(vec.x, vec.y, 1, 1).data; + callback([r, g, b], vec); + }; + + onMouseMoveListener(e); + + const mouseUpListener = () => { + isDragging.current = false; + window.removeEventListener("mousemove", onMouseMoveListener); + window.removeEventListener("mouseup", mouseUpListener); + }; + + window.addEventListener("mousemove", onMouseMoveListener); + window.addEventListener("mouseup", mouseUpListener); + }; + + return { + onMouseDown, + }; +}; diff --git a/src/hook/useDebounce.ts b/src/hook/useDebounce.ts new file mode 100644 index 0000000..95cad4b --- /dev/null +++ b/src/hook/useDebounce.ts @@ -0,0 +1,17 @@ +import { useRef, useEffect } from "react"; +import { ArgumentTypes } from "~/types"; + +export const useDebounce = (timeMs: number, fn: T): T => { + const timeoutRef = useRef(); + + useEffect(() => { + return () => { + window.clearTimeout(timeoutRef.current); + }; + }, []); + + return ((...args: ArgumentTypes) => { + window.clearTimeout(timeoutRef.current); + timeoutRef.current = window.setTimeout(() => fn(...args), timeMs); + }) as any; +}; diff --git a/src/hook/useDidUpdate.ts b/src/hook/useDidUpdate.ts new file mode 100644 index 0000000..c494ac7 --- /dev/null +++ b/src/hook/useDidUpdate.ts @@ -0,0 +1,13 @@ +import { useRef, useEffect } from "react"; + +export const useDidUpdate = (callback: () => void, deps: any[]) => { + const hasMount = useRef(false); + + useEffect(() => { + if (hasMount.current) { + callback(); + } else { + hasMount.current = true; + } + }, deps); +}; diff --git a/src/listener/requestAction.ts b/src/listener/requestAction.ts index ead165f..a1dc1c4 100644 --- a/src/listener/requestAction.ts +++ b/src/listener/requestAction.ts @@ -79,6 +79,16 @@ const performRequestedAction = ( }, submitAction: (name = "Unknown action") => { + if (!getActionId()) { + console.warn("Attempted to submit an action that does not exist."); + return; + } + + if (getActionId() !== actionId) { + console.warn("Attempted to submit with the wrong action id."); + return; + } + if ( typeof shouldAddToStack === "function" ? !shouldAddToStack(getCurrentState(), getActionState()) diff --git a/src/nodeEditor/NodeEditor.tsx b/src/nodeEditor/NodeEditor.tsx index cf20391..aeb6a6a 100644 --- a/src/nodeEditor/NodeEditor.tsx +++ b/src/nodeEditor/NodeEditor.tsx @@ -20,6 +20,7 @@ import { Vec2LerpNode } from "~/nodeEditor/nodes/Vec2LerpNode"; import { Vec2InputNode } from "~/nodeEditor/nodes/Vec2InputNode"; import { PropertyInputNode } from "~/nodeEditor/nodes/property/PropertyInputNode"; import { PropertyOutputNode } from "~/nodeEditor/nodes/property/PropertyOutputNode"; +import { ColorInputNode } from "~/nodeEditor/nodes/color/ColorInputNode"; const s = compileStylesheetLabelled(styles); @@ -158,6 +159,11 @@ const NodeEditorComponent: React.FC = (props) => { break; } + case NodeEditorNodeType.color_input: { + NodeComponent = ColorInputNode; + break; + } + case NodeEditorNodeType.num_input: { NodeComponent = NumberInputNode; break; diff --git a/src/nodeEditor/components/NodeEditorNumberInput.tsx b/src/nodeEditor/components/NodeEditorNumberInput.tsx index f296199..5b24c4c 100644 --- a/src/nodeEditor/components/NodeEditorNumberInput.tsx +++ b/src/nodeEditor/components/NodeEditorNumberInput.tsx @@ -33,10 +33,16 @@ interface Props { horizontalPadding?: boolean; paddingRight?: boolean; paddingLeft?: boolean; + decimalPlaces?: number; } export const NodeEditorNumberInput: React.FC = (props) => { - const { horizontalPadding = false, paddingLeft = false, paddingRight = false } = props; + const { + horizontalPadding = false, + paddingLeft = false, + paddingRight = false, + decimalPlaces, + } = props; return (
= (props) => { onChangeEnd={props.onChangeEnd} max={props.max} min={props.min} - decimalPlaces={1} + decimalPlaces={decimalPlaces} fillWidth fullWidth /> diff --git a/src/nodeEditor/components/NodeEditorTValueInput.tsx b/src/nodeEditor/components/NodeEditorTValueInput.tsx index 42aa90b..0e52109 100644 --- a/src/nodeEditor/components/NodeEditorTValueInput.tsx +++ b/src/nodeEditor/components/NodeEditorTValueInput.tsx @@ -44,8 +44,8 @@ export const NodeEditorTValueInput: React.FC = (props) => { onChangeEnd={props.onChangeEnd} max={1} min={0} - decimalPlaces={4} - tick={0.001} + decimalPlaces={2} + tick={0.01} fullWidth fillWidth /> diff --git a/src/nodeEditor/graph/computeLayerGraph.ts b/src/nodeEditor/graph/computeLayerGraph.ts index 5c05904..b640240 100644 --- a/src/nodeEditor/graph/computeLayerGraph.ts +++ b/src/nodeEditor/graph/computeLayerGraph.ts @@ -11,8 +11,8 @@ type Fn = ( graph?: NodeEditorGraphState, ) => { [propertyId: string]: { - computedValue: number; - rawValue: number; + computedValue: any; + rawValue: any; }; }; @@ -25,8 +25,8 @@ export const computeLayerGraph = (graph?: NodeEditorGraphState): Fn => { return properties.reduce<{ [propertyId: string]: { - computedValue: number; - rawValue: number; + computedValue: any; + rawValue: any; }; }>((obj, p) => { const rawValue = p.timelineId diff --git a/src/nodeEditor/graph/computeNode.ts b/src/nodeEditor/graph/computeNode.ts index 6cd94fc..bc23693 100644 --- a/src/nodeEditor/graph/computeNode.ts +++ b/src/nodeEditor/graph/computeNode.ts @@ -1,10 +1,10 @@ import { NodeEditorNode, NodeEditorNodeState } from "~/nodeEditor/nodeEditorIO"; -import { NodeEditorNodeType, ValueType } from "~/types"; +import { NodeEditorNodeType, ValueType, RGBAColor } from "~/types"; import { DEG_TO_RAD_FAC, RAD_TO_DEG_FAC } from "~/constants"; import { CompositionProperty } from "~/composition/compositionTypes"; import { TimelineState } from "~/timeline/timelineReducer"; import { getTimelineValueAtIndex } from "~/timeline/timelineUtils"; -import { interpolate } from "~/util/math"; +import { interpolate, capToRange } from "~/util/math"; import { getExpressionIO } from "~/util/math/expressions"; import * as mathjs from "mathjs"; import { TimelineSelectionState } from "~/timeline/timelineSelectionReducer"; @@ -42,6 +42,31 @@ const parseNum = (arg: ComputeNodeArg): number => { } }; +const parseColor = (arg: ComputeNodeArg): RGBAColor => { + switch (arg.type) { + case ValueType.Color: + return arg.value; + case ValueType.Any: { + if (Array.isArray(arg.value)) { + const [r, g, b, a] = arg.value.map((n) => capToRange(0, 255, parseInt(n))); + const color: RGBAColor = [r, g, b, a]; + + for (let i = 0; i < color.length; i += 1) { + if (isNaN(color[i])) { + throw new Error("Unsuccessful conversion from Arg of type Any to Number"); + } + } + + return color; + } + + throw new Error("Unsuccessful conversion from Arg of type Any to Number"); + } + default: + throw new Error(`Unexpected Arg Type '${arg.type}'`); + } +}; + const parseVec2 = (arg: ComputeNodeArg): Vec2 => { switch (arg.type) { case ValueType.Vec2: @@ -132,6 +157,10 @@ const toArg = { type: ValueType.Rect, value, }), + color: (value: RGBAColor) => ({ + type: ValueType.Color, + value, + }), any: (value: any) => ({ type: ValueType.Any, value, @@ -210,6 +239,24 @@ const compute: { return [toArg.vec2(Vec2.new(x, y))]; }, + [Type.color_input]: ( + _args, + _ctx, + state: NodeEditorNodeState, + ) => { + return [toArg.color(state.color)]; + }, + + [Type.color_from_rgba_factors]: (args) => { + const [r, g, b, a] = args.map((x) => parseNum(x)); + return [toArg.color([r, g, b, a])]; + }, + + [Type.color_to_rgba_factors]: (args) => { + const color = parseColor(args[0]); + return color.map((x) => toArg.number(x)); + }, + [Type.expr]: (args, _ctx, state: NodeEditorNodeState) => { const expression = state.expression; const io = getExpressionIO(expression); diff --git a/src/nodeEditor/nodeEditorIO.ts b/src/nodeEditor/nodeEditorIO.ts index 19d49f1..bbcd655 100644 --- a/src/nodeEditor/nodeEditorIO.ts +++ b/src/nodeEditor/nodeEditorIO.ts @@ -1,4 +1,4 @@ -import { NodeEditorNodeType, ValueType } from "~/types"; +import { NodeEditorNodeType, ValueType, RGBAColor } from "~/types"; export const getNodeEditorNodeDefaultInputs = (type: NodeEditorNodeType): NodeEditorNodeInput[] => { switch (type) { @@ -154,6 +154,47 @@ export const getNodeEditorNodeDefaultInputs = (type: NodeEditorNodeType): NodeEd }, ]; + case NodeEditorNodeType.color_input: + return []; + + case NodeEditorNodeType.color_from_rgba_factors: + return [ + { + type: ValueType.Number, + name: "R", + value: 0, + pointer: null, + }, + { + type: ValueType.Number, + name: "G", + value: 0, + pointer: null, + }, + { + type: ValueType.Number, + name: "B", + value: 0, + pointer: null, + }, + { + type: ValueType.Number, + name: "A", + value: 1, + pointer: null, + }, + ]; + + case NodeEditorNodeType.color_to_rgba_factors: + return [ + { + type: ValueType.Color, + name: "Color", + value: 0, + pointer: null, + }, + ]; + case NodeEditorNodeType.property_input: return []; @@ -256,6 +297,41 @@ export const getNodeEditorNodeDefaultOutputs = ( }, ]; + case NodeEditorNodeType.color_input: + return [ + { + type: ValueType.Color, + name: "Color", + }, + ]; + case NodeEditorNodeType.color_from_rgba_factors: + return [ + { + type: ValueType.Color, + name: "Color", + }, + ]; + + case NodeEditorNodeType.color_to_rgba_factors: + return [ + { + type: ValueType.Number, + name: "R", + }, + { + type: ValueType.Number, + name: "G", + }, + { + type: ValueType.Number, + name: "B", + }, + { + type: ValueType.Number, + name: "A", + }, + ]; + case NodeEditorNodeType.property_input: return []; @@ -278,6 +354,9 @@ export const getNodeEditorNodeDefaultState = (type case NodeEditorNodeType.num_input: return { value: 0, type: "value" }; + case NodeEditorNodeType.color_input: + return { color: [0, 0, 0, 1] } as NodeEditorNodeState; + default: return {} as any; } @@ -315,6 +394,9 @@ type NodeEditorNodeStateMap = { [NodeEditorNodeType.vec2_input]: {}; [NodeEditorNodeType.empty]: {}; [NodeEditorNodeType.rect_translate]: {}; + [NodeEditorNodeType.color_input]: { color: RGBAColor }; + [NodeEditorNodeType.color_from_rgba_factors]: {}; + [NodeEditorNodeType.color_to_rgba_factors]: {}; [NodeEditorNodeType.property_input]: { layerId: string; propertyId: string }; [NodeEditorNodeType.property_output]: { propertyId: string }; [NodeEditorNodeType.expr]: { @@ -339,4 +421,5 @@ export const nodeValidInputToOutputsMap: { [key in ValueType]: ValueType[] } = { [ValueType.Number]: [ValueType.Any, ValueType.Number], [ValueType.Rect]: [ValueType.Any, ValueType.Rect], [ValueType.Vec2]: [ValueType.Any, ValueType.Vec2], + [ValueType.Color]: [ValueType.Any, ValueType.Color], }; diff --git a/src/nodeEditor/nodes/Node.styles.ts b/src/nodeEditor/nodes/Node.styles.ts index 8d4f4b3..a8b1a8b 100644 --- a/src/nodeEditor/nodes/Node.styles.ts +++ b/src/nodeEditor/nodes/Node.styles.ts @@ -132,4 +132,22 @@ export default ({ css }: StyleParams) => ({ height: 2px; cursor: ns-resize; `, + + colorInput__colorPickerWrapper: css` + background: ${cssVariables.dark700}; + border: 1px solid ${cssVariables.gray600}; + padding: 16px; + border-radius: 4px; + border-bottom-left-radius: 0; + `, + + colorInput__colorValue: css` + margin-left: ${NODE_EDITOR_NODE_H_PADDING}; + border: none; + background: black; + border-radius: 3px; + margin-top: 1px; + height: 14px; + width: 32px; + `, }); diff --git a/src/nodeEditor/nodes/color/ColorInputNode.tsx b/src/nodeEditor/nodes/color/ColorInputNode.tsx new file mode 100644 index 0000000..defcd1b --- /dev/null +++ b/src/nodeEditor/nodes/color/ColorInputNode.tsx @@ -0,0 +1,202 @@ +import React, { useRef, useEffect } from "react"; +import { connectActionState } from "~/state/stateUtils"; +import { compileStylesheetLabelled } from "~/util/stylesheets"; +import NodeStyles from "~/nodeEditor/nodes/Node.styles"; +import { nodeHandlers } from "~/nodeEditor/nodes/nodeHandlers"; +import { NodeEditorNodeState, NodeEditorNodeOutput } from "~/nodeEditor/nodeEditorIO"; +import { NodeEditorNodeType, RGBAColor, RGBColor } from "~/types"; +import { NodeEditorNumberInput } from "~/nodeEditor/components/NodeEditorNumberInput"; +import { nodeEditorActions } from "~/nodeEditor/nodeEditorActions"; +import { NodeEditorTValueInput } from "~/nodeEditor/components/NodeEditorTValueInput"; +import { NodeBody } from "~/nodeEditor/components/NodeBody"; +import { useNumberInputAction } from "~/hook/useNumberInputAction"; +import { requestAction } from "~/listener/requestAction"; +import { ContextMenuBaseProps, OpenCustomContextMenuOptions } from "~/contextMenu/contextMenuTypes"; +import { useRefRect, useGetRefRectFn } from "~/hook/useRefRect"; +import { useKeyDownEffect } from "~/hook/useKeyDown"; +import { contextMenuActions } from "~/contextMenu/contextMenuActions"; +import { ColorPicker } from "~/components/colorPicker/ColorPicker"; +import { NODE_HEIGHT_CONSTANTS } from "~/nodeEditor/util/calculateNodeHeight"; + +const s = compileStylesheetLabelled(NodeStyles); + +const labels = ["Red", "Green", "Blue", "Alpha"]; + +interface OwnProps { + areaId: string; + graphId: string; + nodeId: string; +} +interface StateProps { + outputs: NodeEditorNodeOutput[]; + state: NodeEditorNodeState; +} + +type Props = OwnProps & StateProps; + +const ColorInputNodeComponent: React.FC = (props) => { + const { areaId, graphId, nodeId, outputs, state } = props; + + const buttonRef = useRef(null); + const getButtonRect = useGetRefRectFn(buttonRef); + + const inputActions = state.color.map((_, i) => + useNumberInputAction({ + onChange: (value, params) => { + const color = [...state.color] as RGBAColor; + color[i] = value; + params.dispatch( + nodeEditorActions.updateNodeState( + graphId, + nodeId, + { + color, + }, + ), + ); + }, + onChangeEnd: (_type, params) => { + params.submitAction("Update color input factor"); + }, + }), + ); + + const onClick = () => { + const [r, g, b, a] = state.color; + const rgb: RGBColor = [r, g, b]; + + requestAction({ history: true }, (params) => { + const Component: React.FC = ({ updateRect }) => { + const ref = useRef(null); + const rect = useRefRect(ref); + const latestColor = useRef(rgb); + + useEffect(() => { + updateRect(rect!); + }, [rect]); + + const onChange = (rgbColor: RGBColor) => { + latestColor.current = rgbColor; + + const rgbaColor = [...rgbColor, a] as RGBAColor; + + params.dispatch( + nodeEditorActions.updateNodeState(graphId, nodeId, { color: rgbaColor }), + ); + }; + + // Submit on enter + useKeyDownEffect("Enter", (down) => { + if (!down) { + return; + } + + let changed = false; + + for (let i = 0; i < rgb.length; i += 1) { + if (rgb[i] !== latestColor.current[i]) { + changed = true; + break; + } + } + + if (!changed) { + params.cancelAction(); + return; + } + + params.dispatch(contextMenuActions.closeContextMenu()); + params.submitAction("Update color"); + }); + + return ( +
+ +
+ ); + }; + + const rect = getButtonRect()!; + + const options: OpenCustomContextMenuOptions = { + component: Component, + props: {}, + position: Vec2.new(rect.left + rect.width + 8, rect.top + rect.height), + alignPosition: "bottom-left", + closeMenuBuffer: Infinity, + close: () => params.cancelAction(), + }; + params.dispatch(contextMenuActions.openCustomContextMenu(options)); + }); + }; + + return ( + + {outputs.map((output, i) => { + return ( +
+
+ nodeHandlers.onOutputMouseDown( + e, + props.areaId, + props.graphId, + props.nodeId, + i, + ) + } + /> +
{output.name}
+
+ ); + })} +