Compare commits

..

6 Commits

Author SHA1 Message Date
Tom Moor 682fbeb10a Merge main 2024-06-16 12:42:32 -04:00
Tom Moor 391f72aeb4 Add config on Settings plugin 2024-06-16 10:16:54 -04:00
Tom Moor 1b95838a16 Rebase main 2024-06-16 10:06:05 -04:00
Tom Moor fc8a491133 tsc 2024-06-16 10:04:53 -04:00
Tom Moor b219e42cc8 docs 2024-06-16 10:04:53 -04:00
Tom Moor f4e2c2de77 Update clientside plugin management to work as server 2024-06-16 10:04:53 -04:00
379 changed files with 6770 additions and 15166 deletions
+2 -2
View File
@@ -12,7 +12,7 @@
"legacy": true
}
],
"@babel/plugin-transform-class-properties",
"@babel/plugin-proposal-class-properties",
[
"transform-inline-environment-variables",
{
@@ -60,4 +60,4 @@
]
}
}
}
}
+1 -5
View File
@@ -131,7 +131,7 @@ GITHUB_APP_PRIVATE_KEY=
# => https://discord.com/developers/applications/
#
# When configuring the Client ID, add a redirect URL under "OAuth2":
# https://<URL>/auth/discord.callback
# https://<URL>/api/discord.callback
DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=
@@ -189,10 +189,6 @@ SLACK_VERIFICATION_TOKEN=your_token
SLACK_APP_ID=A0XXXXXXX
SLACK_MESSAGE_ACTIONS=true
# For Dropbox integration, follow these instructions to get the key https://www.dropbox.com/developers/embedder#setup
# and do not forget to whitelist your domain name in the app settings
DROPBOX_APP_KEY=
# Optionally enable Sentry (sentry.io) to track errors and performance,
# and optionally add a Sentry proxy tunnel for bypassing ad blockers in the UI:
# https://docs.sentry.io/platforms/javascript/troubleshooting/#using-the-tunnel-option)
+1 -2
View File
@@ -41,7 +41,6 @@
"@typescript-eslint/no-shadow": [
"warn",
{
"allow": ["transaction"],
"hoist": "all",
"ignoreTypeValueShadow": true
}
@@ -140,4 +139,4 @@
"typescript": {}
}
}
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
# Configuration for probot-no-response - https://github.com/probot/no-response
# Number of days of inactivity before an Issue is closed for lack of response
daysUntilClose: 7
daysUntilClose: 14
# Label requiring a response
responseRequiredLabel: more information needed
+3 -3
View File
@@ -1,5 +1,5 @@
ARG APP_PATH=/opt/outline
FROM outlinewiki/outline-base AS base
FROM outlinewiki/outline-base as base
ARG APP_PATH
WORKDIR $APP_PATH
@@ -11,7 +11,7 @@ LABEL org.opencontainers.image.source="https://github.com/outline/outline"
ARG APP_PATH
WORKDIR $APP_PATH
ENV NODE_ENV=production
ENV NODE_ENV production
COPY --from=base $APP_PATH/build ./build
COPY --from=base $APP_PATH/server ./server
@@ -27,7 +27,7 @@ RUN addgroup --gid 1001 nodejs && \
mkdir -p /var/lib/outline && \
chown -R nodejs:nodejs /var/lib/outline
ENV FILE_STORAGE_LOCAL_ROOT_DIR=/var/lib/outline/data
ENV FILE_STORAGE_LOCAL_ROOT_DIR /var/lib/outline/data
RUN mkdir -p "$FILE_STORAGE_LOCAL_ROOT_DIR" && \
chown -R nodejs:nodejs "$FILE_STORAGE_LOCAL_ROOT_DIR" && \
chmod 1777 "$FILE_STORAGE_LOCAL_ROOT_DIR"
-7
View File
@@ -6,10 +6,6 @@ WORKDIR $APP_PATH
COPY ./package.json ./yarn.lock ./
COPY ./patches ./patches
RUN apt-get update \
&& apt-get install -y wget \
&& rm -rf /var/lib/apt/lists/*
RUN yarn install --no-optional --frozen-lockfile --network-timeout 1000000 && \
yarn cache clean
@@ -21,6 +17,3 @@ RUN rm -rf node_modules
RUN yarn install --production=true --frozen-lockfile --network-timeout 1000000 && \
yarn cache clean
ENV PORT=3000
HEALTHCHECK CMD wget -qO- http://localhost:${PORT}/_health | grep -q "OK" || exit 1
+2 -2
View File
@@ -1,7 +1,7 @@
import { PlusIcon } from "outline-icons";
import * as React from "react";
import stores from "~/stores";
import ApiKeyNew from "~/scenes/ApiKeyNew";
import APIKeyNew from "~/scenes/APIKeyNew";
import { createAction } from "..";
import { SettingsSection } from "../sections";
@@ -19,7 +19,7 @@ export const createApiKey = createAction({
stores.dialogs.openModal({
title: t("New API key"),
content: <ApiKeyNew onSubmit={stores.dialogs.closeAllModals} />,
content: <APIKeyNew onSubmit={stores.dialogs.closeAllModals} />,
});
},
});
+2 -27
View File
@@ -4,7 +4,6 @@ import {
PadlockIcon,
PlusIcon,
SearchIcon,
ShapesIcon,
StarredIcon,
TrashIcon,
UnstarredIcon,
@@ -22,7 +21,7 @@ import { createAction } from "~/actions";
import { CollectionSection } from "~/actions/sections";
import { setPersistedState } from "~/hooks/usePersistedState";
import history from "~/utils/history";
import { newTemplatePath, searchPath } from "~/utils/routeHelpers";
import { searchPath } from "~/utils/routeHelpers";
const ColorCollectionIcon = ({ collection }: { collection: Collection }) => (
<DynamicCollectionIcon collection={collection} />
@@ -112,7 +111,6 @@ export const editCollectionPermissions = createAction({
stores.dialogs.openModal({
title: t("Share this collection"),
style: { marginBottom: -12 },
content: (
<SharePopover
collection={collection}
@@ -129,9 +127,7 @@ export const searchInCollection = createAction({
analyticsName: "Search collection",
section: CollectionSection,
icon: <SearchIcon />,
visible: ({ activeCollectionId }) =>
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).readDocument,
visible: ({ activeCollectionId }) => !!activeCollectionId,
perform: ({ activeCollectionId }) => {
history.push(searchPath(undefined, { collectionId: activeCollectionId }));
},
@@ -224,27 +220,6 @@ export const deleteCollection = createAction({
},
});
export const createTemplate = createAction({
name: ({ t }) => t("New template"),
analyticsName: "New template",
section: CollectionSection,
icon: <ShapesIcon />,
keywords: "new create template",
visible: ({ activeCollectionId, stores }) =>
!!(
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).createDocument
),
perform: ({ activeCollectionId, event }) => {
if (!activeCollectionId) {
return;
}
event?.preventDefault();
event?.stopPropagation();
history.push(newTemplatePath(activeCollectionId));
},
});
export const rootCollectionActions = [
openCollection,
createCollection,
-90
View File
@@ -1,90 +0,0 @@
import { DoneIcon, TrashIcon } from "outline-icons";
import * as React from "react";
import { toast } from "sonner";
import stores from "~/stores";
import Comment from "~/models/Comment";
import CommentDeleteDialog from "~/components/CommentDeleteDialog";
import history from "~/utils/history";
import { createAction } from "..";
import { DocumentSection } from "../sections";
export const deleteCommentFactory = ({
comment,
onDelete,
}: {
comment: Comment;
onDelete: () => void;
}) =>
createAction({
name: ({ t }) => `${t("Delete")}`,
analyticsName: "Delete comment",
section: DocumentSection,
icon: <TrashIcon />,
keywords: "trash",
dangerous: true,
visible: () => stores.policies.abilities(comment.id).delete,
perform: ({ t, event }) => {
event?.preventDefault();
event?.stopPropagation();
stores.dialogs.openModal({
title: t("Delete comment"),
content: <CommentDeleteDialog comment={comment} onSubmit={onDelete} />,
});
},
});
export const resolveCommentFactory = ({
comment,
onResolve,
}: {
comment: Comment;
onResolve: () => void;
}) =>
createAction({
name: ({ t }) => t("Mark as resolved"),
analyticsName: "Resolve thread",
section: DocumentSection,
icon: <DoneIcon outline />,
visible: () =>
stores.policies.abilities(comment.id).resolve &&
stores.policies.abilities(comment.documentId).update,
perform: async ({ t }) => {
await comment.resolve();
history.replace({
...history.location,
state: null,
});
onResolve();
toast.success(t("Thread resolved"));
},
});
export const unresolveCommentFactory = ({
comment,
onUnresolve,
}: {
comment: Comment;
onUnresolve: () => void;
}) =>
createAction({
name: ({ t }) => t("Mark as unresolved"),
analyticsName: "Unresolve thread",
section: DocumentSection,
icon: <DoneIcon outline />,
visible: () =>
stores.policies.abilities(comment.id).unresolve &&
stores.policies.abilities(comment.documentId).update,
perform: async () => {
await comment.unresolve();
history.replace({
...history.location,
state: null,
});
onUnresolve();
},
});
@@ -1,25 +0,0 @@
import { PlusIcon } from "outline-icons";
import * as React from "react";
import stores from "~/stores";
import { DataAttributeNew } from "~/components/DataAttribute/DataAttributeNew";
import { createAction } from "..";
import { SettingsSection } from "../sections";
export const createDataAttribute = createAction({
name: ({ t }) => t("New attribute"),
analyticsName: "New attribute",
section: SettingsSection,
icon: <PlusIcon />,
keywords: "create",
visible: () =>
stores.policies.abilities(stores.auth.team?.id || "").createDataAttribute,
perform: ({ t, event }) => {
event?.preventDefault();
event?.stopPropagation();
stores.dialogs.openModal({
title: t("New attribute"),
content: <DataAttributeNew onSubmit={stores.dialogs.closeAllModals} />,
});
},
});
+35 -106
View File
@@ -37,10 +37,10 @@ import DocumentMove from "~/scenes/DocumentMove";
import DocumentPermanentDelete from "~/scenes/DocumentPermanentDelete";
import DocumentPublish from "~/scenes/DocumentPublish";
import DeleteDocumentsInTrash from "~/scenes/Trash/components/DeleteDocumentsInTrash";
import DocumentTemplatizeDialog from "~/components/DocumentTemplatizeDialog";
import DuplicateDialog from "~/components/DuplicateDialog";
import SharePopover from "~/components/Sharing/Document";
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
import DocumentTemplatizeDialog from "~/components/TemplatizeDialog";
import { createAction } from "~/actions";
import { DocumentSection, TrashSection } from "~/actions/sections";
import env from "~/env";
@@ -51,11 +51,11 @@ import {
documentHistoryPath,
homePath,
newDocumentPath,
newNestedDocumentPath,
searchPath,
documentPath,
urlify,
trashPath,
newTemplatePath,
} from "~/utils/routeHelpers";
export const openDocument = createAction({
@@ -141,10 +141,15 @@ export const createNestedDocument = createAction({
!!activeDocumentId &&
stores.policies.abilities(currentTeamId).createDocument &&
stores.policies.abilities(activeDocumentId).createChildDocument,
perform: ({ activeDocumentId, inStarredSection }) =>
history.push(newNestedDocumentPath(activeDocumentId), {
starred: inStarredSection,
}),
perform: ({ activeCollectionId, activeDocumentId, inStarredSection }) =>
history.push(
newDocumentPath(activeCollectionId, {
parentDocumentId: activeDocumentId,
}),
{
starred: inStarredSection,
}
),
});
export const starDocument = createAction({
@@ -223,7 +228,7 @@ export const publishDocument = createAction({
return;
}
if (document?.collectionId || document?.template) {
if (document?.collectionId) {
await document.save(undefined, {
publish: true,
});
@@ -348,7 +353,6 @@ export const shareDocument = createAction({
}
stores.dialogs.openModal({
style: { marginBottom: -12 },
title: t("Share this document"),
content: (
<SharePopover
@@ -673,34 +677,37 @@ export const importDocument = createAction({
},
});
export const createTemplateFromDocument = createAction({
name: ({ t }) => t("Templatize"),
export const createTemplate = createAction({
name: ({ t, activeDocumentId }) =>
activeDocumentId ? t("Templatize") : t("New template"),
analyticsName: "Templatize document",
section: DocumentSection,
icon: <ShapesIcon />,
keywords: "new create template",
visible: ({ activeCollectionId, activeDocumentId, stores }) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (document?.isTemplate || !document?.isActive) {
return false;
if (activeDocumentId) {
const document = stores.documents.get(activeDocumentId);
if (document?.isTemplate || !document?.isActive) {
return false;
}
}
return !!(
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).updateDocument
stores.policies.abilities(activeCollectionId).update
);
},
perform: ({ activeDocumentId, stores, t, event }) => {
if (!activeDocumentId) {
return;
}
perform: ({ activeCollectionId, activeDocumentId, stores, t, event }) => {
event?.preventDefault();
event?.stopPropagation();
stores.dialogs.openModal({
title: t("Create template"),
content: <DocumentTemplatizeDialog documentId={activeDocumentId} />,
});
if (activeDocumentId) {
stores.dialogs.openModal({
title: t("Create template"),
content: <DocumentTemplatizeDialog documentId={activeDocumentId} />,
});
} else if (activeCollectionId) {
history.push(newTemplatePath(activeCollectionId));
}
},
});
@@ -735,50 +742,11 @@ export const searchDocumentsForQuery = (searchQuery: string) =>
visible: ({ location }) => location.pathname !== searchPath(),
});
export const moveTemplateToWorkspace = createAction({
name: ({ t }) => t("Move to workspace"),
analyticsName: "Move template to workspace",
section: DocumentSection,
icon: <MoveIcon />,
iconInContextMenu: false,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
}
const document = stores.documents.get(activeDocumentId);
if (!document || !document.template || document.isWorkspaceTemplate) {
return false;
}
return !!stores.policies.abilities(activeDocumentId).move;
},
perform: async ({ activeDocumentId, stores }) => {
if (activeDocumentId) {
const document = stores.documents.get(activeDocumentId);
if (!document) {
return;
}
await document.move({
collectionId: null,
});
}
},
});
export const moveDocumentToCollection = createAction({
name: ({ activeDocumentId, stores, t }) => {
if (!activeDocumentId) {
return t("Move");
}
const document = stores.documents.get(activeDocumentId);
return document?.template && document?.collectionId
? t("Move to collection")
: t("Move");
},
export const moveDocument = createAction({
name: ({ t }) => t("Move"),
analyticsName: "Move document",
section: DocumentSection,
icon: <MoveIcon />,
iconInContextMenu: false,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
@@ -802,44 +770,6 @@ export const moveDocumentToCollection = createAction({
},
});
export const moveDocument = createAction({
name: ({ t }) => t("Move"),
analyticsName: "Move document",
section: DocumentSection,
icon: <MoveIcon />,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
}
const document = stores.documents.get(activeDocumentId);
// Don't show the button if this is a non-workspace template.
if (!document || (document.template && !document.isWorkspaceTemplate)) {
return false;
}
return !!stores.policies.abilities(activeDocumentId).move;
},
perform: moveDocumentToCollection.perform,
});
export const moveTemplate = createAction({
name: ({ t }) => t("Move"),
analyticsName: "Move document",
section: DocumentSection,
icon: <MoveIcon />,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
}
const document = stores.documents.get(activeDocumentId);
// Don't show the menu if this is not a template (or) a workspace template.
if (!document || !document.template || document.isWorkspaceTemplate) {
return false;
}
return !!stores.policies.abilities(activeDocumentId).move;
},
children: [moveTemplateToWorkspace, moveDocumentToCollection],
});
export const archiveDocument = createAction({
name: ({ t }) => t("Archive"),
analyticsName: "Archive document",
@@ -1061,7 +991,7 @@ export const rootDocumentActions = [
openDocument,
archiveDocument,
createDocument,
createTemplateFromDocument,
createTemplate,
deleteDocument,
importDocument,
downloadDocument,
@@ -1074,8 +1004,7 @@ export const rootDocumentActions = [
subscribeDocument,
unsubscribeDocument,
duplicateDocument,
moveTemplateToWorkspace,
moveDocumentToCollection,
moveDocument,
openRandomDocument,
permanentlyDeleteDocument,
permanentlyDeleteDocumentsInTrash,
+2 -6
View File
@@ -5,11 +5,10 @@ import * as React from "react";
type Props = React.HTMLAttributes<HTMLDivElement> & {
children: () => React.ReactNode;
onEscape?: (ev: React.KeyboardEvent<HTMLDivElement>) => void;
items: unknown[];
};
function ArrowKeyNavigation(
{ children, onEscape, items, ...rest }: Props,
{ children, onEscape, ...rest }: Props,
ref: React.RefObject<HTMLDivElement>
) {
const handleKeyDown = React.useCallback(
@@ -37,10 +36,7 @@ function ArrowKeyNavigation(
);
return (
<RovingTabIndexProvider
options={{ focusOnClick: true, direction: "both" }}
items={items}
>
<RovingTabIndexProvider options={{ focusOnClick: true, direction: "both" }}>
<div {...rest} onKeyDown={handleKeyDown} ref={ref}>
{children()}
</div>
+3 -3
View File
@@ -25,7 +25,7 @@ const RealButton = styled(ActionButton)<RealProps>`
background: ${s("accent")};
color: ${s("accentText")};
box-shadow: rgba(0, 0, 0, 0.2) 0px 1px 2px;
border-radius: 6px;
border-radius: 4px;
font-size: 14px;
font-weight: 500;
height: 32px;
@@ -49,8 +49,8 @@ const RealButton = styled(ActionButton)<RealProps>`
&:disabled {
cursor: default;
pointer-events: none;
color: ${(props) => transparentize(0.3, props.theme.accentText)};
background: ${(props) => transparentize(0.1, props.theme.accent)};
color: ${(props) => transparentize(0.5, props.theme.accentText)};
background: ${(props) => lighten(0.2, props.theme.accent)};
svg {
fill: ${(props) => props.theme.white50};
+16 -33
View File
@@ -11,22 +11,19 @@ import { CollectionValidation } from "@shared/validations";
import Collection from "~/models/Collection";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import Icon from "~/components/Icon";
import IconPicker from "~/components/IconPicker";
import Input from "~/components/Input";
import InputSelectPermission from "~/components/InputSelectPermission";
import Switch from "~/components/Switch";
import Text from "~/components/Text";
import useBoolean from "~/hooks/useBoolean";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import { EmptySelectValue } from "~/types";
import { Feature, FeatureFlags } from "~/utils/FeatureFlags";
const IconPicker = React.lazy(() => import("~/components/IconPicker"));
export interface FormData {
name: string;
icon: string;
color: string | null;
color: string;
sharing: boolean;
permission: CollectionPermission | undefined;
}
@@ -40,16 +37,7 @@ export const CollectionForm = observer(function CollectionForm_({
}) {
const team = useCurrentTeam();
const { t } = useTranslation();
const [hasOpenedIconPicker, setHasOpenedIconPicker] = useBoolean(false);
const iconColor = React.useMemo(
() => collection?.color ?? randomElement(colorPalette),
[collection?.color]
);
const fallbackIcon = <Icon value="collection" color={iconColor} />;
const {
register,
handleSubmit: formHandleSubmit,
@@ -65,7 +53,7 @@ export const CollectionForm = observer(function CollectionForm_({
icon: collection?.icon,
sharing: collection?.sharing ?? true,
permission: collection?.permission,
color: iconColor,
color: collection?.color ?? randomElement(colorPalette),
},
});
@@ -82,20 +70,20 @@ export const CollectionForm = observer(function CollectionForm_({
"collection"
);
}
}, [collection, hasOpenedIconPicker, setValue, values.name, values.icon]);
}, [values.name, collection]);
React.useEffect(() => {
setTimeout(() => setFocus("name", { shouldSelect: true }), 100);
}, [setFocus]);
const handleIconChange = React.useCallback(
(icon: string, color: string | null) => {
const handleIconPickerChange = React.useCallback(
(color: string, icon: string) => {
if (icon !== values.icon) {
setFocus("name");
}
setValue("icon", icon);
setValue("color", color);
setValue("icon", icon);
},
[setFocus, setValue, values.icon]
);
@@ -117,16 +105,13 @@ export const CollectionForm = observer(function CollectionForm_({
maxLength: CollectionValidation.maxNameLength,
})}
prefix={
<React.Suspense fallback={fallbackIcon}>
<StyledIconPicker
icon={values.icon}
color={values.color ?? iconColor}
initial={values.name[0]}
popoverPosition="right"
onOpen={setHasOpenedIconPicker}
onChange={handleIconChange}
/>
</React.Suspense>
<StyledIconPicker
onOpen={setHasOpenedIconPicker}
onChange={handleIconPickerChange}
initial={values.name[0]}
color={values.color}
icon={values.icon}
/>
}
autoComplete="off"
autoFocus
@@ -143,10 +128,8 @@ export const CollectionForm = observer(function CollectionForm_({
<InputSelectPermission
ref={field.ref}
value={field.value}
onChange={(
value: CollectionPermission | typeof EmptySelectValue
) => {
field.onChange(value === EmptySelectValue ? null : value);
onChange={(value: CollectionPermission) => {
field.onChange(value);
}}
note={t(
"The default access for workspace members, you can share with more users or groups later."
+13 -19
View File
@@ -1,17 +1,16 @@
import { LocationDescriptor } from "history";
import { CheckmarkIcon } from "outline-icons";
import { ellipsis, transparentize } from "polished";
import { ellipsis } from "polished";
import * as React from "react";
import { mergeRefs } from "react-merge-refs";
import { MenuItem as BaseMenuItem } from "reakit/Menu";
import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Text from "../Text";
import MenuIconWrapper from "./MenuIconWrapper";
type Props = {
id?: string;
onClick?: (event: React.MouseEvent) => void | Promise<void>;
onClick?: (event: React.SyntheticEvent) => void | Promise<void>;
active?: boolean;
selected?: boolean;
disabled?: boolean;
@@ -22,7 +21,7 @@ type Props = {
as?: string | React.ComponentType<any>;
hide?: () => void;
level?: number;
icon?: React.ReactElement | null;
icon?: React.ReactElement;
children?: React.ReactNode;
ref?: React.LegacyRef<HTMLButtonElement> | undefined;
};
@@ -43,31 +42,30 @@ const MenuItem = (
) => {
const content = React.useCallback(
(props) => {
// Preventing default mousedown otherwise menu items do not work in Firefox,
// which triggers the hideOnClickOutside handler first via mousedown hiding
// and un-rendering the menu contents.
const preventDefault = (ev: React.MouseEvent) => {
ev.preventDefault();
ev.stopPropagation();
};
const handleClick = async (ev: React.MouseEvent) => {
hide?.();
if (onClick) {
preventDefault(ev);
ev.preventDefault();
await onClick(ev);
}
};
// Preventing default mousedown otherwise menu items do not work in Firefox,
// which triggers the hideOnClickOutside handler first via mousedown hiding
// and un-rendering the menu contents.
const handleMouseDown = (ev: React.MouseEvent) => {
ev.preventDefault();
ev.stopPropagation();
};
return (
<MenuAnchor
{...props}
$active={active}
as={onClick ? "button" : as}
onClick={handleClick}
onPointerDown={preventDefault}
onMouseDown={preventDefault}
onMouseDown={handleMouseDown}
ref={mergeRefs([
ref,
props.ref as React.RefObject<HTMLAnchorElement>,
@@ -162,10 +160,6 @@ export const MenuAnchorCSS = css<MenuAnchorProps>`
color: ${props.theme.accentText};
fill: ${props.theme.accentText};
}
${Text} {
color: ${transparentize(0.5, props.theme.accentText)};
}
}
}
`}
+6 -13
View File
@@ -30,7 +30,6 @@ type Props = Omit<MenuStateReturn, "items"> & {
actions?: (Action | MenuSeparator | MenuHeading)[];
context?: Partial<ActionContext>;
items?: TMenuItem[];
showIcons?: boolean;
};
const Disclosure = styled(ExpandedIcon)`
@@ -99,7 +98,7 @@ export function filterTemplateItems(items: TMenuItem[]): TMenuItem[] {
});
}
function Template({ items, actions, context, showIcons, ...menu }: Props) {
function Template({ items, actions, context, ...menu }: Props) {
const ctx = useActionContext({
isContextMenu: true,
});
@@ -125,8 +124,7 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
if (
iconIsPresentInAnyMenuItem &&
item.type !== "separator" &&
item.type !== "heading" &&
showIcons !== false
item.type !== "heading"
) {
item.icon = item.icon || <MenuIconWrapper aria-hidden />;
}
@@ -140,7 +138,7 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
key={index}
disabled={item.disabled}
selected={item.selected}
icon={showIcons !== false ? item.icon : undefined}
icon={item.icon}
{...menu}
>
{item.title}
@@ -158,7 +156,7 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
selected={item.selected}
level={item.level}
target={item.href.startsWith("#") ? undefined : "_blank"}
icon={showIcons !== false ? item.icon : undefined}
icon={item.icon}
{...menu}
>
{item.title}
@@ -176,7 +174,7 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
selected={item.selected}
dangerous={item.dangerous}
key={index}
icon={showIcons !== false ? item.icon : undefined}
icon={item.icon}
{...menu}
>
{item.title}
@@ -192,12 +190,7 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
id={`${item.title}-${index}`}
templateItems={item.items}
parentMenuState={menu}
title={
<Title
title={item.title}
icon={showIcons !== false ? item.icon : undefined}
/>
}
title={<Title title={item.title} icon={item.icon} />}
{...menu}
/>
);
@@ -1,34 +0,0 @@
import { observer } from "mobx-react";
import * as React from "react";
import { toast } from "sonner";
import DataAttribute from "~/models/DataAttribute";
import { DataAttributeForm, FormData } from "./DataAttributeForm";
type Props = {
dataAttribute: DataAttribute;
onSubmit: () => void;
};
export const DataAttributeEdit = observer(function DataAttributeEdit_({
dataAttribute,
onSubmit,
}: Props) {
const handleSubmit = React.useCallback(
async (data: FormData) => {
try {
await dataAttribute.save(data);
onSubmit?.();
} catch (error) {
toast.error(error.message);
}
},
[dataAttribute, onSubmit]
);
return (
<DataAttributeForm
dataAttribute={dataAttribute}
handleSubmit={handleSubmit}
/>
);
});
@@ -1,212 +0,0 @@
import { observer } from "mobx-react";
import { CloseIcon, PlusIcon } from "outline-icons";
import * as React from "react";
import { Controller, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import styled, { useTheme } from "styled-components";
import {
DataAttributeDataType,
type DataAttributeOptions,
} from "@shared/models/types";
import { DataAttributeValidation } from "@shared/validations";
import type DataAttribute from "~/models/DataAttribute";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import Input from "~/components/Input";
import { DataAttributesHelper } from "~/utils/DataAttributesHelper";
import InputSelect from "../InputSelect";
import NudeButton from "../NudeButton";
type Props = {
handleSubmit: (data: FormData) => void;
dataAttribute?: DataAttribute;
};
export interface FormData {
name: string;
description?: string;
dataType: DataAttributeDataType;
options?: DataAttributeOptions;
}
export const DataAttributeForm = observer(function DataAttributeForm_({
handleSubmit,
dataAttribute,
}: Props) {
const theme = useTheme();
const { t } = useTranslation();
const {
register,
handleSubmit: formHandleSubmit,
formState,
watch,
control,
setFocus,
setValue,
} = useForm<FormData>({
mode: "all",
defaultValues: {
name: dataAttribute?.name,
description: dataAttribute?.description ?? undefined,
dataType: dataAttribute?.dataType ?? DataAttributeDataType.String,
options: dataAttribute?.options ?? undefined,
},
});
const values = watch();
const isEditing = !!dataAttribute;
React.useEffect(() => {
if (isEditing) {
return;
}
setTimeout(() => setFocus("name", { shouldSelect: true }), 100);
}, [isEditing, setFocus]);
return (
<form onSubmit={formHandleSubmit(handleSubmit)}>
<div>
<Controller
control={control}
name="dataType"
render={({ field }) => (
<InputSelect
ref={field.ref}
value={field.value}
disabled={isEditing}
onChange={(value: DataAttributeDataType) => {
field.onChange(value);
if (value === DataAttributeDataType.List) {
setValue("options", {
options: [
{
value: "",
},
{
value: "",
},
],
});
}
}}
ariaLabel={t("Format")}
label={t("Format")}
options={Object.values(DataAttributeDataType).map((dataType) => ({
value: dataType,
label: DataAttributesHelper.getName(dataType, t),
}))}
style={{ width: "auto" }}
/>
)}
/>
</div>
{values.dataType === DataAttributeDataType.List && (
<Options gap={8} column>
{values.options?.options?.map((option, index) => (
<Flex gap={4} align="center" key={index}>
<Input
value={option.value}
onChange={(event) => {
const newOptions = [...(values.options?.options ?? [])];
newOptions[index] = { value: event.target.value };
setValue("options", { options: newOptions });
}}
type="text"
autoComplete="off"
autoFocus={index !== 1}
minLength={DataAttributeValidation.minOptionLength}
maxLength={DataAttributeValidation.maxOptionLength}
margin={0}
required
flex
/>
<NudeButton
disabled={
(values.options?.options?.length ?? 0) <=
DataAttributeValidation.minOptions
}
onClick={() => {
const newOptions = [...(values.options?.options ?? [])];
newOptions.splice(index, 1);
setValue("options", { options: newOptions });
}}
>
<CloseIcon color={theme.textSecondary} />
</NudeButton>
</Flex>
))}
<div>
<Controller
control={control}
name="options"
render={({ field }) => (
<Button
neutral
borderOnHover
icon={<PlusIcon size={20} />}
disabled={
(values.options?.options?.length ?? 0) >=
DataAttributeValidation.maxOptions
}
onClick={() => {
field.onChange({
options: [
...(field.value?.options ?? []),
{
value: "",
},
],
});
}}
>
{t("Add option")}
</Button>
)}
/>
</div>
</Options>
)}
<Input
type="text"
label={t("Name")}
{...register("name", {
required: true,
minLength: DataAttributeValidation.minNameLength,
maxLength: DataAttributeValidation.maxNameLength,
})}
autoComplete="off"
autoFocus
flex
/>
<Input
type="text"
label={t("Description")}
placeholder={t("Optional")}
{...register("description", {
maxLength: DataAttributeValidation.maxDescriptionLength,
})}
autoComplete="off"
flex
/>
<Flex justify="flex-end">
<Button
type="submit"
disabled={formState.isSubmitting || !formState.isValid}
>
{dataAttribute
? formState.isSubmitting
? `${t("Saving")}`
: t("Save")
: formState.isSubmitting
? `${t("Creating")}`
: t("Create")}
</Button>
</Flex>
</form>
);
});
const Options = styled(Flex)`
margin-left: 16px;
margin-bottom: 16px;
`;
@@ -1,30 +0,0 @@
import { observer } from "mobx-react";
import * as React from "react";
import { toast } from "sonner";
import DataAttribute from "~/models/DataAttribute";
import useStores from "~/hooks/useStores";
import { DataAttributeForm, FormData } from "./DataAttributeForm";
type Props = {
onSubmit: () => void;
};
export const DataAttributeNew = observer(function DataAttributeNew_({
onSubmit,
}: Props) {
const { dataAttributes } = useStores();
const handleSubmit = React.useCallback(
async (data: FormData) => {
try {
const dataAttribute = new DataAttribute(data, dataAttributes);
await dataAttribute.save();
onSubmit?.();
} catch (error) {
toast.error(error.message);
}
},
[dataAttributes, onSubmit]
);
return <DataAttributeForm handleSubmit={handleSubmit} />;
});
-1
View File
@@ -25,7 +25,6 @@ function Dialogs() {
fullscreen={modal.fullscreen ?? false}
onRequestClose={() => dialogs.closeModal(id)}
title={modal.title}
style={modal.style}
>
{modal.content}
</Modal>
+3 -7
View File
@@ -6,7 +6,6 @@ import styled from "styled-components";
import type { NavigationNode } from "@shared/types";
import Document from "~/models/Document";
import Breadcrumb from "~/components/Breadcrumb";
import Icon from "~/components/Icon";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import useStores from "~/hooks/useStores";
import { MenuInternalLink } from "~/types";
@@ -16,6 +15,7 @@ import {
settingsPath,
trashPath,
} from "~/utils/routeHelpers";
import EmojiIcon from "./Icons/EmojiIcon";
type Props = {
children?: React.ReactNode;
@@ -106,9 +106,9 @@ const DocumentBreadcrumb: React.FC<Props> = ({
path.slice(0, -1).forEach((node: NavigationNode) => {
output.push({
type: "route",
title: node.icon ? (
title: node.emoji ? (
<>
<StyledIcon value={node.icon} color={node.color} /> {node.title}
<EmojiIcon emoji={node.emoji} /> {node.title}
</>
) : (
node.title
@@ -144,10 +144,6 @@ const DocumentBreadcrumb: React.FC<Props> = ({
);
};
const StyledIcon = styled(Icon)`
margin-right: 2px;
`;
const SmallSlash = styled(GoToIcon)`
width: 12px;
height: 12px;
+8 -36
View File
@@ -9,17 +9,15 @@ import { Link } from "react-router-dom";
import styled, { useTheme } from "styled-components";
import Squircle from "@shared/components/Squircle";
import { s, ellipsis } from "@shared/styles";
import { IconType } from "@shared/types";
import { determineIconType } from "@shared/utils/icon";
import Document from "~/models/Document";
import Pin from "~/models/Pin";
import Flex from "~/components/Flex";
import Icon from "~/components/Icon";
import NudeButton from "~/components/NudeButton";
import Time from "~/components/Time";
import useStores from "~/hooks/useStores";
import { hover } from "~/styles";
import CollectionIcon from "./Icons/CollectionIcon";
import EmojiIcon from "./Icons/EmojiIcon";
import Text from "./Text";
import Tooltip from "./Tooltip";
@@ -54,8 +52,6 @@ function DocumentCard(props: Props) {
disabled: !isDraggable || !canUpdatePin,
});
const hasEmojiInTitle = determineIconType(document.icon) === IconType.Emoji;
const style = {
transform: CSS.Transform.toString(transform),
transition,
@@ -113,18 +109,12 @@ function DocumentCard(props: Props) {
<path d="M19.5 19.5H6C2.96243 19.5 0.5 17.0376 0.5 14V0.5H0.792893L19.5 19.2071V19.5Z" />
</Fold>
{document.icon ? (
<DocumentSquircle
icon={document.icon}
color={document.color ?? undefined}
/>
{document.emoji ? (
<Squircle color={theme.slateLight}>
<EmojiIcon emoji={document.emoji} size={24} />
</Squircle>
) : (
<Squircle
color={
collection?.color ??
(!pin?.collectionId ? theme.slateLight : theme.slateDark)
}
>
<Squircle color={collection?.color}>
{collection?.icon &&
collection?.icon !== "letter" &&
collection?.icon !== "collection" &&
@@ -137,8 +127,8 @@ function DocumentCard(props: Props) {
)}
<div>
<Heading dir={document.dir}>
{hasEmojiInTitle
? document.titleWithDefault.replace(document.icon!, "")
{document.emoji
? document.titleWithDefault.replace(document.emoji, "")
: document.titleWithDefault}
</Heading>
<DocumentMeta size="xsmall">
@@ -169,24 +159,6 @@ function DocumentCard(props: Props) {
);
}
const DocumentSquircle = ({
icon,
color,
}: {
icon: string;
color?: string;
}) => {
const theme = useTheme();
const iconType = determineIconType(icon)!;
const squircleColor = iconType === IconType.SVG ? color : theme.slateLight;
return (
<Squircle color={squircleColor}>
<Icon value={icon} color={theme.white} forceColor />
</Squircle>
);
};
const Clock = styled(ClockIcon)`
flex-shrink: 0;
`;
+10 -15
View File
@@ -18,8 +18,8 @@ import { NavigationNode } from "@shared/types";
import DocumentExplorerNode from "~/components/DocumentExplorerNode";
import DocumentExplorerSearchResult from "~/components/DocumentExplorerSearchResult";
import Flex from "~/components/Flex";
import Icon from "~/components/Icon";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import EmojiIcon from "~/components/Icons/EmojiIcon";
import { Outline } from "~/components/Input";
import InputSearch from "~/components/InputSearch";
import Text from "~/components/Text";
@@ -216,30 +216,25 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
}) => {
const node = data[index];
const isCollection = node.type === "collection";
let renderedIcon,
title: string,
icon: string | undefined,
color: string | undefined,
path;
let icon, title: string, emoji: string | undefined, path;
if (isCollection) {
const col = collections.get(node.collectionId as string);
renderedIcon = col && (
icon = col && (
<CollectionIcon collection={col} expanded={isExpanded(index)} />
);
title = node.title;
} else {
const doc = documents.get(node.id);
icon = doc?.icon ?? node.icon ?? node.emoji;
color = doc?.color ?? node.color;
emoji = doc?.emoji ?? node.emoji;
title = doc?.title ?? node.title;
if (icon) {
renderedIcon = <Icon value={icon} color={color} />;
if (emoji) {
icon = <EmojiIcon emoji={emoji} />;
} else if (doc?.isStarred) {
renderedIcon = <StarredIcon color={theme.yellow} />;
icon = <StarredIcon color={theme.yellow} />;
} else {
renderedIcon = <DocumentIcon color={theme.textSecondary} />;
icon = <DocumentIcon color={theme.textSecondary} />;
}
path = ancestors(node)
@@ -259,7 +254,7 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
}}
onPointerMove={() => setActiveNode(index)}
onClick={() => toggleSelect(index)}
icon={renderedIcon}
icon={icon}
title={title}
path={path}
/>
@@ -280,7 +275,7 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
selected={isSelected(index)}
active={activeNode === index}
expanded={isExpanded(index)}
icon={renderedIcon}
icon={icon}
title={title}
depth={node.depth as number}
hasChildren={hasChildren(index)}
-1
View File
@@ -120,7 +120,6 @@ export const Node = styled.span<{
color: ${props.theme.white};
svg {
color: ${props.theme.white};
fill: ${props.theme.white};
}
`}
+10 -11
View File
@@ -15,7 +15,6 @@ import Badge from "~/components/Badge";
import DocumentMeta from "~/components/DocumentMeta";
import Flex from "~/components/Flex";
import Highlight from "~/components/Highlight";
import Icon from "~/components/Icon";
import NudeButton from "~/components/NudeButton";
import StarButton, { AnimatedStar } from "~/components/Star";
import Tooltip from "~/components/Tooltip";
@@ -24,6 +23,7 @@ import useCurrentUser from "~/hooks/useCurrentUser";
import DocumentMenu from "~/menus/DocumentMenu";
import { hover } from "~/styles";
import { documentPath } from "~/utils/routeHelpers";
import EmojiIcon from "./Icons/EmojiIcon";
type Props = {
document: Document;
@@ -76,7 +76,8 @@ function DocumentListItem(
const queryIsInTitle =
!!highlight &&
!!document.title.toLowerCase().includes(highlight.toLowerCase());
const canStar = !document.isArchived && !document.isTemplate;
const canStar =
!document.isDraft && !document.isArchived && !document.isTemplate;
return (
<DocumentLink
@@ -96,9 +97,9 @@ function DocumentListItem(
>
<Content>
<Heading dir={document.dir}>
{document.icon && (
{document.emoji && (
<>
<Icon value={document.icon} color={document.color ?? undefined} />
<EmojiIcon emoji={document.emoji} size={24} />
&nbsp;
</>
)}
@@ -110,6 +111,11 @@ function DocumentListItem(
{document.isBadgedNew && document.createdBy?.id !== user.id && (
<Badge yellow>{t("New")}</Badge>
)}
{canStar && (
<StarPositioner>
<StarButton document={document} />
</StarPositioner>
)}
{document.isDraft && showDraft && (
<Tooltip
content={t("Only visible to you")}
@@ -119,11 +125,6 @@ function DocumentListItem(
<Badge>{t("Draft")}</Badge>
</Tooltip>
)}
{canStar && (
<StarPositioner>
<StarButton document={document} />
</StarPositioner>
)}
{document.isTemplate && showTemplate && (
<Badge primary>{t("Template")}</Badge>
)}
@@ -274,8 +275,6 @@ const ResultContext = styled(Highlight)`
font-size: 15px;
margin-top: -0.25em;
margin-bottom: 0.25em;
max-height: 90px;
overflow: hidden;
`;
export default observer(React.forwardRef(DocumentListItem));
+9
View File
@@ -128,6 +128,15 @@ const DocumentMeta: React.FC<Props> = ({
<Time dateTime={publishedAt} addSuffix />
</span>
);
} else if (isDraft) {
content = (
<span>
{lastUpdatedByCurrentUser
? t("You saved")
: t("{{ userName }} saved", { userName })}{" "}
<Time dateTime={updatedAt} addSuffix />
</span>
);
} else {
content = (
<Modified highlight={modifiedSinceViewed && !lastUpdatedByCurrentUser}>
@@ -0,0 +1,49 @@
import invariant from "invariant";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { useHistory } from "react-router-dom";
import { toast } from "sonner";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import useStores from "~/hooks/useStores";
import { documentPath } from "~/utils/routeHelpers";
type Props = {
documentId: string;
};
function DocumentTemplatizeDialog({ documentId }: Props) {
const history = useHistory();
const { t } = useTranslation();
const { documents } = useStores();
const document = documents.get(documentId);
invariant(document, "Document must exist");
const handleSubmit = React.useCallback(async () => {
const template = await document?.templatize();
if (template) {
history.push(documentPath(template));
toast.success(t("Template created, go ahead and customize it"));
}
}, [document, history, t]);
return (
<ConfirmationDialog
onSubmit={handleSubmit}
submitText={t("Create template")}
savingText={`${t("Creating")}`}
>
<Trans
defaults="Creating a template from <em>{{titleWithDefault}}</em> is a non-destructive action we'll make a copy of the document and turn it into a template that can be used as a starting point for new documents."
values={{
titleWithDefault: document.titleWithDefault,
}}
components={{
em: <strong />,
}}
/>
</ConfirmationDialog>
);
}
export default observer(DocumentTemplatizeDialog);
-10
View File
@@ -28,7 +28,6 @@ import { NotFoundError } from "~/utils/errors";
import { uploadFile } from "~/utils/files";
import lazyWithRetry from "~/utils/lazyWithRetry";
import DocumentBreadcrumb from "./DocumentBreadcrumb";
import Icon from "./Icon";
const LazyLoadedEditor = lazyWithRetry(() => import("~/editor"));
@@ -90,12 +89,6 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
title: document.title,
subtitle: `Updated ${time}`,
url: document.url,
icon: document.icon ? (
<Icon
value={document.icon}
color={document.color ?? undefined}
/>
) : undefined,
},
];
} catch (error) {
@@ -114,9 +107,6 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
title: document.title,
subtitle: <DocumentBreadcrumb document={document} onlyText />,
url: document.url,
icon: document.icon ? (
<Icon value={document.icon} color={document.color ?? undefined} />
) : undefined,
})),
(document) =>
deburr(document.title)
+23
View File
@@ -0,0 +1,23 @@
import styled from "styled-components";
import Button from "~/components/Button";
import { hover } from "~/styles";
import Flex from "../Flex";
export const EmojiButton = styled(Button)`
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
&: ${hover},
&:active,
&[aria-expanded= "true"] {
opacity: 1 !important;
}
`;
export const Emoji = styled(Flex)<{ size?: number }>`
line-height: 1.6;
${(props) => (props.size ? `font-size: ${props.size}px` : "")}
`;
+262
View File
@@ -0,0 +1,262 @@
import data from "@emoji-mart/data";
import Picker from "@emoji-mart/react";
import { SmileyIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
import styled, { useTheme } from "styled-components";
import { depths, s } from "@shared/styles";
import { toRGB } from "@shared/utils/color";
import Button from "~/components/Button";
import Popover from "~/components/Popover";
import useStores from "~/hooks/useStores";
import useUserLocale from "~/hooks/useUserLocale";
import { Emoji, EmojiButton } from "./components";
/* Locales supported by emoji-mart */
const supportedLocales = [
"en",
"ar",
"be",
"cs",
"de",
"es",
"fa",
"fi",
"fr",
"hi",
"it",
"ja",
"ko",
"nl",
"pl",
"pt",
"ru",
"sa",
"tr",
"uk",
"vi",
"zh",
];
/**
* React hook to derive emoji picker's theme from UI theme
*
* @returns {string} Theme to use for emoji picker
*/
function usePickerTheme(): string {
const { ui } = useStores();
const { theme } = ui;
if (theme === "system") {
return "auto";
}
return theme;
}
type Props = {
/** The selected emoji, if any */
value?: string | null;
/** Callback when an emoji is selected */
onChange: (emoji: string | null) => void | Promise<void>;
/** Callback when the picker is opened */
onOpen?: () => void;
/** Callback when the picker is closed */
onClose?: () => void;
/** Callback when the picker is clicked outside of */
onClickOutside: () => void;
/** Whether to auto focus the search input on open */
autoFocus?: boolean;
/** Class name to apply to the trigger button */
className?: string;
};
function EmojiPicker({
value,
onOpen,
onClose,
onChange,
onClickOutside,
autoFocus,
className,
}: Props) {
const { t } = useTranslation();
const pickerTheme = usePickerTheme();
const theme = useTheme();
const locale = useUserLocale(true) ?? "en";
const popover = usePopoverState({
placement: "bottom-start",
modal: true,
unstable_offset: [0, 0],
});
const [emojisPerLine, setEmojisPerLine] = React.useState(9);
const pickerRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (popover.visible) {
onOpen?.();
} else {
onClose?.();
}
}, [popover.visible, onOpen, onClose]);
React.useEffect(() => {
if (popover.visible && pickerRef.current) {
// 28 is picker's observed width when perLine is set to 0
// and 36 is the default emojiButtonSize
// Ref: https://github.com/missive/emoji-mart#options--props
setEmojisPerLine(Math.floor((pickerRef.current.clientWidth - 28) / 36));
}
}, [popover.visible]);
const handleEmojiChange = React.useCallback(
async (emoji) => {
popover.hide();
await onChange(emoji ? emoji.native : null);
},
[popover, onChange]
);
const handleClick = React.useCallback(
(ev: React.MouseEvent) => {
ev.stopPropagation();
if (popover.visible) {
popover.hide();
} else {
popover.show();
}
},
[popover]
);
const handleClickOutside = React.useCallback(() => {
// It was observed that onClickOutside got triggered
// even when the picker wasn't open or opened at all.
// Hence, this guard here...
if (popover.visible) {
onClickOutside();
}
}, [popover.visible, onClickOutside]);
// Auto focus search input when picker is opened
React.useLayoutEffect(() => {
if (autoFocus && popover.visible) {
requestAnimationFrame(() => {
const searchInput = pickerRef.current
?.querySelector("em-emoji-picker")
?.shadowRoot?.querySelector(
"input[type=search]"
) as HTMLInputElement | null;
searchInput?.focus();
});
}
}, [autoFocus, popover.visible]);
return (
<>
<PopoverDisclosure {...popover}>
{(props) => (
<EmojiButton
{...props}
className={className}
onClick={handleClick}
icon={
value ? (
<Emoji size={32} align="center" justify="center">
{value}
</Emoji>
) : (
<StyledSmileyIcon size={32} color={theme.textTertiary} />
)
}
neutral
borderOnHover
/>
)}
</PopoverDisclosure>
<PickerPopover
{...popover}
tabIndex={0}
// This prevents picker from closing when any of its
// children are focused, e.g, clicking on search bar or
// a click on skin tone button
onClick={(e) => e.stopPropagation()}
width={352}
aria-label={t("Emoji Picker")}
>
{popover.visible && (
<>
{value && (
<RemoveButton neutral onClick={() => handleEmojiChange(null)}>
{t("Remove")}
</RemoveButton>
)}
<PickerStyles ref={pickerRef}>
<Picker
locale={supportedLocales.includes(locale) ? locale : "en"}
data={data}
onEmojiSelect={handleEmojiChange}
theme={pickerTheme}
previewPosition="none"
perLine={emojisPerLine}
onClickOutside={handleClickOutside}
/>
</PickerStyles>
</>
)}
</PickerPopover>
</>
);
}
const StyledSmileyIcon = styled(SmileyIcon)`
flex-shrink: 0;
@media print {
display: none;
}
`;
const RemoveButton = styled(Button)`
margin-left: -12px;
margin-bottom: 8px;
border-radius: 6px;
height: 24px;
font-size: 13px;
> :first-child {
min-height: unset;
line-height: unset;
}
`;
const PickerPopover = styled(Popover)`
z-index: ${depths.popover};
> :first-child {
padding-top: 8px;
padding-bottom: 0;
max-height: 488px;
overflow: unset;
}
`;
const PickerStyles = styled.div`
margin-left: -24px;
margin-right: -24px;
em-emoji-picker {
--shadow: none;
--font-family: ${s("fontFamily")};
--rgb-background: ${(props) => toRGB(props.theme.menuBackground)};
--rgb-accent: ${(props) => toRGB(props.theme.accent)};
--border-radius: 6px;
margin-left: auto;
margin-right: auto;
min-height: 443px;
}
`;
export default EmojiPicker;
+4 -4
View File
@@ -5,8 +5,8 @@ import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { MAX_AVATAR_DISPLAY } from "@shared/constants";
import { s } from "@shared/styles";
import CollectionGroupMembership from "~/models/CollectionGroupMembership";
import Group from "~/models/Group";
import GroupMembership from "~/models/GroupMembership";
import GroupMembers from "~/scenes/GroupMembers";
import Facepile from "~/components/Facepile";
import Flex from "~/components/Flex";
@@ -19,19 +19,19 @@ import NudeButton from "./NudeButton";
type Props = {
group: Group;
membership?: GroupMembership;
membership?: CollectionGroupMembership;
showFacepile?: boolean;
showAvatar?: boolean;
renderActions: (params: { openMembersModal: () => void }) => React.ReactNode;
};
function GroupListItem({ group, showFacepile, renderActions }: Props) {
const { groupUsers } = useStores();
const { groupMemberships } = useStores();
const { t } = useTranslation();
const [membersModalOpen, setMembersModalOpen, setMembersModalClosed] =
useBoolean();
const memberCount = group.memberCount;
const membershipsInGroup = groupUsers.inGroup(group.id);
const membershipsInGroup = groupMemberships.inGroup(group.id);
const users = membershipsInGroup
.slice(0, MAX_AVATAR_DISPLAY)
.map((gm) => gm.user);
+1 -2
View File
@@ -2,7 +2,6 @@ import escapeRegExp from "lodash/escapeRegExp";
import * as React from "react";
import replace from "string-replace-to-array";
import styled from "styled-components";
import { s } from "@shared/styles";
type Props = React.HTMLAttributes<HTMLSpanElement> & {
highlight: (string | null | undefined) | RegExp;
@@ -44,7 +43,7 @@ function Highlight({
}
export const Mark = styled.mark`
color: ${s("text")};
color: inherit;
background: transparent;
font-weight: 600;
`;
@@ -1,9 +1,7 @@
import * as React from "react";
import { richExtensions } from "@shared/editor/nodes";
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
import Editor from "~/components/Editor";
import Flex from "~/components/Flex";
import ErrorBoundary from "../ErrorBoundary";
import {
Preview,
Title,
@@ -23,23 +21,20 @@ const HoverPreviewDocument = React.forwardRef(function _HoverPreviewDocument(
<Preview to={url}>
<Card ref={ref}>
<CardContent>
<ErrorBoundary showTitle={false} reloadOnChunkMissing={false}>
<Flex column gap={2}>
<Title>{title}</Title>
<Info>{lastActivityByViewer}</Info>
<Description as="div">
<React.Suspense fallback={<div />}>
<Editor
key={id}
extensions={richExtensions}
defaultValue={summary}
embedsDisabled
readOnly
/>
</React.Suspense>
</Description>
</Flex>
</ErrorBoundary>
<Flex column gap={2}>
<Title>{title}</Title>
<Info>{lastActivityByViewer}</Info>
<Description as="div">
<React.Suspense fallback={<div />}>
<Editor
key={id}
defaultValue={summary}
embedsDisabled
readOnly
/>
</React.Suspense>
</Description>
</Flex>
</CardContent>
</Card>
</Preview>
-130
View File
@@ -1,130 +0,0 @@
import { observer } from "mobx-react";
import { getLuminance } from "polished";
import * as React from "react";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { randomElement } from "@shared/random";
import { IconType } from "@shared/types";
import { IconLibrary } from "@shared/utils/IconLibrary";
import { colorPalette } from "@shared/utils/collections";
import { determineIconType } from "@shared/utils/icon";
import EmojiIcon from "~/components/Icons/EmojiIcon";
import useStores from "~/hooks/useStores";
import Logger from "~/utils/Logger";
import Flex from "./Flex";
export type Props = {
/** The icon to render */
value: string;
/** The color of the icon */
color?: string;
/** The size of the icon */
size?: number;
/** The initial to display if the icon is a letter icon */
initial?: string;
/** Optional additional class name */
className?: string;
/**
* Ensure the color does not change in response to theme and contrast. Should only be
* used in color picker UI.
*/
forceColor?: boolean;
};
const Icon = ({
value: icon,
color,
size = 24,
initial,
forceColor,
className,
}: Props) => {
const iconType = determineIconType(icon);
if (!iconType) {
Logger.warn("Failed to determine icon type", {
icon,
});
return null;
}
try {
if (iconType === IconType.SVG) {
return (
<SVGIcon
value={icon}
color={color}
size={size}
initial={initial}
className={className}
forceColor={forceColor}
/>
);
}
return <EmojiIcon emoji={icon} size={size} className={className} />;
} catch (err) {
Logger.warn("Failed to render icon", {
icon,
});
}
return null;
};
const SVGIcon = observer(
({
value: icon,
color: inputColor,
initial,
size,
className,
forceColor,
}: Props) => {
const { ui } = useStores();
let color = inputColor ?? randomElement(colorPalette);
// If the chosen icon color is very dark then we invert it in dark mode
if (!forceColor) {
if (ui.resolvedTheme === "dark" && color !== "currentColor") {
color = getLuminance(color) > 0.09 ? color : "currentColor";
}
// If the chosen icon color is very light then we invert it in light mode
if (ui.resolvedTheme === "light" && color !== "currentColor") {
color = getLuminance(color) < 0.9 ? color : "currentColor";
}
}
const Component = IconLibrary.getComponent(icon);
return (
<Component color={color} size={size} className={className}>
{initial}
</Component>
);
}
);
export const IconTitleWrapper = styled(Flex)<{ dir?: string }>`
align-items: center;
justify-content: center;
position: absolute;
top: 3px;
height: 40px;
width: 40px;
// Always move above TOC
z-index: 1;
${(props: { dir?: string }) =>
props.dir === "rtl" ? "right: -40px" : "left: -40px"};
${breakpoint("desktop")`
${(props: { dir?: string }) =>
props.dir === "rtl" ? "right: -44px" : "left: -44px"};
`}
`;
export default Icon;
+211
View File
@@ -0,0 +1,211 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import { PopoverDisclosure, usePopoverState } from "reakit";
import { MenuItem } from "reakit/Menu";
import styled, { useTheme } from "styled-components";
import { IconLibrary } from "@shared/utils/IconLibrary";
import { colorPalette } from "@shared/utils/collections";
import Flex from "~/components/Flex";
import NudeButton from "~/components/NudeButton";
import Text from "~/components/Text";
import useOnClickOutside from "~/hooks/useOnClickOutside";
import lazyWithRetry from "~/utils/lazyWithRetry";
import DelayedMount from "./DelayedMount";
import InputSearch from "./InputSearch";
import Popover from "./Popover";
const icons = IconLibrary.mapping;
const TwitterPicker = lazyWithRetry(
() => import("react-color/lib/components/twitter/Twitter")
);
type Props = {
onOpen?: () => void;
onClose?: () => void;
onChange: (color: string, icon: string) => void;
initial: string;
icon: string;
color: string;
className?: string;
};
function IconPicker({
onOpen,
onClose,
icon,
initial,
color,
onChange,
className,
}: Props) {
const [query, setQuery] = React.useState("");
const { t } = useTranslation();
const theme = useTheme();
const popover = usePopoverState({
gutter: 0,
placement: "right",
modal: true,
});
React.useEffect(() => {
if (popover.visible) {
onOpen?.();
} else {
onClose?.();
setQuery("");
}
}, [onOpen, onClose, popover.visible]);
const filteredIcons = IconLibrary.findIcons(query);
const handleFilter = (event: React.ChangeEvent<HTMLInputElement>) => {
setQuery(event.target.value.toLowerCase());
};
const styles = React.useMemo(
() => ({
default: {
body: {
padding: 0,
marginRight: -8,
},
hash: {
color: theme.text,
background: theme.inputBorder,
},
swatch: {
cursor: "var(--cursor-pointer)",
},
input: {
color: theme.text,
boxShadow: `inset 0 0 0 1px ${theme.inputBorder}`,
background: "transparent",
},
},
}),
[theme]
);
// Custom click outside handling rather than using `hideOnClickOutside` from reakit so that we can
// prevent event bubbling.
useOnClickOutside(
popover.unstable_popoverRef,
(event) => {
if (popover.visible) {
event.stopPropagation();
event.preventDefault();
popover.hide();
}
},
{ capture: true }
);
const iconNames = Object.keys(icons);
const delayPerIcon = 250 / iconNames.length;
return (
<>
<PopoverDisclosure {...popover}>
{(props) => (
<NudeButton
aria-label={t("Show menu")}
className={className}
{...props}
>
<Icon
as={IconLibrary.getComponent(icon || "collection")}
color={color}
>
{initial}
</Icon>
</NudeButton>
)}
</PopoverDisclosure>
<Popover
{...popover}
width={552}
aria-label={t("Choose an icon")}
hideOnClickOutside={false}
>
<Flex column gap={12}>
<Text size="large" weight="xbold">
{t("Choose an icon")}
</Text>
<InputSearch
value={query}
placeholder={`${t("Filter")}`}
onChange={handleFilter}
autoFocus
/>
<div>
{iconNames.map((name, index) => (
<MenuItem key={name} onClick={() => onChange(color, name)}>
{(props) => (
<IconButton
style={
{
opacity: query
? filteredIcons.includes(name)
? 1
: 0.3
: undefined,
"--delay": `${Math.round(index * delayPerIcon)}ms`,
} as React.CSSProperties
}
{...props}
>
<Icon
as={IconLibrary.getComponent(name)}
color={color}
size={30}
>
{initial}
</Icon>
</IconButton>
)}
</MenuItem>
))}
</div>
<Flex>
<React.Suspense
fallback={
<DelayedMount>
<Text>{t("Loading")}</Text>
</DelayedMount>
}
>
<ColorPicker
color={color}
onChange={(color) => onChange(color.hex, icon)}
colors={colorPalette}
triangle="hide"
styles={styles}
/>
</React.Suspense>
</Flex>
</Flex>
</Popover>
</>
);
}
const Icon = styled.svg`
transition: color 150ms ease-in-out, fill 150ms ease-in-out;
transition-delay: var(--delay);
`;
const IconButton = styled(NudeButton)`
vertical-align: top;
border-radius: 4px;
margin: 0px 6px 6px 0px;
width: 30px;
height: 30px;
`;
const ColorPicker = styled(TwitterPicker)`
box-shadow: none !important;
background: transparent !important;
width: 100% !important;
`;
export default IconPicker;
@@ -1,218 +0,0 @@
import { BackIcon } from "outline-icons";
import React from "react";
import styled from "styled-components";
import { breakpoints, s } from "@shared/styles";
import { colorPalette } from "@shared/utils/collections";
import { validateColorHex } from "@shared/utils/color";
import Flex from "~/components/Flex";
import NudeButton from "~/components/NudeButton";
import Text from "~/components/Text";
import { hover } from "~/styles";
enum Panel {
Builtin,
Hex,
}
type Props = {
width: number;
activeColor: string;
onSelect: (color: string) => void;
};
const ColorPicker = ({ width, activeColor, onSelect }: Props) => {
const [localValue, setLocalValue] = React.useState(activeColor);
const [panel, setPanel] = React.useState(
colorPalette.includes(activeColor) ? Panel.Builtin : Panel.Hex
);
const handleSwitcherClick = React.useCallback(() => {
setPanel(panel === Panel.Builtin ? Panel.Hex : Panel.Builtin);
}, [panel, setPanel]);
const isLargeMobile = width > breakpoints.mobileLarge + 12; // 12px for the Container padding
React.useEffect(() => {
setLocalValue(activeColor);
setPanel(colorPalette.includes(activeColor) ? Panel.Builtin : Panel.Hex);
}, [activeColor]);
return isLargeMobile ? (
<Container justify="space-between">
<LargeMobileBuiltinColors activeColor={activeColor} onClick={onSelect} />
<LargeMobileCustomColor
value={localValue}
setLocalValue={setLocalValue}
onValidHex={onSelect}
/>
</Container>
) : (
<Container gap={12}>
<PanelSwitcher align="center">
<SwitcherButton panel={panel} onClick={handleSwitcherClick}>
{panel === Panel.Builtin ? "#" : <BackIcon />}
</SwitcherButton>
</PanelSwitcher>
{panel === Panel.Builtin ? (
<BuiltinColors activeColor={activeColor} onClick={onSelect} />
) : (
<CustomColor
value={localValue}
setLocalValue={setLocalValue}
onValidHex={onSelect}
/>
)}
</Container>
);
};
const BuiltinColors = ({
activeColor,
onClick,
className,
}: {
activeColor: string;
onClick: (color: string) => void;
className?: string;
}) => (
<Flex className={className} justify="space-between" align="center" auto>
{colorPalette.map((color) => (
<ColorButton
key={color}
color={color}
active={color === activeColor}
onClick={() => onClick(color)}
>
<Selected />
</ColorButton>
))}
</Flex>
);
const CustomColor = ({
value,
setLocalValue,
onValidHex,
className,
}: {
value: string;
setLocalValue: (value: string) => void;
onValidHex: (color: string) => void;
className?: string;
}) => {
const hasHexChars = React.useCallback(
(color: string) => /(^#[0-9A-F]{1,6}$)/i.test(color),
[]
);
const handleInputChange = React.useCallback(
(ev: React.ChangeEvent<HTMLInputElement>) => {
const val = ev.target.value;
if (val === "" || val === "#") {
setLocalValue("#");
return;
}
const uppercasedVal = val.toUpperCase();
if (hasHexChars(uppercasedVal)) {
setLocalValue(uppercasedVal);
}
if (validateColorHex(uppercasedVal)) {
onValidHex(uppercasedVal);
}
},
[setLocalValue, hasHexChars, onValidHex]
);
return (
<Flex className={className} align="center" gap={8}>
<Text type="tertiary" size="small">
HEX
</Text>
<CustomColorInput
maxLength={7}
value={value}
onChange={handleInputChange}
/>
</Flex>
);
};
const Container = styled(Flex)`
height: 48px;
padding: 8px 12px;
border-bottom: 1px solid ${s("inputBorder")};
`;
const Selected = styled.span`
width: 10px;
height: 5px;
border-left: 2px solid white;
border-bottom: 2px solid white;
transform: translateY(-25%) rotate(-45deg);
`;
const ColorButton = styled(NudeButton)<{ color: string; active: boolean }>`
display: inline-flex;
justify-content: center;
align-items: center;
width: 24px;
height: 24px;
border-radius: 50%;
background-color: ${({ color }) => color};
&: ${hover} {
outline: 2px solid ${s("menuBackground")} !important;
box-shadow: ${({ color }) => `0px 0px 3px 3px ${color}`};
}
& ${Selected} {
display: ${({ active }) => (active ? "block" : "none")};
}
`;
const PanelSwitcher = styled(Flex)`
width: 40px;
border-right: 1px solid ${s("inputBorder")};
`;
const SwitcherButton = styled(NudeButton)<{ panel: Panel }>`
display: inline-flex;
justify-content: center;
align-items: center;
font-size: 14px;
border: 1px solid ${s("inputBorder")};
transition: all 100ms ease-in-out;
&: ${hover} {
border-color: ${s("inputBorderFocused")};
}
`;
const LargeMobileBuiltinColors = styled(BuiltinColors)`
max-width: 380px;
padding-right: 8px;
`;
const LargeMobileCustomColor = styled(CustomColor)`
padding-left: 8px;
border-left: 1px solid ${s("inputBorder")};
width: 120px;
`;
const CustomColorInput = styled.input.attrs(() => ({
type: "text",
autocomplete: "off",
}))`
font-size: 14px;
color: ${s("textSecondary")};
background: transparent;
border: 0;
outline: 0;
`;
export default ColorPicker;
@@ -1,8 +0,0 @@
import styled from "styled-components";
import { s } from "@shared/styles";
export const Emoji = styled.span`
font-family: ${s("fontFamilyEmoji")};
width: 24px;
height: 24px;
`;
@@ -1,245 +0,0 @@
import concat from "lodash/concat";
import React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { EmojiCategory, EmojiSkinTone, IconType } from "@shared/types";
import { getEmojis, getEmojisWithCategory, search } from "@shared/utils/emoji";
import Flex from "~/components/Flex";
import InputSearch from "~/components/InputSearch";
import usePersistedState from "~/hooks/usePersistedState";
import {
FREQUENTLY_USED_COUNT,
DisplayCategory,
emojiSkinToneKey,
emojisFreqKey,
lastEmojiKey,
sortFrequencies,
} from "../utils";
import GridTemplate, { DataNode } from "./GridTemplate";
import SkinTonePicker from "./SkinTonePicker";
/**
* This is needed as a constant for react-window.
* Calculated from the heights of TabPanel and InputSearch.
*/
const GRID_HEIGHT = 362;
const useEmojiState = () => {
const [emojiSkinTone, setEmojiSkinTone] = usePersistedState<EmojiSkinTone>(
emojiSkinToneKey,
EmojiSkinTone.Default
);
const [emojisFreq, setEmojisFreq] = usePersistedState<Record<string, number>>(
emojisFreqKey,
{}
);
const [lastEmoji, setLastEmoji] = usePersistedState<string | undefined>(
lastEmojiKey,
undefined
);
const incrementEmojiCount = React.useCallback(
(emoji: string) => {
emojisFreq[emoji] = (emojisFreq[emoji] ?? 0) + 1;
setEmojisFreq({ ...emojisFreq });
setLastEmoji(emoji);
},
[emojisFreq, setEmojisFreq, setLastEmoji]
);
const getFreqEmojis = React.useCallback(() => {
const freqs = Object.entries(emojisFreq);
if (freqs.length > FREQUENTLY_USED_COUNT.Track) {
sortFrequencies(freqs).splice(FREQUENTLY_USED_COUNT.Track);
setEmojisFreq(Object.fromEntries(freqs));
}
const emojis = sortFrequencies(freqs)
.slice(0, FREQUENTLY_USED_COUNT.Get)
.map(([emoji, _]) => emoji);
const isLastPresent = emojis.includes(lastEmoji ?? "");
if (lastEmoji && !isLastPresent) {
emojis.pop();
emojis.push(lastEmoji);
}
return emojis;
}, [emojisFreq, setEmojisFreq, lastEmoji]);
return {
emojiSkinTone,
setEmojiSkinTone,
incrementEmojiCount,
getFreqEmojis,
};
};
type Props = {
panelWidth: number;
query: string;
panelActive: boolean;
onEmojiChange: (emoji: string) => void;
onQueryChange: (query: string) => void;
};
const EmojiPanel = ({
panelWidth,
query,
panelActive,
onEmojiChange,
onQueryChange,
}: Props) => {
const { t } = useTranslation();
const searchRef = React.useRef<HTMLInputElement | null>(null);
const scrollableRef = React.useRef<HTMLDivElement | null>(null);
const {
emojiSkinTone: skinTone,
setEmojiSkinTone,
incrementEmojiCount,
getFreqEmojis,
} = useEmojiState();
const freqEmojis = React.useMemo(() => getFreqEmojis(), [getFreqEmojis]);
const handleFilter = React.useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
onQueryChange(event.target.value);
},
[onQueryChange]
);
const handleSkinChange = React.useCallback(
(emojiSkinTone: EmojiSkinTone) => {
setEmojiSkinTone(emojiSkinTone);
},
[setEmojiSkinTone]
);
const handleEmojiSelection = React.useCallback(
({ id, value }: { id: string; value: string }) => {
onEmojiChange(value);
incrementEmojiCount(id);
},
[onEmojiChange, incrementEmojiCount]
);
const isSearch = query !== "";
const templateData: DataNode[] = isSearch
? getSearchResults({
query,
skinTone,
})
: getAllEmojis({
skinTone,
freqEmojis,
});
React.useEffect(() => {
if (scrollableRef.current) {
scrollableRef.current.scrollTop = 0;
}
searchRef.current?.focus();
}, [panelActive]);
return (
<Flex column>
<UserInputContainer align="center" gap={12}>
<StyledInputSearch
ref={searchRef}
value={query}
placeholder={`${t("Search emoji")}`}
onChange={handleFilter}
/>
<SkinTonePicker skinTone={skinTone} onChange={handleSkinChange} />
</UserInputContainer>
<GridTemplate
ref={scrollableRef}
width={panelWidth}
height={GRID_HEIGHT}
data={templateData}
onIconSelect={handleEmojiSelection}
/>
</Flex>
);
};
const getSearchResults = ({
query,
skinTone,
}: {
query: string;
skinTone: EmojiSkinTone;
}): DataNode[] => {
const emojis = search({ query, skinTone });
return [
{
category: DisplayCategory.Search,
icons: emojis.map((emoji) => ({
type: IconType.Emoji,
id: emoji.id,
value: emoji.value,
})),
},
];
};
const getAllEmojis = ({
skinTone,
freqEmojis,
}: {
skinTone: EmojiSkinTone;
freqEmojis: string[];
}): DataNode[] => {
const emojisWithCategory = getEmojisWithCategory({ skinTone });
const getFrequentEmojis = (): DataNode => {
const emojis = getEmojis({ ids: freqEmojis, skinTone });
return {
category: DisplayCategory.Frequent,
icons: emojis.map((emoji) => ({
type: IconType.Emoji,
id: emoji.id,
value: emoji.value,
})),
};
};
const getCategoryData = (emojiCategory: EmojiCategory): DataNode => {
const emojis = emojisWithCategory[emojiCategory] ?? [];
return {
category: emojiCategory,
icons: emojis.map((emoji) => ({
type: IconType.Emoji,
id: emoji.id,
value: emoji.value,
})),
};
};
return concat(
getFrequentEmojis(),
getCategoryData(EmojiCategory.People),
getCategoryData(EmojiCategory.Nature),
getCategoryData(EmojiCategory.Foods),
getCategoryData(EmojiCategory.Activity),
getCategoryData(EmojiCategory.Places),
getCategoryData(EmojiCategory.Objects),
getCategoryData(EmojiCategory.Symbols),
getCategoryData(EmojiCategory.Flags)
);
};
const UserInputContainer = styled(Flex)`
height: 48px;
padding: 6px 12px 0px;
`;
const StyledInputSearch = styled(InputSearch)`
flex-grow: 1;
`;
export default EmojiPanel;
@@ -1,61 +0,0 @@
import React from "react";
import { FixedSizeList, ListChildComponentProps } from "react-window";
import styled from "styled-components";
type Props = {
width: number;
height: number;
data: React.ReactNode[][];
columns: number;
itemWidth: number;
};
const Grid = (
{ width, height, data, columns, itemWidth }: Props,
ref: React.Ref<HTMLDivElement>
) => (
<Container
outerRef={ref}
width={width}
height={height}
itemCount={data.length}
itemSize={itemWidth}
itemData={{ data, columns }}
>
{Row}
</Container>
);
type RowProps = {
data: React.ReactNode[][];
columns: number;
};
const Row = ({ index, style, data }: ListChildComponentProps<RowProps>) => {
const { data: rows, columns } = data;
const row = rows[index];
return (
<RowContainer style={style} columns={columns}>
{row}
</RowContainer>
);
};
const Container = styled(FixedSizeList<RowProps>)`
padding: 0px 12px;
// Needed for the absolutely positioned children
// to respect the VirtualList's padding
& > div {
position: relative;
}
`;
const RowContainer = styled.div<{ columns: number }>`
display: grid;
grid-template-columns: ${({ columns }) => `repeat(${columns}, 1fr)`};
align-content: center;
`;
export default React.forwardRef(Grid);
@@ -1,120 +0,0 @@
import chunk from "lodash/chunk";
import compact from "lodash/compact";
import React from "react";
import styled from "styled-components";
import { IconType } from "@shared/types";
import { IconLibrary } from "@shared/utils/IconLibrary";
import Text from "~/components/Text";
import { TRANSLATED_CATEGORIES } from "../utils";
import { Emoji } from "./Emoji";
import Grid from "./Grid";
import { IconButton } from "./IconButton";
/**
* icon/emoji size is 24px; and we add 4px padding on all sides,
*/
const BUTTON_SIZE = 32;
type OutlineNode = {
type: IconType.SVG;
name: string;
color: string;
initial: string;
delay: number;
};
type EmojiNode = {
type: IconType.Emoji;
id: string;
value: string;
};
export type DataNode = {
category: keyof typeof TRANSLATED_CATEGORIES;
icons: (OutlineNode | EmojiNode)[];
};
type Props = {
width: number;
height: number;
data: DataNode[];
onIconSelect: ({ id, value }: { id: string; value: string }) => void;
};
const GridTemplate = (
{ width, height, data, onIconSelect }: Props,
ref: React.Ref<HTMLDivElement>
) => {
// 24px padding for the Grid Container
const itemsPerRow = Math.floor((width - 24) / BUTTON_SIZE);
const gridItems = compact(
data.flatMap((node) => {
if (node.icons.length === 0) {
return [];
}
const category = (
<CategoryName
key={node.category}
type="tertiary"
size="xsmall"
weight="bold"
>
{TRANSLATED_CATEGORIES[node.category]}
</CategoryName>
);
const items = node.icons.map((item) => {
if (item.type === IconType.SVG) {
return (
<IconButton
key={item.name}
onClick={() => onIconSelect({ id: item.name, value: item.name })}
delay={item.delay}
>
<Icon as={IconLibrary.getComponent(item.name)} color={item.color}>
{item.initial}
</Icon>
</IconButton>
);
}
return (
<IconButton
key={item.id}
onClick={() => onIconSelect({ id: item.id, value: item.value })}
>
<Emoji>{item.value}</Emoji>
</IconButton>
);
});
const chunks = chunk(items, itemsPerRow);
return [[category], ...chunks];
})
);
return (
<Grid
ref={ref}
width={width}
height={height}
data={gridItems}
columns={itemsPerRow}
itemWidth={BUTTON_SIZE}
/>
);
};
const CategoryName = styled(Text)`
grid-column: 1 / -1;
padding-left: 6px;
`;
const Icon = styled.svg`
transition: color 150ms ease-in-out, fill 150ms ease-in-out;
transition-delay: var(--delay);
`;
export default React.forwardRef(GridTemplate);
@@ -1,15 +0,0 @@
import styled from "styled-components";
import { s } from "@shared/styles";
import NudeButton from "~/components/NudeButton";
import { hover } from "~/styles";
export const IconButton = styled(NudeButton)<{ delay?: number }>`
width: 32px;
height: 32px;
padding: 4px;
--delay: ${({ delay }) => delay && `${delay}ms`};
&: ${hover} {
background: ${s("listItemHoverBackground")};
}
`;
@@ -1,200 +0,0 @@
import React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { IconType } from "@shared/types";
import { IconLibrary } from "@shared/utils/IconLibrary";
import Flex from "~/components/Flex";
import InputSearch from "~/components/InputSearch";
import usePersistedState from "~/hooks/usePersistedState";
import {
FREQUENTLY_USED_COUNT,
DisplayCategory,
iconsFreqKey,
lastIconKey,
sortFrequencies,
} from "../utils";
import ColorPicker from "./ColorPicker";
import GridTemplate, { DataNode } from "./GridTemplate";
const IconNames = Object.keys(IconLibrary.mapping);
const TotalIcons = IconNames.length;
/**
* This is needed as a constant for react-window.
* Calculated from the heights of TabPanel, ColorPicker and InputSearch.
*/
const GRID_HEIGHT = 314;
const useIconState = () => {
const [iconsFreq, setIconsFreq] = usePersistedState<Record<string, number>>(
iconsFreqKey,
{}
);
const [lastIcon, setLastIcon] = usePersistedState<string | undefined>(
lastIconKey,
undefined
);
const incrementIconCount = React.useCallback(
(icon: string) => {
iconsFreq[icon] = (iconsFreq[icon] ?? 0) + 1;
setIconsFreq({ ...iconsFreq });
setLastIcon(icon);
},
[iconsFreq, setIconsFreq, setLastIcon]
);
const getFreqIcons = React.useCallback(() => {
const freqs = Object.entries(iconsFreq);
if (freqs.length > FREQUENTLY_USED_COUNT.Track) {
sortFrequencies(freqs).splice(FREQUENTLY_USED_COUNT.Track);
setIconsFreq(Object.fromEntries(freqs));
}
const icons = sortFrequencies(freqs)
.slice(0, FREQUENTLY_USED_COUNT.Get)
.map(([icon, _]) => icon);
const isLastPresent = icons.includes(lastIcon ?? "");
if (lastIcon && !isLastPresent) {
icons.pop();
icons.push(lastIcon);
}
return icons;
}, [iconsFreq, setIconsFreq, lastIcon]);
return {
incrementIconCount,
getFreqIcons,
};
};
type Props = {
panelWidth: number;
initial: string;
color: string;
query: string;
panelActive: boolean;
onIconChange: (icon: string) => void;
onColorChange: (icon: string) => void;
onQueryChange: (query: string) => void;
};
const IconPanel = ({
panelWidth,
initial,
color,
query,
panelActive,
onIconChange,
onColorChange,
onQueryChange,
}: Props) => {
const { t } = useTranslation();
const searchRef = React.useRef<HTMLInputElement | null>(null);
const scrollableRef = React.useRef<HTMLDivElement | null>(null);
const { incrementIconCount, getFreqIcons } = useIconState();
const freqIcons = React.useMemo(() => getFreqIcons(), [getFreqIcons]);
const totalFreqIcons = freqIcons.length;
const filteredIcons = React.useMemo(
() => IconLibrary.findIcons(query),
[query]
);
const isSearch = query !== "";
const category = isSearch ? DisplayCategory.Search : DisplayCategory.All;
const delayPerIcon = 250 / (TotalIcons + totalFreqIcons);
const handleFilter = React.useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
onQueryChange(event.target.value);
},
[onQueryChange]
);
const handleIconSelection = React.useCallback(
({ id, value }: { id: string; value: string }) => {
onIconChange(value);
incrementIconCount(id);
},
[onIconChange, incrementIconCount]
);
const baseIcons: DataNode = {
category,
icons: filteredIcons.map((name, index) => ({
type: IconType.SVG,
name,
color,
initial,
delay: Math.round((index + totalFreqIcons) * delayPerIcon),
onClick: handleIconSelection,
})),
};
const templateData: DataNode[] = isSearch
? [baseIcons]
: [
{
category: DisplayCategory.Frequent,
icons: freqIcons.map((name, index) => ({
type: IconType.SVG,
name,
color,
initial,
delay: Math.round((index + totalFreqIcons) * delayPerIcon),
onClick: handleIconSelection,
})),
},
baseIcons,
];
React.useEffect(() => {
if (scrollableRef.current) {
scrollableRef.current.scrollTop = 0;
}
searchRef.current?.focus();
}, [panelActive]);
return (
<Flex column>
<InputSearchContainer align="center">
<StyledInputSearch
ref={searchRef}
value={query}
placeholder={`${t("Search icons")}`}
onChange={handleFilter}
/>
</InputSearchContainer>
<ColorPicker
width={panelWidth}
activeColor={color}
onSelect={onColorChange}
/>
<GridTemplate
ref={scrollableRef}
width={panelWidth}
height={GRID_HEIGHT}
data={templateData}
onIconSelect={handleIconSelection}
/>
</Flex>
);
};
const InputSearchContainer = styled(Flex)`
height: 48px;
padding: 6px 12px 0px;
`;
const StyledInputSearch = styled(InputSearch)`
flex-grow: 1;
`;
export default IconPanel;
@@ -1,20 +0,0 @@
import styled, { css } from "styled-components";
import { s } from "@shared/styles";
import NudeButton from "~/components/NudeButton";
import { hover } from "~/styles";
export const PopoverButton = styled(NudeButton)<{ $borderOnHover?: boolean }>`
&: ${hover},
&:active,
&[aria-expanded= "true"] {
opacity: 1 !important;
${({ $borderOnHover }) =>
$borderOnHover &&
css`
background: ${s("buttonNeutralBackground")};
box-shadow: rgba(0, 0, 0, 0.07) 0px 1px 2px,
${s("buttonNeutralBorder")} 0 0 0 1px inset;
`};
}
`;
@@ -1,92 +0,0 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Menu, MenuButton, MenuItem, useMenuState } from "reakit";
import styled from "styled-components";
import { depths, s } from "@shared/styles";
import { EmojiSkinTone } from "@shared/types";
import { getEmojiVariants } from "@shared/utils/emoji";
import Flex from "~/components/Flex";
import NudeButton from "~/components/NudeButton";
import { hover } from "~/styles";
import { Emoji } from "./Emoji";
import { IconButton } from "./IconButton";
const SkinTonePicker = ({
skinTone,
onChange,
}: {
skinTone: EmojiSkinTone;
onChange: (skin: EmojiSkinTone) => void;
}) => {
const { t } = useTranslation();
const handEmojiVariants = React.useMemo(
() => getEmojiVariants({ id: "hand" }),
[]
);
const menu = useMenuState({
placement: "bottom",
});
const handleSkinClick = React.useCallback(
(emojiSkin) => {
menu.hide();
onChange(emojiSkin);
},
[menu, onChange]
);
const menuItems = React.useMemo(
() =>
Object.entries(handEmojiVariants).map(([eskin, emoji]) => (
<MenuItem {...menu} key={emoji.value}>
{(menuprops) => (
<IconButton {...menuprops} onClick={() => handleSkinClick(eskin)}>
<Emoji>{emoji.value}</Emoji>
</IconButton>
)}
</MenuItem>
)),
[menu, handEmojiVariants, handleSkinClick]
);
return (
<>
<MenuButton {...menu}>
{(props) => (
<StyledMenuButton
{...props}
aria-label={t("Choose default skin tone")}
>
{handEmojiVariants[skinTone]!.value}
</StyledMenuButton>
)}
</MenuButton>
<Menu {...menu} aria-label={t("Choose default skin tone")}>
{(props) => <MenuContainer {...props}>{menuItems}</MenuContainer>}
</Menu>
</>
);
};
const MenuContainer = styled(Flex)`
z-index: ${depths.menu};
padding: 4px;
border-radius: 4px;
background: ${s("menuBackground")};
box-shadow: ${s("menuShadow")};
`;
const StyledMenuButton = styled(NudeButton)`
width: 32px;
height: 32px;
border: 1px solid ${s("inputBorder")};
padding: 4px;
&: ${hover} {
border: 1px solid ${s("inputBorderFocused")};
}
`;
export default SkinTonePicker;
-312
View File
@@ -1,312 +0,0 @@
import { SmileyIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import {
PopoverDisclosure,
Tab,
TabList,
TabPanel,
usePopoverState,
useTabState,
} from "reakit";
import styled, { css } from "styled-components";
import { s } from "@shared/styles";
import theme from "@shared/styles/theme";
import { IconType } from "@shared/types";
import { determineIconType } from "@shared/utils/icon";
import Flex from "~/components/Flex";
import Icon from "~/components/Icon";
import NudeButton from "~/components/NudeButton";
import Popover from "~/components/Popover";
import useMobile from "~/hooks/useMobile";
import useOnClickOutside from "~/hooks/useOnClickOutside";
import usePrevious from "~/hooks/usePrevious";
import useWindowSize from "~/hooks/useWindowSize";
import { hover } from "~/styles";
import EmojiPanel from "./components/EmojiPanel";
import IconPanel from "./components/IconPanel";
import { PopoverButton } from "./components/PopoverButton";
const TAB_NAMES = {
Icon: "icon",
Emoji: "emoji",
} as const;
const POPOVER_WIDTH = 408;
type Props = {
icon: string | null;
color: string;
size?: number;
initial?: string;
className?: string;
popoverPosition: "bottom-start" | "right";
allowDelete?: boolean;
borderOnHover?: boolean;
onChange: (icon: string | null, color: string | null) => void;
onOpen?: () => void;
onClose?: () => void;
};
const IconPicker = ({
icon,
color,
size = 24,
initial,
className,
popoverPosition,
allowDelete,
onChange,
onOpen,
onClose,
borderOnHover,
}: Props) => {
const { t } = useTranslation();
const { width: windowWidth } = useWindowSize();
const isMobile = useMobile();
const [query, setQuery] = React.useState("");
const [chosenColor, setChosenColor] = React.useState(color);
const contentRef = React.useRef<HTMLDivElement | null>(null);
const iconType = determineIconType(icon);
const defaultTab = React.useMemo(
() =>
iconType === IconType.Emoji ? TAB_NAMES["Emoji"] : TAB_NAMES["Icon"],
[iconType]
);
const popover = usePopoverState({
placement: popoverPosition,
modal: true,
unstable_offset: [0, 0],
});
const tab = useTabState({ selectedId: defaultTab });
const previouslyVisible = usePrevious(popover.visible);
const popoverWidth = isMobile ? windowWidth : POPOVER_WIDTH;
// In mobile, popover is absolutely positioned to leave 8px on both sides.
const panelWidth = isMobile ? windowWidth - 16 : popoverWidth;
const resetDefaultTab = React.useCallback(() => {
tab.select(defaultTab);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [defaultTab]);
const handleIconChange = React.useCallback(
(ic: string) => {
popover.hide();
const icType = determineIconType(ic);
const finalColor = icType === IconType.SVG ? chosenColor : null;
onChange(ic, finalColor);
},
[popover, onChange, chosenColor]
);
const handleIconColorChange = React.useCallback(
(c: string) => {
setChosenColor(c);
const icType = determineIconType(icon);
// Outline icon set; propagate color change
if (icType === IconType.SVG) {
onChange(icon, c);
}
},
[icon, onChange]
);
const handleIconRemove = React.useCallback(() => {
popover.hide();
onChange(null, null);
}, [popover, onChange]);
const handlePopoverButtonClick = React.useCallback(
(ev: React.MouseEvent) => {
ev.stopPropagation();
if (popover.visible) {
popover.hide();
} else {
popover.show();
}
},
[popover]
);
// Popover open effect
React.useEffect(() => {
if (popover.visible && !previouslyVisible) {
onOpen?.();
} else if (!popover.visible && previouslyVisible) {
onClose?.();
setQuery("");
resetDefaultTab();
}
}, [popover.visible, previouslyVisible, onOpen, onClose, resetDefaultTab]);
// Custom click outside handling rather than using `hideOnClickOutside` from reakit so that we can
// prevent event bubbling.
useOnClickOutside(
contentRef,
(event) => {
if (
popover.visible &&
!popover.unstable_disclosureRef.current?.contains(event.target as Node)
) {
event.stopPropagation();
event.preventDefault();
popover.hide();
}
},
{ capture: true }
);
return (
<>
<PopoverDisclosure {...popover}>
{(props) => (
<PopoverButton
{...props}
aria-label={t("Show menu")}
className={className}
size={size}
onClick={handlePopoverButtonClick}
$borderOnHover={borderOnHover}
>
{iconType && icon ? (
<Icon value={icon} color={color} size={size} initial={initial} />
) : (
<StyledSmileyIcon color={theme.placeholder} size={size} />
)}
</PopoverButton>
)}
</PopoverDisclosure>
<Popover
{...popover}
ref={contentRef}
width={popoverWidth}
shrink
aria-label={t("Icon Picker")}
onClick={(e) => e.stopPropagation()}
hideOnClickOutside={false}
>
<>
<TabActionsWrapper justify="space-between" align="center">
<TabList {...tab}>
<StyledTab
{...tab}
id={TAB_NAMES["Icon"]}
aria-label={t("Icons")}
active={tab.selectedId === TAB_NAMES["Icon"]}
>
{t("Icons")}
</StyledTab>
<StyledTab
{...tab}
id={TAB_NAMES["Emoji"]}
aria-label={t("Emojis")}
active={tab.selectedId === TAB_NAMES["Emoji"]}
>
{t("Emojis")}
</StyledTab>
</TabList>
{allowDelete && icon && (
<RemoveButton onClick={handleIconRemove}>
{t("Remove")}
</RemoveButton>
)}
</TabActionsWrapper>
<StyledTabPanel {...tab}>
<IconPanel
panelWidth={panelWidth}
initial={initial ?? "?"}
color={chosenColor}
query={query}
panelActive={
popover.visible && tab.selectedId === TAB_NAMES["Icon"]
}
onIconChange={handleIconChange}
onColorChange={handleIconColorChange}
onQueryChange={setQuery}
/>
</StyledTabPanel>
<StyledTabPanel {...tab}>
<EmojiPanel
panelWidth={panelWidth}
query={query}
panelActive={
popover.visible && tab.selectedId === TAB_NAMES["Emoji"]
}
onEmojiChange={handleIconChange}
onQueryChange={setQuery}
/>
</StyledTabPanel>
</>
</Popover>
</>
);
};
const StyledSmileyIcon = styled(SmileyIcon)`
flex-shrink: 0;
@media print {
display: none;
}
`;
const RemoveButton = styled(NudeButton)`
width: auto;
font-weight: 500;
font-size: 14px;
color: ${s("textTertiary")};
padding: 8px 12px;
transition: color 100ms ease-in-out;
&: ${hover} {
color: ${s("textSecondary")};
}
`;
const TabActionsWrapper = styled(Flex)`
padding-left: 12px;
border-bottom: 1px solid ${s("inputBorder")};
`;
const StyledTab = styled(Tab)<{ active: boolean }>`
position: relative;
font-weight: 500;
font-size: 14px;
cursor: var(--pointer);
background: none;
border: 0;
padding: 8px 12px;
user-select: none;
color: ${({ active }) => (active ? s("textSecondary") : s("textTertiary"))};
transition: color 100ms ease-in-out;
&: ${hover} {
color: ${s("textSecondary")};
}
${({ active }) =>
active &&
css`
&:after {
content: "";
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 1px;
background: ${s("textSecondary")};
}
`}
`;
const StyledTabPanel = styled(TabPanel)`
height: 410px;
overflow-y: auto;
`;
export default IconPicker;
-50
View File
@@ -1,50 +0,0 @@
import i18next from "i18next";
export enum DisplayCategory {
All = "All",
Frequent = "Frequent",
Search = "Search",
}
export const TRANSLATED_CATEGORIES = {
All: i18next.t("All"),
Frequent: i18next.t("Frequently Used"),
Search: i18next.t("Search Results"),
People: i18next.t("Smileys & People"),
Nature: i18next.t("Animals & Nature"),
Foods: i18next.t("Food & Drink"),
Activity: i18next.t("Activity"),
Places: i18next.t("Travel & Places"),
Objects: i18next.t("Objects"),
Symbols: i18next.t("Symbols"),
Flags: i18next.t("Flags"),
};
export const FREQUENTLY_USED_COUNT = {
Get: 24,
Track: 30,
};
const STORAGE_KEYS = {
Base: "icon-state",
EmojiSkinTone: "emoji-skintone",
IconsFrequency: "icons-freq",
EmojisFrequency: "emojis-freq",
LastIcon: "last-icon",
LastEmoji: "last-emoji",
};
const getStorageKey = (key: string) => `${STORAGE_KEYS.Base}.${key}`;
export const emojiSkinToneKey = getStorageKey(STORAGE_KEYS.EmojiSkinTone);
export const iconsFreqKey = getStorageKey(STORAGE_KEYS.IconsFrequency);
export const emojisFreqKey = getStorageKey(STORAGE_KEYS.EmojisFrequency);
export const lastIconKey = getStorageKey(STORAGE_KEYS.LastIcon);
export const lastEmojiKey = getStorageKey(STORAGE_KEYS.LastEmoji);
export const sortFrequencies = (freqs: [string, number][]) =>
freqs.sort((a, b) => (a[1] >= b[1] ? -1 : 1));
+25 -32
View File
@@ -2,10 +2,10 @@ import { observer } from "mobx-react";
import { CollectionIcon } from "outline-icons";
import { getLuminance } from "polished";
import * as React from "react";
import { colorPalette } from "@shared/utils/collections";
import { IconLibrary } from "@shared/utils/IconLibrary";
import Collection from "~/models/Collection";
import Icon from "~/components/Icon";
import useStores from "~/hooks/useStores";
import Logger from "~/utils/Logger";
type Props = {
/** The collection to show an icon for */
@@ -16,7 +16,6 @@ type Props = {
size?: number;
/** The color of the icon, defaults to the collection color */
color?: string;
className?: string;
};
function ResolvedCollectionIcon({
@@ -24,41 +23,35 @@ function ResolvedCollectionIcon({
color: inputColor,
expanded,
size,
className,
}: Props) {
const { ui } = useStores();
if (!collection.icon || collection.icon === "collection") {
// If the chosen icon color is very dark then we invert it in dark mode
// otherwise it will be impossible to see against the dark background.
const collectionColor = collection.color ?? colorPalette[0];
const color =
inputColor ||
(ui.resolvedTheme === "dark" && collectionColor !== "currentColor"
? getLuminance(collectionColor) > 0.09
? collectionColor
: "currentColor"
: collectionColor);
// If the chosen icon color is very dark then we invert it in dark mode
// otherwise it will be impossible to see against the dark background.
const color =
inputColor ||
(ui.resolvedTheme === "dark" && collection.color !== "currentColor"
? getLuminance(collection.color) > 0.09
? collection.color
: "currentColor"
: collection.color);
return (
<CollectionIcon
color={color}
expanded={expanded}
size={size}
className={className}
/>
);
if (collection.icon && collection.icon !== "collection") {
try {
const Component = IconLibrary.getComponent(collection.icon);
return (
<Component color={color} size={size}>
{collection.initial}
</Component>
);
} catch (error) {
Logger.warn("Failed to render custom icon", {
icon: collection.icon,
});
}
}
return (
<Icon
value={collection.icon}
color={inputColor ?? collection.color ?? undefined}
size={size}
initial={collection.initial}
className={className}
/>
);
return <CollectionIcon color={color} expanded={expanded} size={size} />;
}
export default observer(ResolvedCollectionIcon);
+8 -19
View File
@@ -1,13 +1,11 @@
import * as React from "react";
import styled from "styled-components";
import { s } from "@shared/styles";
type Props = {
/** The emoji to render */
emoji: string;
/** The size of the emoji, 24px is default to match standard icons */
size?: number;
className?: string;
};
/**
@@ -17,28 +15,19 @@ type Props = {
export default function EmojiIcon({ size = 24, emoji, ...rest }: Props) {
return (
<Span $size={size} {...rest}>
<SVG size={size} emoji={emoji} />
{emoji}
</Span>
);
}
const Span = styled.span<{ $size: number }>`
font-family: ${s("fontFamilyEmoji")};
display: inline-block;
display: inline-flex;
align-items: center;
justify-content: center;
text-align: center;
flex-shrink: 0;
width: ${(props) => props.$size}px;
height: ${(props) => props.$size}px;
text-indent: -0.15em;
font-size: ${(props) => props.$size - 10}px;
`;
const SVG = ({ size, emoji }: { size: number; emoji: string }) => (
<svg width={size} height={size} xmlns="http://www.w3.org/2000/svg">
<text
x="50%"
y={"55%"}
dominantBaseline="middle"
textAnchor="middle"
fontSize={size * 0.7}
>
{emoji}
</text>
</svg>
);
+4 -12
View File
@@ -143,12 +143,8 @@ export interface Props
onRequestSubmit?: (
ev: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>
) => unknown;
onFocus?: (
ev: React.SyntheticEvent<HTMLInputElement | HTMLTextAreaElement>
) => unknown;
onBlur?: (
ev: React.SyntheticEvent<HTMLInputElement | HTMLTextAreaElement>
) => unknown;
onFocus?: (ev: React.SyntheticEvent) => unknown;
onBlur?: (ev: React.SyntheticEvent) => unknown;
}
function Input(
@@ -158,9 +154,7 @@ function Input(
const internalRef = React.useRef<HTMLInputElement | HTMLTextAreaElement>();
const [focused, setFocused] = React.useState(false);
const handleBlur = (
ev: React.SyntheticEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
const handleBlur = (ev: React.SyntheticEvent) => {
setFocused(false);
if (props.onBlur) {
@@ -168,9 +162,7 @@ function Input(
}
};
const handleFocus = (
ev: React.SyntheticEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
const handleFocus = (ev: React.SyntheticEvent) => {
setFocused(true);
if (props.onFocus) {
-8
View File
@@ -11,21 +11,13 @@ import { searchPath } from "~/utils/routeHelpers";
import Input, { Outline } from "./Input";
type Props = {
/** A string representing where the search started, for tracking. */
source: string;
/** Placeholder text for the input. */
placeholder?: string;
/** Label for the input. */
label?: string;
/** Whether the label should be hidden. */
labelHidden?: boolean;
/** An optional ID of a collection to search within. */
collectionId?: string;
/** The current value of the input. */
value?: string;
/** Event handler for when the input value changes. */
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => unknown;
/** Event handler for when a key is pressed. */
onKeyDown?: (event: React.KeyboardEvent<HTMLInputElement>) => unknown;
};
+3 -21
View File
@@ -50,13 +50,6 @@ export type Props = {
note?: React.ReactNode;
onChange?: (value: string | null) => void;
style?: React.CSSProperties;
/**
* Set to true if this component is rendered inside a Modal.
* The Modal will take care of preventing body scroll behaviour.
*/
skipBodyScroll?: boolean;
autoFocus?: boolean;
placeholder?: string;
};
export interface InputSelectRef {
@@ -86,9 +79,6 @@ const InputSelect = (props: Props, ref: React.RefObject<InputSelectRef>) => {
note,
icon,
nude,
skipBodyScroll,
autoFocus,
placeholder,
...rest
} = props;
@@ -101,7 +91,7 @@ const InputSelect = (props: Props, ref: React.RefObject<InputSelectRef>) => {
const popover = useSelectPopover({
...select,
hideOnClickOutside: false,
preventBodyScroll: skipBodyScroll ? false : true,
preventBodyScroll: true,
disabled,
});
@@ -218,7 +208,6 @@ const InputSelect = (props: Props, ref: React.RefObject<InputSelectRef>) => {
neutral
disclosure
className={className}
autoFocus={autoFocus}
icon={icon}
$nude={nude}
{...buttonProps}
@@ -226,19 +215,12 @@ const InputSelect = (props: Props, ref: React.RefObject<InputSelectRef>) => {
{option ? (
labelForOption(option)
) : (
<Placeholder>
{placeholder ?? `Select a ${ariaLabel.toLowerCase()}`}
</Placeholder>
<Placeholder>Select a {ariaLabel.toLowerCase()}</Placeholder>
)}
</StyledButton>
)}
</Select>
<SelectPopover
{...select}
{...popover}
aria-label={ariaLabel}
preventBodyScroll={skipBodyScroll ? false : true}
>
<SelectPopover {...select} {...popover} aria-label={ariaLabel}>
{(popoverProps: InnerProps) => {
const topAnchor = popoverProps.style?.top === "0";
const rightAnchor = popoverProps.placement === "bottom-end";
+9 -72
View File
@@ -4,12 +4,10 @@ import {
} from "@getoutline/react-roving-tabindex";
import { LocationDescriptor } from "history";
import * as React from "react";
import scrollIntoView from "smooth-scroll-into-view-if-needed";
import styled, { useTheme } from "styled-components";
import { s, ellipsis } from "@shared/styles";
import Flex from "~/components/Flex";
import NavLink from "~/components/NavLink";
import { hover } from "~/styles";
export type Props = Omit<React.HTMLAttributes<HTMLAnchorElement>, "title"> & {
/** An icon or image to display to the left of the list item */
@@ -18,8 +16,6 @@ export type Props = Omit<React.HTMLAttributes<HTMLAnchorElement>, "title"> & {
to?: LocationDescriptor;
/** An optional click handler, if provided the list item will have hover styles */
onClick?: React.MouseEventHandler<HTMLAnchorElement>;
/** An optional keydown handler, if provided the list item will have hover styles */
onKeyDown?: React.KeyboardEventHandler<HTMLAnchorElement>;
/** Whether to match the location exactly */
exact?: boolean;
/** The title of the list item */
@@ -32,49 +28,26 @@ export type Props = Omit<React.HTMLAttributes<HTMLAnchorElement>, "title"> & {
border?: boolean;
/** Whether to display the list item in a compact style */
small?: boolean;
/** Whether to enable keyboard navigation */
keyboardNavigation?: boolean;
};
const ListItem = (
{
image,
title,
subtitle,
actions,
small,
border,
to,
keyboardNavigation,
...rest
}: Props,
ref: React.RefObject<HTMLAnchorElement>
{ image, title, subtitle, actions, small, border, to, ...rest }: Props,
ref?: React.Ref<HTMLAnchorElement>
) => {
const theme = useTheme();
const compact = !subtitle;
let itemRef: React.RefObject<HTMLAnchorElement> =
let itemRef: React.Ref<HTMLAnchorElement> =
React.useRef<HTMLAnchorElement>(null);
if (ref) {
itemRef = ref;
}
const { focused, ...rovingTabIndex } = useRovingTabIndex(
itemRef,
keyboardNavigation || to ? false : true
itemRef as React.RefObject<HTMLAnchorElement>,
to ? false : true
);
useFocusEffect(focused, itemRef);
const handleFocus = React.useCallback(() => {
if (itemRef.current) {
scrollIntoView(itemRef.current, {
scrollMode: "if-needed",
behavior: "auto",
block: "center",
boundary: window.document.body,
});
}
}, [itemRef]);
useFocusEffect(focused, itemRef as React.RefObject<HTMLAnchorElement>);
const content = (selected: boolean) => (
<>
@@ -116,16 +89,6 @@ const ListItem = (
}
rovingTabIndex.onClick(ev);
}}
onKeyDown={(ev) => {
if (rest.onKeyDown) {
rest.onKeyDown(ev);
}
rovingTabIndex.onKeyDown(ev);
}}
onFocus={(ev) => {
rovingTabIndex.onFocus(ev);
handleFocus();
}}
as={NavLink}
to={to}
>
@@ -135,26 +98,7 @@ const ListItem = (
}
return (
<Wrapper
ref={itemRef}
$border={border}
$small={small}
$hover={!!rest.onClick}
{...rest}
{...rovingTabIndex}
onClick={(ev) => {
rest.onClick?.(ev);
rovingTabIndex.onClick(ev);
}}
onKeyDown={(ev) => {
rest.onKeyDown?.(ev);
rovingTabIndex.onKeyDown(ev);
}}
onFocus={(ev) => {
rovingTabIndex.onFocus(ev);
handleFocus();
}}
>
<Wrapper ref={itemRef} $border={border} $small={small} {...rest}>
{content(false)}
</Wrapper>
);
@@ -163,7 +107,6 @@ const ListItem = (
const Wrapper = styled.a<{
$small?: boolean;
$border?: boolean;
$hover?: boolean;
onClick?: React.MouseEventHandler<HTMLAnchorElement>;
to?: LocationDescriptor;
}>`
@@ -180,15 +123,9 @@ const Wrapper = styled.a<{
border-bottom: 0;
}
&:focus-visible {
outline: none;
}
&:${hover},
&:focus,
&:focus-within {
&:hover {
background: ${(props) =>
props.$hover ? props.theme.secondaryBackground : "inherit"};
props.onClick ? props.theme.secondaryBackground : "inherit"};
}
cursor: ${(props) =>
+3 -5
View File
@@ -25,7 +25,6 @@ type Props = {
isOpen: boolean;
fullscreen?: boolean;
title?: React.ReactNode;
style?: React.CSSProperties;
onRequestClose: () => void;
};
@@ -34,7 +33,6 @@ const Modal: React.FC<Props> = ({
isOpen,
fullscreen = true,
title = "Untitled",
style,
onRequestClose,
}: Props) => {
const dialog = useDialogState({
@@ -117,7 +115,7 @@ const Modal: React.FC<Props> = ({
column
reverse
>
<SmallContent style={style} shadow>
<SmallContent shadow>
<ErrorBoundary component="div">{children}</ErrorBoundary>
</SmallContent>
<Header>
@@ -256,7 +254,7 @@ const Header = styled(Flex)`
const Small = styled.div`
animation: ${fadeAndScaleIn} 250ms ease;
margin: 25vh auto auto auto;
margin: auto auto;
width: 75vw;
min-width: 350px;
max-width: 450px;
@@ -271,7 +269,7 @@ const Small = styled.div`
outline: none;
${NudeButton} {
&:hover:not(:disabled),
&:hover,
&[aria-expanded="true"] {
background: ${s("sidebarControlHoverBackground")};
}
+3 -11
View File
@@ -1,5 +1,5 @@
import isEqual from "lodash/isEqual";
import { observable, action, computed } from "mobx";
import { observable, action } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import { withTranslation, WithTranslation } from "react-i18next";
@@ -39,9 +39,7 @@ type Props<T> = WithTranslation &
};
@observer
class PaginatedList<T extends PaginatedItem> extends React.PureComponent<
Props<T>
> {
class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
@observable
error?: Error;
@@ -147,11 +145,6 @@ class PaginatedList<T extends PaginatedItem> extends React.PureComponent<
}
};
@computed
get itemsToRender() {
return this.props.items?.slice(0, this.renderCount) ?? [];
}
render() {
const {
items = [],
@@ -195,11 +188,10 @@ class PaginatedList<T extends PaginatedItem> extends React.PureComponent<
aria-label={this.props["aria-label"]}
onEscape={onEscape}
className={this.props.className}
items={this.itemsToRender}
>
{() => {
let previousHeading = "";
return this.itemsToRender.map((item, index) => {
return items.slice(0, this.renderCount).map((item, index) => {
const children = this.props.renderItem(item, index);
// If there is no renderHeading method passed then no date
+10 -15
View File
@@ -20,18 +20,15 @@ type Props = PopoverProps & {
hide: () => void;
};
const Popover = (
{
children,
shrink,
width = 380,
scrollable = true,
flex,
mobilePosition,
...rest
}: Props,
ref: React.Ref<HTMLDivElement>
) => {
const Popover: React.FC<Props> = ({
children,
shrink,
width = 380,
scrollable = true,
flex,
mobilePosition,
...rest
}: Props) => {
const isMobile = useMobile();
// Custom Escape handler rather than using hideOnEsc from reakit so we can
@@ -53,7 +50,6 @@ const Popover = (
return (
<Dialog {...rest} modal>
<Contents
ref={ref}
$shrink={shrink}
$scrollable={scrollable}
$flex={flex}
@@ -68,7 +64,6 @@ const Popover = (
return (
<StyledPopover {...rest} hideOnEsc={false} hideOnClickOutside>
<Contents
ref={ref}
$shrink={shrink}
$width={width}
$scrollable={scrollable}
@@ -128,4 +123,4 @@ const Contents = styled.div<ContentsProps>`
`};
`;
export default React.forwardRef(Popover);
export default Popover;
@@ -24,7 +24,7 @@ type Props = {
};
function CollectionMemberList({ collection, invitedInSession }: Props) {
const { memberships, groupMemberships } = useStores();
const { memberships, collectionGroupMemberships } = useStores();
const can = usePolicy(collection);
const { t } = useTranslation();
const theme = useTheme();
@@ -39,8 +39,8 @@ function CollectionMemberList({ collection, invitedInSession }: Props) {
const { request: fetchGroupMemberships } = useRequest(
React.useCallback(
() => groupMemberships.fetchAll({ id: collectionId }),
[groupMemberships, collectionId]
() => collectionGroupMemberships.fetchAll({ id: collectionId }),
[collectionGroupMemberships, collectionId]
)
);
@@ -75,7 +75,7 @@ function CollectionMemberList({ collection, invitedInSession }: Props) {
return (
<>
{groupMemberships
{collectionGroupMemberships
.inCollection(collection.id)
.sort((a, b) =>
(
@@ -103,12 +103,12 @@ function CollectionMemberList({ collection, invitedInSession }: Props) {
permission: CollectionPermission | typeof EmptySelectValue
) => {
if (permission === EmptySelectValue) {
await groupMemberships.delete({
await collectionGroupMemberships.delete({
collectionId: collection.id,
groupId: membership.groupId,
});
} else {
await groupMemberships.create({
await collectionGroupMemberships.create({
collectionId: collection.id,
groupId: membership.groupId,
permission,
@@ -20,7 +20,6 @@ import useBoolean from "~/hooks/useBoolean";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useKeyDown from "~/hooks/useKeyDown";
import usePolicy from "~/hooks/usePolicy";
import usePrevious from "~/hooks/usePrevious";
import useStores from "~/hooks/useStores";
import { EmptySelectValue, Permission } from "~/types";
import { collectionPath, urlify } from "~/utils/routeHelpers";
@@ -44,7 +43,8 @@ type Props = {
function SharePopover({ collection, visible, onRequestClose }: Props) {
const theme = useTheme();
const team = useCurrentTeam();
const { groupMemberships, users, groups, memberships } = useStores();
const { collectionGroupMemberships, users, groups, memberships } =
useStores();
const { t } = useTranslation();
const can = usePolicy(collection);
const [query, setQuery] = React.useState("");
@@ -56,11 +56,6 @@ function SharePopover({ collection, visible, onRequestClose }: Props) {
CollectionPermission.Read
);
const prevPendingIds = usePrevious(pendingIds);
const suggestionsRef = React.useRef<HTMLDivElement | null>(null);
const searchInputRef = React.useRef<HTMLInputElement | null>(null);
useKeyDown(
"Escape",
(ev) => {
@@ -102,19 +97,6 @@ function SharePopover({ collection, visible, onRequestClose }: Props) {
}
}, [visible]);
React.useEffect(() => {
if (prevPendingIds && pendingIds.length > prevPendingIds.length) {
setQuery("");
searchInputRef.current?.focus();
} else if (prevPendingIds && pendingIds.length < prevPendingIds.length) {
const firstPending = suggestionsRef.current?.firstElementChild;
if (firstPending) {
(firstPending as HTMLAnchorElement).focus();
}
}
}, [pendingIds, prevPendingIds]);
const handleQuery = React.useCallback(
(event) => {
showPicker();
@@ -137,39 +119,6 @@ function SharePopover({ collection, visible, onRequestClose }: Props) {
[setPendingIds]
);
const handleKeyDown = React.useCallback(
(ev: React.KeyboardEvent<HTMLInputElement>) => {
if (ev.nativeEvent.isComposing) {
return;
}
if (ev.key === "ArrowDown" && !ev.shiftKey) {
ev.preventDefault();
if (ev.currentTarget.value) {
const length = ev.currentTarget.value.length;
const selectionStart = ev.currentTarget.selectionStart || 0;
if (selectionStart < length) {
ev.currentTarget.selectionStart = length;
ev.currentTarget.selectionEnd = length;
return;
}
}
const firstSuggestion = suggestionsRef.current?.firstElementChild;
if (firstSuggestion) {
(firstSuggestion as HTMLAnchorElement).focus();
}
}
},
[]
);
const handleEscape = React.useCallback(
() => searchInputRef.current?.focus(),
[]
);
const inviteAction = React.useMemo(
() =>
createAction({
@@ -205,10 +154,10 @@ function SharePopover({ collection, visible, onRequestClose }: Props) {
}
if (group) {
await groupMemberships.create({
await collectionGroupMemberships.create({
collectionId: collection.id,
groupId: group.id,
permission,
permission: CollectionPermission.Read,
});
return group;
}
@@ -267,7 +216,7 @@ function SharePopover({ collection, visible, onRequestClose }: Props) {
}),
[
collection.id,
groupMemberships,
collectionGroupMemberships,
groups,
hidePicker,
memberships,
@@ -343,10 +292,8 @@ function SharePopover({ collection, visible, onRequestClose }: Props) {
<Wrapper>
{can.update && (
<SearchInput
ref={searchInputRef}
onChange={handleQuery}
onClick={showPicker}
onKeyDown={handleKeyDown}
query={query}
back={backButton}
action={rightButton}
@@ -354,16 +301,15 @@ function SharePopover({ collection, visible, onRequestClose }: Props) {
)}
{picker && (
<Suggestions
ref={suggestionsRef}
query={query}
collection={collection}
pendingIds={pendingIds}
addPendingId={handleAddPendingId}
removePendingId={handleRemovePendingId}
onEscape={handleEscape}
showGroups
/>
<div>
<Suggestions
query={query}
collection={collection}
pendingIds={pendingIds}
addPendingId={handleAddPendingId}
removePendingId={handleRemovePendingId}
/>
</div>
)}
<div style={{ display: picker ? "none" : "block" }}>
+10 -21
View File
@@ -4,8 +4,7 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { useTheme } from "styled-components";
import Squircle from "@shared/components/Squircle";
import { CollectionPermission, IconType } from "@shared/types";
import { determineIconType } from "@shared/utils/icon";
import { CollectionPermission } from "@shared/types";
import type Collection from "~/models/Collection";
import type Document from "~/models/Document";
import Flex from "~/components/Flex";
@@ -55,7 +54,15 @@ export const OtherAccess = observer(({ document, children }: Props) => {
/>
) : usersInCollection ? (
<ListItem
image={<CollectionSquircle collection={collection} />}
image={
<Squircle color={collection.color} size={AvatarSize.Medium}>
<CollectionIcon
collection={collection}
color={theme.white}
size={16}
/>
</Squircle>
}
title={collection.name}
subtitle={t("Everyone in the collection")}
actions={<AccessTooltip>{t("Can view")}</AccessTooltip>}
@@ -129,24 +136,6 @@ const AccessTooltip = ({
);
};
const CollectionSquircle = ({ collection }: { collection: Collection }) => {
const theme = useTheme();
const iconType = determineIconType(collection.icon)!;
const squircleColor =
iconType === IconType.SVG ? collection.color! : theme.slateLight;
const iconSize = iconType === IconType.SVG ? 16 : 22;
return (
<Squircle color={squircleColor} size={AvatarSize.Medium}>
<CollectionIcon
collection={collection}
color={theme.white}
size={iconSize}
/>
</Squircle>
);
};
function useUsersInCollection(collection?: Collection) {
const { users, memberships } = useStores();
const { request } = useRequest(() =>
@@ -18,7 +18,6 @@ import useBoolean from "~/hooks/useBoolean";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useKeyDown from "~/hooks/useKeyDown";
import usePolicy from "~/hooks/usePolicy";
import usePrevious from "~/hooks/usePrevious";
import useStores from "~/hooks/useStores";
import { Permission } from "~/types";
import { documentPath, urlify } from "~/utils/routeHelpers";
@@ -65,11 +64,6 @@ function SharePopover({
DocumentPermission.Read
);
const prevPendingIds = usePrevious(pendingIds);
const suggestionsRef = React.useRef<HTMLDivElement | null>(null);
const searchInputRef = React.useRef<HTMLInputElement | null>(null);
useKeyDown(
"Escape",
(ev) => {
@@ -113,19 +107,6 @@ function SharePopover({
}
}, [picker]);
React.useEffect(() => {
if (prevPendingIds && pendingIds.length > prevPendingIds.length) {
setQuery("");
searchInputRef.current?.focus();
} else if (prevPendingIds && pendingIds.length < prevPendingIds.length) {
const firstPending = suggestionsRef.current?.firstElementChild;
if (firstPending) {
(firstPending as HTMLAnchorElement).focus();
}
}
}, [pendingIds, prevPendingIds]);
const inviteAction = React.useMemo(
() =>
createAction({
@@ -221,39 +202,6 @@ function SharePopover({
[setPendingIds]
);
const handleKeyDown = React.useCallback(
(ev: React.KeyboardEvent<HTMLInputElement>) => {
if (ev.nativeEvent.isComposing) {
return;
}
if (ev.key === "ArrowDown" && !ev.shiftKey) {
ev.preventDefault();
if (ev.currentTarget.value) {
const length = ev.currentTarget.value.length;
const selectionStart = ev.currentTarget.selectionStart || 0;
if (selectionStart < length) {
ev.currentTarget.selectionStart = length;
ev.currentTarget.selectionEnd = length;
return;
}
}
const firstSuggestion = suggestionsRef.current?.firstElementChild;
if (firstSuggestion) {
(firstSuggestion as HTMLAnchorElement).focus();
}
}
},
[]
);
const handleEscape = React.useCallback(
() => searchInputRef.current?.focus(),
[]
);
const permissions = React.useMemo(
() =>
[
@@ -318,10 +266,8 @@ function SharePopover({
<Wrapper>
{can.manageUsers && (
<SearchInput
ref={searchInputRef}
onChange={handleQuery}
onClick={showPicker}
onKeyDown={handleKeyDown}
query={query}
back={backButton}
action={rightButton}
@@ -329,15 +275,15 @@ function SharePopover({
)}
{picker && (
<Suggestions
ref={suggestionsRef}
document={document}
query={query}
pendingIds={pendingIds}
addPendingId={handleAddPendingId}
removePendingId={handleRemovePendingId}
onEscape={handleEscape}
/>
<div>
<Suggestions
document={document}
query={query}
pendingIds={pendingIds}
addPendingId={handleAddPendingId}
removePendingId={handleRemovePendingId}
/>
</div>
)}
<div style={{ display: picker ? "none" : "block" }}>
@@ -15,9 +15,7 @@ export const ListItem = styled(BaseListItem).attrs({
padding: 6px 16px;
border-radius: 8px;
&: ${hover} ${InviteIcon},
&:focus ${InviteIcon},
&:focus-within ${InviteIcon} {
&: ${hover} ${InviteIcon} {
opacity: 1;
}
`;
@@ -1,7 +1,6 @@
import { AnimatePresence } from "framer-motion";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { mergeRefs } from "react-merge-refs";
import Flex from "~/components/Flex";
import useMobile from "~/hooks/useMobile";
import Input, { NativeInput } from "../../Input";
@@ -11,18 +10,13 @@ type Props = {
query: string;
onChange: React.ChangeEventHandler;
onClick: React.MouseEventHandler;
onKeyDown: React.KeyboardEventHandler;
back: React.ReactNode;
action: React.ReactNode;
};
export const SearchInput = React.forwardRef(function _SearchInput(
{ onChange, onClick, onKeyDown, query, back, action }: Props,
ref: React.Ref<HTMLInputElement>
) {
export function SearchInput({ onChange, onClick, query, back, action }: Props) {
const { t } = useTranslation();
const inputRef = React.useRef<HTMLInputElement>(null);
const isMobile = useMobile();
const focusInput = React.useCallback(
@@ -45,7 +39,6 @@ export const SearchInput = React.forwardRef(function _SearchInput(
value={query}
onChange={onChange}
onClick={onClick}
onKeyDown={onKeyDown}
autoFocus
margin={0}
flex
@@ -59,16 +52,15 @@ export const SearchInput = React.forwardRef(function _SearchInput(
{back}
<NativeInput
key="input"
ref={mergeRefs([inputRef, ref])}
ref={inputRef}
placeholder={`${t("Add or invite")}`}
value={query}
onChange={onChange}
onClick={onClick}
onKeyDown={onKeyDown}
style={{ padding: "6px 0" }}
/>
{action}
</AnimatePresence>
</HeaderInput>
);
});
}
@@ -1,5 +1,4 @@
import { isEmail } from "class-validator";
import concat from "lodash/concat";
import { observer } from "mobx-react";
import { CheckmarkIcon, CloseIcon, GroupIcon } from "outline-icons";
import * as React from "react";
@@ -12,14 +11,11 @@ import Collection from "~/models/Collection";
import Document from "~/models/Document";
import Group from "~/models/Group";
import User from "~/models/User";
import ArrowKeyNavigation from "~/components/ArrowKeyNavigation";
import Avatar from "~/components/Avatar";
import { AvatarSize, IAvatar } from "~/components/Avatar/Avatar";
import Empty from "~/components/Empty";
import Placeholder from "~/components/List/Placeholder";
import Scrollable from "~/components/Scrollable";
import useCurrentUser from "~/hooks/useCurrentUser";
import useMaxHeight from "~/hooks/useMaxHeight";
import useStores from "~/hooks/useStores";
import useThrottledCallback from "~/hooks/useThrottledCallback";
import { hover } from "~/styles";
@@ -44,34 +40,23 @@ type Props = {
removePendingId: (id: string) => void;
/** Show group suggestions. */
showGroups?: boolean;
/** Handles escape from suggestions list */
onEscape?: (ev: React.KeyboardEvent<HTMLDivElement>) => void;
};
export const Suggestions = observer(
React.forwardRef(function _Suggestions(
{
document,
collection,
query,
pendingIds,
addPendingId,
removePendingId,
showGroups,
onEscape,
}: Props,
ref: React.Ref<HTMLDivElement>
) {
({
document,
collection,
query,
pendingIds,
addPendingId,
removePendingId,
showGroups,
}: Props) => {
const neverRenderedList = React.useRef(false);
const { users, groups } = useStores();
const { t } = useTranslation();
const user = useCurrentUser();
const theme = useTheme();
const containerRef = React.useRef<HTMLDivElement | null>(null);
const maxHeight = useMaxHeight({
elementRef: containerRef,
maxViewportPercentage: 70,
});
const fetchUsersByQuery = useThrottledCallback(
(query: string) => {
@@ -107,7 +92,7 @@ export const Suggestions = observer(
: collection
? users.notInCollection(collection.id, query)
: users.orderedData
).filter((u) => !u.isSuspended);
).filter((u) => u.id !== user.id && !u.isSuspended);
if (isEmail(query)) {
filtered.push(getSuggestionForEmail(query));
@@ -122,8 +107,6 @@ export const Suggestions = observer(
getSuggestionForEmail,
users,
users.orderedData,
groups,
groups.orderedData,
document?.id,
document?.members,
collection?.id,
@@ -191,65 +174,34 @@ export const Suggestions = observer(
neverRenderedList.current = false;
return (
<ScrollableContainer
ref={containerRef}
hiddenScrollbars
style={{ maxHeight }}
>
<ArrowKeyNavigation
ref={ref}
onEscape={onEscape}
aria-label={t("Suggestions for invitation")}
items={concat(pending, suggestionsWithPending)}
>
{() => [
...pending.map((suggestion) => (
<PendingListItem
keyboardNavigation
{...getListItemProps(suggestion)}
key={suggestion.id}
onClick={() => removePendingId(suggestion.id)}
onKeyDown={(ev) => {
if (ev.key === "Enter") {
ev.preventDefault();
ev.stopPropagation();
removePendingId(suggestion.id);
}
}}
actions={
<>
<InvitedIcon />
<RemoveIcon />
</>
}
/>
)),
pending.length > 0 &&
(suggestionsWithPending.length > 0 || isEmpty) && <Separator />,
...suggestionsWithPending.map((suggestion) => (
<ListItem
keyboardNavigation
{...getListItemProps(suggestion as User)}
key={suggestion.id}
onClick={() => addPendingId(suggestion.id)}
onKeyDown={(ev) => {
if (ev.key === "Enter") {
ev.preventDefault();
ev.stopPropagation();
addPendingId(suggestion.id);
}
}}
actions={<InviteIcon />}
/>
)),
isEmpty && (
<Empty style={{ marginTop: 22 }}>{t("No matches")}</Empty>
),
]}
</ArrowKeyNavigation>
</ScrollableContainer>
<>
{pending.map((suggestion) => (
<PendingListItem
{...getListItemProps(suggestion)}
key={suggestion.id}
onClick={() => removePendingId(suggestion.id)}
actions={
<>
<InvitedIcon />
<RemoveIcon />
</>
}
/>
))}
{pending.length > 0 &&
(suggestionsWithPending.length > 0 || isEmpty) && <Separator />}
{suggestionsWithPending.map((suggestion) => (
<ListItem
{...getListItemProps(suggestion as User)}
key={suggestion.id}
onClick={() => addPendingId(suggestion.id)}
actions={<InviteIcon />}
/>
))}
{isEmpty && <Empty style={{ marginTop: 22 }}>{t("No matches")}</Empty>}
</>
);
})
}
);
const InvitedIcon = styled(CheckmarkIcon)`
@@ -276,8 +228,3 @@ const Separator = styled.div`
border-top: 1px dashed ${s("divider")};
margin: 12px 0;
`;
const ScrollableContainer = styled(Scrollable)`
padding: 12px 24px;
margin: -12px -24px;
`;
+1 -2
View File
@@ -50,8 +50,7 @@ function Right({ children, border, className }: Props) {
}
}, []);
const handleMouseDown = React.useCallback((event) => {
event.preventDefault();
const handleMouseDown = React.useCallback(() => {
setResizing(true);
}, []);
+1 -1
View File
@@ -29,7 +29,7 @@ function SharedSidebar({ rootNode, shareId }: Props) {
return (
<Sidebar>
{team?.name && (
{team && (
<SidebarButton
title={team.name}
image={<TeamLogo model={team} size={32} alt={t("Logo")} />}
-4
View File
@@ -94,7 +94,6 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
const handleMouseDown = React.useCallback(
(event) => {
event.preventDefault();
setOffset(event.pageX - width);
setResizing(true);
setAnimating(false);
@@ -144,11 +143,8 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
React.useEffect(() => {
if (isResizing) {
document.body.style.cursor = "col-resize";
document.addEventListener("mousemove", handleDrag);
document.addEventListener("mouseup", handleStopDrag);
} else {
document.body.style.cursor = "initial";
}
return () => {
@@ -100,7 +100,7 @@ const CollectionLink: React.FC<Props> = ({
),
});
} else {
await documents.move({ documentId: id, collectionId: collection.id });
await documents.move(id, collection.id);
if (!expanded) {
onDisclosureClick();
@@ -52,11 +52,7 @@ function CollectionLinkChildren({
if (!collection) {
return;
}
void documents.move({
documentId: item.id,
collectionId: collection.id,
index: 0,
});
void documents.move(item.id, collection.id, undefined, 0);
},
collect: (monitor) => ({
isOverReorder: !!monitor.isOver(),
@@ -14,14 +14,13 @@ import { DocumentValidation } from "@shared/validations";
import Collection from "~/models/Collection";
import Document from "~/models/Document";
import Fade from "~/components/Fade";
import Icon from "~/components/Icon";
import NudeButton from "~/components/NudeButton";
import Tooltip from "~/components/Tooltip";
import useBoolean from "~/hooks/useBoolean";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import DocumentMenu from "~/menus/DocumentMenu";
import { newNestedDocumentPath } from "~/utils/routeHelpers";
import { newDocumentPath } from "~/utils/routeHelpers";
import DropCursor from "./DropCursor";
import DropToImport from "./DropToImport";
import EditableTitle, { RefHandle } from "./EditableTitle";
@@ -143,8 +142,6 @@ function InnerDocumentLink(
const isMoving = documents.movingDocumentId === node.id;
const manualSort = collection?.sort.field === "index";
const can = policies.abilities(node.id);
const icon = document?.icon || node.icon || node.emoji;
const color = document?.color || node.color;
// Draggable
const [{ isDragging }, drag, preview] = useDrag({
@@ -152,7 +149,6 @@ function InnerDocumentLink(
item: () => ({
...node,
depth,
icon: icon ? <Icon value={icon} color={color} /> : undefined,
active: isActiveDocument,
collectionId: collection?.id || "",
}),
@@ -187,11 +183,7 @@ function InnerDocumentLink(
if (!collection) {
return;
}
await documents.move({
documentId: item.id,
collectionId: collection.id,
parentDocumentId: node.id,
});
await documents.move(item.id, collection.id, node.id);
setExpanded(true);
},
canDrop: (item, monitor) =>
@@ -253,21 +245,11 @@ function InnerDocumentLink(
}
if (expanded) {
void documents.move({
documentId: item.id,
collectionId: collection.id,
parentDocumentId: node.id,
index: 0,
});
void documents.move(item.id, collection.id, node.id, 0);
return;
}
void documents.move({
documentId: item.id,
collectionId: collection.id,
parentDocumentId: parentId,
index: index + 1,
});
void documents.move(item.id, collection.id, parentId, index + 1);
},
collect: (monitor) => ({
isOverReorder: monitor.isOver(),
@@ -297,8 +279,9 @@ function InnerDocumentLink(
node,
]);
const doc = documents.get(node.id);
const title = doc?.title || node.title || t("Untitled");
const title =
(activeDocument?.id === node.id ? activeDocument.title : node.title) ||
t("Untitled");
const isExpanded = expanded && !isDragging;
const hasChildren = nodeChildren.length > 0;
@@ -341,7 +324,7 @@ function InnerDocumentLink(
starred: inStarredSection,
},
}}
icon={icon && <Icon value={icon} color={color} />}
emoji={document?.emoji || node.emoji}
label={
<EditableTitle
title={title}
@@ -376,7 +359,9 @@ function InnerDocumentLink(
type={undefined}
aria-label={t("New nested document")}
as={Link}
to={newNestedDocumentPath(document.id)}
to={newDocumentPath(document.collectionId, {
parentDocumentId: document.id,
})}
>
<PlusIcon />
</NudeButton>
@@ -5,7 +5,6 @@ import { useTranslation } from "react-i18next";
import { NavigationNode } from "@shared/types";
import Collection from "~/models/Collection";
import Document from "~/models/Document";
import Icon from "~/components/Icon";
import useStores from "~/hooks/useStores";
import { sharedDocumentPath } from "~/utils/routeHelpers";
import { descendants } from "~/utils/tree";
@@ -101,8 +100,6 @@ function DocumentLink(
(activeDocument?.id === node.id ? activeDocument.title : node.title) ||
t("Untitled");
const icon = node.icon ?? node.emoji;
return (
<>
<SidebarLink
@@ -114,7 +111,7 @@ function DocumentLink(
}}
expanded={hasChildDocuments && depth !== 0 ? expanded : undefined}
onDisclosureClick={handleDisclosureClick}
icon={icon && <Icon value={icon} color={node.color} />}
emoji={node.emoji}
label={title}
depth={depth}
exact={false}
@@ -2,8 +2,7 @@ import fractionalIndex from "fractional-index";
import { observer } from "mobx-react";
import * as React from "react";
import styled from "styled-components";
import { IconType, NotificationEventType } from "@shared/types";
import { determineIconType } from "@shared/utils/icon";
import { NotificationEventType } from "@shared/types";
import UserMembership from "~/models/UserMembership";
import Fade from "~/components/Fade";
import useBoolean from "~/hooks/useBoolean";
@@ -79,11 +78,10 @@ function SharedWithMeLink({ userMembership }: Props) {
return null;
}
const { icon: docIcon } = document;
const label =
determineIconType(docIcon) === IconType.Emoji
? document.title.replace(docIcon!, "")
: document.titleWithDefault;
const { emoji } = document;
const label = emoji
? document.title.replace(emoji, "")
: document.titleWithDefault;
const collection = document.collectionId
? collections.get(document.collectionId)
: undefined;
@@ -5,6 +5,7 @@ import breakpoint from "styled-components-breakpoint";
import EventBoundary from "@shared/components/EventBoundary";
import { s } from "@shared/styles";
import { NavigationNode } from "@shared/types";
import EmojiIcon from "~/components/Icons/EmojiIcon";
import NudeButton from "~/components/NudeButton";
import { UnreadBadge } from "~/components/UnreadBadge";
import useUnmount from "~/hooks/useUnmount";
@@ -26,6 +27,7 @@ type Props = Omit<NavLinkProps, "to"> & {
onClickIntent?: () => void;
onDisclosureClick?: React.MouseEventHandler<HTMLButtonElement>;
icon?: React.ReactNode;
emoji?: string | null;
label?: React.ReactNode;
menu?: React.ReactNode;
unreadBadge?: boolean;
@@ -50,6 +52,7 @@ function SidebarLink(
onClick,
onClickIntent,
to,
emoji,
label,
active,
isActiveDrop,
@@ -139,6 +142,7 @@ function SidebarLink(
/>
)}
{icon && <IconWrapper>{icon}</IconWrapper>}
{emoji && <EmojiIcon emoji={emoji} />}
<Label>{label}</Label>
{unreadBadge && <UnreadBadge />}
</Content>
@@ -1,7 +1,7 @@
import { DocumentIcon } from "outline-icons";
import * as React from "react";
import Icon from "~/components/Icon";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import EmojiIcon from "~/components/Icons/EmojiIcon";
import useStores from "~/hooks/useStores";
interface SidebarItem {
@@ -21,11 +21,7 @@ export function useSidebarLabelAndIcon(
if (document) {
return {
label: document.titleWithDefault,
icon: document.icon ? (
<Icon value={document.icon} color={document.color ?? undefined} />
) : (
icon
),
icon: document.emoji ? <EmojiIcon emoji={document.emoji} /> : icon,
};
}
}
-22
View File
@@ -1,22 +0,0 @@
import React from "react";
import styled from "styled-components";
import Flex from "~/components/Flex";
const Label = ({ icon, value }: { icon: React.ReactNode; value: string }) => (
<Flex align="center" gap={4}>
<IconWrapper>{icon}</IconWrapper>
{value}
</Flex>
);
const IconWrapper = styled.span`
display: flex;
justify-content: center;
align-items: center;
height: 24px;
width: 24px;
overflow: hidden;
flex-shrink: 0;
`;
export default Label;
@@ -1,113 +0,0 @@
import { observer } from "mobx-react";
import React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { AvatarSize } from "~/components/Avatar/Avatar";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import InputSelect, { Option } from "~/components/InputSelect";
import TeamLogo from "~/components/TeamLogo";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import usePolicy from "~/hooks/usePolicy";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import Label from "./Label";
type Props = {
/** Collection ID to select by default. */
defaultCollectionId?: string | null;
/** Callback to be called when a collection is selected. */
onSelect: (collectionId: string | null) => void;
};
const SelectLocation = ({ defaultCollectionId, onSelect }: Props) => {
const { t } = useTranslation();
const team = useCurrentTeam();
const { collections, policies } = useStores();
const can = usePolicy(team);
const { loading, error } = useRequest(
React.useCallback(async () => {
if (!collections.isLoaded) {
await collections.fetchAll({
limit: 100,
});
}
}, [collections])
);
const workspaceOption: Option | null = can.createTemplate
? {
label: (
<Label
icon={<TeamLogo model={team} size={AvatarSize.Toast} />}
value={t("Workspace")}
/>
),
value: "workspace",
}
: null;
const collectionOptions: Option[] = React.useMemo(
() =>
collections.orderedData.reduce<Option[]>((memo, collection) => {
const canCollection = policies.abilities(collection.id);
if (canCollection.createDocument) {
memo.push({
label: (
<Label
icon={<CollectionIcon collection={collection} />}
value={collection.name}
/>
),
value: collection.id,
});
}
return memo;
}, []),
[collections.orderedData, policies]
);
const options: Option[] = workspaceOption
? collectionOptions.length
? [
workspaceOption,
...collectionOptions.map((opt, idx) => {
if (idx !== 0) {
return opt;
}
opt.divider = true;
return opt;
}),
]
: [workspaceOption]
: collectionOptions;
const handleSelection = React.useCallback(
(value: string | null) => {
onSelect(value === "workspace" ? null : value);
},
[onSelect]
);
if (error) {
toast.error(t("Collections could not be loaded, please reload the app"));
}
if (loading || !options.length) {
return null;
}
return (
<InputSelect
value={defaultCollectionId ?? "workspace"}
options={options}
onChange={handleSelection}
ariaLabel={t("Location")}
label={t("Location")}
/>
);
};
export default observer(SelectLocation);
-82
View File
@@ -1,82 +0,0 @@
import invariant from "invariant";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { useHistory } from "react-router-dom";
import { toast } from "sonner";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import Flex from "~/components/Flex";
import Switch from "~/components/Switch";
import useStores from "~/hooks/useStores";
import { documentPath } from "~/utils/routeHelpers";
import SelectLocation from "./SelectLocation";
type Props = {
documentId: string;
};
function DocumentTemplatizeDialog({ documentId }: Props) {
const history = useHistory();
const { t } = useTranslation();
const { documents } = useStores();
const document = documents.get(documentId);
invariant(document, "Document must exist");
const [publish, setPublish] = React.useState(true);
const [collectionId, setCollectionId] = React.useState(
document.collectionId ?? null
);
const handlePublishChange = React.useCallback(
(ev: React.ChangeEvent<HTMLInputElement>) => {
setPublish(ev.target.checked);
},
[]
);
const handleSubmit = React.useCallback(async () => {
const template = await document?.templatize({
collectionId,
publish,
});
if (template) {
history.push(documentPath(template));
toast.success(t("Template created, go ahead and customize it"));
}
}, [t, document, history, collectionId, publish]);
return (
<ConfirmationDialog
onSubmit={handleSubmit}
submitText={t("Create template")}
savingText={`${t("Creating")}`}
>
<Flex column gap={12}>
<div>
<Trans
defaults="Creating a template from <em>{{titleWithDefault}}</em> is a non-destructive action we'll make a copy of the document and turn it into a template that can be used as a starting point for new documents."
values={{
titleWithDefault: document.titleWithDefault,
}}
components={{
em: <strong />,
}}
/>
</div>
<SelectLocation
defaultCollectionId={collectionId}
onSelect={setCollectionId}
/>
<Switch
name="publish"
label={t("Published")}
note={t("Enable other members to use the template immediately")}
checked={publish}
onChange={handlePublishChange}
/>
</Flex>
</ConfirmationDialog>
);
}
export default observer(DocumentTemplatizeDialog);
-23
View File
@@ -10,7 +10,6 @@ import { FileOperationState, FileOperationType } from "@shared/types";
import RootStore from "~/stores/RootStore";
import Collection from "~/models/Collection";
import Comment from "~/models/Comment";
import DataAttribute from "~/models/DataAttribute";
import Document from "~/models/Document";
import FileOperation from "~/models/FileOperation";
import Group from "~/models/Group";
@@ -83,7 +82,6 @@ class WebsocketProvider extends React.Component<Props> {
this.socket.authenticated = false;
const {
auth,
dataAttributes,
documents,
collections,
groups,
@@ -292,27 +290,6 @@ class WebsocketProvider extends React.Component<Props> {
}
);
this.socket.on(
"dataAttributes.create",
(event: PartialWithId<DataAttribute>) => {
dataAttributes.add(event);
}
);
this.socket.on(
"dataAttributes.update",
(event: PartialWithId<DataAttribute>) => {
dataAttributes.add(event);
}
);
this.socket.on(
"dataAttributes.delete",
(event: WebsocketEntityDeletedEvent) => {
dataAttributes.remove(event.modelId);
}
);
this.socket.on("comments.create", (event: PartialWithId<Comment>) => {
comments.add(event);
});
+41 -20
View File
@@ -1,7 +1,11 @@
import data, { type Emoji as TEmoji } from "@emoji-mart/data";
import { init, Data } from "emoji-mart";
import FuzzySearch from "fuzzy-search";
import capitalize from "lodash/capitalize";
import sortBy from "lodash/sortBy";
import React from "react";
import { emojiMartToGemoji, snakeCase } from "@shared/editor/lib/emoji";
import { search as emojiSearch } from "@shared/utils/emoji";
import { isMac } from "@shared/utils/browser";
import EmojiMenuItem from "./EmojiMenuItem";
import SuggestionsMenu, {
Props as SuggestionsMenuProps,
@@ -15,6 +19,13 @@ type Emoji = {
attrs: { markup: string; "data-name": string };
};
init({
data,
noCountryFlags: isMac() ? false : undefined,
});
let searcher: FuzzySearch<TEmoji>;
type Props = Omit<
SuggestionsMenuProps<Emoji>,
"renderMenuItem" | "items" | "embeds" | "trigger"
@@ -23,26 +34,36 @@ type Props = Omit<
const EmojiMenu = (props: Props) => {
const { search = "" } = props;
const items = React.useMemo(
() =>
emojiSearch({ query: search })
.map((item) => {
// We snake_case the shortcode for backwards compatability with gemoji to
// avoid multiple formats being written into documents.
const shortcode = snakeCase(emojiMartToGemoji[item.id] || item.id);
const emoji = item.value;
if (!searcher) {
searcher = new FuzzySearch(Object.values(Data.emojis), ["search"], {
caseSensitive: false,
sort: true,
});
}
return {
name: "emoji",
title: emoji,
description: capitalize(item.name.toLowerCase()),
emoji,
attrs: { markup: shortcode, "data-name": shortcode },
};
})
.slice(0, 15),
[search]
);
const items = React.useMemo(() => {
const n = search.toLowerCase();
return sortBy(searcher.search(n), (item) => {
const nlc = item.name.toLowerCase();
return nlc === n ? -1 : nlc.startsWith(n) ? 0 : 1;
})
.map((item) => {
// We snake_case the shortcode for backwards compatability with gemoji to
// avoid multiple formats being written into documents.
const shortcode = snakeCase(emojiMartToGemoji[item.id] || item.id);
const emoji = item.skins[0].native;
return {
name: "emoji",
title: emoji,
description: capitalize(item.name.toLowerCase()),
emoji,
attrs: { markup: shortcode, "data-name": shortcode },
};
})
.slice(0, 15);
}, [search]);
return (
<SuggestionsMenu
+4 -5
View File
@@ -5,7 +5,6 @@ import { Portal as ReactPortal } from "react-portal";
import styled, { css } from "styled-components";
import { isCode } from "@shared/editor/lib/isCode";
import { findParentNode } from "@shared/editor/queries/findParentNode";
import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper";
import { depths, s } from "@shared/styles";
import { Portal } from "~/components/Portal";
import useComponentSize from "~/hooks/useComponentSize";
@@ -132,10 +131,10 @@ function usePosition({
if (isImageSelection) {
const element = view.nodeDOM(selection.from);
// Images are wrapped which impacts positioning - need to get the element
// specifically tagged as the handle
const imageElement = (element as HTMLElement).getElementsByClassName(
EditorStyleHelper.imageHandle
// Images are wrapped which impacts positioning - need to traverse through
// p > span > div.image
const imageElement = (element as HTMLElement).getElementsByTagName(
"img"
)[0];
const { left, top, width } = imageElement.getBoundingClientRect();
+1 -2
View File
@@ -26,7 +26,6 @@ import Tooltip from "./Tooltip";
export type SearchResult = {
title: string;
subtitle?: React.ReactNode;
icon?: React.ReactNode;
url: string;
};
@@ -360,7 +359,7 @@ class LinkEditor extends React.Component<Props, State> {
key={result.url}
title={result.title}
subtitle={result.subtitle}
icon={result.icon ?? <DocumentIcon />}
icon={<DocumentIcon />}
onPointerMove={() => this.handleFocusLink(index)}
onClick={this.handleSelectLink(result.url, result.title)}
selected={index === selectedIndex}
+2 -10
View File
@@ -1,6 +1,6 @@
import * as React from "react";
import scrollIntoView from "smooth-scroll-into-view-if-needed";
import styled, { css } from "styled-components";
import styled from "styled-components";
import { s, ellipsis } from "@shared/styles";
type Props = React.HTMLAttributes<HTMLDivElement> & {
@@ -60,15 +60,7 @@ const IconWrapper = styled.span<{ selected: boolean }>`
margin-right: 4px;
height: 24px;
opacity: 0.8;
${(props) =>
props.selected &&
css`
svg {
fill: ${s("accentText")};
color: ${s("accentText")};
}
`};
color: ${(props) => (props.selected ? s("accentText") : s("textSecondary"))};
`;
const ListItem = styled.div<{
+4 -27
View File
@@ -67,7 +67,7 @@ export type Props<T extends MenuItem = MenuItem> = {
index: number,
options: {
selected: boolean;
onClick: (event: React.SyntheticEvent) => void;
onClick: () => void;
}
) => React.ReactNode;
filterable?: boolean;
@@ -78,10 +78,6 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
const { view, commands } = useEditor();
const dictionary = useDictionary();
const hasActivated = React.useRef(false);
const pointerRef = React.useRef<{ clientX: number; clientY: number }>({
clientX: 0,
clientY: 0,
});
const menuRef = React.useRef<HTMLDivElement>(null);
const inputRef = React.useRef<HTMLInputElement>(null);
const [position, setPosition] = React.useState<Position>(defaultPosition);
@@ -348,9 +344,6 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
const handleFilesPicked = async (
event: React.ChangeEvent<HTMLInputElement>
) => {
// Re-focus the editor as it loses focus when file picker is opened on iOS
view.focus();
const { uploadFile, onFileUploadStart, onFileUploadStop } = props;
const files = getEventFiles(event);
const parent = findParentNode((node) => !!node)(view.state.selection);
@@ -583,23 +576,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
return null;
}
const handlePointerMove = (ev: React.PointerEvent) => {
if (
selectedIndex !== index &&
// Safari triggers pointermove with identical coordinates when the pointer has not moved.
// This causes the menu selection to flicker when the pointer is over the menu but not moving.
(pointerRef.current.clientX !== ev.clientX ||
pointerRef.current.clientY !== ev.clientY)
) {
setSelectedIndex(index);
}
pointerRef.current = {
clientX: ev.clientX,
clientY: ev.clientY,
};
};
const handlePointerDown = () => {
const handlePointer = () => {
if (selectedIndex !== index) {
setSelectedIndex(index);
}
@@ -608,8 +585,8 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
return (
<ListItem
key={index}
onPointerMove={handlePointerMove}
onPointerDown={handlePointerDown}
onPointerMove={handlePointer}
onPointerDown={handlePointer}
>
{props.renderMenuItem(item as any, index, {
selected: index === selectedIndex,
@@ -5,17 +5,11 @@ import MenuItem from "~/components/ContextMenu/MenuItem";
import { usePortalContext } from "~/components/Portal";
export type Props = {
/** Whether the item is selected */
selected: boolean;
/** Whether the item is disabled */
disabled?: boolean;
/** Callback when the item is clicked */
onClick: (event: React.SyntheticEvent) => void;
/** An optional icon for the item */
onClick: () => void;
icon?: React.ReactElement;
/** The title of the item */
title: React.ReactNode;
/** A string representing the keyboard shortcut for the item */
shortcut?: string;
};
+10 -15
View File
@@ -40,21 +40,16 @@ function ToolbarDropdown(props: { active: boolean; item: MenuItem }) {
};
return item.children
? item.children.map((child) => {
if (child.name === "separator") {
return { type: "separator", visible: child.visible };
}
return {
type: "button",
title: child.label,
icon: child.icon,
dangerous: child.dangerous,
visible: child.visible,
selected:
child.active !== undefined ? child.active(state) : undefined,
onClick: handleClick(child),
};
})
? item.children.map((child) => ({
type: "button",
title: child.label,
icon: child.icon,
dangerous: child.dangerous,
visible: child.visible,
selected:
child.active !== undefined ? child.active(state) : undefined,
onClick: handleClick(child),
}))
: [];
}, [item.children, commands, state]);
+4 -9
View File
@@ -7,8 +7,6 @@ import isMarkdown from "@shared/editor/lib/isMarkdown";
import normalizePastedMarkdown from "@shared/editor/lib/markdown/normalize";
import { isInCode } from "@shared/editor/queries/isInCode";
import { isInList } from "@shared/editor/queries/isInList";
import { IconType } from "@shared/types";
import { determineIconType } from "@shared/utils/icon";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import { isDocumentUrl, isUrl } from "@shared/utils/urls";
import stores from "~/stores";
@@ -91,7 +89,7 @@ export default class PasteHandler extends Extension {
},
handlePaste: (view, event: ClipboardEvent) => {
// Do nothing if the document isn't currently editable
if (!view.editable) {
if (view.props.editable && !view.props.editable(view.state)) {
return false;
}
@@ -181,12 +179,9 @@ export default class PasteHandler extends Extension {
if (document) {
const { hash } = new URL(text);
const hasEmoji =
determineIconType(document.icon) === IconType.Emoji;
const title = `${hasEmoji ? document.icon + " " : ""}${
document.titleWithDefault
}`;
const title = `${
document.emoji ? document.emoji + " " : ""
}${document.titleWithDefault}`;
insertLink(`${document.path}${hash}`, title);
}
})
+2 -2
View File
@@ -4,8 +4,8 @@ import { InputRule } from "@shared/editor/lib/InputRule";
const rightArrow = new InputRule(/->$/, "→");
const emdash = new InputRule(/--$/, "—");
const oneHalf = new InputRule(/(?:^|\s)1\/2$/, "½");
const threeQuarters = new InputRule(/(?:^|\s)3\/4$/, "¾");
const oneHalf = new InputRule(/1\/2$/, "½");
const threeQuarters = new InputRule(/3\/4$/, "¾");
const copyright = new InputRule(/\(c\)$/, "©️");
const registered = new InputRule(/\(r\)$/, "®️");
const trademarked = new InputRule(/\(tm\)$/, "™️");
+8 -43
View File
@@ -640,63 +640,29 @@ export class Editor extends React.PureComponent<
public getComments = () => ProsemirrorHelper.getComments(this.view.state.doc);
/**
* Remove all marks related to a specific comment from the document.
* Remove a specific comment mark from the document.
*
* @param commentId The id of the comment to remove
*/
public removeComment = (commentId: string) => {
const { state, dispatch } = this.view;
const tr = state.tr;
let found = false;
state.doc.descendants((node, pos) => {
if (!node.isInline) {
if (!node.isInline || found) {
return;
}
const mark = node.marks.find(
(m) => m.type === state.schema.marks.comment && m.attrs.id === commentId
(mark) =>
mark.type === state.schema.marks.comment &&
mark.attrs.id === commentId
);
if (mark) {
tr.removeMark(pos, pos + node.nodeSize, mark);
dispatch(state.tr.removeMark(pos, pos + node.nodeSize, mark));
found = true;
}
});
dispatch(tr);
};
/**
* Update all marks related to a specific comment in the document.
*
* @param commentId The id of the comment to remove
* @param attrs The attributes to update
*/
public updateComment = (commentId: string, attrs: { resolved: boolean }) => {
const { state, dispatch } = this.view;
const tr = state.tr;
state.doc.descendants((node, pos) => {
if (!node.isInline) {
return;
}
const mark = node.marks.find(
(m) => m.type === state.schema.marks.comment && m.attrs.id === commentId
);
if (mark) {
const from = pos;
const to = pos + node.nodeSize;
const newMark = state.schema.marks.comment.create({
...mark.attrs,
...attrs,
});
tr.removeMark(from, to, mark).addMark(from, to, newMark);
}
});
dispatch(tr);
};
/**
@@ -842,7 +808,6 @@ const EditorContainer = styled(Styles)<{
css`
#comment-${props.focusedCommentId} {
background: ${transparentize(0.5, props.theme.brand.marine)};
border-bottom: 2px solid ${props.theme.commentMarkBackground};
}
`}
+8 -40
View File
@@ -1,9 +1,7 @@
import { CopyIcon, ExpandedIcon } from "outline-icons";
import { Node as ProseMirrorNode } from "prosemirror-model";
import { EditorState } from "prosemirror-state";
import * as React from "react";
import { LANGUAGES } from "@shared/editor/extensions/Prism";
import { getFrequentCodeLanguages } from "@shared/editor/lib/code";
import { MenuItem } from "@shared/editor/types";
import { Dictionary } from "~/hooks/useDictionary";
@@ -14,26 +12,6 @@ export default function codeMenuItems(
): MenuItem[] {
const node = state.selection.$from.node();
const allLanguages = Object.entries(LANGUAGES);
const frequentLanguages = getFrequentCodeLanguages();
const frequentLangMenuItems = frequentLanguages.map((value) => {
const label = LANGUAGES[value];
return langToMenuItem({ node, value, label });
});
const remainingLangMenuItems = allLanguages
.filter(([value]) => !frequentLanguages.includes(value))
.map(([value, label]) => langToMenuItem({ node, value, label }));
const languageMenuItems = frequentLangMenuItems.length
? [
...frequentLangMenuItems,
{ name: "separator" },
...remainingLangMenuItems,
]
: remainingLangMenuItems;
return [
{
name: "copyToClipboard",
@@ -50,24 +28,14 @@ export default function codeMenuItems(
name: "code_block",
icon: <ExpandedIcon />,
label: LANGUAGES[node.attrs.language ?? "none"],
children: languageMenuItems,
children: Object.entries(LANGUAGES).map(([value, label]) => ({
name: "code_block",
label,
active: () => node.attrs.language === value,
attrs: {
language: value,
},
})),
},
];
}
const langToMenuItem = ({
node,
value,
label,
}: {
node: ProseMirrorNode;
value: string;
label: string;
}): MenuItem => ({
name: "code_block",
label,
active: () => node.attrs.language === value,
attrs: {
language: value,
},
});
+1 -1
View File
@@ -209,7 +209,7 @@ export default function formattingMenuItems(
tooltip: dictionary.comment,
icon: <CommentIcon />,
label: isCodeBlock ? dictionary.comment : undefined,
active: isMarkActive(schema.marks.comment, { resolved: false }),
active: isMarkActive(schema.marks.comment),
visible: !isMobile || !isEmpty,
},
{
-43
View File
@@ -1,43 +0,0 @@
import * as React from "react";
import useMobile from "./useMobile";
import useWindowSize from "./useWindowSize";
const useMaxHeight = ({
elementRef,
maxViewportPercentage = 90,
margin = 16,
}: {
/** The maximum height of the element as a percentage of the viewport. */
maxViewportPercentage?: number;
/** A ref pointing to the element. */
elementRef?: React.RefObject<HTMLElement | null>;
/** The margin to apply to the positioning. */
margin?: number;
}) => {
const [maxHeight, setMaxHeight] = React.useState<number | undefined>(10);
const isMobile = useMobile();
const { height: windowHeight } = useWindowSize();
React.useLayoutEffect(() => {
if (!isMobile && elementRef?.current) {
const mxHeight = (windowHeight / 100) * maxViewportPercentage;
setMaxHeight(
Math.min(
mxHeight,
elementRef?.current
? windowHeight -
elementRef.current.getBoundingClientRect().top -
margin
: 0
)
);
} else {
setMaxHeight(0);
}
}, [elementRef, windowHeight, margin, isMobile, maxViewportPercentage]);
return maxHeight;
};
export default useMaxHeight;
+7 -21
View File
@@ -14,7 +14,6 @@ import {
ImportIcon,
ShapesIcon,
Icon,
DatabaseIcon,
} from "outline-icons";
import React, { ComponentProps } from "react";
import { useTranslation } from "react-i18next";
@@ -30,7 +29,6 @@ import useCurrentUser from "./useCurrentUser";
import usePolicy from "./usePolicy";
const ApiKeys = lazy(() => import("~/scenes/Settings/ApiKeys"));
const DataAttributes = lazy(() => import("~/scenes/Settings/DataAttributes"));
const Details = lazy(() => import("~/scenes/Settings/Details"));
const Export = lazy(() => import("~/scenes/Settings/Export"));
const Features = lazy(() => import("~/scenes/Settings/Features"));
@@ -121,14 +119,6 @@ const useSettingsConfig = () => {
group: t("Workspace"),
icon: BeakerIcon,
},
{
name: t("Data Attributes"),
path: settingsPath("attributes"),
component: DataAttributes,
enabled: can.createDataAttribute,
group: t("Workspace"),
icon: DatabaseIcon,
},
{
name: t("Members"),
path: settingsPath("members"),
@@ -149,7 +139,7 @@ const useSettingsConfig = () => {
name: t("Templates"),
path: settingsPath("templates"),
component: Templates,
enabled: can.readTemplate,
enabled: can.update,
group: t("Workspace"),
icon: ShapesIcon,
},
@@ -198,21 +188,17 @@ const useSettingsConfig = () => {
// Plugins
PluginManager.getHooks(Hook.Settings).forEach((plugin) => {
const group = plugin.value.group ?? "Integrations";
const insertIndex = plugin.value.after
? items.findIndex((i) => i.name === t(plugin.value.after!)) + 1
: items.findIndex((i) => i.group === t(group));
: items.findIndex(
(i) => i.group === t(plugin.value.group ?? "Integrations")
);
items.splice(insertIndex, 0, {
name: t(plugin.name),
path:
group === "Integrations"
? integrationSettingsPath(plugin.id)
: settingsPath(plugin.id),
group: t(group),
path: integrationSettingsPath(plugin.id),
group: t(plugin.value.group),
component: plugin.value.component,
enabled: plugin.value.enabled
? plugin.value.enabled(team, user)
: can.update,
enabled: plugin.roles?.includes(user.role) || can.update,
icon: plugin.value.icon,
} as ConfigItem);
});
+3 -3
View File
@@ -1,6 +1,6 @@
import { NewDocumentIcon, ShapesIcon } from "outline-icons";
import * as React from "react";
import Icon from "~/components/Icon";
import EmojiIcon from "~/components/Icons/EmojiIcon";
import { createAction } from "~/actions";
import { DocumentSection } from "~/actions/sections";
import history from "~/utils/history";
@@ -21,8 +21,8 @@ const useTemplatesActions = () => {
name: item.titleWithDefault,
analyticsName: "New document",
section: DocumentSection,
icon: item.icon ? (
<Icon value={item.icon} color={item.color ?? undefined} />
icon: item.emoji ? (
<EmojiIcon emoji={item.emoji} />
) : (
<NewDocumentIcon />
),
+1 -1
View File
@@ -27,8 +27,8 @@ import {
starCollection,
unstarCollection,
searchInCollection,
createTemplate,
} from "~/actions/definitions/collections";
import { createTemplate } from "~/actions/definitions/documents";
import useActionContext from "~/hooks/useActionContext";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import usePolicy from "~/hooks/usePolicy";
+29 -59
View File
@@ -1,22 +1,16 @@
import copy from "copy-to-clipboard";
import { observer } from "mobx-react";
import { CopyIcon, EditIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useMenuState } from "reakit/Menu";
import { toast } from "sonner";
import EventBoundary from "@shared/components/EventBoundary";
import Comment from "~/models/Comment";
import CommentDeleteDialog from "~/components/CommentDeleteDialog";
import ContextMenu from "~/components/ContextMenu";
import MenuItem from "~/components/ContextMenu/MenuItem";
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
import Template from "~/components/ContextMenu/Template";
import { actionToMenuItem } from "~/actions";
import {
deleteCommentFactory,
resolveCommentFactory,
unresolveCommentFactory,
} from "~/actions/definitions/comments";
import useActionContext from "~/hooks/useActionContext";
import Separator from "~/components/ContextMenu/Separator";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { commentPath, urlify } from "~/utils/routeHelpers";
@@ -30,26 +24,24 @@ type Props = {
onEdit: () => void;
/** Callback when the comment has been deleted */
onDelete: () => void;
/** Callback when the comment has been updated */
onUpdate: (attrs: { resolved: boolean }) => void;
};
function CommentMenu({
comment,
onEdit,
onDelete,
onUpdate,
className,
}: Props) {
function CommentMenu({ comment, onEdit, onDelete, className }: Props) {
const menu = useMenuState({
modal: true,
});
const { documents } = useStores();
const { documents, dialogs } = useStores();
const { t } = useTranslation();
const can = usePolicy(comment);
const context = useActionContext({ isContextMenu: true });
const document = documents.get(comment.documentId);
const handleDelete = React.useCallback(() => {
dialogs.openModal({
title: t("Delete comment"),
content: <CommentDeleteDialog comment={comment} onSubmit={onDelete} />,
});
}, [dialogs, comment, onDelete, t]);
const handleCopyLink = React.useCallback(() => {
if (document) {
copy(urlify(commentPath(document, comment)));
@@ -66,46 +58,24 @@ function CommentMenu({
{...menu}
/>
</EventBoundary>
<ContextMenu {...menu} aria-label={t("Comment options")}>
<Template
{...menu}
items={[
{
type: "button",
title: `${t("Edit")}`,
icon: <EditIcon />,
onClick: onEdit,
visible: can.update,
},
actionToMenuItem(
resolveCommentFactory({
comment,
onResolve: () => onUpdate({ resolved: true }),
}),
context
),
actionToMenuItem(
unresolveCommentFactory({
comment,
onUnresolve: () => onUpdate({ resolved: false }),
}),
context
),
{
type: "button",
icon: <CopyIcon />,
title: t("Copy link"),
onClick: handleCopyLink,
},
{
type: "separator",
},
actionToMenuItem(
deleteCommentFactory({ comment, onDelete }),
context
),
]}
/>
{can.update && (
<MenuItem {...menu} onClick={onEdit}>
{t("Edit")}
</MenuItem>
)}
<MenuItem {...menu} onClick={handleCopyLink}>
{t("Copy link")}
</MenuItem>
{can.delete && (
<>
<Separator />
<MenuItem {...menu} onClick={handleDelete} dangerous>
{t("Delete")}
</MenuItem>
</>
)}
</ContextMenu>
</>
);
-67
View File
@@ -1,67 +0,0 @@
import copy from "copy-to-clipboard";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useMenuState } from "reakit/Menu";
import { toast } from "sonner";
import DataAttribute from "~/models/DataAttribute";
import ContextMenu from "~/components/ContextMenu";
import MenuItem from "~/components/ContextMenu/MenuItem";
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
import Separator from "~/components/ContextMenu/Separator";
import { DataAttributeEdit } from "~/components/DataAttribute/DataAttributeEdit";
import useStores from "~/hooks/useStores";
type Props = {
/** The DataAttribute to associate with the menu */
dataAttribute: DataAttribute;
};
function DataAttributeMenu({ dataAttribute }: Props) {
const menu = useMenuState({
modal: true,
});
const { dialogs } = useStores();
const { t } = useTranslation();
const handleEdit = React.useCallback(() => {
dialogs.openModal({
title: t("Edit attribute"),
content: (
<DataAttributeEdit
dataAttribute={dataAttribute}
onSubmit={dialogs.closeAllModals}
/>
),
});
}, [t, dialogs, dataAttribute]);
const handleCopy = React.useCallback(() => {
copy(dataAttribute.id);
toast.success("Copied to clipboard");
}, [dataAttribute]);
const handleDelete = React.useCallback(() => {
void dataAttribute.delete();
}, [dataAttribute]);
return (
<>
<OverflowMenuButton aria-label={t("Show menu")} {...menu} />
<ContextMenu {...menu}>
<MenuItem {...menu} onClick={handleEdit}>
{t("Edit")}
</MenuItem>
<MenuItem {...menu} onClick={handleCopy}>
{t("Copy ID")}
</MenuItem>
<Separator />
<MenuItem {...menu} onClick={handleDelete} dangerous>
{t("Delete")}
</MenuItem>
</ContextMenu>
</>
);
}
export default observer(DataAttributeMenu);
+5 -18
View File
@@ -1,4 +1,3 @@
import capitalize from "lodash/capitalize";
import { observer } from "mobx-react";
import { EditIcon, InputIcon, RestoreIcon, SearchIcon } from "outline-icons";
import * as React from "react";
@@ -22,7 +21,7 @@ import Switch from "~/components/Switch";
import { actionToMenuItem } from "~/actions";
import {
pinDocument,
createTemplateFromDocument,
createTemplate,
subscribeDocument,
unsubscribeDocument,
moveDocument,
@@ -45,7 +44,6 @@ import {
shareDocument,
copyDocument,
searchInDocument,
moveTemplate,
} from "~/actions/definitions/documents";
import useActionContext from "~/hooks/useActionContext";
import useCurrentUser from "~/hooks/useCurrentUser";
@@ -126,11 +124,7 @@ function DocumentMenu({
}
) => {
await document.restore(options);
toast.success(
t("{{ documentName }} restored", {
documentName: capitalize(document.noun),
})
);
toast.success(t("Document restored"));
},
[t, document]
);
@@ -234,10 +228,7 @@ function DocumentMenu({
{
type: "button",
title: t("Restore"),
visible:
((document.isWorkspaceTemplate || !!collection) &&
can.restore) ||
can.unarchive,
visible: (!!collection && can.restore) || can.unarchive,
onClick: (ev) => handleRestore(ev),
icon: <RestoreIcon />,
},
@@ -245,10 +236,7 @@ function DocumentMenu({
type: "submenu",
title: t("Restore"),
visible:
!document.isWorkspaceTemplate &&
!collection &&
!!can.restore &&
restoreItems.length !== 0,
!collection && !!can.restore && restoreItems.length !== 0,
style: {
left: -170,
position: "relative",
@@ -296,13 +284,12 @@ function DocumentMenu({
},
actionToMenuItem(createNestedDocument, context),
actionToMenuItem(importDocument, context),
actionToMenuItem(createTemplateFromDocument, context),
actionToMenuItem(createTemplate, context),
actionToMenuItem(duplicateDocument, context),
actionToMenuItem(publishDocument, context),
actionToMenuItem(unpublishDocument, context),
actionToMenuItem(archiveDocument, context),
actionToMenuItem(moveDocument, context),
actionToMenuItem(moveTemplate, context),
actionToMenuItem(pinDocument, context),
actionToMenuItem(createDocumentFromTemplate, context),
{
+47 -50
View File
@@ -5,10 +5,8 @@ import { useMenuState, MenuButton, MenuButtonHTMLProps } from "reakit/Menu";
import Document from "~/models/Document";
import ContextMenu from "~/components/ContextMenu";
import Template from "~/components/ContextMenu/Template";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { MenuItem } from "~/types";
import { newDocumentPath, newNestedDocumentPath } from "~/utils/routeHelpers";
import { newDocumentPath } from "~/utils/routeHelpers";
type Props = {
label?: (props: MenuButtonHTMLProps) => React.ReactNode;
@@ -19,59 +17,58 @@ function NewChildDocumentMenu({ document, label }: Props) {
const menu = useMenuState({
modal: true,
});
const { t } = useTranslation();
const canCollection = usePolicy(document.collectionId);
const { collections } = useStores();
const items: MenuItem[] = [];
if (canCollection.createDocument) {
const collection = document.collectionId
? collections.get(document.collectionId)
: undefined;
const collectionName = collection ? collection.name : t("collection");
items.push({
type: "route",
title: (
<span>
<Trans
defaults="New document in <em>{{ collectionName }}</em>"
values={{
collectionName,
}}
components={{
em: <strong />,
}}
/>
</span>
),
to: newDocumentPath(document.collectionId),
});
}
items.push({
type: "route",
title: (
<span>
<Trans
defaults="New document in <em>{{ collectionName }}</em>"
values={{
collectionName: document.title,
}}
components={{
em: <strong />,
}}
/>
</span>
),
to: newNestedDocumentPath(document.id),
});
const { t } = useTranslation();
const collection = document.collectionId
? collections.get(document.collectionId)
: undefined;
const collectionName = collection ? collection.name : t("collection");
return (
<>
<MenuButton {...menu}>{label}</MenuButton>
<ContextMenu {...menu} aria-label={t("New child document")}>
<Template {...menu} items={items} />
<Template
{...menu}
items={[
{
type: "route",
title: (
<span>
<Trans
defaults="New document in <em>{{ collectionName }}</em>"
values={{
collectionName,
}}
components={{
em: <strong />,
}}
/>
</span>
),
to: newDocumentPath(document.collectionId),
},
{
type: "route",
title: (
<span>
<Trans
defaults="New document in <em>{{ collectionName }}</em>"
values={{
collectionName: document.title,
}}
components={{
em: <strong />,
}}
/>
</span>
),
to: newDocumentPath(document.collectionId, {
parentDocumentId: document.id,
}),
},
]}
/>
</ContextMenu>
</>
);
+4 -33
View File
@@ -5,9 +5,9 @@ import { useTranslation } from "react-i18next";
import { MenuButton, useMenuState } from "reakit/Menu";
import Button from "~/components/Button";
import ContextMenu from "~/components/ContextMenu";
import Header from "~/components/ContextMenu/Header";
import Template from "~/components/ContextMenu/Template";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import TeamLogo from "~/components/TeamLogo";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
@@ -28,16 +28,7 @@ function NewTemplateMenu() {
});
}, [collections]);
const workspaceItem: MenuItem | null = can.createTemplate
? {
type: "route",
to: newTemplatePath(),
title: t("Save in workspace"),
icon: <TeamLogo model={team} />,
}
: null;
const collectionItems = React.useMemo(
const items = React.useMemo(
() =>
collections.orderedData.reduce<MenuItem[]>((filtered, collection) => {
const can = policies.abilities(collection.id);
@@ -56,28 +47,7 @@ function NewTemplateMenu() {
[collections.orderedData, policies]
);
const collectionItemsWithHeader: MenuItem[] = React.useMemo(
() =>
collectionItems.length
? [
{ type: "heading", title: t("Choose a collection") },
...collectionItems,
]
: [],
[t, collectionItems]
);
const items = workspaceItem
? collectionItemsWithHeader.length
? [
workspaceItem,
{ type: "separator" } as MenuItem,
...collectionItemsWithHeader,
]
: [workspaceItem]
: collectionItemsWithHeader;
if (items.length === 0) {
if (!can.createDocument || items.length === 0) {
return null;
}
@@ -91,6 +61,7 @@ function NewTemplateMenu() {
)}
</MenuButton>
<ContextMenu aria-label={t("New template")} {...menu}>
<Header>{t("Choose a collection")}</Header>
<Template {...menu} items={items} />
</ContextMenu>
</>
+30 -50
View File
@@ -6,11 +6,11 @@ import { MenuButton, useMenuState } from "reakit/Menu";
import Document from "~/models/Document";
import Button from "~/components/Button";
import ContextMenu from "~/components/ContextMenu";
import Template from "~/components/ContextMenu/Template";
import Icon from "~/components/Icon";
import MenuItem from "~/components/ContextMenu/MenuItem";
import Separator from "~/components/ContextMenu/Separator";
import EmojiIcon from "~/components/Icons/EmojiIcon";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import { MenuItem } from "~/types";
import { replaceTitleVariables } from "~/utils/date";
type Props = {
@@ -25,56 +25,32 @@ function TemplatesMenu({ onSelectTemplate, document }: Props) {
const user = useCurrentUser();
const { documents } = useStores();
const { t } = useTranslation();
const templates = documents.templates;
const templateToMenuItem = React.useCallback(
(tmpl: Document): MenuItem => ({
type: "button",
title: replaceTitleVariables(tmpl.titleWithDefault, user),
icon: tmpl.icon ? (
<Icon value={tmpl.icon} color={tmpl.color ?? undefined} />
) : (
<DocumentIcon />
),
onClick: () => onSelectTemplate(tmpl),
}),
[user, onSelectTemplate]
);
const templates = documents.templates.filter((tmpl) => tmpl.publishedAt);
const collectionItems = templates
.filter(
(tmpl) =>
!tmpl.isWorkspaceTemplate && tmpl.collectionId === document.collectionId
)
.map(templateToMenuItem);
const workspaceTemplates = templates
.filter((tmpl) => tmpl.isWorkspaceTemplate)
.map(templateToMenuItem);
const workspaceItems: MenuItem[] = React.useMemo(
() =>
workspaceTemplates.length
? [{ type: "heading", title: t("Workspace") }, ...workspaceTemplates]
: [],
[t, workspaceTemplates]
);
const items = collectionItems
? workspaceItems.length
? [
...collectionItems,
{ type: "separator" } as MenuItem,
...workspaceItems,
]
: collectionItems
: workspaceItems;
if (!items.length) {
if (!templates.length) {
return null;
}
const templatesInCollection = templates.filter(
(t) => t.collectionId === document.collectionId
);
const otherTemplates = templates.filter(
(t) => t.collectionId !== document.collectionId
);
const renderTemplate = (template: Document) => (
<MenuItem
key={template.id}
onClick={() => onSelectTemplate(template)}
icon={
template.emoji ? <EmojiIcon emoji={template.emoji} /> : <DocumentIcon />
}
{...menu}
>
{replaceTitleVariables(template.titleWithDefault, user)}
</MenuItem>
);
return (
<>
<MenuButton {...menu}>
@@ -85,7 +61,11 @@ function TemplatesMenu({ onSelectTemplate, document }: Props) {
)}
</MenuButton>
<ContextMenu {...menu} aria-label={t("Templates")}>
<Template {...menu} items={items} />
{templatesInCollection.map(renderTemplate)}
{otherTemplates.length && templatesInCollection.length ? (
<Separator />
) : undefined}
{otherTemplates.map(renderTemplate)}
</ContextMenu>
</>
);
+1 -26
View File
@@ -1,5 +1,4 @@
import { isPast } from "date-fns";
import { computed, observable } from "mobx";
import { observable } from "mobx";
import Model from "./base/Model";
import Field from "./decorators/Field";
@@ -10,35 +9,11 @@ class ApiKey extends Model {
@observable
id: string;
/**
* The user chosen name of the API key.
*/
@Field
@observable
name: string;
/**
* An optional datetime that the API key expires.
*/
@Field
@observable
expiresAt?: string;
/**
* An optional datetime that the API key was last used at.
*/
@observable
lastActiveAt?: string;
secret: string;
/**
* Whether the API key has an expiry in the past.
*/
@computed
get isExpired() {
return this.expiresAt ? isPast(new Date(this.expiresAt)) : false;
}
}
export default ApiKey;
+3 -3
View File
@@ -40,18 +40,18 @@ export default class Collection extends ParanoidModel {
data: ProsemirrorData;
/**
* An icon (or) emoji to use as the collection icon.
* An emoji to use as the collection icon.
*/
@Field
@observable
icon: string;
/**
* The color to use for the collection icon and other highlights.
* A color to use for the collection icon and other highlights.
*/
@Field
@observable
color?: string | null;
color: string;
/**
* The default permission for workspace users.

Some files were not shown because too many files have changed in this diff Show More