Files
outline/app/editor/components/BlockMenu.tsx
T
Tom Moor 0139b91b5d chore: Replace lodash with es-toolkit (#12281)
* chore: Replace lodash with es-toolkit

Migrate all direct lodash imports to es-toolkit/compat for a smaller,
faster, lodash-compatible utility library. Transitive lodash usage from
other packages remains unchanged.

* fix: Restore isPlainObject semantics in CanCan policy

The lodash migration aliased `isObject` to `lodash/isPlainObject` and
the codemod incorrectly mapped the local name to es-toolkit's `isObject`,
which also returns true for arrays and functions. This caused condition
objects in policy definitions to be skipped, breaking authorization
checks across the codebase.

* fix: Restore unicode-aware length counting in validators

es-toolkit/compat's size() returns string.length, while lodash's _.size()
counts unicode code points. Switch to [...value].length to preserve the
previous behavior so multi-byte characters like emoji count as one.
2026-05-06 21:03:47 -04:00

149 lines
4.3 KiB
TypeScript

import { DocumentIcon, ShapesIcon } from "outline-icons";
import { cloneDeep } from "es-toolkit/compat";
import { observer } from "mobx-react";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import Icon from "@shared/components/Icon";
import type { MenuItem } from "@shared/editor/types";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { TextHelper } from "@shared/utils/TextHelper";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import getMenuItems from "../menus/block";
import { useEditor } from "./EditorContext";
import type { Props as SuggestionsMenuProps } from "./SuggestionsMenu";
import SuggestionsMenu from "./SuggestionsMenu";
import SuggestionsMenuItem from "./SuggestionsMenuItem";
/**
* Hook that returns a template menu item with children for inserting template
* content into the editor, or undefined if no templates are available.
*/
function useTemplateMenuItem(): MenuItem | undefined {
const { t } = useTranslation();
const user = useCurrentUser({ rejectOnEmpty: false });
const { documents, templates: templatesStore } = useStores();
const editor = useEditor();
const documentId = editor.props.id;
const document = documentId ? documents.get(documentId) : undefined;
const collectionId = document?.collectionId;
return useMemo(() => {
if (!user) {
return undefined;
}
const allTemplates = templatesStore.orderedData.filter(
(template) => template.isActive
);
const hasTemplates = allTemplates.some(
(template) =>
template.isWorkspaceTemplate || template.collectionId === collectionId
);
if (!hasTemplates) {
return undefined;
}
const toMenuItem = (template: (typeof allTemplates)[0]): MenuItem => ({
name: "noop",
title: TextHelper.replaceTemplateVariables(
template.titleWithDefault,
user
),
icon: template.icon ? (
<Icon
value={template.icon}
initial={template.initial}
color={template.color ?? undefined}
/>
) : (
<DocumentIcon />
),
keywords: template.titleWithDefault,
onClick: () => {
const data = cloneDeep(template.data);
ProsemirrorHelper.replaceTemplateVariables(data, user);
editor.insertContent(data);
},
});
const children = (): MenuItem[] => {
const collectionTemplates = allTemplates.filter(
(template) =>
!template.isWorkspaceTemplate &&
template.collectionId === collectionId
);
const workspaceTemplates = allTemplates.filter(
(tmpl) => tmpl.isWorkspaceTemplate
);
const items: MenuItem[] = collectionTemplates.map(toMenuItem);
if (collectionTemplates.length && workspaceTemplates.length) {
items.push({ name: "separator" });
}
if (workspaceTemplates.length) {
for (const template of workspaceTemplates) {
items.push(toMenuItem(template));
}
}
return items;
};
return {
name: "noop",
title: t("Templates"),
icon: <ShapesIcon />,
keywords: "template",
children,
} satisfies MenuItem;
}, [user, templatesStore.orderedData, collectionId, editor, t]);
}
type Props = Omit<SuggestionsMenuProps, "renderMenuItem" | "items"> &
Required<Pick<SuggestionsMenuProps, "embeds">>;
function BlockMenu(props: Props) {
const { t } = useTranslation();
const { elementRef } = useEditor();
const templateMenuItem = useTemplateMenuItem();
const items = useMemo(() => {
const baseItems = getMenuItems(t, elementRef);
if (!templateMenuItem) {
return baseItems;
}
return [...baseItems, { name: "separator" } as MenuItem, templateMenuItem];
}, [t, elementRef, templateMenuItem]);
const renderMenuItem = useCallback(
(item, _index, options) => (
<SuggestionsMenuItem
{...options}
icon={item.icon}
title={item.title}
shortcut={item.shortcut}
disclosure={options.disclosure}
/>
),
[]
);
return (
<SuggestionsMenu
{...props}
filterable
trigger="/"
renderMenuItem={renderMenuItem}
items={items}
/>
);
}
export default observer(BlockMenu);