/** * Copyright 2023-present DreamNum Co., Ltd. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import type { LocaleService } from '@univerjs/core'; import type { ComponentProps, ComponentType, DragEvent, FocusEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react'; import { Button, clsx, Input, StateIconButton } from '@univerjs/design'; import { ArrowDownIcon, ArrowUpIcon, EyeIcon, EyelashIcon, LocateFixedIcon, LockIcon, MoreDownIcon, UnlockIcon, } from '@univerjs/icons'; import { useEffect, useMemo, useRef, useState } from 'react'; import { OBJECT_LIST_CANVAS_SECTION_ID, OBJECT_LIST_FLOATING_SECTION_ID } from './object-list-panel-layer'; export interface IObjectListPanelCapabilities { arrange?: boolean; description?: boolean; locate?: boolean; name?: boolean; reorder?: boolean; selectable?: boolean; visible?: boolean; } type ObjectListFilterMode = 'all' | 'hidden' | 'locked'; export interface IObjectListPanelItem { id: string; name: string; description?: string; visible: boolean; selectable?: boolean; disabled?: boolean; level?: number; isGroup?: boolean; expanded?: boolean; canMoveForward?: boolean; canMoveBackward?: boolean; canReorder?: boolean; parentId?: string; sectionId?: string; sectionTitle?: string; capabilities?: IObjectListPanelCapabilities; } export interface IObjectListPanelLabels { title: string; empty: string; showAll: string; hideAll: string; lockAll?: string; unlockAll?: string; moveForward: string; moveBackward: string; close: string; show: string; hide: string; lock: string; unlock: string; lockHint?: string; unlockHint?: string; name: string; nameInput: string; description: string; descriptionPlaceholder: string; details: string; noSelection: string; locate: string; expand: string; collapse: string; dragToReorder: string; search: string; filterAll: string; filterHidden: string; filterLocked: string; sectionCanvas: string; sectionFloating: string; } export type ObjectListPanelTypeNameKey = | 'chart' | 'connector' | 'container' | 'dom' | 'group' | 'image' | 'object' | 'placeholder' | 'shape' | 'smartArt' | 'table' | 'text' | 'unit' | 'video'; export function getObjectListPanelLabels(localeService: LocaleService): IObjectListPanelLabels { return { title: localeService.t('drawing-ui.objectListPanel.title'), empty: localeService.t('drawing-ui.objectListPanel.empty'), showAll: localeService.t('drawing-ui.objectListPanel.showAll'), hideAll: localeService.t('drawing-ui.objectListPanel.hideAll'), lockAll: localeService.t('drawing-ui.objectListPanel.lockAll'), unlockAll: localeService.t('drawing-ui.objectListPanel.unlockAll'), moveForward: localeService.t('drawing-ui.objectListPanel.moveForward'), moveBackward: localeService.t('drawing-ui.objectListPanel.moveBackward'), close: localeService.t('drawing-ui.objectListPanel.close'), show: localeService.t('drawing-ui.objectListPanel.show'), hide: localeService.t('drawing-ui.objectListPanel.hide'), lock: localeService.t('drawing-ui.objectListPanel.lock'), unlock: localeService.t('drawing-ui.objectListPanel.unlock'), lockHint: localeService.t('drawing-ui.objectListPanel.lockHint'), unlockHint: localeService.t('drawing-ui.objectListPanel.unlockHint'), name: localeService.t('drawing-ui.objectListPanel.name'), nameInput: localeService.t('drawing-ui.objectListPanel.nameInput'), description: localeService.t('drawing-ui.objectListPanel.description'), descriptionPlaceholder: localeService.t('drawing-ui.objectListPanel.descriptionPlaceholder'), details: localeService.t('drawing-ui.objectListPanel.details'), noSelection: localeService.t('drawing-ui.objectListPanel.noSelection'), locate: localeService.t('drawing-ui.objectListPanel.locate'), expand: localeService.t('drawing-ui.objectListPanel.expand'), collapse: localeService.t('drawing-ui.objectListPanel.collapse'), dragToReorder: localeService.t('drawing-ui.objectListPanel.dragToReorder'), search: localeService.t('drawing-ui.objectListPanel.search'), filterAll: localeService.t('drawing-ui.objectListPanel.filterAll'), filterHidden: localeService.t('drawing-ui.objectListPanel.filterHidden'), filterLocked: localeService.t('drawing-ui.objectListPanel.filterLocked'), sectionCanvas: localeService.t('drawing-ui.objectListPanel.sectionCanvas'), sectionFloating: localeService.t('drawing-ui.objectListPanel.sectionFloating'), }; } export function getObjectListPanelTypeName(localeService: LocaleService, typeName: ObjectListPanelTypeNameKey): string { return localeService.t(`drawing-ui.objectListPanel.typeNames.${typeName}`); } export interface IObjectListPanelBaseProps { items: IObjectListPanelItem[]; selectedIds: string[]; allObjectIds?: string[]; allItems?: Pick[]; focusedId?: string | null; renderPermissionAction?: (item: IObjectListPanelItem) => ReactNode; labels: IObjectListPanelLabels; showHeader?: boolean; capabilities?: IObjectListPanelCapabilities; onSelect: (objectId: string, multiSelect: boolean) => void; onSetVisible: (objectIds: string[], visible: boolean) => void; onCommitName: (objectId: string, value: string) => void; onCommitDescription: (objectId: string, value: string) => void; onMoveForward?: (objectId: string) => void; onMoveBackward?: (objectId: string) => void; onToggleExpanded?: (objectId: string) => void; onToggleSelectable?: (objectId: string) => void; onSetSelectable?: (objectIds: string[], selectable: boolean) => void; onLocate?: (objectId: string) => void; onReorder?: (sourceObjectId: string, targetObjectId: string) => void; } const iconButtonClassName = ` univer-flex univer-size-7 univer-shrink-0 univer-items-center univer-justify-center univer-rounded-md univer-border-0 univer-bg-transparent univer-p-0 univer-text-gray-500 univer-outline-none univer-transition-colors hover:univer-bg-gray-100 hover:univer-text-gray-900 disabled:univer-cursor-not-allowed disabled:univer-opacity-40 dark:!univer-text-gray-300 dark:hover:!univer-bg-gray-800 dark:hover:!univer-text-gray-100 `; const sectionOrder = new Map([ [OBJECT_LIST_FLOATING_SECTION_ID, 0], [OBJECT_LIST_CANVAS_SECTION_ID, 1], ['', 2], ]); function normalizeText(value: string): string { return value.trim(); } export function hasCapability( panelCapabilities: IObjectListPanelCapabilities | undefined, item: Pick | null | undefined, key: keyof IObjectListPanelCapabilities, fallback = true ): boolean { return item?.capabilities?.[key] ?? panelCapabilities?.[key] ?? fallback; } export function useObjectListPanel(props: IObjectListPanelBaseProps) { const { items, selectedIds, allObjectIds, allItems = items, focusedId, capabilities, onMoveForward, onMoveBackward, onLocate, onReorder, } = props; const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds]); const [draggingId, setDraggingId] = useState(null); const [searchQuery, setSearchQuery] = useState(''); const [filterMode, setFilterMode] = useState('all'); const [collapsedSectionIds, setCollapsedSectionIds] = useState>(() => new Set()); const selectedItem = useMemo( () => items.find((item) => item.id === focusedId) ?? items.find((item) => selectedIdSet.has(item.id)) ?? null, [focusedId, items, selectedIdSet] ); const visibleItems = useMemo(() => { const normalizedQuery = normalizeText(searchQuery).toLocaleLowerCase(); return items.filter((item) => { if (filterMode === 'hidden' && item.visible !== false) { return false; } if (filterMode === 'locked' && item.selectable !== false) { return false; } if (!normalizedQuery) { return true; } return item.name.toLocaleLowerCase().includes(normalizedQuery) || (item.description ?? '').toLocaleLowerCase().includes(normalizedQuery); }); }, [filterMode, items, searchQuery]); const visibleItemSections = useMemo(() => { const sectionMap = new Map(); visibleItems.forEach((item) => { const sectionId = item.sectionId ?? ''; const existingSection = sectionMap.get(sectionId); if (existingSection) { existingSection.items.push(item); return; } sectionMap.set(sectionId, { id: sectionId, title: item.sectionTitle ?? '', items: [item], }); }); return [...sectionMap.values()].sort((a, b) => (sectionOrder.get(a.id) ?? 3) - (sectionOrder.get(b.id) ?? 3)); }, [visibleItems]); const showSectionHeaders = visibleItemSections.length > 1 || visibleItemSections.some((section) => section.id === OBJECT_LIST_FLOATING_SECTION_ID); const bulkItems = useMemo(() => { if (!allObjectIds) { return allItems; } const itemMap = new Map(allItems.map((item) => [item.id, item])); return allObjectIds.map((id) => itemMap.get(id) ?? { id, visible: true }); }, [allObjectIds, allItems]); const visibilityItems = bulkItems.filter((item) => !item.disabled && hasCapability(capabilities, item, 'visible')); const selectableItems = bulkItems.filter((item) => !item.disabled && hasCapability(capabilities, item, 'selectable')); const allVisible = visibilityItems.length > 0 && visibilityItems.every((item) => item.visible); const allLocked = selectableItems.length > 0 && selectableItems.every((item) => item.selectable === false); const selectedIndex = selectedItem ? items.findIndex((item) => item.id === selectedItem.id) : -1; const canMoveForward = !!selectedItem && !!onMoveForward && !selectedItem.disabled && ( selectedItem.canMoveForward ?? selectedIndex > 0 ) && hasCapability(capabilities, selectedItem, 'arrange'); const canMoveBackward = !!selectedItem && !!onMoveBackward && !selectedItem.disabled && ( selectedItem.canMoveBackward ?? (selectedIndex >= 0 && selectedIndex < items.length - 1) ) && hasCapability(capabilities, selectedItem, 'arrange'); const showArrangeControls = hasCapability(capabilities, selectedItem, 'arrange') && (!!onMoveForward || !!onMoveBackward); const showLocateControl = !!onLocate && hasCapability(capabilities, selectedItem, 'locate'); const canReorder = !!onReorder && (capabilities?.reorder ?? true); const findItem = (objectId: string | null): IObjectListPanelItem | null => { return objectId ? items.find((item) => item.id === objectId) ?? null : null; }; const handleDrop = (targetId: string) => { const source = findItem(draggingId); const target = findItem(targetId); setDraggingId(null); if (!source || !target || source.id === target.id || source.disabled || target.disabled) { return; } if ((source.parentId ?? '') !== (target.parentId ?? '')) { return; } if ((source.sectionId ?? '') !== (target.sectionId ?? '')) { return; } if (source.canReorder === false || target.canReorder === false) { return; } onReorder?.(source.id, target.id); }; const toggleSectionCollapsed = (sectionId: string) => { if (!sectionId) { return; } setCollapsedSectionIds((current) => { const next = new Set(current); if (next.has(sectionId)) { next.delete(sectionId); } else { next.add(sectionId); } return next; }); }; return { selectedIdSet, draggingId, setDraggingId, searchQuery, setSearchQuery, filterMode, setFilterMode, collapsedSectionIds, selectedItem, visibleItems, visibleItemSections, showSectionHeaders, visibilityItems, selectableItems, allVisible, allLocked, canMoveForward, canMoveBackward, showArrangeControls, showLocateControl, canReorder, handleDrop, toggleSectionCollapsed }; } export function ObjectListPanelBase(props: IObjectListPanelBaseProps) { const { labels, showHeader = true, capabilities, onSelect, onSetVisible, onCommitName, onCommitDescription, onMoveForward, onMoveBackward, onToggleExpanded, onToggleSelectable, onSetSelectable, onLocate, renderPermissionAction } = props; const { selectedIdSet, draggingId, setDraggingId, searchQuery, setSearchQuery, filterMode, setFilterMode, collapsedSectionIds, selectedItem, visibleItems, visibleItemSections, showSectionHeaders, visibilityItems, selectableItems, allVisible, allLocked, canMoveForward, canMoveBackward, showArrangeControls, showLocateControl, canReorder, handleDrop, toggleSectionCollapsed } = useObjectListPanel(props); return (
{showHeader && (
{labels.title}
)}
{onSetSelectable && ( )}
{showLocateControl && ( )} {showArrangeControls && ( <> )}
{([ ['all', labels.filterAll], ['hidden', labels.filterHidden], ['locked', labels.filterLocked], ] as const).map(([mode, label]) => ( ))}
{visibleItems.length === 0 ?
{labels.empty}
: (
{visibleItemSections.map((section) => (
{showSectionHeaders && section.title && (() => { const sectionCollapsed = !!section.id && collapsedSectionIds.has(section.id); return ( ); })()} {(!section.id || !collapsedSectionIds.has(section.id)) && section.items.map((item) => ( onSelect(item.id, multiSelect)} onToggleExpanded={() => onToggleExpanded?.(item.id)} onToggleVisible={() => onSetVisible([item.id], !item.visible)} onToggleSelectable={() => onToggleSelectable?.(item.id)} onCommitName={(value) => onCommitName(item.id, value)} onDragStart={() => setDraggingId(item.id)} onDragEnd={() => setDraggingId(null)} onDrop={() => handleDrop(item.id)} /> ))}
))}
)}
selectedItem && onCommitName(selectedItem.id, value)} onCommitDescription={(value) => selectedItem && onCommitDescription(selectedItem.id, value)} />
); } export interface IObjectListRowProps { permissionAction?: ReactNode; item: IObjectListPanelItem; selected: boolean; labels: IObjectListPanelLabels; showLock: boolean; showName: boolean; showVisible: boolean; draggable: boolean; dragging: boolean; onSelect: (multiSelect: boolean) => void; onToggleExpanded: () => void; onToggleVisible: () => void; onToggleSelectable: () => void; onCommitName: (value: string) => void; onDragStart: () => void; onDragEnd: () => void; onDrop: () => void; } function ObjectListRow(props: IObjectListRowProps) { const { permissionAction, item, selected, labels, showLock, showName, showVisible, draggable, dragging, onSelect, onToggleExpanded, onToggleVisible, onToggleSelectable, onDragStart, onDragEnd, onDrop } = props; const { draftName, setDraftName, disabled, level, locked, handleBlur, handleKeyDown } = useObjectListRow(props); return (
) => onSelect(event.ctrlKey || event.metaKey)} onDragStart={(event: DragEvent) => { if (!draggable) { event.preventDefault(); return; } event.dataTransfer.effectAllowed = 'move'; event.dataTransfer.setData('text/plain', item.id); onDragStart(); }} onDragEnd={onDragEnd} onDragOver={(event: DragEvent) => { if (draggable) { event.preventDefault(); event.dataTransfer.dropEffect = 'move'; } }} onDrop={(event: DragEvent) => { event.preventDefault(); onDrop(); }} > {item.isGroup ? ( ) : } {showName ? ( { event.stopPropagation(); if (!disabled) { onSelect(event.ctrlKey || event.metaKey); } }} /> ) : {item.name}} {permissionAction} {showLock && ( { event.stopPropagation(); onToggleSelectable(); }} > {locked ? )} {showVisible && ( { event.stopPropagation(); onToggleVisible(); }} > {item.visible ? : } )}
); } export function ObjectDetailsEditor(props: { item: IObjectListPanelItem | null; InputComponent?: ComponentType>; descriptionClassName?: string; labels: IObjectListPanelLabels; showName: boolean; showDescription: boolean; onCommitName: (value: string) => void; onCommitDescription: (value: string) => void; }) { const { item, InputComponent = Input, descriptionClassName, labels, showName, showDescription, onCommitName, onCommitDescription } = props; const [draftName, setDraftName] = useState(item?.name ?? ''); const [draftDescription, setDraftDescription] = useState(item?.description ?? ''); const disabled = item?.disabled === true; useEffect(() => { setDraftName(item?.name ?? ''); setDraftDescription(item?.description ?? ''); }, [item?.description, item?.id, item?.name]); if (!item) { return (
{labels.noSelection}
); } return (
{labels.details}
{showName && ( )} {showDescription && (