snapshot of https://github.com/dream-num/univer.git (dev, 2026-09-16; blobs > 131072B omitted)
Some checks failed
📦 Build / build (push) Has been cancelled
🧪 CI Test / test shard (1/4) (push) Has been cancelled
🧪 CI Test / test shard (2/4) (push) Has been cancelled
🧪 CI Test / test shard (3/4) (push) Has been cancelled
🧪 CI Test / test shard (4/4) (push) Has been cancelled
🧪 CI Test / test (push) Has been cancelled
🧪 CI Test / eslint (push) Has been cancelled
🧪 CI Test / typecheck (push) Has been cancelled
🔫 Sync to Pro / dispatch-sync (push) Has been cancelled
🔮 Sync Mirror / sync (push) Has been cancelled
Some checks failed
📦 Build / build (push) Has been cancelled
🧪 CI Test / test shard (1/4) (push) Has been cancelled
🧪 CI Test / test shard (2/4) (push) Has been cancelled
🧪 CI Test / test shard (3/4) (push) Has been cancelled
🧪 CI Test / test shard (4/4) (push) Has been cancelled
🧪 CI Test / test (push) Has been cancelled
🧪 CI Test / eslint (push) Has been cancelled
🧪 CI Test / typecheck (push) Has been cancelled
🔫 Sync to Pro / dispatch-sync (push) Has been cancelled
🔮 Sync Mirror / sync (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,910 @@
|
||||
/**
|
||||
* 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<IObjectListPanelItem, 'id' | 'visible' | 'selectable' | 'disabled' | 'capabilities'>[];
|
||||
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<IObjectListPanelItem, 'capabilities'> | 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<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [filterMode, setFilterMode] = useState<ObjectListFilterMode>('all');
|
||||
const [collapsedSectionIds, setCollapsedSectionIds] = useState<Set<string>>(() => 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<string, { id: string; title: string; items: IObjectListPanelItem[] }>();
|
||||
|
||||
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 (
|
||||
<div
|
||||
className={clsx(`
|
||||
univer-box-border univer-flex univer-min-w-0 univer-max-w-full univer-flex-col univer-gap-3 univer-py-2
|
||||
univer-text-gray-700
|
||||
dark:!univer-text-gray-200
|
||||
`, 'univer-size-full univer-overflow-hidden')}
|
||||
data-drawing-object-list-panel="true"
|
||||
>
|
||||
{showHeader && (
|
||||
<div className="univer-flex univer-h-8 univer-shrink-0 univer-items-center univer-gap-2">
|
||||
<div
|
||||
className="
|
||||
univer-min-w-0 univer-flex-1 univer-truncate univer-text-sm univer-font-semibold
|
||||
univer-text-gray-900
|
||||
dark:!univer-text-gray-100
|
||||
"
|
||||
>
|
||||
{labels.title}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="univer-flex univer-min-w-0 univer-items-center univer-justify-between univer-gap-2">
|
||||
<div className="univer-flex univer-min-w-0 univer-flex-1 univer-gap-2">
|
||||
<Button
|
||||
|
||||
size="small"
|
||||
disabled={visibilityItems.length === 0}
|
||||
onClick={() => onSetVisible(visibilityItems.map((item) => item.id), !allVisible)}
|
||||
>
|
||||
{allVisible ? labels.hideAll : labels.showAll}
|
||||
</Button>
|
||||
{onSetSelectable && (
|
||||
<Button
|
||||
size="small"
|
||||
disabled={selectableItems.length === 0}
|
||||
onClick={() => onSetSelectable(selectableItems.map((item) => item.id), allLocked)}
|
||||
>
|
||||
{allLocked ? (labels.unlockAll ?? labels.unlock) : (labels.lockAll ?? labels.lock)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="univer-flex univer-shrink-0 univer-gap-1">
|
||||
{showLocateControl && (
|
||||
<button
|
||||
type="button"
|
||||
className={iconButtonClassName}
|
||||
title={labels.locate}
|
||||
aria-label={labels.locate}
|
||||
disabled={!selectedItem?.id}
|
||||
onClick={() => selectedItem && onLocate?.(selectedItem.id)}
|
||||
>
|
||||
<LocateFixedIcon />
|
||||
</button>
|
||||
)}
|
||||
{showArrangeControls && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={iconButtonClassName}
|
||||
title={labels.moveForward}
|
||||
aria-label={labels.moveForward}
|
||||
disabled={!canMoveForward}
|
||||
onClick={() => selectedItem && onMoveForward?.(selectedItem.id)}
|
||||
>
|
||||
<ArrowUpIcon />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={iconButtonClassName}
|
||||
title={labels.moveBackward}
|
||||
aria-label={labels.moveBackward}
|
||||
disabled={!canMoveBackward}
|
||||
onClick={() => selectedItem && onMoveBackward?.(selectedItem.id)}
|
||||
>
|
||||
<ArrowDownIcon />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="univer-flex univer-shrink-0 univer-flex-col univer-gap-2">
|
||||
<Input
|
||||
|
||||
value={searchQuery}
|
||||
aria-label={labels.search}
|
||||
placeholder={labels.search}
|
||||
onChange={setSearchQuery}
|
||||
/>
|
||||
<div className="univer-flex univer-min-w-0 univer-gap-1">
|
||||
{([
|
||||
['all', labels.filterAll],
|
||||
['hidden', labels.filterHidden],
|
||||
['locked', labels.filterLocked],
|
||||
] as const).map(([mode, label]) => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
className={clsx(
|
||||
`
|
||||
univer-min-w-0 univer-flex-1 univer-rounded-md univer-border univer-border-solid
|
||||
univer-px-2 univer-text-xs univer-outline-none univer-transition-colors
|
||||
dark:!univer-border-gray-700
|
||||
`,
|
||||
'univer-h-7',
|
||||
filterMode === mode
|
||||
? `
|
||||
dark:!univer-bg-primary-900/30
|
||||
univer-border-primary-500 univer-bg-primary-50 univer-text-primary-600
|
||||
dark:!univer-text-primary-200
|
||||
`
|
||||
: clsx(`
|
||||
univer-border-gray-200 univer-bg-gray-0 univer-text-gray-600
|
||||
dark:!univer-bg-gray-900 dark:!univer-text-gray-300
|
||||
`, `
|
||||
hover:univer-bg-gray-50
|
||||
dark:hover:!univer-bg-gray-800
|
||||
`)
|
||||
)}
|
||||
aria-pressed={filterMode === mode}
|
||||
onClick={() => setFilterMode(mode)}
|
||||
>
|
||||
<span className="univer-block univer-truncate">{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="univer-min-h-0 univer-flex-1 univer-overflow-y-auto univer-overflow-x-hidden"
|
||||
>
|
||||
{visibleItems.length === 0
|
||||
? <div className="univer-py-6 univer-text-center univer-text-sm univer-text-gray-500">{labels.empty}</div>
|
||||
: (
|
||||
<div className="univer-flex univer-min-w-0 univer-flex-col univer-gap-0.5">
|
||||
{visibleItemSections.map((section) => (
|
||||
<div
|
||||
key={section.id || 'default'}
|
||||
className="univer-flex univer-min-w-0 univer-flex-col univer-gap-0.5"
|
||||
>
|
||||
{showSectionHeaders && section.title && (() => {
|
||||
const sectionCollapsed = !!section.id && collapsedSectionIds.has(section.id);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(`
|
||||
univer-box-border univer-flex univer-h-7 univer-w-full
|
||||
univer-items-center univer-gap-1 univer-rounded-md univer-border-0
|
||||
univer-bg-transparent univer-px-2 univer-pt-1 univer-text-xs
|
||||
univer-font-semibold univer-uppercase univer-text-gray-400
|
||||
univer-outline-none
|
||||
disabled:univer-cursor-default
|
||||
dark:!univer-text-gray-500
|
||||
`, `
|
||||
hover:univer-bg-gray-50 hover:univer-text-gray-600
|
||||
disabled:hover:univer-bg-transparent
|
||||
dark:hover:!univer-bg-gray-800 dark:hover:!univer-text-gray-300
|
||||
`)}
|
||||
aria-label={sectionCollapsed ? labels.expand : labels.collapse}
|
||||
disabled={!section.id}
|
||||
onClick={() => toggleSectionCollapsed(section.id)}
|
||||
>
|
||||
{section.id
|
||||
? (
|
||||
<span
|
||||
className="
|
||||
univer-flex univer-size-4 univer-shrink-0
|
||||
univer-items-center univer-justify-center
|
||||
"
|
||||
>
|
||||
<MoreDownIcon
|
||||
className={clsx({
|
||||
'-univer-rotate-90 rtl:univer-rotate-90': sectionCollapsed,
|
||||
})}
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
: <span className="univer-size-4 univer-shrink-0" />}
|
||||
<span
|
||||
className="
|
||||
univer-min-w-0 univer-flex-1 univer-truncate univer-text-left
|
||||
"
|
||||
>
|
||||
{section.title}
|
||||
</span>
|
||||
<span
|
||||
className="
|
||||
univer-shrink-0 univer-text-[10px] univer-font-medium
|
||||
univer-text-gray-400
|
||||
dark:!univer-text-gray-500
|
||||
"
|
||||
>
|
||||
{section.items.length}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
{(!section.id || !collapsedSectionIds.has(section.id)) && section.items.map((item) => (
|
||||
<ObjectListRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
selected={selectedIdSet.has(item.id)}
|
||||
labels={labels}
|
||||
permissionAction={renderPermissionAction?.(item)}
|
||||
showLock={!!onToggleSelectable && hasCapability(capabilities, item, 'selectable')}
|
||||
showName={hasCapability(capabilities, item, 'name')}
|
||||
showVisible={hasCapability(capabilities, item, 'visible')}
|
||||
draggable={canReorder && hasCapability(capabilities, item, 'reorder') && !item.disabled && item.canReorder !== false}
|
||||
dragging={draggingId === item.id}
|
||||
onSelect={(multiSelect) => 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)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ObjectDetailsEditor
|
||||
item={selectedItem}
|
||||
labels={labels}
|
||||
showName={hasCapability(capabilities, selectedItem, 'name')}
|
||||
showDescription={hasCapability(capabilities, selectedItem, 'description')}
|
||||
onCommitName={(value) => selectedItem && onCommitName(selectedItem.id, value)}
|
||||
onCommitDescription={(value) => selectedItem && onCommitDescription(selectedItem.id, value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
className={clsx(
|
||||
`
|
||||
univer-box-border univer-flex univer-w-full univer-min-w-0 univer-max-w-full univer-items-center
|
||||
univer-gap-1.5 univer-overflow-hidden univer-rounded-md univer-pr-1.5 univer-transition-colors
|
||||
`,
|
||||
'univer-h-8',
|
||||
!disabled && `
|
||||
hover:univer-bg-gray-100
|
||||
dark:hover:!univer-bg-gray-800
|
||||
`,
|
||||
selected && `
|
||||
univer-bg-gray-100
|
||||
dark:!univer-bg-gray-800
|
||||
`,
|
||||
disabled && `
|
||||
univer-text-gray-400
|
||||
dark:!univer-text-gray-500
|
||||
`,
|
||||
dragging && 'univer-opacity-50'
|
||||
)}
|
||||
style={{ paddingLeft: level * 14 + 4 }}
|
||||
draggable={draggable}
|
||||
title={draggable ? labels.dragToReorder : undefined}
|
||||
onClick={disabled ? undefined : (event: MouseEvent<HTMLDivElement>) => onSelect(event.ctrlKey || event.metaKey)}
|
||||
onDragStart={(event: DragEvent<HTMLDivElement>) => {
|
||||
if (!draggable) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
event.dataTransfer.setData('text/plain', item.id);
|
||||
onDragStart();
|
||||
}}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragOver={(event: DragEvent<HTMLDivElement>) => {
|
||||
if (draggable) {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = 'move';
|
||||
}
|
||||
}}
|
||||
onDrop={(event: DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
onDrop();
|
||||
}}
|
||||
>
|
||||
{item.isGroup
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(`
|
||||
univer-flex univer-shrink-0 univer-items-center univer-justify-center univer-rounded
|
||||
univer-border-0 univer-bg-transparent univer-p-0 univer-text-gray-500 univer-outline-none
|
||||
univer-transition-transform
|
||||
dark:!univer-text-gray-300
|
||||
`, `
|
||||
univer-size-5
|
||||
hover:univer-bg-gray-100 hover:univer-text-gray-900
|
||||
dark:hover:!univer-bg-gray-800 dark:hover:!univer-text-gray-100
|
||||
`)}
|
||||
title={item.expanded ? labels.collapse : labels.expand}
|
||||
aria-label={item.expanded ? labels.collapse : labels.expand}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onToggleExpanded();
|
||||
}}
|
||||
>
|
||||
<MoreDownIcon
|
||||
className={clsx({
|
||||
'-univer-rotate-90 rtl:univer-rotate-90': !item.expanded,
|
||||
})}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
: <span className="univer-size-5 univer-shrink-0" />}
|
||||
{showName
|
||||
? (
|
||||
<Input
|
||||
key={item.name}
|
||||
className={clsx('univer-h-7 univer-w-0 univer-min-w-0 univer-flex-1', disabled && `
|
||||
univer-cursor-not-allowed
|
||||
`)}
|
||||
inputClass={clsx(
|
||||
`
|
||||
univer-min-w-0 univer-overflow-hidden univer-text-ellipsis univer-whitespace-nowrap
|
||||
!univer-rounded !univer-border-transparent !univer-bg-transparent !univer-px-1
|
||||
univer-text-sm univer-text-gray-900 !univer-shadow-none
|
||||
focus:!univer-border-primary-500 focus:!univer-bg-gray-0 focus:!univer-ring-1
|
||||
focus:!univer-ring-primary-100
|
||||
disabled:!univer-bg-transparent disabled:univer-opacity-100
|
||||
dark:!univer-text-gray-100
|
||||
dark:focus:!univer-bg-gray-900 dark:focus:!univer-ring-primary-900
|
||||
`,
|
||||
'!univer-h-7',
|
||||
disabled && `
|
||||
univer-text-gray-400
|
||||
dark:!univer-text-gray-500
|
||||
`
|
||||
)}
|
||||
inputStyle={{ maxWidth: '100%', boxSizing: 'border-box' }}
|
||||
value={draftName}
|
||||
disabled={disabled}
|
||||
aria-label={labels.nameInput}
|
||||
onChange={setDraftName}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (!disabled) {
|
||||
onSelect(event.ctrlKey || event.metaKey);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
: <span className="univer-min-w-0 univer-flex-1 univer-truncate univer-px-1 univer-text-sm">{item.name}</span>}
|
||||
{permissionAction}
|
||||
{showLock && (
|
||||
<StateIconButton
|
||||
active={locked}
|
||||
emphasizeActive
|
||||
aria-pressed={locked}
|
||||
type="button"
|
||||
|
||||
disabled={disabled}
|
||||
title={locked ? (labels.unlockHint ?? labels.unlock) : (labels.lockHint ?? labels.lock)}
|
||||
aria-label={locked ? labels.unlock : labels.lock}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onToggleSelectable();
|
||||
}}
|
||||
>
|
||||
{locked ? <LockIcon aria-hidden="true" /> : <UnlockIcon aria-hidden="true" />}
|
||||
</StateIconButton>
|
||||
)}
|
||||
{showVisible && (
|
||||
<StateIconButton
|
||||
active={!item.visible}
|
||||
aria-pressed={!item.visible}
|
||||
type="button"
|
||||
|
||||
disabled={disabled}
|
||||
title={item.visible ? labels.hide : labels.show}
|
||||
aria-label={item.visible ? labels.hide : labels.show}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onToggleVisible();
|
||||
}}
|
||||
>
|
||||
{item.visible ? <EyeIcon /> : <EyelashIcon />}
|
||||
</StateIconButton>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ObjectDetailsEditor(props: {
|
||||
item: IObjectListPanelItem | null;
|
||||
InputComponent?: ComponentType<ComponentProps<typeof Input>>;
|
||||
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 (
|
||||
<div
|
||||
className="
|
||||
univer-rounded-md univer-border univer-border-solid univer-border-gray-200 univer-p-3 univer-text-sm
|
||||
univer-text-gray-500
|
||||
dark:!univer-border-gray-700 dark:!univer-text-gray-400
|
||||
"
|
||||
>
|
||||
{labels.noSelection}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="
|
||||
univer-flex univer-shrink-0 univer-flex-col univer-gap-2 univer-border-0 univer-border-t
|
||||
univer-border-solid univer-border-gray-200 univer-pt-3
|
||||
dark:!univer-border-gray-700
|
||||
"
|
||||
>
|
||||
<div
|
||||
className="
|
||||
univer-text-xs univer-font-semibold univer-uppercase univer-text-gray-500
|
||||
dark:!univer-text-gray-400
|
||||
"
|
||||
>
|
||||
{labels.details}
|
||||
</div>
|
||||
{showName && (
|
||||
<label className="univer-flex univer-flex-col univer-gap-1">
|
||||
<span
|
||||
className="
|
||||
univer-text-xs univer-text-gray-500
|
||||
dark:!univer-text-gray-400
|
||||
"
|
||||
>
|
||||
{labels.name}
|
||||
</span>
|
||||
<InputComponent
|
||||
|
||||
value={draftName}
|
||||
aria-label={labels.nameInput}
|
||||
disabled={disabled}
|
||||
onChange={setDraftName}
|
||||
onBlur={() => {
|
||||
if (!disabled && normalizeText(draftName) !== item.name) {
|
||||
onCommitName(draftName);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
{showDescription && (
|
||||
<label className="univer-flex univer-flex-col univer-gap-1">
|
||||
<span
|
||||
className="
|
||||
univer-text-xs univer-text-gray-500
|
||||
dark:!univer-text-gray-400
|
||||
"
|
||||
>
|
||||
{labels.description}
|
||||
</span>
|
||||
<textarea
|
||||
className={clsx(descriptionClassName, `
|
||||
univer-box-border univer-min-h-16 univer-w-full univer-resize-none univer-rounded-md
|
||||
univer-border univer-border-solid univer-border-gray-200 univer-bg-gray-0 univer-p-2
|
||||
univer-text-sm univer-text-gray-900 univer-outline-none
|
||||
focus:univer-border-primary-500 focus:univer-ring-1 focus:univer-ring-primary-100
|
||||
disabled:univer-cursor-not-allowed disabled:univer-opacity-70
|
||||
dark:!univer-border-gray-700 dark:!univer-bg-gray-900 dark:!univer-text-gray-100
|
||||
dark:focus:!univer-ring-primary-900
|
||||
`)}
|
||||
value={draftDescription}
|
||||
disabled={disabled}
|
||||
placeholder={labels.descriptionPlaceholder}
|
||||
aria-label={labels.description}
|
||||
onChange={(event) => setDraftDescription(event.currentTarget.value)}
|
||||
onBlur={() => {
|
||||
if (!disabled && normalizeText(draftDescription) !== (item.description ?? '')) {
|
||||
onCommitDescription(draftDescription);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type IDrawingObjectListItem = IObjectListPanelItem;
|
||||
export type IDrawingObjectListPanelLabels = IObjectListPanelLabels;
|
||||
export type IDrawingObjectListPanelProps = IObjectListPanelBaseProps;
|
||||
export function useObjectListRow({ item, onCommitName }: Pick<IObjectListRowProps, 'item' | 'onCommitName'>) {
|
||||
const [draftName, setDraftName] = useState(item.name);
|
||||
const skipCommitOnBlurRef = useRef(false);
|
||||
const disabled = item.disabled === true;
|
||||
const level = item.level ?? 0;
|
||||
const locked = item.selectable === false;
|
||||
|
||||
useEffect(() => {
|
||||
setDraftName(item.name);
|
||||
}, [item.name]);
|
||||
|
||||
const handleBlur = (event: FocusEvent<HTMLInputElement>) => {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
if (skipCommitOnBlurRef.current) {
|
||||
skipCommitOnBlurRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (event.currentTarget.value !== item.name) {
|
||||
onCommitName(event.currentTarget.value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.currentTarget.blur();
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
skipCommitOnBlurRef.current = true;
|
||||
setDraftName(item.name);
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
};
|
||||
|
||||
return { draftName, setDraftName, disabled, level, locked, handleBlur, handleKeyDown };
|
||||
}
|
||||
Reference in New Issue
Block a user