1
0
Эх сурвалжийг харах

The list tree can be multi-selected.

oldj 4 жил өмнө
parent
commit
618b63657a

+ 23 - 4
src/common/hostsFn.ts

@@ -8,6 +8,8 @@ import lodash from 'lodash'
 
 type PartHostsObjectType = Partial<IHostsListObject> & { id: string }
 
+type Predicate = (obj: IHostsListObject) => boolean;
+
 export const flatten = (list: IHostsListObject[]): IHostsListObject[] => {
   let new_list: IHostsListObject[] = []
 
@@ -106,11 +108,28 @@ export const deleteItemById = (list: IHostsListObject[], id: string) => {
   list.map(item => deleteItemById(item.children || [], id))
 }
 
-export const getNextSelectedItem = (list: IHostsListObject[], id: string): IHostsListObject | undefined => {
-  let flat = flatten(list)
-  let idx = flat.findIndex(item => item.id === id)
+// export const getNextSelectedItem = (list: IHostsListObject[], id: string): IHostsListObject | undefined => {
+//   let flat = flatten(list)
+//   let idx = flat.findIndex(item => item.id === id)
+//
+//   return flat[idx + 1] || flat[idx - 1]
+// }
+
+export const getNextSelectedItem = (tree: IHostsListObject[], predicate: Predicate): IHostsListObject | undefined => {
+  let flat = flatten(tree)
+  let idx_1 = -1
+  let idx_2 = -1
+
+  flat.map((i, idx) => {
+    if (predicate(i)) {
+      if (idx_1 === -1) {
+        idx_1 = idx
+      }
+      idx_2 = idx
+    }
+  })
 
-  return flat[idx + 1] || flat[idx - 1]
+  return flat[idx_2 + 1] || flat[idx_1 - 1]
 }
 
 export const getParentOfItem = (list: IHostsListObject[], item_id: string): IHostsListObject | undefined => {

+ 2 - 0
src/common/i18n/languages/en.ts

@@ -76,6 +76,7 @@ export default {
   is_latest_version_inform: 'Great, you are running the latest version!',
   item_found: '{0} item found.',
   items_found: '{0} items found.',
+  items: 'items',
   language: 'Language',
   last_refresh: 'Last refresh: ',
   latest_version_desc: 'The latest version is: {0}',
@@ -89,6 +90,7 @@ export default {
   minimize: 'Minimize',
   minute: 'minute',
   minutes: 'minutes',
+  move_items_to_trashcan: 'Move {0} items to trashcan',
   move_to_trashcan: 'Move to trashcan',
   need_to_relaunch: 'Need to relaunch',
   never: 'Never',

+ 2 - 0
src/common/i18n/languages/zh.ts

@@ -77,6 +77,7 @@ const lang: LanguageDict = {
   import_from_url: '从 URL 导入',
   is_latest_version_inform: '太棒了,你正在运行的是最新版本!',
   item_found: '{0} 项匹配',
+  items: '项',
   items_found: '{0} 项匹配',
   language: '语言',
   last_refresh: '最后刷新:',
@@ -91,6 +92,7 @@ const lang: LanguageDict = {
   minimize: '最小化',
   minute: '分钟',
   minutes: '分钟',
+  move_items_to_trashcan: '移动 {0} 项到回收站',
   move_to_trashcan: '移到回收站',
   need_to_relaunch: '需要重启',
   never: '从不',

+ 2 - 1
src/main/actions/index.ts

@@ -26,8 +26,9 @@ export { default as deleteHistory } from './hosts/deleteHistory'
 export { default as getList } from './list/getList'
 export { default as setList } from './list/setList'
 export { default as getItemFromList } from './list/getItem'
-export { default as moveToTrashcan } from './list/moveItemToTrashcan'
 export { default as getContentOfList } from './list/getContentOfList'
+export { default as moveToTrashcan } from './list/moveItemToTrashcan'
+export { default as moveManyToTrashcan } from './list/moveManyToTrashcan'
 
 export { default as getTrashcanList } from './trashcan/getList'
 export { default as clearTrashcan } from './trashcan/clear'

+ 12 - 0
src/main/actions/list/moveManyToTrashcan.ts

@@ -0,0 +1,12 @@
+/**
+ * @author: oldj
+ * @homepage: https://oldj.net
+ */
+
+import { moveToTrashcan } from '@main/actions'
+
+export default async function (ids: string[]) {
+  for (let id of ids) {
+    await moveToTrashcan(id)
+  }
+}

+ 1 - 1
src/renderer/components/EditHostsInfo.tsx

@@ -276,7 +276,7 @@ const EditHostsInfo = () => {
               colorScheme="pink"
               onClick={() => {
                 if (hosts) {
-                  agent.broadcast(events.move_to_trashcan, hosts.id)
+                  agent.broadcast(events.move_to_trashcan, [hosts.id])
                   onCancel()
                 }
               }}

+ 7 - 6
src/renderer/components/LeftPanel/Trashcan.tsx

@@ -21,8 +21,8 @@ interface Props {
 const Trashcan = (props: Props) => {
   const { lang } = useModel('useI18n')
   const { hosts_data, current_hosts, setCurrentHosts } = useModel('useHostsData')
-  const [ trash_list, setTrashList ] = useState<ITrashcanListObject[]>([])
-  const [ is_collapsed, setIsCollapsed ] = useState(true)
+  const [trash_list, setTrashList] = useState<ITrashcanListObject[]>([])
+  const [is_collapsed, setIsCollapsed] = useState(true)
 
   useEffect(() => {
     let root: ITrashcanListObject = {
@@ -41,7 +41,7 @@ const Trashcan = (props: Props) => {
       parent_id: null,
     }
 
-    let list: ITrashcanListObject[] = [ root ]
+    let list: ITrashcanListObject[] = [root]
 
     hosts_data.trashcan.map(i => {
       root.children && root.children.push({
@@ -53,9 +53,10 @@ const Trashcan = (props: Props) => {
     })
 
     setTrashList(list)
-  }, [ hosts_data.trashcan, is_collapsed ])
+  }, [hosts_data.trashcan, is_collapsed])
 
-  const onSelect = (id: string) => {
+  const onSelect = (ids: string[]) => {
+    let id = ids[0]
     let item = hosts_data.trashcan.find(i => i.data.id === id)
     if (!item) return
     setCurrentHosts(item.data)
@@ -71,7 +72,7 @@ const Trashcan = (props: Props) => {
         nodeSelectedClassName={list_styles.node_selected}
         nodeCollapseArrowClassName={list_styles.arrow}
         onSelect={onSelect}
-        selected_id={current_hosts?.id}
+        selected_ids={current_hosts ? [current_hosts.id] : []}
         onChange={list => setIsCollapsed(!!list[0]?.is_collapsed)}
       />
     </div>

+ 31 - 22
src/renderer/components/List/ListItem.tsx

@@ -21,12 +21,13 @@ import events from '@root/common/events'
 
 interface Props {
   data: IHostsListObject;
+  selected_ids: string[];
   is_tray?: boolean;
 }
 
 const ListItem = (props: Props) => {
-  const { data, is_tray } = props
-  const { lang } = useModel('useI18n')
+  const { data, is_tray, selected_ids } = props
+  const { lang, i18n } = useModel('useI18n')
   const { hosts_data, setList, current_hosts, setCurrentHosts } = useModel('useHostsData')
   const [ is_collapsed, setIsCollapsed ] = useState(!!data.is_collapsed)
   const [ is_on, setIsOn ] = useState(data.on)
@@ -75,24 +76,6 @@ const ListItem = (props: Props) => {
   const is_folder = data.type === 'folder'
   const is_selected = data.id === current_hosts?.id
 
-  const menu = new PopupMenu([
-    {
-      label: lang.edit,
-      click() {
-        agent.broadcast(events.edit_hosts_info, data)
-      },
-    },
-    {
-      type: 'separator',
-    },
-    {
-      label: lang.move_to_trashcan,
-      click() {
-        agent.broadcast(events.move_to_trashcan, data.id)
-      },
-    },
-  ])
-
   return (
     <div
       className={clsx(
@@ -103,14 +86,40 @@ const ListItem = (props: Props) => {
       // className={clsx(styles.item, is_selected && styles.selected, is_collapsed && styles.is_collapsed)}
       // style={{ paddingLeft: `${1.3 * level}em` }}
       onContextMenu={(e) => {
+        let deal_count = 1
+        if (selected_ids.includes(data.id)) {
+          deal_count = selected_ids.length
+        }
+
+        const menu = new PopupMenu([
+          {
+            label: lang.edit,
+            click() {
+              agent.broadcast(events.edit_hosts_info, data)
+            },
+          },
+          {
+            type: 'separator',
+          },
+          {
+            label: deal_count === 1 ? lang.move_to_trashcan : i18n.trans('move_items_to_trashcan', [deal_count.toLocaleString()]),
+            click() {
+              let ids = deal_count === 1 ? [data.id] : selected_ids
+              agent.broadcast(events.move_to_trashcan, ids)
+            },
+          },
+        ])
+
         !data.is_sys && !is_tray && menu.show()
         e.preventDefault()
         e.stopPropagation()
       }}
       ref={el}
       onClick={(e: React.MouseEvent) => {
-        e.preventDefault()
-        e.stopPropagation()
+        if (is_tray) {
+          e.preventDefault()
+          e.stopPropagation()
+        }
       }}
     >
       <div className={styles.title} onClick={onSelect}>

+ 8 - 0
src/renderer/components/List/index.less

@@ -49,3 +49,11 @@
     margin-right: 8px;
   }
 }
+
+.items_count {
+  background: var(--swh-tree-selected-bg);
+  margin-left: 0.5em;
+  padding: 2px 0.5em;
+  border-radius: var(--swh-border-radius);
+  font-size: 12px;
+}

+ 19 - 18
src/renderer/components/List/index.tsx

@@ -36,6 +36,7 @@ const List = (props: Props) => {
   } = useModel('useHostsData')
   const { configs } = useModel('useConfigs')
   const { lang } = useModel('useI18n')
+  const [selected_ids, setSelectedIds] = useState<string[]>([current_hosts?.id || '0'])
   const [show_list, setShowList] = useState<IHostsListObject[]>([])
   const toast = useToast()
 
@@ -124,21 +125,16 @@ const List = (props: Props) => {
     useOnBroadcast(events.tray_list_updated, loadHostsData)
   }
 
-  useOnBroadcast(events.move_to_trashcan, async (id: string) => {
-    console.log(`move_to_trashcan: #${id}`)
-
-    let next_hosts: IHostsListObject | undefined
-    // console.log(current_hosts)
-    if (current_hosts && current_hosts.id === id) {
-      next_hosts = getNextSelectedItem(hosts_data.list, id)
-      // console.log(next_hosts)
-    }
-
-    await actions.moveToTrashcan(id)
+  useOnBroadcast(events.move_to_trashcan, async (ids: string[]) => {
+    console.log(`move_to_trashcan: #${ids}`)
+    await actions.moveManyToTrashcan(ids)
     await loadHostsData()
 
-    if (next_hosts) {
-      await setCurrentHosts(next_hosts)
+    if (current_hosts && ids.includes(current_hosts.id)) {
+      // 选中删除指定节点后的兄弟节点
+      let next_item = getNextSelectedItem(hosts_data.list, i => ids.includes(i.id))
+      setCurrentHosts(next_item || null)
+      setSelectedIds(next_item ? [next_item.id] : [])
     }
   }, [current_hosts, hosts_data])
 
@@ -176,16 +172,17 @@ const List = (props: Props) => {
       {/*<SystemHostsItem/>*/}
       <Tree
         data={show_list}
-        selected_id={current_hosts?.id || '0'}
+        selected_ids={selected_ids}
         onChange={list => {
           setShowList(list)
           setList(list).catch(e => console.error(e))
         }}
-        // onSelect={(id) => {
-        //   agent.broadcast(events.select_hosts, id)
-        //}}
+        onSelect={(ids: string[]) => {
+          console.log(ids)
+          setSelectedIds(ids)
+        }}
         nodeRender={(data) => (
-          <ListItem key={data.id} data={data} is_tray={is_tray}/>
+          <ListItem key={data.id} data={data} is_tray={is_tray} selected_ids={selected_ids}/>
         )}
         collapseArrow={<Center w="20px" h="20px"><BiChevronRight/></Center>}
         nodeAttr={(item) => {
@@ -207,6 +204,9 @@ const List = (props: Props) => {
               </span>
               <span>
                 {data.title || lang.untitled}
+                {selected_ids.length > 1 ? (
+                  <span className={styles.items_count}>{selected_ids.length} {lang.items}</span>
+                ) : null}
               </span>
             </div>
           )
@@ -215,6 +215,7 @@ const List = (props: Props) => {
         nodeDropInClassName={styles.node_drop_in}
         nodeSelectedClassName={styles.node_selected}
         nodeCollapseArrowClassName={styles.arrow}
+        allowed_multiple_selection={true}
       />
     </div>
   )

+ 107 - 15
src/renderer/components/Tree/Node.tsx

@@ -5,10 +5,18 @@
  */
 
 import clsx from 'clsx'
+import lodash from 'lodash'
 import React, { useRef } from 'react'
-import { isChildOf } from './fn'
+import { isChildOf, isSelfOrChild } from './fn'
 import styles from './style.less'
-import { DropWhereType, NodeIdType } from './Tree'
+import { DropWhereType, MultipleSelectType, NodeIdType } from './Tree'
+
+declare global {
+  interface Window {
+    _t_dragover_id?: string;
+    _t_dragover_ts: number;
+  }
+}
 
 export type NodeUpdate = (data: Partial<ITreeNodeData>) => void
 
@@ -33,25 +41,26 @@ interface INodeProps {
   nodeDropInClassName?: string;
   nodeSelectedClassName?: string;
   nodeCollapseArrowClassName?: string;
-  drag_source_id?: NodeIdType | null;
-  drop_target_id?: NodeIdType | null;
-  drag_target_where?: DropWhereType | null;
+  drag_source_id: NodeIdType | null;
+  drop_target_id: NodeIdType | null;
+  drag_target_where: DropWhereType | null;
   onDragStart: (id: NodeIdType) => void;
   onDragEnd: () => void;
   setDropTargetId: (id: NodeIdType | null) => void;
   setDropWhere: (where: DropWhereType | null) => void;
-  selected_id: NodeIdType | null;
-  onSelect: (id: NodeIdType) => void;
+  selected_ids: NodeIdType[];
+  onSelect: (id: NodeIdType, multiple_type?: MultipleSelectType) => void;
   level: number;
   is_dragging: boolean;
   render?: (data: ITreeNodeData, update: NodeUpdate) => React.ReactElement | null;
-  draggingNodeRender?: (data: ITreeNodeData) => React.ReactElement;
+  draggingNodeRender?: (data: ITreeNodeData, source_ids: string[]) => React.ReactElement;
   collapseArrow?: string | React.ReactElement;
   onChange: (id: NodeIdType, data: Partial<ITreeNodeData>) => void;
   indent_px?: number;
   nodeAttr?: (node: ITreeNodeData) => Partial<ITreeNodeData>;
   has_no_child: boolean;
   no_child_no_indent?: boolean;
+  allowed_multiple_selection?: boolean;
 }
 
 const Node = (props: INodeProps) => {
@@ -67,7 +76,7 @@ const Node = (props: INodeProps) => {
     render,
     draggingNodeRender,
     indent_px,
-    selected_id,
+    selected_ids,
     onSelect,
     onChange,
     nodeAttr,
@@ -111,6 +120,7 @@ const Node = (props: INodeProps) => {
   }
 
   // const onDragEnter = (e: React.DragEvent) => {
+  //   console.log(`enter: ` + data.id)
   // }
 
   const onDragOver = (e: React.DragEvent) => {
@@ -127,6 +137,19 @@ const Node = (props: INodeProps) => {
 
     setDropTargetId(data.id)
 
+    let now = (new Date()).getTime()
+    if (window._t_dragover_id !== data.id) {
+      window._t_dragover_id = data.id
+      window._t_dragover_ts = now
+    }
+    if (
+      data.children?.length
+      && data.is_collapsed
+      && now - window._t_dragover_ts > 1000
+    ) {
+      props.onChange(data.id, { is_collapsed: false })
+    }
+
     // where
     let ne = e.nativeEvent
     let h = el_target.offsetHeight
@@ -158,6 +181,7 @@ const Node = (props: INodeProps) => {
   }
 
   // const onDragLeave = (e: React.DragEvent) => {
+  //   console.log(`leave: ` + data.id)
   // }
 
   const onDragEnd = (e: React.DragEvent) => {
@@ -166,6 +190,8 @@ const Node = (props: INodeProps) => {
     // console.log('onDragEnd.')
     props.onDragEnd()
 
+    window._t_dragover_id = ''
+
     el_dragging.current && (el_dragging.current.style.display = 'none')
   }
 
@@ -175,7 +201,7 @@ const Node = (props: INodeProps) => {
 
   const is_drag_source = drag_source_id === data.id
   const is_drop_target = drop_target_id === data.id
-  const is_selected = selected_id === data.id
+  const is_selected = selected_ids.includes(data.id)
   const is_parent_is_drag_source = drag_source_id ? isChildOf(props.tree, data.id, drag_source_id) : false
   const has_children = Array.isArray(data.children) && data.children.length > 0
 
@@ -202,9 +228,20 @@ const Node = (props: INodeProps) => {
         // onDragLeave={onDragLeave}
         onDragEnd={onDragEnd}
         onDrop={onDragEnd}
-        onClick={() => attr.can_select !== false && onSelect(data.id)}
+        onClick={(e) => {
+          if (attr.can_select === false) {
+            return
+          }
+          let multiple_type: MultipleSelectType = 0
+          if (e.shiftKey) {
+            multiple_type = 2
+          } else if (e.metaKey) {
+            multiple_type = 1
+          }
+          onSelect(data.id, multiple_type)
+        }}
         style={{
-          paddingLeft: level * (indent_px || 20),
+          paddingLeft: level * (indent_px || 20) + 4,
         }}
       >
         <div className={clsx(
@@ -237,16 +274,71 @@ const Node = (props: INodeProps) => {
       </div>
       {draggingNodeRender && (
         <div ref={el_dragging} className={styles.for_dragging}>
-          {draggingNodeRender(data)}
+          {
+            draggingNodeRender(data, selected_ids.includes(data.id) ? selected_ids : [data.id])
+          }
         </div>
       )}
       {has_children && data.children && !data.is_collapsed
         ? data.children.map((node) => (
-          <Node {...props} key={node.id} data={node} level={level + 1}/>
+          <Node
+            {...props}
+            key={node.id}
+            data={node}
+            level={level + 1}
+          />
         ))
         : null}
     </>
   )
 }
 
-export default Node
+function diff<T> (a: T[], b: T[]): T[] {
+  return [
+    ...a.filter(i => !b.includes(i)),
+    ...b.filter(i => !a.includes(i)),
+  ]
+}
+
+function isEqual (prevProps: INodeProps, nextProps: INodeProps): boolean {
+  let { data, selected_ids, allowed_multiple_selection } = nextProps
+
+  if (!lodash.isEqual(prevProps.data, data)) {
+    return false
+  }
+
+  // select
+  let prev_selected_ids = prevProps.selected_ids
+
+  let diff_ids = diff<NodeIdType>(prev_selected_ids, selected_ids)
+  if (diff_ids.length > 0) {
+    if (allowed_multiple_selection) {
+      return false
+    } else {
+      for (let id of diff_ids) {
+        if (isSelfOrChild(data, id)) {
+          return false
+        }
+      }
+    }
+  }
+
+  // drag
+  if (prevProps.is_dragging !== nextProps.is_dragging) {
+    return false
+  }
+
+  let { drag_source_id, drop_target_id } = nextProps
+  if (
+    isSelfOrChild(data, drag_source_id)
+    || isSelfOrChild(data, drop_target_id)
+    || isSelfOrChild(data, prevProps.drag_source_id)
+    || isSelfOrChild(data, prevProps.drop_target_id)
+  ) {
+    return false
+  }
+
+  return true
+}
+
+export default React.memo(Node, isEqual)

+ 90 - 59
src/renderer/components/Tree/Tree.tsx

@@ -7,12 +7,13 @@
 import clsx from 'clsx'
 import lodash from 'lodash'
 import React, { useEffect, useState } from 'react'
-import { flatten, getNodeById, treeMoveNode } from './fn'
+import { canBeSelected, flatten, getNodeById, selectTo, treeMoveNode } from './fn'
 import Node, { ITreeNodeData, NodeUpdate } from './Node'
 import styles from './style.less'
 
 export type NodeIdType = string;
 export type DropWhereType = 'before' | 'in' | 'after';
+export type MultipleSelectType = 0 | 1 | 2
 
 interface ITreeProps {
   data: ITreeNodeData[];
@@ -23,36 +24,34 @@ interface ITreeProps {
   nodeCollapseArrowClassName?: string;
   nodeRender?: (node: ITreeNodeData, update: NodeUpdate) => React.ReactElement | null;
   nodeAttr?: (node: ITreeNodeData) => Partial<ITreeNodeData>;
-  draggingNodeRender?: (node: ITreeNodeData) => React.ReactElement;
+  draggingNodeRender?: (node: ITreeNodeData, source_ids: string[]) => React.ReactElement;
   collapseArrow?: string | React.ReactElement;
   onChange?: (tree: ITreeNodeData[]) => void;
   indent_px?: number;
-  selected_id?: NodeIdType;
-  onSelect?: (id: NodeIdType) => void;
+  selected_ids: NodeIdType[];
+  onSelect?: (ids: NodeIdType[]) => void;
   no_child_no_indent?: boolean;
+  allowed_multiple_selection?: boolean;
 }
 
 const Tree = (props: ITreeProps) => {
-  const { data, className, onChange } = props
-  const [ tree, setTree ] = useState<ITreeNodeData[]>([])
-  const [ is_dragging, setIsDragging ] = useState(false)
-  const [ drag_source_id, setDragSourceId ] = useState<NodeIdType | null>(null)
-  const [ drop_target_id, setDropTargetId ] = useState<NodeIdType | null>(null)
-  const [ selected_id, setSelectedId ] = useState<NodeIdType | null>(null)
-  const [ drop_where, setDropWhere ] = useState<DropWhereType | null>(null)
+  const { data, className, onChange, allowed_multiple_selection } = props
+  const [tree, setTree] = useState<ITreeNodeData[]>([])
+  const [is_dragging, setIsDragging] = useState(false)
+  const [drag_source_id, setDragSourceId] = useState<NodeIdType | null>(null)
+  const [drop_target_id, setDropTargetId] = useState<NodeIdType | null>(null)
+  const [selected_ids, setSelectedIds] = useState<NodeIdType[]>(props.selected_ids || [])
+  const [drop_where, setDropWhere] = useState<DropWhereType | null>(null)
 
   useEffect(() => {
     setTree(lodash.cloneDeep(data))
-  }, [ data ])
+  }, [data])
 
   useEffect(() => {
-    setSelectedId(props.selected_id || null)
-  }, [ props.selected_id ])
-
-  // useEffect(() => {
-  //   document.addEventListener('drop', onDragEnd, false)
-  //   return () => document.removeEventListener('drop', onDragEnd, false)
-  // }, [])
+    if (props.selected_ids && props.selected_ids.join(',') !== selected_ids.join(',')) {
+      setSelectedIds(props.selected_ids)
+    }
+  }, [props.selected_ids])
 
   const onDragStart = (id: NodeIdType) => {
     // console.log('onDragStart...')
@@ -63,12 +62,18 @@ const Tree = (props: ITreeProps) => {
   }
 
   const onDragEnd = () => {
-    console.log(`onDragEnd, ${is_dragging}`)
+    // console.log(`onDragEnd, ${is_dragging}`)
     if (!is_dragging) return
 
     if (drag_source_id && drop_target_id && drop_where) {
       // console.log(`onDragEnd: ${source_id} -> ${target_id} | ${drop_where}`)
-      let tree2 = treeMoveNode(tree, drag_source_id, drop_target_id, drop_where)
+      let source_ids: string[]
+      if (selected_ids.includes(drag_source_id)) {
+        source_ids = selected_ids
+      } else {
+        source_ids = [drag_source_id]
+      }
+      let tree2 = treeMoveNode(tree, source_ids, drop_target_id, drop_where)
       if (tree2) {
         setTree(tree2)
         onTreeChange(tree2)
@@ -82,61 +87,87 @@ const Tree = (props: ITreeProps) => {
   }
 
   const onTreeChange = (tree: ITreeNodeData[]) => {
-    console.log('onTreeChange...')
+    // console.log('onTreeChange...')
     onChange && onChange(tree)
   }
 
   const onNodeChange = (id: NodeIdType, data: Partial<ITreeNodeData>) => {
-    console.log('onNodeChange...')
-    let node = getNodeById(tree, id)
+    let tree2 = lodash.cloneDeep(tree)
+    let node = getNodeById(tree2, id)
     if (!node) return
 
     Object.assign(node, data)
-    let tree2 = lodash.cloneDeep(tree)
     setTree(tree2)
     onTreeChange(tree2)
-    // console.log(id, data)
   }
 
-  const onSelect = (id: NodeIdType) => {
-    setSelectedId(id)
-    props.onSelect && props.onSelect(id)
+  const onSelectOne = (id: NodeIdType, multiple_type: MultipleSelectType = 0) => {
+    // console.log('multiple_type:', multiple_type, 'ids:', selected_ids, 'id:', id)
+    const { onSelect } = props
+    let new_selected_ids: NodeIdType[] = []
+
+    if (!allowed_multiple_selection) {
+      multiple_type = 0
+    }
+
+    if (multiple_type === 0) {
+      new_selected_ids = [id]
+    } else if (multiple_type === 1) {
+      // 按住 cmd/ctrl 多选
+      if (!canBeSelected(tree, selected_ids, id)) {
+        return
+      }
+      if (selected_ids.includes(id)) {
+        new_selected_ids = selected_ids.filter(i => i !== id)
+      } else {
+        new_selected_ids = [...selected_ids, id]
+      }
+    } else if (multiple_type === 2) {
+      // 按住 shift 多选
+      new_selected_ids = selectTo(tree, selected_ids, id)
+    }
+
+    setSelectedIds(new_selected_ids)
+    onSelect && onSelect(new_selected_ids)
   }
 
   const has_no_child = flatten(tree).length === tree.length
 
   return (
     <div className={clsx(styles.root, className)} onDrop={onDragEnd}>
-      {tree.map((node) => (
-        <Node
-          key={node.id}
-          tree={tree}
-          data={node}
-          onDragStart={onDragStart}
-          onDragEnd={onDragEnd}
-          setDropTargetId={setDropTargetId}
-          setDropWhere={setDropWhere}
-          drag_source_id={drag_source_id}
-          drop_target_id={drop_target_id}
-          drag_target_where={drop_where}
-          is_dragging={is_dragging}
-          level={0}
-          render={props.nodeRender}
-          draggingNodeRender={props.draggingNodeRender}
-          collapseArrow={props.collapseArrow}
-          onChange={onNodeChange}
-          indent_px={props.indent_px}
-          selected_id={selected_id}
-          onSelect={onSelect}
-          nodeAttr={props.nodeAttr}
-          nodeClassName={props.nodeClassName}
-          nodeDropInClassName={props.nodeDropInClassName}
-          nodeSelectedClassName={props.nodeSelectedClassName}
-          nodeCollapseArrowClassName={props.nodeCollapseArrowClassName}
-          has_no_child={has_no_child}
-          no_child_no_indent={props.no_child_no_indent}
-        />
-      ))}
+      {tree.map((node) => {
+        return (
+          <Node
+            key={node.id}
+            tree={tree}
+            data={node}
+            onDragStart={onDragStart}
+            onDragEnd={onDragEnd}
+            setDropTargetId={setDropTargetId}
+            setDropWhere={setDropWhere}
+            drag_source_id={drag_source_id}
+            drop_target_id={drop_target_id}
+            drag_target_where={drop_where}
+            is_dragging={is_dragging}
+            level={0}
+            render={props.nodeRender}
+            draggingNodeRender={props.draggingNodeRender}
+            collapseArrow={props.collapseArrow}
+            onChange={onNodeChange}
+            indent_px={props.indent_px}
+            selected_ids={selected_ids}
+            onSelect={onSelectOne}
+            nodeAttr={props.nodeAttr}
+            nodeClassName={props.nodeClassName}
+            nodeDropInClassName={props.nodeDropInClassName}
+            nodeSelectedClassName={props.nodeSelectedClassName}
+            nodeCollapseArrowClassName={props.nodeCollapseArrowClassName}
+            has_no_child={has_no_child}
+            no_child_no_indent={props.no_child_no_indent}
+            allowed_multiple_selection={allowed_multiple_selection}
+          />
+        )
+      })}
     </div>
   )
 }

+ 104 - 18
src/renderer/components/Tree/fn.ts

@@ -8,7 +8,7 @@ interface IObj {
 
 export type KeyMapType = [string, string];
 
-export function flatten(tree_list: ITreeNodeData[]): ITreeNodeData[] {
+export function flatten (tree_list: ITreeNodeData[]): ITreeNodeData[] {
   let arr: any[] = []
 
   Array.isArray(tree_list) &&
@@ -26,7 +26,7 @@ export function flatten(tree_list: ITreeNodeData[]): ITreeNodeData[] {
   return arr
 }
 
-export function getParentList(
+export function getParentList (
   tree_list: ITreeNodeData[],
   id: NodeIdType,
 ): ITreeNodeData[] {
@@ -49,24 +49,25 @@ export function getParentList(
 
 export const treeMoveNode = (
   tree_list: ITreeNodeData[],
-  source_id: NodeIdType,
+  source_ids: NodeIdType[],
   target_id: NodeIdType,
   where: DropWhereType,
 ): ITreeNodeData[] | null => {
   tree_list = lodash.cloneDeep(tree_list)
 
-  if (source_id === target_id) return null
+  if (source_ids.includes(target_id)) return null
 
   // console.log(JSON.stringify(tree_list))
-  let source_parent_list = getParentList(tree_list, source_id)
+  let source_parent_list = getParentList(tree_list, source_ids[0])
   // console.log(JSON.stringify(source_parent_list))
-  let source_idx = source_parent_list.findIndex((i) => i.id === source_id)
 
-  if (source_idx === -1) {
-    // console.log('source_idx === -1')
-    return null
+  let source_nodes: ITreeNodeData[] = []
+  while (true) {
+    let idx = source_parent_list.findIndex(i => source_ids.includes(i.id))
+    if (idx === -1) break
+    let node = source_parent_list.splice(idx, 1)[0]
+    source_nodes.push(node)
   }
-  let source_node = source_parent_list.splice(source_idx, 1)[0]
 
   let target_parent_list = getParentList(tree_list, target_id)
   let target_idx = target_parent_list.findIndex((i) => i.id === target_id)
@@ -80,21 +81,24 @@ export const treeMoveNode = (
     if (!Array.isArray(target_node.children)) {
       target_node.children = []
     }
-    target_node.children.push(source_node)
+    target_node.children.splice(target_node.children.length, 0, ...source_nodes)
   } else if (where === 'before') {
-    target_parent_list.splice(target_idx, 0, source_node)
+    target_parent_list.splice(target_idx, 0, ...source_nodes)
   } else if (where === 'after') {
-    target_parent_list.splice(target_idx + 1, 0, source_node)
+    target_parent_list.splice(target_idx + 1, 0, ...source_nodes)
   }
 
   return tree_list
 }
 
-export function getNodeById(tree_list: ITreeNodeData[], id: NodeIdType): ITreeNodeData | undefined {
+export function getNodeById (tree_list: ITreeNodeData[], id: NodeIdType): ITreeNodeData | undefined {
   return flatten(tree_list).find(i => i.id === id)
 }
 
-export function isChildOf(tree_list: ITreeNodeData[], a_id: NodeIdType, b_id: NodeIdType): boolean {
+/**
+ * a is child of b
+ */
+export function isChildOf (tree_list: ITreeNodeData[], a_id: NodeIdType, b_id: NodeIdType): boolean {
   if (a_id === b_id) return false
 
   let target_node = getNodeById(tree_list, b_id)
@@ -103,7 +107,13 @@ export function isChildOf(tree_list: ITreeNodeData[], a_id: NodeIdType, b_id: No
   return flatten(target_node.children).findIndex(i => i.id === a_id) > -1
 }
 
-export function objKeyMap(obj: IObj, key_maps: KeyMapType[], reversed: boolean = false): IObj {
+export function isSelfOrChild (item: ITreeNodeData, id: NodeIdType | null): boolean {
+  if (!id) return false
+  if (item.id === id) return true
+  return flatten(item.children || []).findIndex(i => i.id === id) > -1
+}
+
+export function objKeyMap (obj: IObj, key_maps: KeyMapType[], reversed: boolean = false): IObj {
   if (reversed) {
     key_maps = keyMapReverse(key_maps)
   }
@@ -131,7 +141,7 @@ export function objKeyMap(obj: IObj, key_maps: KeyMapType[], reversed: boolean =
   return new_obj
 }
 
-export function treeKeyMap(tree_list: IObj[], key_maps: KeyMapType[], reversed: boolean = false): any[] {
+export function treeKeyMap (tree_list: IObj[], key_maps: KeyMapType[], reversed: boolean = false): any[] {
   if (reversed) {
     key_maps = keyMapReverse(key_maps)
   }
@@ -139,6 +149,82 @@ export function treeKeyMap(tree_list: IObj[], key_maps: KeyMapType[], reversed:
   return tree_list.map(item => objKeyMap(item, key_maps))
 }
 
-export function keyMapReverse(key_maps: KeyMapType[]): KeyMapType[] {
+export function keyMapReverse (key_maps: KeyMapType[]): KeyMapType[] {
   return key_maps.map(([a, b]) => [b, a])
 }
+
+export function isParent (tree_list: ITreeNodeData[], item: ITreeNodeData, id: string): boolean {
+  let parents = getParentList(tree_list, item.id)
+  return parents.findIndex(i => i.id === id) > -1
+}
+
+export function canBeSelected (tree_list: ITreeNodeData[], selected_ids: NodeIdType[], new_id: NodeIdType): boolean {
+  let id_one = selected_ids[0]
+  if (!id_one) return true
+
+  if (
+    tree_list.findIndex(i => i.id === id_one) > -1 &&
+    tree_list.findIndex(i => i.id === new_id) > -1
+  ) {
+    return true
+  }
+
+  let flat = flatten(tree_list)
+  let parent = flat.find(i => i.children && i.children.findIndex(j => j.id === id_one) > -1)
+  if (!parent || !parent.children) {
+    return false
+  }
+
+  return parent.children.findIndex(i => i.id === new_id) > -1
+}
+
+export function selectTo (tree_list: ITreeNodeData[], selected_ids: NodeIdType[], new_id: NodeIdType): NodeIdType[] {
+  if (!canBeSelected(tree_list, selected_ids, new_id)) {
+    return selected_ids
+  }
+
+  let list: ITreeNodeData[]
+  if (tree_list.findIndex(i => i.id === new_id) > -1) {
+    list = tree_list
+  } else {
+    let flat = flatten(tree_list)
+    let parent = flat.find(i => i.children && i.children.findIndex(j => j.id === new_id) > -1)
+    if (!parent || !parent.children) {
+      return selected_ids
+    }
+    list = parent.children
+  }
+
+  let new_id_idx: number = -1
+  let first_selected_idx: number = -1
+  let last_selected_idx: number = -1
+  list.map((i, idx) => {
+    if (first_selected_idx < 0 && selected_ids.includes(i.id)) {
+      first_selected_idx = idx
+    }
+    if (selected_ids.includes(i.id)) {
+      last_selected_idx = idx
+    }
+    if (i.id === new_id) {
+      new_id_idx = idx
+    }
+  })
+
+  let from_idx: number = first_selected_idx
+  let to_idx: number = last_selected_idx
+  if (new_id_idx < first_selected_idx) {
+    from_idx = new_id_idx
+  } else {
+    to_idx = new_id_idx
+  }
+
+  let new_selected_ids: NodeIdType[] = []
+  for (let idx = from_idx; idx <= to_idx; idx++) {
+    let item = list[idx]
+    if (item.can_select !== false) {
+      new_selected_ids.push(item.id)
+    }
+  }
+
+  return new_selected_ids
+}

+ 4 - 1
src/renderer/components/Tree/style.less

@@ -37,7 +37,10 @@
   }
 
   .ln_body {
-    padding-left: 4px;
+    width: 100%;
+    overflow: hidden;
+    white-space: nowrap;
+    text-overflow: ellipsis;
   }
 
   .indicator_circle {