diff --git a/LICENSE b/LICENSE index bed20ac..3c8a718 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (C) 2023 DNV GL +Copyright (C) 2024 DNV GL Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/Setup.org b/Setup.org index 114d362..5583654 100644 --- a/Setup.org +++ b/Setup.org @@ -302,6 +302,7 @@ p > code { background: var(--dots-hex); border-radius: .2em; padding: .1em .5em; + display: inline-block; } pre, @@ -310,6 +311,711 @@ code { } #+end_src + + +*** xyflow + +#+begin_src css :tangle docs/css/docs.css +.react-flow { + direction: ltr; + + --xy-edge-stroke-default: #b1b1b7; + --xy-edge-stroke-width-default: 1; + --xy-edge-stroke-selected-default: #555; + + --xy-connectionline-stroke-default: #b1b1b7; + --xy-connectionline-stroke-width-default: 1; + + --xy-attribution-background-color-default: rgba(255, 255, 255, 0.5); + + --xy-minimap-background-color-default: #fff; + --xy-minimap-mask-background-color-default: rgb(240, 240, 240, 0.6); + --xy-minimap-mask-stroke-color-default: transparent; + --xy-minimap-mask-stroke-width-default: 1; + --xy-minimap-node-background-color-default: #e2e2e2; + --xy-minimap-node-stroke-color-default: transparent; + --xy-minimap-node-stroke-width-default: 2; + + --xy-background-color-default: transparent; + --xy-background-pattern-dots-color-default: #91919a; + --xy-background-pattern-lines-color-default: #eee; + --xy-background-pattern-cross-color-default: #e2e2e2; +} + +.react-flow.dark { + --xy-edge-stroke-default: #3e3e3e; + --xy-edge-stroke-width-default: 1; + --xy-edge-stroke-selected-default: #727272; + + --xy-connectionline-stroke-default: #b1b1b7; + --xy-connectionline-stroke-width-default: 1; + + --xy-attribution-background-color-default: rgba(150, 150, 150, 0.25); + + --xy-minimap-background-color-default: #141414; + --xy-minimap-mask-background-color-default: rgb(60, 60, 60, 0.6); + --xy-minimap-mask-stroke-color-default: transparent; + --xy-minimap-mask-stroke-width-default: 1; + --xy-minimap-node-background-color-default: #2b2b2b; + --xy-minimap-node-stroke-color-default: transparent; + --xy-minimap-node-stroke-width-default: 2; + + --xy-background-color-default: #141414; + --xy-background-pattern-dots-color-default: #777; + --xy-background-pattern-lines-color-default: #777; + --xy-background-pattern-cross-color-default: #777; +} + +.react-flow { + background-color: var(--xy-background-color, var(--xy-background-color-default)); +} + +.react-flow__background { + background-color: var(--xy-background-color, var(--xy-background-color-props, var(--xy-background-color-default))); +} + +.react-flow__container { + position: absolute; + width: 100%; + height: 100%; + top: 0; + left: 0; +} + +.react-flow__pane { + z-index: 1; + + &.draggable { + cursor: grab; + } + + &.dragging { + cursor: grabbing; + } + + &.selection { + cursor: pointer; + } +} + +.react-flow__viewport { + transform-origin: 0 0; + z-index: 2; + pointer-events: none; +} + +.react-flow__renderer { + z-index: 4; +} + +.react-flow__selection { + z-index: 6; +} + +.react-flow__nodesselection-rect:focus, +.react-flow__nodesselection-rect:focus-visible { + outline: none; +} + +.react-flow__edge-path { + stroke: var(--xy-edge-stroke, var(--xy-edge-stroke-default)); + stroke-width: var(--xy-edge-stroke-width, var(--xy-edge-stroke-width-default)); + fill: none; +} + +.react-flow__connection-path { + stroke: var(--xy-connectionline-stroke, var(--xy-connectionline-stroke-default)); + stroke-width: var(--xy-connectionline-stroke-width, var(--xy-connectionline-stroke-width-default)); + fill: none; +} + +.react-flow .react-flow__edges { + position: absolute; + + svg { + overflow: visible; + position: absolute; + pointer-events: none; + } +} + +.react-flow__edge { + pointer-events: visibleStroke; + + &.selectable { + cursor: pointer; + } + + &.animated path { + stroke-dasharray: 5; + animation: dashdraw 0.5s linear infinite; + } + + &.animated path.react-flow__edge-interaction { + stroke-dasharray: none; + animation: none; + } + + &.inactive { + pointer-events: none; + } + + &.selected, + &:focus, + &:focus-visible { + outline: none; + } + + &.selected .react-flow__edge-path, + &.selectable:focus .react-flow__edge-path, + &.selectable:focus-visible .react-flow__edge-path { + stroke: var(--xy-edge-stroke-selected, var(--xy-edge-stroke-selected-default)); + } + + &-textwrapper { + pointer-events: all; + } + + .react-flow__edge-text { + pointer-events: none; + user-select: none; + } +} +.react-flow__connection { + pointer-events: none; + + .animated { + stroke-dasharray: 5; + animation: dashdraw 0.5s linear infinite; + } +} + +svg.react-flow__connectionline { + z-index: 1001; + overflow: visible; + position: absolute; +} + +.react-flow__nodes { + pointer-events: none; + transform-origin: 0 0; +} + +.react-flow__node { + position: absolute; + user-select: none; + pointer-events: all; + transform-origin: 0 0; + box-sizing: border-box; + cursor: default; + + &.selectable { + cursor: pointer; + } + + &.draggable { + cursor: grab; + pointer-events: all; + + &.dragging { + cursor: grabbing; + } + } +} + +.react-flow__nodesselection { + z-index: 3; + transform-origin: left top; + pointer-events: none; + + &-rect { + position: absolute; + pointer-events: all; + cursor: grab; + } +} + +.react-flow__handle { + position: absolute; + pointer-events: none; + min-width: 5px; + min-height: 5px; + + &.connectingfrom { + pointer-events: all; + } + + &.connectionindicator { + pointer-events: all; + cursor: crosshair; + } + + &-bottom { + top: auto; + left: 50%; + bottom: 0; + transform: translate(-50%, 50%); + } + + &-top { + top: 0; + left: 50%; + transform: translate(-50%, -50%); + } + + &-left { + top: 50%; + left: 0; + transform: translate(-50%, -50%); + } + + &-right { + top: 50%; + right: 0; + transform: translate(50%, -50%); + } +} + +.react-flow__edgeupdater { + cursor: move; + pointer-events: all; +} + +.react-flow__panel { + position: absolute; + z-index: 5; + margin: 15px; + + &.top { + top: 0; + } + + &.bottom { + bottom: 0; + } + + &.left { + left: 0; + } + + &.right { + right: 0; + } + + &.center { + left: 50%; + transform: translateX(-50%); + } +} + +.react-flow__attribution { + font-size: 10px; + background: var(--xy-attribution-background-color, var(--xy-attribution-background-color-default)); + padding: 2px 3px; + margin: 0; + + a { + text-decoration: none; + color: #999; + } +} + +@keyframes dashdraw { + from { + stroke-dashoffset: 10; + } +} + +.react-flow__edgelabel-renderer { + position: absolute; + width: 100%; + height: 100%; + pointer-events: none; + user-select: none; + left: 0; + top: 0; +} + +.react-flow__viewport-portal { + position: absolute; + width: 100%; + height: 100%; + left: 0; + top: 0; + user-select: none; +} + +.react-flow__minimap { + background: var( + --xy-minimap-background-color-props, + var(--xy-minimap-background-color, var(--xy-minimap-background-color-default)) + ); + + &-svg { + display: block; + } + + &-mask { + fill: var( + --xy-minimap-mask-background-color-props, + var(--xy-minimap-mask-background-color, var(--xy-minimap-mask-background-color-default)) + ); + stroke: var( + --xy-minimap-mask-stroke-color-props, + var(--xy-minimap-mask-stroke-color, var(--xy-minimap-mask-stroke-color-default)) + ); + stroke-width: var( + --xy-minimap-mask-stroke-width-props, + var(--xy-minimap-mask-stroke-width, var(--xy-minimap-mask-stroke-width-default)) + ); + } + + &-node { + fill: var( + --xy-minimap-node-background-color-props, + var(--xy-minimap-node-background-color, var(--xy-minimap-node-background-color-default)) + ); + stroke: var( + --xy-minimap-node-stroke-color-props, + var(--xy-minimap-node-stroke-color, var(--xy-minimap-node-stroke-color-default)) + ); + stroke-width: var( + --xy-minimap-node-stroke-width-props, + var(--xy-minimap-node-stroke-width, var(--xy-minimap-node-stroke-width-default)) + ); + } +} + +.react-flow__background { + pointer-events: none; + z-index: -1; +} + +.react-flow__background-pattern { + &.dots { + fill: var( + --xy-background-pattern-color-props, + var(--xy-background-pattern-color, var(--xy-background-pattern-dots-color-default)) + ); + } + + &.lines { + stroke: var( + --xy-background-pattern-color-props, + var(--xy-background-pattern-color, var(--xy-background-pattern-lines-color-default)) + ); + } + + &.cross { + stroke: var( + --xy-background-pattern-color-props, + var(--xy-background-pattern-color, var(--xy-background-pattern-cross-color-default)) + ); + } +} + +.react-flow__controls { + display: flex; + flex-direction: column; + + &.horizontal { + flex-direction: row; + } + + &-button { + display: flex; + justify-content: center; + align-items: center; + height: 26px; + width: 26px; + padding: 4px; + + svg { + width: 100%; + max-width: 12px; + max-height: 12px; + fill: currentColor; + } + } +} + + + +.react-flow { + --xy-node-color-default: inherit; + --xy-node-border-default: 1px solid #1a192b; + --xy-node-background-color-default: #fff; + --xy-node-group-background-color-default: rgba(240, 240, 240, 0.25); + --xy-node-boxshadow-hover-default: 0 1px 4px 1px rgba(0, 0, 0, 0.08); + --xy-node-boxshadow-selected-default: 0 0 0 0.5px #1a192b; + --xy-node-border-radius-default: 3px; + + --xy-handle-background-color-default: #1a192b; + --xy-handle-border-color-default: #fff; + + --xy-selection-background-color-default: rgba(0, 89, 220, 0.08); + --xy-selection-border-default: 1px dotted rgba(0, 89, 220, 0.8); + + --xy-controls-button-background-color-default: #fefefe; + --xy-controls-button-background-color-hover-default: #f4f4f4; + --xy-controls-button-color-default: inherit; + --xy-controls-button-color-hover-default: inherit; + --xy-controls-button-border-color-default: #eee; + --xy-controls-box-shadow-default: 0 0 2px 1px rgba(0, 0, 0, 0.08); + + --xy-edge-label-background-color-default: #ffffff; + --xy-edge-label-color-default: inherit; +} + +.react-flow.dark { + --xy-node-color-default: #f8f8f8; + --xy-node-border-default: 1px solid #3c3c3c; + --xy-node-background-color-default: #1e1e1e; + --xy-node-group-background-color-default: rgba(240, 240, 240, 0.25); + --xy-node-boxshadow-hover-default: 0 1px 4px 1px rgba(255, 255, 255, 0.08); + --xy-node-boxshadow-selected-default: 0 0 0 0.5px #999; + + --xy-handle-background-color-default: #bebebe; + --xy-handle-border-color-default: #1e1e1e; + + --xy-selection-background-color-default: rgba(200, 200, 220, 0.08); + --xy-selection-border-default: 1px dotted rgba(200, 200, 220, 0.8); + + --xy-controls-button-background-color-default: #2b2b2b; + --xy-controls-button-background-color-hover-default: #3e3e3e; + --xy-controls-button-color-default: #f8f8f8; + --xy-controls-button-color-hover-default: #fff; + --xy-controls-button-border-color-default: #5b5b5b; + --xy-controls-box-shadow-default: 0 0 2px 1px rgba(0, 0, 0, 0.08); + + --xy-edge-label-background-color-default: #141414; + --xy-edge-label-color-default: #f8f8f8; +} + +.react-flow__edge { + &.updating { + .react-flow__edge-path { + stroke: #777; + } + } + + &-text { + font-size: 10px; + } +} + +.react-flow__node.selectable { + &:focus, + &:focus-visible { + outline: none; + } +} + +.react-flow__node-input, +.react-flow__node-default, +.react-flow__node-output, +.react-flow__node-group { + padding: 10px; + border-radius: var(--xy-node-border-radius, var(--xy-node-border-radius-default)); + width: 150px; + font-size: 12px; + color: var(--xy-node-color, var(--xy-node-color-default)); + text-align: center; + border: var(--xy-node-border, var(--xy-node-border-default)); + background-color: var(--xy-node-background-color, var(--xy-node-background-color-default)); + + &.selectable { + &:hover { + box-shadow: var(--xy-node-boxshadow-hover, var(--xy-node-boxshadow-hover-default)); + } + + &.selected, + &:focus, + &:focus-visible { + box-shadow: var(--xy-node-boxshadow-selected, var(--xy-node-boxshadow-selected-default)); + } + } +} + +.react-flow__node-group { + background-color: var(--xy-node-group-background-color, var(--xy-node-group-background-color-default)); +} + +.react-flow__nodesselection-rect, +.react-flow__selection { + background: var(--xy-selection-background-color, var(--xy-selection-background-color-default)); + border: var(--xy-selection-border, var(--xy-selection-border-default)); + + &:focus, + &:focus-visible { + outline: none; + } +} + +.react-flow__handle { + width: 6px; + height: 6px; + background-color: var(--xy-handle-background-color, var(--xy-handle-background-color-default)); + border: 1px solid var(--xy-handle-border-color, var(--xy-handle-border-color-default)); + border-radius: 100%; +} + +.react-flow__controls { + box-shadow: var(--xy-controls-box-shadow, var(--xy-controls-box-shadow-default)); + + &-button { + border: none; + background: var(--xy-controls-button-background-color, var(--xy-controls-button-background-color-default)); + border-bottom: 1px solid + var( + --xy-controls-button-border-color-props, + var(--xy-controls-button-border-color, var(--xy-controls-button-border-color-default)) + ); + color: var( + --xy-controls-button-color-props, + var(--xy-controls-button-color, var(--xy-controls-button-color-default)) + ); + cursor: pointer; + user-select: none; + + &:hover { + background: var( + --xy-controls-button-background-color-hover-props, + var(--xy-controls-button-background-color-hover, var(--xy-controls-button-background-color-hover-default)) + ); + color: var( + --xy-controls-button-color-hover-props, + var(--xy-controls-button-color-hover, var(--xy-controls-button-color-hover-default)) + ); + } + + &:disabled { + pointer-events: none; + + svg { + fill-opacity: 0.4; + } + } + } + + &-button:last-child { + border-bottom: none; + } +} + + + +.react-flow { + --xy-resize-background-color-default: #3367d9; +} + +.react-flow__resize-control { + position: absolute; +} + +.react-flow__resize-control.left, +.react-flow__resize-control.right { + cursor: ew-resize; +} + +.react-flow__resize-control.top, +.react-flow__resize-control.bottom { + cursor: ns-resize; +} + +.react-flow__resize-control.top.left, +.react-flow__resize-control.bottom.right { + cursor: nwse-resize; +} + +.react-flow__resize-control.bottom.left, +.react-flow__resize-control.top.right { + cursor: nesw-resize; +} + +/* handle styles */ +.react-flow__resize-control.handle { + width: 4px; + height: 4px; + border: 1px solid #fff; + border-radius: 1px; + background-color: var(--xy-resize-background-color, var(--xy-resize-background-color-default)); + transform: translate(-50%, -50%); +} + +.react-flow__resize-control.handle.left { + left: 0; + top: 50%; +} +.react-flow__resize-control.handle.right { + left: 100%; + top: 50%; +} +.react-flow__resize-control.handle.top { + left: 50%; + top: 0; +} +.react-flow__resize-control.handle.bottom { + left: 50%; + top: 100%; +} +.react-flow__resize-control.handle.top.left { + left: 0; +} +.react-flow__resize-control.handle.bottom.left { + left: 0; +} +.react-flow__resize-control.handle.top.right { + left: 100%; +} +.react-flow__resize-control.handle.bottom.right { + left: 100%; +} + +/* line styles */ +.react-flow__resize-control.line { + border-color: var(--xy-resize-background-color, var(--xy-resize-background-color-default)); + border-width: 0; + border-style: solid; +} + +.react-flow__resize-control.line.left, +.react-flow__resize-control.line.right { + width: 1px; + transform: translate(-50%, 0); + top: 0; + height: 100%; +} + +.react-flow__resize-control.line.left { + left: 0; + border-left-width: 1px; +} + +.react-flow__resize-control.line.right { + left: 100%; + border-right-width: 1px; +} + +.react-flow__resize-control.line.top, +.react-flow__resize-control.line.bottom { + height: 1px; + transform: translate(0, -50%); + left: 0; + width: 100%; +} + +.react-flow__resize-control.line.top { + top: 0; + border-top-width: 1px; +} + +.react-flow__resize-control.line.bottom { + border-bottom-width: 1px; + top: 100%; +} +#+end_src + *** ReactFlow #+begin_src css :tangle docs/css/docs.css @@ -320,6 +1026,11 @@ code { top: 0; left: 0; } +.react-flow__container svg { + overflow: visible; + position: absolute; + pointer-events: none; +} .react-flow__pane { z-index: 1; cursor: -webkit-grab; @@ -799,7 +1510,7 @@ const main = (w, d) => { } }); observer.observe(example); - }); + }); } main(window, document); diff --git a/index.org b/index.org index 704dd42..4a3c39d 100644 --- a/index.org +++ b/index.org @@ -9,7 +9,7 @@ * Usage You can mostly follow the [[https://reactflow.dev/docs/][ReactFlow documentation]] and be sure to replace -"react" with "reagent" and use ~snake-casing~ instead of ~kebabCasing~. +"react" with "reagent" and use ~kebab-casing~ instead of ~camelCasing~. There are some exceptions. - Types are prefixed with ~Flow~ and uses the original ~camelCasing~. @@ -47,7 +47,6 @@ You can read more about the API at [[https://cljdoc.org/d/net.clojars.simtech/re :header-args:clojurescript: :tangle babel/examples/src/custom_nodes/core.cljs :exports none :noweb yes :end: - Connect the nodes, pick a color and see the nodes change interactively. #+html:
#+begin_src clojurescript @@ -57,14 +56,14 @@ Connect the nodes, pick a color and see the nodes change interactively. [reagent.dom.client :as rdom] [reagent-flow.core :refer [add-edge apply-edge-changes apply-node-changes - background handle reagent-flow + background handle reagent-flow node-resizer get-connections-by-node-id get-node-by-id]])) #+end_src We use atoms to store nodes & edges. The nodes you see here with the ~types~ parameter are custom nodes. #+begin_src clojurescript :exports code -(defonce nodes +(def nodes (r/atom [{:id :explanation :connectable false :draggable false @@ -83,7 +82,7 @@ We use atoms to store nodes & edges. The nodes you see here with the :data {:label "Preview color"} :target-position :left}])) -(defonce edges +(def edges (r/atom [])) #+end_src @@ -115,24 +114,28 @@ make changes to our atom above. As with the color-node, the preview-node uses the ~isConnected~ parameter. Note that it doesn't follow idiomatic Clojure naming as the -rest of ReagentFlow. This is due to ReactFlow calling our function directly. +rest of ReagentFlow. This is due to ReactFlow calling our function +directly. Also note how easy it is to make a node resizable. #+begin_src clojurescript :exports code (defn preview-node [{id :id - is-connectable :isConnectable}] + is-connectable :isConnectable + selected :selected}] (let [node (get-node-by-id @nodes id) {:keys [label]} (:data node) connection (first (get-connections-by-node-id @edges id)) source (get-node-by-id @nodes (:source connection)) color (-> source :data :color)] [:<> + [node-resizer {:is-visible selected + :min-width 80 + :min-height 50}] [:div {:style (merge {:background-color :white :display :flex :align-items :center :justify-content :center - :width :10em - :height :5em :border-radius :5px - :border "1px solid black"} + :height "100%" + :padding :1em} (when connection {:background-color color}))} [:strong {:style {:color color :filter "invert(100%) grayscale(1)"}} label]] @@ -167,10 +170,10 @@ it's render-loop. :connection-line-type :smoothstep :default-edge-options {:animated true :type :smoothstep}} - [background]]))) + [background {:style {:background-color "#ffffff"}}]]))) #+end_src -#+begin_src clojurescript +#+begin_src clojurescript <> #+end_src @@ -178,6 +181,7 @@ it's render-loop. #+html:
+ ** Drop it like it’s hot :properties: :header-args:clojurescript: :tangle babel/examples/src/drop_it_like_its_hot/core.cljs :exports none :noweb yes @@ -237,11 +241,11 @@ map with ~x~, ~y~ & ~zoom~ values, just as the hook equivalent. (handle-drop [event] (.preventDefault event) (when-let [node-type (.getData (-> event .-dataTransfer) data-type)] - (let [{:keys [project]} @provider + (let [{:keys [screen-to-flow-position]} @provider flow-el (-> flow .-state .-firstChild) rect (.getBoundingClientRect flow-el) - position (project {:x (- (.-clientX event) rect.left) - :y (- (.-clientY event) rect.top)})] + position (screen-to-flow-position {:x (.-clientX event) + :y (.-clientY event)})] (swap! node-id inc) (swap! nodes conj {:id (str "node-" @node-id) :type node-type @@ -269,7 +273,7 @@ map with ~x~, ~y~ & ~zoom~ values, just as the hook equivalent. :on-viewport-change #(reset! viewport %) :connection-line-type :smoothstep :default-edge-options {:type :smoothstep}} - [background]]])))) + [background {:style {:background-color "#ffffff"}}]]])))) #+end_src #+begin_src clojurescript :exports code @@ -303,7 +307,7 @@ running between every node. There's one sum-node that adds together the value of each of the connected nodes. Only connections that affect the sum are animated. #+begin_src clojurescript :exports code -(def num-nodes 250) +(def num-nodes 100) (def rows (/ num-nodes 10)) (def cols (/ num-nodes rows)) @@ -315,10 +319,10 @@ modified. Try making a connection from =Node #99= to the =Sum node= and see the value of the nodes propagate through the grid. #+begin_src clojurescript :exports code (defonce nodes - (r/atom (into (->> (range 1 num-nodes) + (r/atom (into (->> (range 1 (inc num-nodes)) (mapv (fn [idx] - (let [x (* 200 (mod idx cols)) - y (* 200 (quot idx cols))] + (let [x (* 200 (mod (dec idx) cols)) + y (* 200 (quot (dec idx) cols))] {:id (str "node-" idx) :type (if (= idx 1) :input :default) :position {:x x :y y} @@ -330,13 +334,13 @@ see the value of the nodes propagate through the grid. :position {:x (* 200 (dec cols)) :y (* 200 rows)}}]))) (defonce edges - (r/atom (->> (range 1 num-nodes) + (r/atom (->> (range 1 (inc num-nodes)) (mapv (fn [idx] (merge {:id (str "edge-" idx)} (when (> idx 1) {:source (str "node-" (dec idx))}) - (when (< idx num-nodes) + (when (< idx (inc num-nodes)) {:target (str "node-" idx)}))))))) #+end_src @@ -411,7 +415,7 @@ upon initialization and how that is treated as a regular ClojureScript function. (handle-init [{:keys [set-center] :as provider}] (let [x (* 200 (dec cols)) y (* 200 (dec rows))] - (set-center x y {:zoom 0.85})))] + (set-center x y {:zoom 0.85})))] (fn [] [reagent-flow {:id :stress :nodes @nodes @@ -423,7 +427,7 @@ upon initialization and how that is treated as a regular ClojureScript function. :on-init handle-init :connection-line-type :smoothstep :default-edge-options {:type :smoothstep}} - [background]]))) + [background {:style {:background-color "#ffffff"}}]]))) #+end_src #+begin_src clojurescript @@ -536,20 +540,20 @@ other third party libraries. The library we're using here is [[https://leva.ment *** Deps.edn #+begin_src clojurescript :tangle babel/examples/deps.edn -{:deps {org.clojure/clojure {:mvn/version "1.11.1"} - org.clojure/clojurescript {:mvn/version "1.11.60"} +{:deps {org.clojure/clojure {:mvn/version "1.11.3"} + org.clojure/clojurescript {:mvn/version "1.11.132"} com.google.javascript/closure-compiler-unshaded {:mvn/version "v20230411"} reagent/reagent {:mvn/version "1.2.0"} - re-frame/re-frame {:mvn/version "1.3.0"} - ;; io.github.mentat-collective/leva.cljs {:git/sha "bb24493c8b4a0fcd862d69b4960fa297561fa5bb"} + re-frame/re-frame {:mvn/version "1.4.3"} + ;; io.github.mentat-collective/leva.cljs {:git/sha "bb24493c8b4a0fcd862d69b4960fa297561fa5bb"} net.clojars.simtech/reagent-flow {:local/root "../"}} :paths ["src"] :aliases - {:watch {:extra-deps {thheller/shadow-cljs {:mvn/version "2.23.3"} - binaryage/devtools {:mvn/version "1.0.6"}} + {:watch {:extra-deps {thheller/shadow-cljs {:mvn/version "2.28.14"} + binaryage/devtools {:mvn/version "1.0.7"}} :main-opts ["-m" "shadow.cljs.devtools.cli" "watch" "examples"]} - :build {:extra-deps {thheller/shadow-cljs {:mvn/version "2.23.3"} - binaryage/devtools {:mvn/version "1.0.6"}} + :build {:extra-deps {thheller/shadow-cljs {:mvn/version "2.28.14"} + binaryage/devtools {:mvn/version "1.0.7"}} :main-opts ["-m" "shadow.cljs.devtools.cli" "compile" "examples"]}}} #+end_src @@ -582,12 +586,7 @@ We use the same version-scheme as ReactFlow. You're currently viewing version: #+name: version #+begin_src text -11.7.4 -#+end_src - -#+name: reagent-flow-version -#+begin_src text -11.7.4-clj.01 +12.2.0 #+end_src Here you'll find all the names of the classes, functions, hooks, and types of @@ -601,8 +600,13 @@ this version of ReactFlow listed. - BezierEdge - ControlButton - Controls +- EdgeLabelRenderer - EdgeText - Handle +- MiniMap +- NodeResizer +- NodeResizeControl +- NodeToolbar - Panel - Position - ReactFlow @@ -814,8 +818,8 @@ these processed lists. [cljs.core :refer [IDeref IEditableCollection]] [medley.core :refer [map-keys map-vals]] [reagent.core :as r] - ["reactflow$default" :as ReactFlow] - ["reactflow" :as rf + ["@xyflow/react$default" :as ReactFlow] + ["@xyflow/react" :as rf :refer [addEdge applyEdgeChanges applyNodeChanges @@ -830,6 +834,10 @@ these processed lists. To create our main entry-point functions, we need a few private helper-functions: #+begin_src clojurescript :tangle babel/src/reagent_flow/core.cljs :noweb yes +(def -->kebab-case (memoize ->kebab-case)) + +(def -->camelCase (memoize ->camelCase)) + (defn- ->params "Normalize arguments to always have the form [props children] like hiccup elements." @@ -837,7 +845,7 @@ helper-functions: (cond-> args (-> args first map? not) (conj nil))) -(defn change-keys +(defn- change-keys "Walks a map and replaces all keys by applying function to the keys." [m f] (let [f (fn [[k v]] (if (or (string? k) (keyword? k)) [(f k) v] [k v]))] @@ -847,7 +855,7 @@ helper-functions: "Convert a JavaScript object to a Clojure map with kebab-cased keys." (let [obj (js->clj o :keywordize-keys true)] (if (map? obj) - (change-keys obj ->kebab-case) + (change-keys (dissoc obj "") -->kebab-case) (if (vector? obj) (map flowjs->clj obj) obj)))) @@ -855,7 +863,7 @@ helper-functions: (defn- clj->flowjs "Convert Clojure map into a JavaScript object with camelCased keys." [o] - (->> (change-keys o ->camelCase) + (->> (change-keys o -->camelCase) (clj->js))) (defn- apply-changes [f delta src] @@ -881,10 +889,10 @@ the other state directly via atoms. on-init (when-let [init (:on-init params)] (fn [provider] (let [provider (flowjs->clj provider) - {:keys [set-center project]} provider] + {:keys [set-center screen-to-flow-position]} provider] (init (assoc provider :set-center (fn [x y & options] (set-center x y (clj->js (first options)))) - :project #(project (clj->js %))))))) + :screen-to-flow-position #(screen-to-flow-position (clj->js %))))))) on-nodes-change (when-let [node-change (:on-nodes-change params)] (fn [delta] (node-change (flowjs->clj delta)))) on-edges-change (when-let [edge-change (:on-edges-change params)] @@ -929,17 +937,17 @@ client-code. (defn apply-node-changes "Returns a vector of nodes with `changes` applied to the `source`." [changes source] - (apply-changes applyNodeChanges changes source)) + (vec (apply-changes applyNodeChanges changes source))) (defn apply-edge-changes "Returns a vector of edges with `changes` applied to the `source`." [changes source] - (apply-changes applyEdgeChanges changes source)) + (vec (apply-changes applyEdgeChanges changes source))) (defn add-edge "Returns a vector of edges with `edge` added to the `source`." [edge source] - (apply-changes addEdge edge source)) + (vec (apply-changes addEdge edge source))) (defn get-node-by-id "Returns a map of the node with `id` from `nodes`. @@ -1013,7 +1021,30 @@ client-code. (ns reagent-flow.core-test (:require [cljs.test :refer-macros [deftest testing is]] - [reagent-flow.core :refer [get-node-by-id get-connections-by-node-id]])) + [clojure.test.check.clojure-test :refer [defspec]] + [reagent-flow.core :refer [get-node-by-id get-connections-by-node-id]] + [clojure.spec.alpha :as s] + [clojure.test.check.properties :as prop] + [clojure.test.check.generators :as gen])) + +;; Map with string or keyword keys and scalar values +(s/def ::key (s/and (s/or :str string? :kw keyword?) (complement empty?))) +(s/def ::value (s/or :string string? :number number? :bool boolean?)) +(s/def ::map (s/map-of ::key ::value)) +(s/def ::nested-map + (s/map-of ::key (s/or :map ::map :vec (s/coll-of ::value) :value ::value))) + +(defspec test-change-keys 100 + (prop/for-all [sample-map (s/gen ::nested-map)] + (let [transformed (#'reagent-flow.core/change-keys sample-map keyword)] + (is (every? (comp keyword? first) transformed))))) + +;; TODO Could use a generative test for `test-flowjs->clj` as well +(deftest test-flowjs->clj + (testing "Ensure flowjs->clj handles JS objects correctly" + (let [js-obj {:aKey "value" :nestedObj {:anotherKey 42} :aList [1 2 3]}] + (is (= (#'reagent-flow.core/flowjs->clj js-obj) + {:a-key "value" :nested-obj {:another-key 42} :a-list [1 2 3]}))))) (deftest getting-node-by-id (let [nodes [{:id :node1} {:id :node2}]] @@ -1046,21 +1077,22 @@ client-code. **** Deps.edn #+begin_src clojurescript :tangle babel/deps.edn -{:deps {org.clojure/clojure {:mvn/version "1.11.1"} - org.clojure/clojurescript {:mvn/version "1.11.60"} - com.google.javascript/closure-compiler-unshaded {:mvn/version "v20230411"} - camel-snake-kebab/camel-snake-kebab {:mvn/version "0.4.3"} - dev.weavejester/medley {:mvn/version "1.5.0"} - reagent/reagent {:mvn/version "1.2.0"}} +{:deps {org.clojure/clojure {:mvn/version "1.11.3"} + org.clojure/clojurescript {:mvn/version "1.11.132"} + camel-snake-kebab/camel-snake-kebab {:mvn/version "0.4.3"} + dev.weavejester/medley {:mvn/version "1.8.1"} + reagent/reagent {:mvn/version "1.2.0"}} :paths ["src"] :aliases - {:build {:extra-deps {thheller/shadow-cljs {:mvn/version "2.23.3"}} + {:build {:extra-deps {thheller/shadow-cljs {:mvn/version "2.28.14"}} :main-opts ["-m" "shadow.cljs.devtools.cli" "release" "reagent-flow"]} - :test {:extra-deps {olical/cljs-test-runner {:mvn/version "3.8.0"}} + :test {:extra-deps {olical/cljs-test-runner {:mvn/version "3.8.1"} + org.clojure/test.check {:mvn/version "1.1.1"}} :extra-paths ["test" "cljs-test-runner-out/gen"] :main-opts ["-m" "cljs-test-runner.main" "-d" "test"]} - :package {:deps {io.github.clojure/tools.build {:mvn/version "0.9.4"} - slipset/deps-deploy {:mvn/version "0.2.0"}} + :package {:deps {io.github.clojure/tools.build {:git/url "https://github.com/clojure/tools.build" + :git/sha "143611fcf965919f1d9c18a10eeeed319305e034"} + slipset/deps-deploy {:mvn/version "0.2.2"}} :ns-default package}}} #+end_src @@ -1077,22 +1109,24 @@ client-code. #+end_src #+begin_src clojurescript :tangle babel/src/deps.cljs :noweb yes -{:npm-deps {"reactflow" "<>"}} +{:npm-deps {"@xyflow/react" "<>" + "react" "^18.3.1" + "react-dom" "^18.3.1"}} #+end_src **** Package.json #+begin_src javascript :tangle babel/package.json :noweb yes { "name": "reagent-flow", - "version": "<>", + "version": "<>", "private": true, "license": "MIT", "dependencies": { - "reactflow": "<>" + "@xyflow/react": "<>" }, "devDependencies": { - "react": "^18.2.0", - "react-dom": "^18.2.0" + "react": "^18.3.1", + "react-dom": "^18.3.1" } } #+end_src @@ -1132,7 +1166,7 @@ uses the below structure as it's base. (:require [clojure.tools.build.api :as b])) -(def version "<>") +(def version "<>") (def lib 'net.clojars.simtech/reagent-flow) (def class-dir "target/classes") (def basis (b/create-basis {:project "deps.edn"}))