diff --git a/apple/Package.resolved b/apple/Package.resolved index 1b59c472..510c5a8c 100644 --- a/apple/Package.resolved +++ b/apple/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "50c93b9ea1bb93d1fa61b85c7b25dd0d358a653366c003cf88a40fbbf345b01d", + "originHash" : "95360ea85c846a4a2655c81c615521c2d64d2de6e4f150207def2adc2ea40933", "pins" : [ { "identity" : "highlightswift", @@ -15,7 +15,8 @@ "kind" : "remoteSourceControl", "location" : "ssh://git@github.com/logseq/lui.git", "state" : { - "revision" : "d2b3b0739bf047a1df5d22d3f316a45e430a68bd" + "branch" : "55166025", + "revision" : "5516602566127dd437c4d2ad9d1700c474b76970" } }, { diff --git a/apple/Package.swift b/apple/Package.swift index 7f357177..72f1aca1 100644 --- a/apple/Package.swift +++ b/apple/Package.swift @@ -34,7 +34,7 @@ let package = Package( .library(name: "LogseqChatModel", type: .dynamic, targets: ["LogseqChatModel"]), ], dependencies: [ - .package(url: "ssh://git@github.com/logseq/lui.git", revision: "d2b3b07"), + .package(url: "ssh://git@github.com/logseq/lui.git", revision: "55166025"), .package(url: "https://github.com/gonzalezreal/swiftui-math", from: "0.1.0"), .package(url: "https://github.com/appstefan/highlightswift.git", from: "1.1.0") ], diff --git a/apple/Sources/LogseqChat/LGChatRenderer.swift b/apple/Sources/LogseqChat/LGChatRenderer.swift index 93a666c0..179c0aa3 100644 --- a/apple/Sources/LogseqChat/LGChatRenderer.swift +++ b/apple/Sources/LogseqChat/LGChatRenderer.swift @@ -89,6 +89,8 @@ enum LGChatIconPolicy { "toolbar-delete": .systemName("trash"), "toolbar-hide-keyboard": .assetName("toolbar_hide_keyboard"), "toolbar-indent": .assetName("toolbar_indent"), + "toolbar-move-down": .systemName("arrow.down"), + "toolbar-move-up": .systemName("arrow.up"), "toolbar-outdent": .assetName("toolbar_outdent"), "toolbar-tag": .assetName("toolbar_tag"), "toolbar-task": .assetName("task_done"), @@ -109,6 +111,8 @@ enum LGChatIconPolicy { result["toolbar-camera"] = .systemName("camera") result["toolbar-hide-keyboard"] = .systemName("keyboard.chevron.compact.down") result["toolbar-indent"] = .systemName("arrow.right") + result["toolbar-move-down"] = .systemName("arrow.down") + result["toolbar-move-up"] = .systemName("arrow.up") result["toolbar-outdent"] = .systemName("arrow.left") result["toolbar-tag"] = .systemName("number") result["toolbar-task"] = .systemName("checkmark.square") diff --git a/flutter/lib/logseq_chat_icons.dart b/flutter/lib/logseq_chat_icons.dart index d411b3ed..bb0928a0 100644 --- a/flutter/lib/logseq_chat_icons.dart +++ b/flutter/lib/logseq_chat_icons.dart @@ -49,6 +49,8 @@ const logseqChatAppIcons = { 'toolbar-delete': Icons.delete_outline_rounded, 'toolbar-hide-keyboard': Icons.keyboard_hide_rounded, 'toolbar-indent': Icons.format_indent_increase, + 'toolbar-move-down': Icons.arrow_downward_rounded, + 'toolbar-move-up': Icons.arrow_upward_rounded, 'toolbar-outdent': Icons.format_indent_decrease, 'toolbar-tag': Icons.tag_rounded, 'toolbar-task': Icons.check_box_outlined, diff --git a/flutter/pubspec.lock b/flutter/pubspec.lock index 5cadff46..f628577a 100644 --- a/flutter/pubspec.lock +++ b/flutter/pubspec.lock @@ -159,8 +159,8 @@ packages: dependency: "direct main" description: path: "platform/flutter" - ref: d2b3b0739bf047a1df5d22d3f316a45e430a68bd - resolved-ref: d2b3b0739bf047a1df5d22d3f316a45e430a68bd + ref: 5516602566127dd437c4d2ad9d1700c474b76970 + resolved-ref: 5516602566127dd437c4d2ad9d1700c474b76970 url: "ssh://git@github.com/logseq/lui.git" source: git version: "0.1.0" diff --git a/flutter/pubspec.yaml b/flutter/pubspec.yaml index f4f99422..f5540679 100644 --- a/flutter/pubspec.yaml +++ b/flutter/pubspec.yaml @@ -15,7 +15,7 @@ dependencies: lui_flutter_backend: git: url: ssh://git@github.com/logseq/lui.git - ref: d2b3b0739bf047a1df5d22d3f316a45e430a68bd + ref: 5516602566127dd437c4d2ad9d1700c474b76970 path: platform/flutter webview_flutter: ^4.14.1 diff --git a/shared/src/logseq_chat/core/outliner_state.ml b/shared/src/logseq_chat/core/outliner_state.ml index 02af8e23..10ae3696 100644 --- a/shared/src/logseq_chat/core/outliner_state.ml +++ b/shared/src/logseq_chat/core/outliner_state.ml @@ -37,6 +37,8 @@ type outliner_toolbar = | Task | Outdent | Indent + | Move_up + | Move_down | Tag_action | Page_reference | Camera @@ -804,22 +806,46 @@ let outdent (context : outliner_context) selected = |> (fun parent -> match parent with | Some (parent : Model.block) -> - if - selection_is_contiguous roots - (sorted_siblings context (Some parent.uuid)) - then + let siblings = sorted_siblings context (Some parent.uuid) in + if selection_is_contiguous roots siblings then let parent_uuid = match parent.parent_id with | Some uuid -> uuid | None -> parent.page_id in - let siblings = sorted_siblings context (Some parent_uuid) in - (match index_of_uuid parent.uuid siblings with + let parent_row = sorted_siblings context (Some parent_uuid) in + (match index_of_uuid parent.uuid parent_row with | Some index -> - moves_with_orders roots parent_uuid parent.order - (if index + 1 < List.length siblings then - (List.nth siblings (index + 1)).order - else None) + (match + moves_with_orders roots parent_uuid parent.order + (if index + 1 < List.length parent_row then + (List.nth parent_row (index + 1)).order + else None) + with + | Some moves -> + (* logseq direct outdenting: the trailing siblings of the + last moved block become its children *) + let last_index = + List.fold_left max 0 (selection_indices roots siblings) + in + let last = List.nth siblings last_index in + let trailing = + List.filteri (fun i _ -> i > last_index) siblings + in + (match trailing with + | [] -> Some moves + | trailing -> + let lower = + match + List.rev (sorted_siblings context (Some last.uuid)) + with + | child :: _ -> child.order + | [] -> None + in + (match moves_with_orders trailing last.uuid lower None with + | Some adopted -> Some (moves @ adopted) + | None -> Some moves)) + | None -> None) | None -> None) else None | None -> None) @@ -1013,6 +1039,86 @@ let toolbar_insert (state : outliner_state) text backward = [ Haptic Impact ] | None -> step state [] +let move (context : outliner_context) selected up_ = + let roots = selected_roots context selected in + match roots with + | [] -> None + | first :: _ -> + if not (same_parent roots (first : Model.block).parent_id) then None + else + let parent = + match first.parent_id with Some uuid -> uuid | None -> first.page_id + in + let siblings = sorted_siblings context (Some parent) in + (match selection_indices roots siblings with + | [] -> None + | first_index :: _ as indices -> + if not (selection_is_contiguous roots siblings) then None + else + let last_index = first_index + List.length indices - 1 in + (* like logseq's move-blocks-up-down: at a boundary the selection + crosses into the parent's neighbor as its first child *) + let parent_neighbor before = + if parent = first.page_id then None + else + match find_block context parent with + | Some parent_block -> + let grandparent = + match (parent_block : Model.block).parent_id with + | Some uuid -> uuid + | None -> parent_block.page_id + in + let parent_siblings = + sorted_siblings context (Some grandparent) + in + (match index_of_uuid parent_block.uuid parent_siblings with + | Some index -> + let neighbor_index = + if before then index - 1 else index + 1 + in + if + neighbor_index < 0 + || neighbor_index >= List.length parent_siblings + then None + else Some (List.nth parent_siblings neighbor_index) + | None -> None) + | None -> None + in + let first_child_order (block : Model.block) = + match sorted_siblings context (Some block.uuid) with + | child :: _ -> child.order + | [] -> None + in + if up_ then + if first_index = 0 then + match parent_neighbor true with + | Some neighbor -> + moves_with_orders roots neighbor.uuid None + (first_child_order neighbor) + | None -> None + else + let target = List.nth siblings (first_index - 1) in + let lower = + if first_index > 1 then + (List.nth siblings (first_index - 2)).order + else None + in + moves_with_orders roots parent lower target.order + else if last_index + 1 >= List.length siblings then + match parent_neighbor false with + | Some neighbor -> + moves_with_orders roots neighbor.uuid None + (first_child_order neighbor) + | None -> None + else + let target = List.nth siblings (last_index + 1) in + let upper = + if last_index + 2 < List.length siblings then + (List.nth siblings (last_index + 2)).order + else None + in + moves_with_orders roots parent target.order upper) + let toolbar_move context (state : outliner_state) outdent_ = let moves = if outdent_ then outdent context (interaction_targets state) @@ -1023,6 +1129,11 @@ let toolbar_move context (state : outliner_state) outdent_ = | Some moves -> [ Reparent_blocks moves; Haptic Impact ] | None -> [ Haptic Impact ]) +let toolbar_reorder context (state : outliner_state) up_ = + match move context (interaction_targets state) up_ with + | Some moves -> step state [ Reparent_blocks moves; Haptic Impact ] + | None -> step state [ Haptic Impact ] + let choose_completion context (state : outliner_state) value = match (state.editing, state.autocomplete) with | Some editing, Some request -> @@ -1172,6 +1283,8 @@ let update context (state : outliner_state) message = | None -> step state []) | Toolbar Indent -> toolbar_move context state false | Toolbar Outdent -> toolbar_move context state true + | Toolbar Move_up -> toolbar_reorder context state true + | Toolbar Move_down -> toolbar_reorder context state false | Toolbar Delete -> let uuids = selected_uuids state in step diff --git a/shared/src/logseq_chat/core/outliner_state.mli b/shared/src/logseq_chat/core/outliner_state.mli index 0810dbc0..f1210f2e 100644 --- a/shared/src/logseq_chat/core/outliner_state.mli +++ b/shared/src/logseq_chat/core/outliner_state.mli @@ -34,6 +34,8 @@ type outliner_toolbar = | Task | Outdent | Indent + | Move_up + | Move_down | Tag_action | Page_reference | Camera @@ -222,6 +224,11 @@ val selection_is_contiguous : val same_parent : Cache_model.block list -> string option -> bool val indent : outliner_context -> Sset.t -> Pending_ops.pending_move list option val outdent : outliner_context -> Sset.t -> Pending_ops.pending_move list option +val move : + outliner_context -> + Sset.t -> + bool -> + Pending_ops.pending_move list option val ancestor_uuids : outliner_context -> Cache_model.block -> Sset.t val drop : outliner_context -> @@ -253,6 +260,11 @@ val toolbar_move : outliner_state -> bool -> outliner_state * outliner_command list +val toolbar_reorder : + outliner_context -> + outliner_state -> + bool -> + outliner_state * outliner_command list val choose_completion : outliner_context -> outliner_state -> diff --git a/shared/src/logseq_chat/core/rpc_wire.ml b/shared/src/logseq_chat/core/rpc_wire.ml index cd6deffa..352ecae1 100644 --- a/shared/src/logseq_chat/core/rpc_wire.ml +++ b/shared/src/logseq_chat/core/rpc_wire.ml @@ -63,6 +63,8 @@ let toolbar_action wire = | "task" -> Ok Outliner.Task | "outdent" -> Ok Outliner.Outdent | "indent" -> Ok Outliner.Indent + | "moveUp" -> Ok Outliner.Move_up + | "moveDown" -> Ok Outliner.Move_down | "tag" -> Ok Outliner.Tag_action | "pageReference" -> Ok Outliner.Page_reference | "camera" -> Ok Outliner.Camera diff --git a/shared/src/logseq_chat/view_outliner.ml b/shared/src/logseq_chat/view_outliner.ml index 63c0b13f..f44cd5e7 100644 --- a/shared/src/logseq_chat/view_outliner.ml +++ b/shared/src/logseq_chat/view_outliner.ml @@ -688,6 +688,10 @@ let outliner_editor_toolbar (context : Lui_ui.ui_context) model_source send "button.outliner.editor.outdent" "outdent" send; toolbar_button (`app "toolbar-indent") "Indent" "button.outliner.editor.indent" "indent" send; + toolbar_button (`app "toolbar-move-up") "Move up" + "button.outliner.editor.moveUp" "moveUp" send; + toolbar_button (`app "toolbar-move-down") "Move down" + "button.outliner.editor.moveDown" "moveDown" send; toolbar_button (`app "toolbar-tag") "Tag" "button.outliner.editor.tag" "tag" send; toolbar_button (`app "toolbar-camera") "Photo" @@ -730,6 +734,10 @@ let outliner_editor_toolbar (context : Lui_ui.ui_context) model_source send "button.outliner.editor.outdent" "outdent"; apple_icon_button (`app "toolbar-indent") "Indent" "button.outliner.editor.indent" "indent"; + apple_icon_button (`app "toolbar-move-up") "Move up" + "button.outliner.editor.moveUp" "moveUp"; + apple_icon_button (`app "toolbar-move-down") "Move down" + "button.outliner.editor.moveDown" "moveDown"; apple_icon_button (`app "toolbar-tag") "Tag" "button.outliner.editor.tag" "tag"; apple_icon_button (`app "toolbar-camera") "Photo" diff --git a/shared/test/logseq_chat/app_test.ml b/shared/test/logseq_chat/app_test.ml index 5b04063a..452220b6 100644 --- a/shared/test/logseq_chat/app_test.ml +++ b/shared/test/logseq_chat/app_test.ml @@ -7676,7 +7676,7 @@ let flutter_outliner_editor_toolbar_uses_a_material_bottom_surface () = (property_string renderer editor_container Lui_protocol.BackgroundValue) "surface-container-low"; check_eq ~msg:"all editor actions remain reachable by horizontal scroll" - (List.length buttons) 9; + (List.length buttons) 11; List.iter (fun button -> check_eq ~msg:"every editor action has a 48dp target" @@ -7689,7 +7689,7 @@ let flutter_outliner_editor_toolbar_uses_a_material_bottom_surface () = (property_string renderer button Lui_protocol.SizeValue) "icon") buttons; - let page_reference = List.nth buttons 7 in + let page_reference = List.nth buttons 9 in check_eq ~msg:"page reference does not use an unrelated code glyph" (property_string renderer page_reference Lui_protocol.InlineIconName) ""; @@ -7831,16 +7831,18 @@ let outliner_editor_toolbar_and_autocomplete_use_core_owned_state () = (0, "app:toolbar-task"); (1, "app:toolbar-outdent"); (2, "app:toolbar-indent"); - (3, "app:toolbar-tag"); - (4, "app:toolbar-camera"); - (5, "app:toolbar-audio"); + (3, "app:toolbar-move-up"); + (4, "app:toolbar-move-down"); + (5, "app:toolbar-tag"); + (6, "app:toolbar-camera"); + (7, "app:toolbar-audio"); ]; check_eq ~msg:"the iOS editor toolbar omits the file picker" (descendant_with_identifier renderer editor_toolbar "button.outliner.editor.attachment") (-1); - let page_reference_button = List.nth editor_buttons 6 in - let hide_keyboard_button = List.nth editor_buttons 7 in + let page_reference_button = List.nth editor_buttons 8 in + let hide_keyboard_button = List.nth editor_buttons 9 in check_eq ~msg:"page reference retains main's compact symbolic label" (property_string renderer page_reference_button Lui_protocol.TextValue) "[[]]"; diff --git a/shared/test/logseq_chat/outliner_state_test.ml b/shared/test/logseq_chat/outliner_state_test.ml index e30fcbf3..aa0ef22e 100644 --- a/shared/test/logseq_chat/outliner_state_test.ml +++ b/shared/test/logseq_chat/outliner_state_test.ml @@ -391,6 +391,81 @@ let selected_indent_and_outdent_stay_atomic_and_preserve_selection () = check (compare a.Ops.order b.Ops.order < 0); check (compare b.Ops.order "a1" < 0) +let move_up_and_down_reorder_siblings_and_keep_focus () = + let ctx = + context_for + [ + row "first" "page" "a0"; + row "second" "page" "a1"; + row "third" "page" "a2"; + ] + in + let editing = fst (step ctx State.empty (State.Tap_block "third")) in + let next, commands = step ctx editing (State.Toolbar State.Move_up) in + let batch = moves commands in + check_eq (State.editing_uuid next) (Some "third"); + check_eq (List.map (fun (m : Ops.pending_move) -> m.uuid) batch) + [ "third" ]; + check_eq (List.hd batch).Ops.parent_uuid "page"; + check (compare "a0" (List.hd batch).Ops.order < 0); + check (compare (List.hd batch).Ops.order "a1" < 0); + let current = + advance ctx State.empty + [ State.Long_press_block "first"; State.Tap_block "second" ] + in + let next, commands = step ctx current (State.Toolbar State.Move_down) in + let batch = moves commands in + check_eq (selected next) [ "first"; "second" ]; + check_eq (List.map (fun (m : Ops.pending_move) -> m.uuid) batch) + [ "first"; "second" ]; + check + (List.for_all + (fun (m : Ops.pending_move) -> compare "a2" m.Ops.order < 0) + batch); + let first_editing = fst (step ctx State.empty (State.Tap_block "first")) in + check_eq (snd (step ctx first_editing (State.Toolbar State.Move_up))) + [ State.Haptic State.Impact ]; + let last_editing = fst (step ctx State.empty (State.Tap_block "third")) in + check_eq (snd (step ctx last_editing (State.Toolbar State.Move_down))) + [ State.Haptic State.Impact ] + +let move_up_down_at_boundary_crosses_into_neighbor_parent () = + let ctx = + context_for + [ + row "ra" "page" "a0"; + row "ca" "ra" "a0"; + row "rb" "page" "a1"; + row "cb1" "rb" "a0"; + row "cb2" "rb" "a1"; + row "rc" "page" "a2"; + ] + in + let editing = fst (step ctx State.empty (State.Tap_block "cb1")) in + let next, commands = step ctx editing (State.Toolbar State.Move_up) in + let batch = moves commands in + check_eq (State.editing_uuid next) (Some "cb1"); + check_eq (List.map (fun (m : Ops.pending_move) -> m.uuid) batch) [ "cb1" ]; + check_eq (List.hd batch).Ops.parent_uuid "ra"; + check (compare (List.hd batch).Ops.order "a0" < 0); + let editing = fst (step ctx State.empty (State.Tap_block "cb2")) in + let _, commands = step ctx editing (State.Toolbar State.Move_down) in + let batch = moves commands in + check_eq (List.map (fun (m : Ops.pending_move) -> m.uuid) batch) [ "cb2" ]; + check_eq (List.hd batch).Ops.parent_uuid "rc"; + let nested = + context_for + [ + row "rp" "page" "a0"; + row "only" "rp" "a0"; + ] + in + let editing = fst (step nested State.empty (State.Tap_block "only")) in + check_eq (snd (step nested editing (State.Toolbar State.Move_up))) + [ State.Haptic State.Impact ]; + check_eq (snd (step nested editing (State.Toolbar State.Move_down))) + [ State.Haptic State.Impact ] + let delete_confirmation_is_consumed_once () = let current = fst (step context State.empty (State.Long_press_block "a")) in let asked, commands = step context current (State.Toolbar State.Delete) in @@ -1047,6 +1122,10 @@ let cases = return_only_outdents_the_final_empty_child; case "selected indent and outdent stay atomic and preserve selection" selected_indent_and_outdent_stay_atomic_and_preserve_selection; + case "move up and down reorder siblings and keep focus" + move_up_and_down_reorder_siblings_and_keep_focus; + case "move up and down cross the parent boundary at edges" + move_up_down_at_boundary_crosses_into_neighbor_parent; case "delete confirmation is consumed once" delete_confirmation_is_consumed_once; case "drop rejects descendants and valid drop clears selection" diff --git a/shared/test/logseq_chat/rpc_test.ml b/shared/test/logseq_chat/rpc_test.ml index d7be6f5b..65a2f3e2 100644 --- a/shared/test/logseq_chat/rpc_test.ml +++ b/shared/test/logseq_chat/rpc_test.ml @@ -5063,6 +5063,8 @@ let toolbar_wire_actions_preserve_all_public_mappings () = ("task", Outliner.Task); ("outdent", Outliner.Outdent); ("indent", Outliner.Indent); + ("moveUp", Outliner.Move_up); + ("moveDown", Outliner.Move_down); ("tag", Outliner.Tag_action); ("pageReference", Outliner.Page_reference); ("camera", Outliner.Camera);