import gleam/dynamic/decode import gleam/float import gleam/list import gleam/option.{type Option, None, Some} import lustre/attribute.{type Attribute} import lustre/event // TYPES ----------------------------------------------------------------------- pub type Position { Position(x: Float, y: Float) } pub type Movement { Free Horizontal Vertical } /// Determines when list modifications should be applied during drag operations. /// /// This controls the timing of when your list gets updated - either continuously /// while dragging for real-time visual feedback, or only when the item is dropped /// for final placement. /// /// ## Variants /// /// - `OnDrag`: Apply list changes immediately as the item is dragged over drop targets. /// Provides real-time visual feedback but triggers more frequent updates. /// - `OnDrop`: Apply list changes only when the drag operation completes. /// More performant for complex lists but less visual feedback during dragging. /// /// ## Use Cases /// /// - **OnDrag**: Best for simple lists where you want immediate visual feedback /// - **OnDrop**: Better for performance-sensitive applications or when you need to validate the drop /// /// ## Example /// /// ```gleam /// // Real-time updates while dragging /// let live_config = dnd.Config( /// listen: dnd.OnDrag, /// // ... other options /// ) /// /// // Updates only on final drop /// let batch_config = dnd.Config( /// listen: dnd.OnDrop, /// // ... other options /// ) /// ``` pub type Listen { OnDrag OnDrop } /// Defines how the list should be modified when an item is dragged and dropped. /// /// Each operation provides a different way of rearranging items in the list, /// affecting how the dragged item and other items are repositioned. /// /// ## Variants /// /// - `InsertAfter`: Move the dragged item to the position immediately after the drop target /// - `InsertBefore`: Move the dragged item to the position immediately before the drop target /// - `Rotate`: Perform a circular shift of all items between the drag and drop positions (includes both endpoints) /// - `Swap`: Exchange the positions of the dragged item and the drop target item /// - `Unaltered`: Do not modify the list (useful for custom logic or visual feedback only) pub type Operation { InsertAfter InsertBefore Rotate Swap Unaltered } /// Configuration for drag-and-drop behavior. /// /// This type defines how the drag-and-drop system should behave, including /// when to apply changes, how items should move, and what operation to perform. /// /// ## Fields /// /// - `before_update`: A callback function that allows you to modify the list /// before the drag-and-drop operation is applied. Receives the drag index, /// drop index, and the updated list. Return the final list to use. /// - `movement`: Constrains how the ghost element can move (Free, Horizontal, or Vertical) /// - `listen`: When to apply list changes (OnDrag for real-time updates, OnDrop for final placement) /// - `operation`: What list operation to perform (InsertAfter, InsertBefore, Rotate, Swap, or Unaltered) /// /// ## Example /// /// ```gleam /// let config = dnd.Config( /// before_update: fn(_, _, list) { list }, // No pre-processing /// movement: dnd.Free, // Allow movement in any direction /// listen: dnd.OnDrag, // Update while dragging /// operation: dnd.Rotate, // Rotate items between positions /// ) /// ``` pub type Config(a) { Config( before_update: fn(Int, Int, List(a)) -> List(a), movement: Movement, listen: Listen, operation: Operation, ) } pub type State { State( drag_index: Int, drop_index: Int, drag_counter: Int, start_position: Position, current_position: Position, drag_element_id: String, drop_element_id: String, ) } pub type Model { Model(Option(State)) } /// Information about the current drag operation when an item is being dragged. /// /// This type provides access to all relevant details about an active drag operation, /// including positions, indices, and element identifiers. Use `system.info(model)` /// to extract this information when a drag is in progress. /// /// ## Fields /// /// - `drag_index`: List index of the item being dragged (original position) /// - `drop_index`: List index of the current drop target (where it would be placed) /// - `drag_element_id`: DOM element ID of the dragged item /// - `drop_element_id`: DOM element ID of the current drop target /// - `start_position`: Mouse coordinates where the drag began /// - `current_position`: Current mouse coordinates during drag /// /// ## Usage /// /// ```gleam /// // Check if currently dragging and get info /// case model.system.info(model.system.model) { /// Some(info) -> { /// // Currently dragging - can use info for styling, ghost positioning, etc. /// html.div([ /// attribute.style("transform", "translate(" /// <> float.to_string(info.current_position.x) <> "px, " /// <> float.to_string(info.current_position.y) <> "px)") /// ], [html.text("Ghost element")]) /// } /// None -> { /// // Not currently dragging /// html.text("") /// } /// } /// ``` /// /// ## Availability /// /// This information is only available during active drag operations (returns `Some(Info)`). /// When no drag is in progress, `system.info()` returns `None`. pub type Info { Info( drag_index: Int, drop_index: Int, drag_element_id: String, drop_element_id: String, start_position: Position, current_position: Position, ) } /// The main drag-and-drop system containing all necessary functions and state. /// /// This type encapsulates the complete drag-and-drop functionality and provides /// the interface between your application and the drag-and-drop behavior. /// Create it using `dnd.create()` and integrate it into your Lustre application. /// /// ## Type Parameters /// /// - `a`: The type of items in your draggable list /// - `msg`: Your application's message type /// /// ## Fields /// /// - `model`: Internal drag state (opaque - use `info()` to access current state) /// - `update`: Function to handle drag messages and update both the model and your list /// - `drag_events`: Function to generate mouse event attributes for draggable elements /// - `drop_events`: Function to generate mouse event attributes for drop targets /// - `ghost_styles`: Function to generate CSS styling for the ghost element /// - `info`: Function to extract current drag information (positions, indices, etc.) /// /// ## Usage /// /// ```gleam /// // In your update function: /// DndMsg(dnd_msg) -> { /// let #(new_dnd, new_items) = /// model.system.update(dnd_msg, model.system.model, model.items) /// let updated_system = dnd.System(..model.system, model: new_dnd) /// Model(system: updated_system, items: new_items) /// } /// /// // In your view function: /// html.div( /// [..model.system.drag_events(index, element_id)], /// [html.text(item)] /// ) /// ``` pub type System(a, msg) { System( model: Model, update: fn(DndMsg, Model, List(a)) -> #(Model, List(a)), drag_events: fn(Int, String) -> List(Attribute(msg)), drop_events: fn(Int, String) -> List(Attribute(msg)), ghost_styles: fn(Model) -> List(Attribute(msg)), info: fn(Model) -> Option(Info), ) } pub type DndMsg { DragStart(Int, String, Position) Drag(Position) DragOver(Int, String) DragEnter(Int) DragLeave DragEnd } // SYSTEM CREATION ------------------------------------------------------------- pub fn create(config: Config(a), step_msg: fn(DndMsg) -> msg) -> System(a, msg) { System( model: Model(None), update: system_update(config), drag_events: drag_events(step_msg), drop_events: drop_events(step_msg), ghost_styles: ghost_styles(config.movement), info: get_info, ) } // SYSTEM UPDATE --------------------------------------------------------------- fn system_update( config: Config(a), ) -> fn(DndMsg, Model, List(a)) -> #(Model, List(a)) { fn(msg: DndMsg, model: Model, list: List(a)) -> #(Model, List(a)) { case msg { DragStart(drag_index, drag_element_id, position) -> { let new_state = State( drag_index: drag_index, drop_index: drag_index, drag_counter: 0, start_position: position, current_position: position, drag_element_id: drag_element_id, drop_element_id: drag_element_id, ) #(Model(Some(new_state)), list) } Drag(position) -> { case model { Model(Some(state)) -> { let updated_state = State( ..state, current_position: position, drag_counter: state.drag_counter + 1, ) #(Model(Some(updated_state)), list) } Model(None) -> #(model, list) } } DragOver(drop_index, drop_element_id) -> { case model { Model(Some(state)) -> { let updated_state = State( ..state, drop_index: drop_index, drop_element_id: drop_element_id, ) #(Model(Some(updated_state)), list) } Model(None) -> #(model, list) } } DragEnter(drop_index) -> { case model, config.listen { Model(Some(state)), OnDrag -> { case state.drag_counter > 1 && state.drag_index != drop_index { True -> { let updated_state = update_state_for_operation( config.operation, drop_index, state, ) let updated_list = update_list_for_operation( config.operation, state.drag_index, drop_index, list, ) #( Model(Some(updated_state)), config.before_update( state.drag_index, drop_index, updated_list, ), ) } False -> #(model, list) } } _, _ -> { case model { Model(Some(state)) -> { let updated_state = State(..state, drag_counter: 0) #(Model(Some(updated_state)), list) } Model(None) -> #(model, list) } } } } DragLeave -> { case model { Model(Some(state)) -> { let updated_state = State(..state, drop_index: state.drag_index) #(Model(Some(updated_state)), list) } Model(None) -> #(model, list) } } DragEnd -> { case model, config.listen { Model(Some(state)), OnDrop -> { case state.drag_index != state.drop_index { True -> { let updated_list = update_list_for_operation( config.operation, state.drag_index, state.drop_index, list, ) #( Model(None), config.before_update( state.drag_index, state.drop_index, updated_list, ), ) } False -> #(Model(None), list) } } _, _ -> #(Model(None), list) } } } } } // STATE AND LIST UPDATE HELPERS ---------------------------------------------- fn update_state_for_operation( operation: Operation, drop_index: Int, state: State, ) -> State { case operation { InsertAfter -> { let new_drag_index = case drop_index < state.drag_index { True -> drop_index + 1 False -> drop_index } State(..state, drag_index: new_drag_index, drag_counter: 0) } InsertBefore -> { let new_drag_index = case state.drag_index < drop_index { True -> drop_index - 1 False -> drop_index } State(..state, drag_index: new_drag_index, drag_counter: 0) } Rotate -> State(..state, drag_index: drop_index, drag_counter: 0) Swap -> State(..state, drag_index: drop_index, drag_counter: 0) Unaltered -> State(..state, drag_counter: 0) } } fn update_list_for_operation( operation: Operation, drag_index: Int, drop_index: Int, list: List(a), ) -> List(a) { case operation { InsertAfter -> insert_after(drag_index, drop_index, list) InsertBefore -> insert_before(drag_index, drop_index, list) Rotate -> rotate_items(drag_index, drop_index, list) Swap -> swap_items(drag_index, drop_index, list) Unaltered -> list } } // Helper function to split a list at a given index pub fn split_at(index: Int, list: List(a)) -> #(List(a), List(a)) { #(list.take(list, index), list.drop(list, index)) } // InsertAfter operation pub fn insert_after(drag_index: Int, drop_index: Int, list: List(a)) -> List(a) { case drag_index < drop_index { True -> after_forward(drag_index, drop_index, list) False -> case drop_index < drag_index { True -> after_backward(drag_index, drop_index, list) False -> list } } } // InsertBefore operation pub fn insert_before(drag_index: Int, drop_index: Int, list: List(a)) -> List(a) { case drag_index < drop_index { True -> before_forward(drag_index, drop_index, list) False -> case drop_index < drag_index { True -> before_backward(drag_index, drop_index, list) False -> list } } } // Rotate operation pub fn rotate_items(drag_index: Int, drop_index: Int, list: List(a)) -> List(a) { case drag_index < drop_index { True -> after_forward(drag_index, drop_index, list) False -> case drop_index < drag_index { True -> before_backward(drag_index, drop_index, list) False -> list } } } // Swap operation pub fn swap_items(drag_index: Int, drop_index: Int, list: List(a)) -> List(a) { case drag_index != drop_index { True -> swap_at(drag_index, drop_index, list) False -> list } } // Helper functions for different movement directions pub fn after_backward(i: Int, j: Int, list: List(a)) -> List(a) { let #(beginning, rest) = split_at(j + 1, list) let #(middle, end) = split_at(i - j - 1, rest) let #(head, tail) = split_at(1, end) list.append(list.append(list.append(beginning, head), middle), tail) } pub fn after_forward(i: Int, j: Int, list: List(a)) -> List(a) { let #(beginning, rest) = split_at(i, list) let #(middle, end) = split_at(j - i + 1, rest) let #(head, tail) = split_at(1, middle) list.append(list.append(list.append(beginning, tail), head), end) } pub fn before_backward(i: Int, j: Int, list: List(a)) -> List(a) { let #(beginning, rest) = split_at(j, list) let #(middle, end) = split_at(i - j, rest) let #(head, tail) = split_at(1, end) list.append(list.append(list.append(beginning, head), middle), tail) } pub fn before_forward(i: Int, j: Int, list: List(a)) -> List(a) { let #(beginning, rest) = split_at(i, list) let #(middle, end) = split_at(j - i, rest) let #(head, tail) = split_at(1, middle) list.append(list.append(list.append(beginning, tail), head), end) } pub fn swap_at(i: Int, j: Int, list: List(a)) -> List(a) { let item_i = list.drop(list, i) |> list.take(1) let item_j = list.drop(list, j) |> list.take(1) list.index_map(list, fn(item, index) { case index { idx if idx == i -> item_j idx if idx == j -> item_i _ -> [item] } }) |> list.flatten() } // EVENT HANDLERS -------------------------------------------------------------- fn drag_events( step_msg: fn(DndMsg) -> msg, ) -> fn(Int, String) -> List(Attribute(msg)) { fn(drag_index: Int, drag_element_id: String) -> List(Attribute(msg)) { [ event.on("mousedown", { use client_x <- decode.field("clientX", decode.float) use client_y <- decode.field("clientY", decode.float) decode.success( step_msg(DragStart( drag_index, drag_element_id, Position(client_x, client_y), )), ) }), ] } } fn drop_events( step_msg: fn(DndMsg) -> msg, ) -> fn(Int, String) -> List(Attribute(msg)) { fn(drop_index: Int, drop_element_id: String) -> List(Attribute(msg)) { [ event.on( "mouseover", decode.success(step_msg(DragOver(drop_index, drop_element_id))), ), event.on("mouseenter", decode.success(step_msg(DragEnter(drop_index)))), event.on("mouseleave", decode.success(step_msg(DragLeave))), ] } } // GHOST STYLES --------------------------------------------------------------- fn ghost_styles(movement: Movement) -> fn(Model) -> List(Attribute(msg)) { fn(model: Model) -> List(Attribute(msg)) { case model { Model(Some(state)) -> { let essential_styles = [ attribute.style("position", "fixed"), attribute.style("pointer-events", "none"), attribute.style("z-index", "1000"), attribute.style("user-select", "none"), attribute.style("-webkit-user-select", "none"), ] let position_styles = case movement { Free -> [ attribute.style( "left", float.to_string(state.current_position.x) <> "px", ), attribute.style( "top", float.to_string(state.current_position.y) <> "px", ), ] Horizontal -> [ attribute.style( "left", float.to_string(state.current_position.x) <> "px", ), attribute.style( "top", float.to_string(state.start_position.y) <> "px", ), ] Vertical -> [ attribute.style( "left", float.to_string(state.start_position.x) <> "px", ), attribute.style( "top", float.to_string(state.current_position.y) <> "px", ), ] } list.append(essential_styles, position_styles) } Model(None) -> [] } } } // INFO EXTRACTION ------------------------------------------------------------- pub fn get_info(model: Model) -> Option(Info) { case model { Model(Some(state)) -> { Some(Info( drag_index: state.drag_index, drop_index: state.drop_index, drag_element_id: state.drag_element_id, drop_element_id: state.drop_element_id, start_position: state.start_position, current_position: state.current_position, )) } Model(None) -> None } }