mirror of
https://github.com/outline/outline.git
synced 2026-06-13 03:14:59 +03:00
0139b91b5d
* 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.
149 lines
4.3 KiB
TypeScript
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);
|