react-use#useLatest TypeScript Examples

The following examples show how to use react-use#useLatest. You can vote up the ones you like or vote down the ones you don't like, and go to the original project or source file by following the links above each example. You may check out the related API usage on the sidebar.
Example #1
Source File: datatype-config.tsx    From erda-ui with GNU Affero General Public License v3.0 4 votes vote down vote up
DataTypeConfig = (props: IProps) => {
  const [{ propertyFormData }, updater] = useUpdate({
    propertyFormData: {} as Obj,
  });
  const { formData, onQuoteNameChange, quotePathMap, isEditMode, dataType, onDataTypeNameChange, dataTypeNameMap } =
    props;
  const { updateOpenApiDoc, updateFormErrorNum } = apiDesignStore;
  const [openApiDoc] = apiDesignStore.useStore((s) => [s.openApiDoc]);

  const openApiDocRef = useLatest(openApiDoc);
  const quotePathMapRef = useLatest(quotePathMap);
  const formRef = React.useRef<IFormExtendType>(null);

  React.useEffect(() => {
    const newFormData = { ...omit(formData, 'name') };
    newFormData[API_FORM_KEY] = formData[API_FORM_KEY] || formData.name;
    updater.propertyFormData(newFormData);
  }, [updater, formData]);

  const extraDataTypes = React.useMemo(() => {
    const tempTypes = get(openApiDoc, 'components.schemas') || {};
    const types = {};
    const forbiddenTypes = get(tempTypes, [dataType, 'x-dice-forbidden-types']) || [dataType];
    forEach(keys(tempTypes), (typeName) => {
      if (!forbiddenTypes?.includes(typeName)) {
        types[typeName] = tempTypes[typeName];
      }
    });
    return types;
  }, [dataType, openApiDoc]);

  const allExtraDataTypes = React.useMemo(() => {
    return get(openApiDoc, 'components.schemas') || {};
  }, [openApiDoc]);

  const getForbiddenTypes = React.useCallback((data: Obj, storage: string[]) => {
    if (data?.properties) {
      forEach(values(data.properties), (item) => {
        const refTypePath = get(item, [QUOTE_PREFIX, 0, '$ref']) || data[QUOTE_PREFIX_NO_EXTENDED];
        if (refTypePath) {
          const tempQuoteName = refTypePath.split('#/components/schemas/')[1];
          if (!storage.includes(tempQuoteName)) {
            storage.push(tempQuoteName);
          }
        }
        getForbiddenTypes(item, storage);
      });
    } else if (data?.items) {
      const itemsData = data.items;
      const refTypePath = get(itemsData, [QUOTE_PREFIX, 0, '$ref']) || itemsData[QUOTE_PREFIX_NO_EXTENDED];
      if (refTypePath) {
        const tempQuoteName = refTypePath.split('#/components/schemas/')[1];
        if (!storage.includes(tempQuoteName)) {
          storage.push(tempQuoteName);
        }
        getForbiddenTypes(itemsData, storage);
      }
    }
  }, []);

  const onFormChange = React.useCallback(
    (_formKey: string, _formData: any, extraProps?: Obj) => {
      const quotedType = extraProps?.quoteTypeName;

      const nName: string = _formData[API_FORM_KEY]; // 当前正在编辑的类型的新name
      const tempMap = {};
      const originalDataTypeMap = get(openApiDocRef.current, ['components', 'schemas']);

      const usedCustomTypes: string[] = []; // 获取所有引用当前类型的其他类型
      const refTypePath = get(_formData, [QUOTE_PREFIX, 0, '$ref']) || _formData[QUOTE_PREFIX_NO_EXTENDED];
      if (refTypePath) {
        const tempQuoteName = refTypePath.split('#/components/schemas/')[1];
        usedCustomTypes.push(tempQuoteName);
      }

      getForbiddenTypes(_formData, usedCustomTypes);

      forEach(keys(originalDataTypeMap), (typeKey: string) => {
        if (typeKey !== dataType) {
          const isSelfUsed = usedCustomTypes.includes(typeKey); // 是否引用了当前正在编辑的类型
          const _originalForbiddenTypes = filter(
            get(originalDataTypeMap, [typeKey, 'x-dice-forbidden-types']) || [],
            (item) => item !== dataType,
          );

          if (!_originalForbiddenTypes.includes(typeKey)) {
            // 每个类型禁止引用自己
            _originalForbiddenTypes.push(typeKey);
          }

          // 若当前编辑的类型引用了该类型,则禁该止引用当前正在编辑的类型
          if (isSelfUsed || quotedType === typeKey) {
            _originalForbiddenTypes.push(nName);
          }

          tempMap[typeKey] = {
            ...originalDataTypeMap[typeKey],
            'x-dice-forbidden-types': _originalForbiddenTypes,
          };
        }
      });

      let originalForbiddenTypes: string[] = _formData['x-dice-forbidden-types'] || [dataType];
      if (dataType !== nName) {
        originalForbiddenTypes = originalForbiddenTypes.filter(
          (item) => item !== dataType && originalDataTypeMap[item],
        );
        originalForbiddenTypes.push(nName);
      }
      tempMap[nName] = {
        ..._formData,
        'x-dice-forbidden-types': originalForbiddenTypes,
      };

      if (dataType !== nName) {
        onDataTypeNameChange(nName);

        const newPathMap = produce(quotePathMapRef.current, (draft) => {
          draft[nName] = filter(draft[dataType], (itemPath) => get(openApiDocRef.current, itemPath));
          unset(draft, dataType);
        });

        const newHref =
          _formData.type === 'object' ? [{ $ref: `#/components/schemas/${nName}` }] : `#/components/schemas/${nName}`;
        const quotePrefix = _formData.type === 'object' ? QUOTE_PREFIX : QUOTE_PREFIX_NO_EXTENDED;

        const _tempDocDetail = produce(openApiDocRef.current, (draft) => {
          set(draft, 'components.schemas', tempMap);
        });

        const tempDocDetail = produce(_tempDocDetail, (draft) => {
          forEach(quotePathMapRef.current[dataType], (curTypeQuotePath) => {
            if (
              get(draft, [...curTypeQuotePath, QUOTE_PREFIX]) ||
              get(draft, [...curTypeQuotePath, QUOTE_PREFIX_NO_EXTENDED])
            ) {
              unset(draft, [...curTypeQuotePath, QUOTE_PREFIX]);
              unset(draft, [...curTypeQuotePath, QUOTE_PREFIX_NO_EXTENDED]);
              set(draft, [...curTypeQuotePath, quotePrefix], newHref);
            }
          });
        });
        updateOpenApiDoc(tempDocDetail);
        onQuoteNameChange(newPathMap);
      } else {
        if (quotedType && extraProps?.typeQuotePath) {
          const prefixPath = `components.schemas.${extraProps.typeQuotePath}`;

          const isQuotedBefore =
            get(openApiDocRef.current, `${prefixPath}.${QUOTE_PREFIX}.0.$ref`) ||
            get(openApiDocRef.current, `${prefixPath}.${QUOTE_PREFIX_NO_EXTENDED}`);
          const oldQuotedType = isQuotedBefore ? isQuotedBefore.split('/').slice(-1)[0] : '';

          const newPathMap = produce(quotePathMapRef.current, (draft) => {
            draft[quotedType] = draft[quotedType] || [];
            draft[quotedType].push(prefixPath.split('.'));
            if (oldQuotedType) {
              draft[oldQuotedType] = filter(draft[oldQuotedType], (item) => item.join('.') !== prefixPath);
            }
          });
          onQuoteNameChange(newPathMap);
        }

        const tempDocDetail = produce(openApiDocRef.current, (draft) => {
          set(draft, 'components.schemas', tempMap);
        });
        updateOpenApiDoc(tempDocDetail);
      }
    },
    [
      dataType,
      getForbiddenTypes,
      onDataTypeNameChange,
      onQuoteNameChange,
      openApiDocRef,
      quotePathMapRef,
      updateOpenApiDoc,
    ],
  );

  return (
    <div className="basic-params-config">
      <FormBuilder isMultiColumn className="param-config-form" ref={formRef}>
        <PropertyItemForm
          onChange={onFormChange}
          onFormErrorNumChange={updateFormErrorNum}
          detailVisible
          formType="DataType"
          formData={propertyFormData}
          extraDataTypes={extraDataTypes}
          allExtraDataTypes={allExtraDataTypes}
          isEditMode={isEditMode}
          allDataTypes={dataTypeNameMap}
        />
      </FormBuilder>
    </div>
  );
}
Example #2
Source File: tree.tsx    From erda-ui with GNU Affero General Public License v3.0 4 votes vote down vote up
TreeCategory = ({
  title,
  titleOperation,
  initTreeData = [],
  treeProps,
  currentKey,
  actions,
  iconMap,
  loading,
  effects,
  searchGroup,
  onSelectNode = noop,
  customNode,
  customNodeClassName,
}: IProps) => {
  const { loadData, getAncestors, copyNode, moveNode, createNode, updateNode, deleteNode, fuzzySearch } = effects;

  const [{ treeData, expandedKeys, isEditing, rootKey, cuttingNodeKey, copyingNodeKey, filterOptions }, updater] =
    useUpdate({
      treeData: map(initTreeData || [], (data) => ({
        ...data,
        icon: getIcon(data, iconMap),
        optionProps: {
          popover: {
            trigger: 'hover',
            getPopupContainer: () => document.body,
            onVisibleChange: (visible: boolean) =>
              onPopMenuVisibleChange(data.isLeaf ? `leaf-${data.key}` : data.key, visible),
          },
        },
      })) as TreeNode[],
      expandedKeys: [] as string[],
      isEditing: false,
      rootKey: get(initTreeData, '0.key') || '0',
      cuttingNodeKey: null as null | string,
      copyingNodeKey: null as null | string,
      filterOptions: { folderGroup: [], fileGroup: [] } as {
        folderGroup: Array<{ label: string; value: string }>;
        fileGroup: Array<{ label: string; value: string }>;
      },
    });

  const latestTreeData = useLatest(treeData);

  const getClassName = (node: TreeNode) => {
    const { isLeaf } = node;
    return isLeaf ? (typeof customNodeClassName === 'function' ? customNodeClassName(node) : customNodeClassName) : '';
  };

  const onPopMenuVisibleChange = (key: string, visible: boolean) => {
    const dataCp = cloneDeep(latestTreeData.current);
    const node = findTargetNode(key, dataCp);
    set(node!, ['optionProps', 'popover', 'visible'], visible);
    updater.treeData(dataCp);
  };

  // 更新一个父节点的儿子节点
  const appendChildren = (parentKey: string, children: TreeNode[]) => {
    const dataCp = cloneDeep(latestTreeData.current);
    const parentNode = findTargetNode(parentKey, dataCp);
    if (parentNode) {
      set(
        parentNode,
        'children',
        map(children, (child) => {
          const { isLeaf, key, title: cTitle, ...rest } = child;
          let originChild = null; // 如果能找到此儿子本身存在,那么此儿子的儿子要保留
          if (parentNode.children && findIndex(parentNode.children, { key }) > -1) {
            originChild = find(parentNode.children, { key });
          }
          return {
            ...rest,
            title: customNode ? customNode(child) : cTitle,
            titleAlias: typeof cTitle === 'string' ? cTitle : undefined,
            isLeaf,
            key: isLeaf ? `leaf-${key}` : key,
            icon: getIcon({ isLeaf, type: rest.type, iconType: rest.iconType }, iconMap),
            parentKey,
            disabled: isEditing,
            children: originChild?.children,
            className: getClassName(child),
            optionProps: {
              popover: {
                trigger: 'hover',
                getPopupContainer: () => document.body,
                onVisibleChange: (visible: boolean) => onPopMenuVisibleChange(isLeaf ? `leaf-${key}` : key, visible),
              },
            },
          };
        }),
      );
      updater.treeData(dataCp);
    }
  };

  // 展开节点,调用接口
  const onLoadTreeData = async (nodeKey: string) => {
    const categories = await loadData({ pinode: nodeKey });
    appendChildren(nodeKey, categories);
  };

  useMount(async () => {
    if (currentKey) {
      // 刷新页面或首次加载时有具体文件id存在,需要自动展开树
      let key = currentKey;
      if (currentKey.startsWith('leaf-')) {
        key = currentKey.slice(5);
      }
      const ancestors = await getAncestors({ inode: key });
      const keys = map(ancestors, 'key');
      updater.expandedKeys(keys);
    } else if (initTreeData && initTreeData.length === 1) {
      // 否则自动展开第一层
      const categories = await loadData({ pinode: rootKey });
      appendChildren(rootKey, categories);
      updater.expandedKeys([rootKey]);
    }
  });

  const onExpand = (keys: string[]) => {
    updater.expandedKeys(keys);
  };

  const onClickNode = (keys: string[], info: { node: { props: AntTreeNodeProps } }) => {
    const isLeaf = !!info.node.props.isLeaf;
    onSelectNode({ inode: isLeaf ? keys[0].slice(5) : keys[0], isLeaf });
  };

  React.useEffect(() => {
    updater.treeData((oldTreeData: TreeNode[]) => {
      const dataCp = cloneDeep(oldTreeData);
      walkTree(dataCp, { disabled: isEditing }, EDIT_KEY);
      return dataCp;
    });
  }, [isEditing, updater]);

  function createTreeNode(nodeKey: string, isCreateFolder: boolean) {
    const dataCp = cloneDeep(treeData);
    const currentNode = findTargetNode(nodeKey, dataCp)!;
    updater.isEditing(true);
    const onEditFolder = async ({ name }: { name: string }) => {
      await createNode!({ name, pinode: nodeKey, type: isCreateFolder ? 'd' : 'f' });
      onLoadTreeData(nodeKey);
      onHide();
      updater.isEditing(false);
    };
    const onHide = () => {
      onLoadTreeData(nodeKey);
      updater.isEditing(false);
    };
    currentNode.children = [
      {
        key: EDIT_KEY,
        titleAlias: EDIT_KEY,
        parentKey: nodeKey,
        isLeaf: !isCreateFolder,
        icon: getIcon({ isLeaf: !isCreateFolder }, iconMap),
        disableAction: true,
        title: <EditCategory contentOnly onSubmit={onEditFolder} onHide={onHide} />,
      },
      ...(currentNode.children || []),
    ];
    !expandedKeys.includes(nodeKey) && updater.expandedKeys(expandedKeys.concat(nodeKey));
    updater.treeData(dataCp);
  }

  // 添加子文件夹(提交在组件内)
  const renameTreeNode = (nodeKey: string, isRenameFolder: boolean) => {
    const dataCp = cloneDeep(treeData);
    const currentNode = findTargetNode(nodeKey, dataCp)!;
    updater.isEditing(true);
    const restoreNode = () => {
      currentNode.key = nodeKey; // 利用闭包恢复key
      currentNode.disableAction = false;
      updater.treeData(dataCp);
      updater.isEditing(false);
    };
    const onEditFolder = async ({ name }: { name: string }) => {
      const updatedNode = await updateNode!({ name, inode: isRenameFolder ? nodeKey : nodeKey.slice(5) });
      currentNode.title = customNode ? customNode(updatedNode) : updatedNode.title;
      currentNode.titleAlias = name;
      restoreNode();
    };
    const onHide = () => {
      currentNode.title = currentNode.titleAlias; // 恢复被edit组件替代了的name
      restoreNode();
    };
    currentNode.key = EDIT_KEY;
    currentNode.disableAction = true;
    currentNode.title = (
      <EditCategory contentOnly defaultName={currentNode.title as string} onSubmit={onEditFolder} onHide={onHide} />
    );
    updater.treeData(dataCp);
  };

  const handleMoveNode = async (nodeKey: string, parentKey: string) => {
    const targetNode = findTargetNode(nodeKey, treeData)!;
    await moveNode!({
      inode: targetNode.isLeaf ? nodeKey.slice(5) : nodeKey,
      pinode: parentKey,
      isLeaf: targetNode!.isLeaf,
    });
    if (cuttingNodeKey === nodeKey) {
      updater.cuttingNodeKey(null);
    }
    await onLoadTreeData(targetNode.parentKey!); // 重新刷被剪切的父文件夹
    await onLoadTreeData(parentKey); // 重新刷被粘贴的父文件夹
    if (!targetNode.isLeaf && !!targetNode.children) {
      // 如果剪切的是文件夹,那么可能这个文件夹已经被展开了,那么刷新父文件夹之后children就丢了。所有手动粘贴一下
      const dataCp = cloneDeep(latestTreeData.current);
      const nodeInNewParent = findTargetNode(nodeKey, dataCp);
      if (nodeInNewParent) {
        nodeInNewParent.children = targetNode.children;
        updater.treeData(dataCp);
      }
    }
    if (!expandedKeys.includes(parentKey)) {
      updater.expandedKeys(expandedKeys.concat(parentKey));
    }
  };

  const onPaste = async (nodeKey: string) => {
    if (cuttingNodeKey) {
      await handleMoveNode(cuttingNodeKey!, nodeKey);
    } else if (copyingNodeKey) {
      const dataCp = cloneDeep(treeData);
      const targetNode = findTargetNode(copyingNodeKey, dataCp)!;
      copyNode &&
        (await copyNode({ inode: targetNode.isLeaf ? copyingNodeKey.slice(5) : copyingNodeKey, pinode: nodeKey }));
      targetNode.className = getClassName(targetNode);
      updater.copyingNodeKey(null);
      updater.treeData(dataCp);
      await onLoadTreeData(nodeKey);
      if (!expandedKeys.includes(nodeKey)) {
        updater.expandedKeys(expandedKeys.concat(nodeKey));
      }
    }
  };

  const onDelete = async (nodeKey: string) => {
    const currentNode = findTargetNode(nodeKey, treeData)!;
    // 检查当前删除的节点是不是currentKey的父级
    await deleteNode!(
      { inode: currentNode.isLeaf ? nodeKey.slice(5) : nodeKey },
      currentNode.isLeaf ? currentKey === nodeKey : isAncestor(treeData, currentKey, nodeKey),
    );
    onLoadTreeData(currentNode.parentKey!);
    if (nodeKey === copyingNodeKey) {
      updater.copyingNodeKey(null);
    } else if (nodeKey === cuttingNodeKey) {
      updater.cuttingNodeKey(null);
    }
  };

  const onCopyOrCut = (nodeKey: string, isCut: boolean) => {
    const dataCp = cloneDeep(treeData);
    if (copyingNodeKey) {
      // 先把上一个剪切的点取消
      const originalNode = findTargetNode(copyingNodeKey, dataCp)!;
      originalNode.className = getClassName(originalNode);
    }
    if (cuttingNodeKey) {
      const originalNode = findTargetNode(cuttingNodeKey, dataCp)!;
      originalNode.className = getClassName(originalNode);
    }
    const node = findTargetNode(nodeKey, dataCp)!;
    node.className = `border-dashed ${getClassName(node)}`;
    if (isCut) {
      updater.cuttingNodeKey(nodeKey); // 复制和剪切互斥关系
      updater.copyingNodeKey(null);
    } else {
      updater.copyingNodeKey(nodeKey);
      updater.cuttingNodeKey(null);
    }
    updater.treeData(dataCp);
  };

  const cancelCutCopyAction = (isCut: boolean) => ({
    node: isCut ? i18n.t('common:cancel cut') : i18n.t('common:cancel copy'),
    func: (key: string) => {
      const dataCp = cloneDeep(treeData);
      const node = findTargetNode(key, dataCp)!;
      node.className = getClassName(node);
      updater.cuttingNodeKey(null);
      updater.copyingNodeKey(null);
      updater.treeData(dataCp);
    },
  });

  const presetMap: {
    [p: string]: {
      node?: string | JSX.Element;
      func: (key: string) => void;
      condition?: (node: TreeNode) => boolean | IAction;
      hookFn?: (nodeKey: string, isCreate: boolean) => Promise<void>;
    } | null;
  } = {
    [NEW_FOLDER]: createNode
      ? {
          func: (key: string) => createTreeNode(key, true),
          condition: (node) => node.key !== copyingNodeKey && node.key !== cuttingNodeKey, // 本身是被复制或剪切中的节点不能创建新节点
        }
      : null,
    [RENAME_FOLDER]: updateNode
      ? {
          func: (key: string) => renameTreeNode(key, true),
          condition: (node) => node.key !== copyingNodeKey && node.key !== cuttingNodeKey, // 本身是被复制或剪切中的节点不能重命名
        }
      : null,
    [DELETE]: deleteNode
      ? {
          node: (
            <Popover trigger="click" content={i18n.t('dop:confirm to delete?')} onCancel={(e) => e.stopPropagation()}>
              <div
                onClick={(e) => {
                  e.stopPropagation();
                }}
              >
                {i18n.t('Delete')}
              </div>
            </Popover>
          ),
          func: async (key: string) => {
            onDelete(key);
          },
        }
      : null,
    [CUT]: moveNode
      ? {
          func: (key: string) => onCopyOrCut(key, true),
          condition: (node) => (node.key !== cuttingNodeKey ? true : cancelCutCopyAction(true)), // 本身就是被剪切的节点要被替换成取消
        }
      : null,
    [PASTE]:
      moveNode || copyNode
        ? {
            func: (key: string) => onPaste(key),
            condition: (node) =>
              (!!cuttingNodeKey || !!copyingNodeKey) &&
              cuttingNodeKey !== node.key &&
              copyingNodeKey !== node.key &&
              !isAncestor(treeData, node.key, cuttingNodeKey) &&
              !isAncestor(treeData, node.key, copyingNodeKey), // 如果当前节点是已被剪切或复制的节点的儿子,那么不能粘贴, 否则会循环引用
          }
        : null,
    [COPY]: copyNode
      ? {
          func: (key: string) => onCopyOrCut(key, false),
          condition: (node) => (node.key !== copyingNodeKey ? true : cancelCutCopyAction(false)), // 本身就是被复制的节点要被替换成取消
        }
      : null,
    [CUSTOM_EDIT]: {
      func: noop,
      hookFn: async (nodeKey: string, isCreate: boolean) => {
        // 用户自定义编辑,这可以提供用户自定义去创建文件和文件夹, 大多数情况是弹窗,所有func由用户自定义,然后透出hook,如果是创建行为则nodeKey本身是父,就用nodeKey用来刷新父目录,如果是Update行为,nodeKey是子,则要取出父来刷
        let parentKey = nodeKey;
        if (!isCreate) {
          const node = findTargetNode(nodeKey, treeData)!;
          parentKey = node.parentKey!;
        }
        await onLoadTreeData(parentKey);
        if (!expandedKeys.includes(parentKey)) {
          onExpand(expandedKeys.concat(parentKey));
        }
      },
    },
    [NEW_FILE]: createNode
      ? {
          func: (key: string) => createTreeNode(key, false),
          condition: (node) => node.key !== copyingNodeKey && node.key !== cuttingNodeKey, // 本身是被复制或剪切中的节点不能创建新节点
        }
      : null,
    [RENAME_FILE]: updateNode
      ? {
          func: (key: string) => renameTreeNode(key, false),
          condition: (node) => node.key !== copyingNodeKey && node.key !== cuttingNodeKey, // 本身是被复制或剪切中的节点不能重命名
        }
      : null,
  };

  const generateActions = (rawActions: TreeAction[], node: TreeNode): IAction[] => {
    const _actions = reduce(
      rawActions,
      (acc: IAction[], action) => {
        const { preset, func, node: actionNode, hasAuth = true, authTip } = action;
        if (preset) {
          const defaultFn =
            (
              fn: (key: string, n: TreeNodeNormal) => Promise<void>,
              hook?: (nodeKey: string, isCreate: boolean) => Promise<void>,
            ) =>
            async (key: string, n: TreeNodeNormal) => {
              await fn(key, n);
              func && func(key, n, hook);
              onPopMenuVisibleChange(key, false);
            };
          const presetAction = presetMap[preset]; // 策略模式取具体action
          if (presetAction) {
            const { node: presetNode, func: presetFunc, condition, hookFn } = presetAction;
            const addable = condition ? condition(node) : true; // condition为true则要添加
            if (!addable) {
              return acc;
            }
            if (typeof addable !== 'boolean') {
              return acc.concat(addable);
            }
            if (preset === DELETE && !hasAuth) {
              // 没权限的时候,去除删除的确认框
              return acc.concat({
                node: (
                  <div>
                    <WithAuth pass={hasAuth} noAuthTip={authTip}>
                      <span>{actionNode}</span>
                    </WithAuth>
                  </div>
                ),
                func: noop,
              });
            }
            return acc.concat({
              node: (
                <div>
                  <WithAuth pass={hasAuth} noAuthTip={authTip}>
                    <span>{presetNode || actionNode}</span>
                  </WithAuth>
                </div>
              ),
              func: hasAuth ? defaultFn(presetFunc, hookFn) : noop,
            });
          }
          return acc;
        }
        return func
          ? acc.concat({
              func: (curKey: string, curNode: TreeNodeNormal) => {
                func(curKey, curNode);
                onPopMenuVisibleChange(curKey, false);
              },
              node: actionNode,
            })
          : acc;
      },
      [],
    );
    return _actions;
  };

  const getActions = (node: TreeNodeNormal): IAction[] => {
    const execNode = node as TreeNode;
    if (execNode.disableAction) {
      return [];
    }
    if (node.isLeaf) {
      const fileActions =
        typeof actions?.fileActions === 'function' ? actions.fileActions(execNode) : actions?.fileActions;
      return generateActions(fileActions || [], execNode);
    }
    const folderActions =
      typeof actions?.folderActions === 'function' ? actions.folderActions(execNode) : actions?.folderActions;
    return generateActions(folderActions || [], execNode);
  };

  const onDrop = async (info: { dragNode: AntTreeNodeProps; node: AntTreeNodeProps }) => {
    const { dragNode, node: dropNode } = info;
    const dragKey = dragNode.key;
    let dropKey = dropNode.key;
    if (dropNode.isLeaf) {
      dropKey = dropNode.parentKey;
    }
    await handleMoveNode(dragKey, dropKey);
  };

  const operations = () => {
    if (titleOperation && Array.isArray(titleOperation)) {
      return map(titleOperation, (operation) => {
        if (operation.preset && operation.preset === NEW_FOLDER && createNode) {
          return newFolderOperation(async ({ name }: { name: string }) => {
            await createNode({ name, pinode: rootKey, type: 'd' });
            onLoadTreeData(rootKey);
          });
        }
        return operation;
      }) as Array<{ title: string }>;
    }
    return [];
  };

  const onSearch = async (query: string) => {
    if (!query || !fuzzySearch) {
      return;
    }
    const searchResult = await fuzzySearch({ fuzzy: query });
    const folderGroup = [] as Array<{ label: string; value: string }>;
    const fileGroup = [] as Array<{ label: string; value: string }>;
    forEach(searchResult, ({ isLeaf, key, titleAlias }) => {
      if (isLeaf) {
        fileGroup.push({ label: titleAlias, value: `leaf-${key}` });
      } else {
        folderGroup.push({ label: titleAlias, value: key });
      }
    });
    updater.filterOptions({ folderGroup, fileGroup });
  };

  const handleSearchChange = async (key?: string) => {
    if (!key) {
      updater.filterOptions({ folderGroup: [], fileGroup: [] });
      return;
    }
    const isLeaf = key.startsWith('leaf');
    const ancestors = await getAncestors({ inode: isLeaf ? key.slice(5) : key });
    const keys = map(ancestors, 'key');
    if (isLeaf) {
      updater.expandedKeys(Array.from(new Set(expandedKeys.concat(keys))));
      onSelectNode({ inode: key.slice(5), isLeaf });
    } else {
      updater.expandedKeys(Array.from(new Set(expandedKeys.concat(keys).concat(key))));
    }
  };

  const generateSearchOptions = () => {
    const { fileGroup, folderGroup } = filterOptions;
    const { file, folder } = searchGroup || { file: i18n.t('File'), folder: i18n.t('common:folder') };
    const options = [];
    if (folderGroup.length > 0) {
      options.push(
        <OptGroup key="folder" label={folder}>
          {map(folderGroup, ({ value, label }) => (
            <Option key={value} value={value}>
              {label}
            </Option>
          ))}
        </OptGroup>,
      );
    }
    if (fileGroup.length > 0) {
      options.push(
        <OptGroup key="file" label={file}>
          {map(fileGroup, ({ value, label }) => (
            <Option key={value} value={value}>
              {label}
            </Option>
          ))}
        </OptGroup>,
      );
    }
    return options;
  };

  return (
    <div>
      {title ? <Title title={title} showDivider={false} operations={operations()} /> : null}
      {fuzzySearch ? (
        <Select
          showSearch
          placeholder={i18n.t('common:Please enter the keyword to search')}
          showArrow={false}
          filterOption
          optionFilterProp={'children'}
          notFoundContent={null}
          onSearch={onSearch}
          onChange={handleSearchChange}
          className="w-full"
          allowClear
        >
          {generateSearchOptions()}
        </Select>
      ) : null}
      <Spin spinning={!!loading}>
        <Tree
          selectedKeys={currentKey ? [currentKey] : []}
          loadData={(node) => onLoadTreeData(node.key)}
          treeData={treeData}
          expandedKeys={expandedKeys}
          className="tree-category-container"
          blockNode
          showIcon
          onExpand={onExpand}
          onSelect={onClickNode}
          titleRender={(nodeData: TreeNodeNormal) => {
            const execNode = nodeData as TreeNode;
            return (
              <span className={`inline-block truncate ${execNode.disableAction ? 'w-full' : 'has-operates'}`}>
                {nodeData.title}
                {!execNode.disableAction && (
                  <Popover
                    content={getActions(nodeData).map((item, idx) => (
                      <div className="action-btn" key={`${idx}`} onClick={() => item.func?.(nodeData.key, nodeData)}>
                        {item.node}
                      </div>
                    ))}
                    footer={false}
                  >
                    <CustomIcon type="gd" className="tree-node-action" />
                  </Popover>
                )}
              </span>
            );
          }}
          draggable={!!moveNode && !cuttingNodeKey && !copyingNodeKey} // 当有剪切复制正在进行中时,不能拖动
          onDrop={onDrop}
          {...treeProps}
        />
      </Spin>
    </div>
  );
}
Example #3
Source File: PageTabs.tsx    From logseq-plugin-tabs with MIT License 4 votes vote down vote up
export function PageTabs(): JSX.Element {
  const [tabs, setTabs] = useStoreTabs();
  const [activeTab, setActiveTab] = useActiveTab(tabs);

  const currActiveTabRef = React.useRef<ITabInfo | null>();
  const latestTabsRef = useLatest(tabs);

  const onCloseTab = useEventCallback((tab: ITabInfo, force?: boolean) => {
    const idx = tabs.findIndex((t) => isTabEqual(t, tab));

    // Do not close pinned
    if (tabs[idx]?.pinned && !force) {
      return;
    }
    const newTabs = [...tabs];
    newTabs.splice(idx, 1);
    setTabs(newTabs);

    if (newTabs.length === 0) {
      logseq.App.pushState("home");
    } else if (isTabEqual(tab, activeTab)) {
      const newTab = newTabs[Math.min(newTabs.length - 1, idx)];
      setActiveTab(newTab);
    }
  });

  const getCurrentActiveIndex = () => {
    return tabs.findIndex((ct) => isTabEqual(ct, currActiveTabRef.current));
  };

  const onCloseAllTabs = useEventCallback((excludeActive: boolean) => {
    const newTabs = tabs.filter(
      (t) =>
        t.pinned || (excludeActive && isTabEqual(t, currActiveTabRef.current))
    );
    setTabs(newTabs);
    if (!excludeActive) {
      logseq.App.pushState("home");
    }
  });

  const onChangeTab = useEventCallback(async (t: ITabInfo) => {
    setActiveTab(t);
    const idx = getCurrentActiveIndex();
    // remember current page's scroll position
    if (idx !== -1) {
      const scrollTop =
        top?.document.querySelector("#main-container")?.scrollTop;

      setTabs(
        produce(tabs, (draft) => {
          draft[idx].scrollTop = scrollTop;
        })
      );
    }
  });

  const onNewTab = useEventCallback((t: ITabInfo | null, open = false) => {
    if (t) {
      const previous = tabs.find((_t) => isTabEqual(t, _t));
      if (!previous) {
        setTabs([...tabs, t]);
      } else {
        open = true;
      }
      if (open) {
        onChangeTab({ ...t, pinned: previous?.pinned });
      }
    }
  });

  useCaptureAddTabAction(onNewTab);
  useDeepCompareEffect(() => {
    let timer = 0;
    let newTabs = latestTabsRef.current;
    const prevTab = currActiveTabRef.current;
    // If a new ActiveTab is set, we will need to replace or insert the tab
    if (activeTab) {
      newTabs = produce(tabs, (draft) => {
        if (tabs.every((t) => !isTabEqual(t, activeTab))) {
          const currentIndex = draft.findIndex((t) => isTabEqual(t, prevTab));
          const currentPinned = draft[currentIndex]?.pinned;
          if (currentIndex === -1 || currentPinned) {
            draft.push(activeTab);
          } else {
            draft[currentIndex] = activeTab;
          }
        } else {
          // Update the data if it is already in the list (to update icons etc)
          const currentIndex = draft.findIndex((t) => isTabEqual(t, activeTab));
          draft[currentIndex] = activeTab;
        }
      });
      timer = setTimeout(async () => {
        const p = await logseq.Editor.getCurrentPage();
        if (!isTabEqual(activeTab, p)) {
          logseq.App.pushState("page", {
            name: isBlock(activeTab)
              ? activeTab.uuid
              : activeTab.originalName ?? activeTab.name,
          });
        }
      }, 200);
    }
    currActiveTabRef.current = activeTab;
    setTabs(newTabs);
    return () => {
      if (timer) {
        clearTimeout(timer);
      }
    };
  }, [activeTab ?? {}]);

  const onPinTab = useEventCallback((t) => {
    setTabs(
      produce(tabs, (draft) => {
        const idx = draft.findIndex((ct) => isTabEqual(ct, t));
        draft[idx].pinned = !draft[idx].pinned;
        sortTabs(draft);
      })
    );
  });

  const onSwapTab = (t0: ITabInfo, t1: ITabInfo) => {
    setTabs(
      produce(tabs, (draft) => {
        const i0 = draft.findIndex((t) => isTabEqual(t, t0));
        const i1 = draft.findIndex((t) => isTabEqual(t, t1));
        draft[i0] = t1;
        draft[i1] = t0;
        sortTabs(draft);
      })
    );
  };

  const ref = React.useRef<HTMLElement>(null);
  const scrollWidth = useScrollWidth(ref);

  useAdaptMainUIStyle(tabs.length > 0, scrollWidth);

  React.useEffect(() => {
    if (activeTab && ref) {
      setTimeout(() => {
        ref.current
          ?.querySelector(`[data-active="true"]`)
          ?.scrollIntoView({ behavior: "smooth" });
      }, 100);
    }
  }, [activeTab, ref]);

  useRegisterKeybindings("tabs:toggle-pin", () => {
    if (currActiveTabRef.current) {
      onPinTab(currActiveTabRef.current);
    }
  });

  useRegisterKeybindings("tabs:close", () => {
    if (currActiveTabRef.current) {
      onCloseTab(currActiveTabRef.current);
    }
  });

  useRegisterKeybindings("tabs:select-next", () => {
    let idx = getCurrentActiveIndex() ?? -1;
    idx = (idx + 1) % tabs.length;
    onChangeTab(tabs[idx]);
  });

  useRegisterKeybindings("tabs:select-prev", () => {
    let idx = getCurrentActiveIndex() ?? -1;
    idx = (idx - 1 + tabs.length) % tabs.length;
    onChangeTab(tabs[idx]);
  });

  useRegisterSelectNthTabKeybindings((idx) => {
    if (idx > 0 && idx <= tabs.length) {
      onChangeTab(tabs[idx - 1]);
    }
  });

  useRegisterCloseAllButPins(onCloseAllTabs);

  return (
    <Tabs
      ref={ref}
      onClickTab={onChangeTab}
      activeTab={activeTab}
      tabs={tabs}
      onSwapTab={onSwapTab}
      onPinTab={onPinTab}
      onCloseTab={onCloseTab}
      onCloseAllTabs={onCloseAllTabs}
    />
  );
}