mirror of
https://github.com/outline/outline.git
synced 2026-06-13 19:35:02 +03:00
Compare commits
194 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fe2c0d75ef | |||
| 2e9c451979 | |||
| 3a97361654 | |||
| a8bdc14ca2 | |||
| 563b41e34a | |||
| 0621706a95 | |||
| ec1cb61807 | |||
| 5850181c29 | |||
| d29bc22676 | |||
| a9dd771598 | |||
| 35d185a971 | |||
| d6c85f0aac | |||
| b721287d19 | |||
| 57296e3139 | |||
| 4210f877b4 | |||
| 4db8682423 | |||
| 74229813d2 | |||
| 75e457feee | |||
| 36682574c6 | |||
| c636c4e5df | |||
| c23b5d1ef4 | |||
| 9f8298d012 | |||
| 9c628dfc54 | |||
| 38b11b3f1e | |||
| d7f53acfa2 | |||
| e033b20d6f | |||
| 87aacae479 | |||
| bbe6df19ea | |||
| fd851dfbd1 | |||
| 1426c4e6ab | |||
| 10b33ff91f | |||
| 15f58d0e15 | |||
| 6f5859a175 | |||
| 52776d5add | |||
| 474baf7b42 | |||
| 516ccce51d | |||
| 16c51c10fe | |||
| d312d00bca | |||
| e4c1281bf7 | |||
| bbbc00baaa | |||
| 78d5992ad4 | |||
| f58ce49e3d | |||
| f0ba98e936 | |||
| ff34a46361 | |||
| 7849af6887 | |||
| 95a87878c3 | |||
| 011ffc450c | |||
| 51c5512902 | |||
| 76d64f90c1 | |||
| f48c05bef3 | |||
| f675a04735 | |||
| e52719c38e | |||
| cd854d4adb | |||
| 7bb7f96008 | |||
| 6d70b4b9c7 | |||
| 78fd39a0fb | |||
| 8186e23d45 | |||
| e4492a32d2 | |||
| cb4f610bb4 | |||
| 14a96e7262 | |||
| e2213dbfa2 | |||
| e3d7fba239 | |||
| 33a43077d5 | |||
| 4ced6c3b7f | |||
| 06a40e8b7c | |||
| 6a6c069e4e | |||
| cf3ef2a839 | |||
| a49f89bda6 | |||
| 82b2defac1 | |||
| c05a8cf73a | |||
| 7f5e2cacd8 | |||
| d8e97e0c1f | |||
| 74bcf152f0 | |||
| 57431720b2 | |||
| b8c6cc603b | |||
| 71c48bcd47 | |||
| 4ad1148aa7 | |||
| 67c0335099 | |||
| c600148f4b | |||
| 299e49625b | |||
| 82d84e4859 | |||
| e7d3dac36c | |||
| 0d0932a6f6 | |||
| 37e68413f0 | |||
| 01bbc48c01 | |||
| f02bbc3942 | |||
| 9419e65837 | |||
| 28cfeba99a | |||
| 2c138687c7 | |||
| f38c948573 | |||
| 1f5ccc3055 | |||
| 58008d84c4 | |||
| ea678e40cc | |||
| 53caddd930 | |||
| 6ab182433b | |||
| f01a45a80a | |||
| 44366e1462 | |||
| b3a24e4917 | |||
| 43cf33fc0a | |||
| 07b6441655 | |||
| 49198aafe9 | |||
| ddd103542a | |||
| 4654dfb658 | |||
| bdcde1aa53 | |||
| c484d1defe | |||
| 1efd3b6f96 | |||
| e07be1ee5e | |||
| 0067d1a58d | |||
| 17451c180a | |||
| 4e989e5c44 | |||
| 335957d914 | |||
| 1711d17e25 | |||
| de90f879f1 | |||
| 303125b682 | |||
| f33026f7b3 | |||
| 06b5efd18a | |||
| c8e67b969d | |||
| b84851a4c3 | |||
| c6408f7b3f | |||
| 18f729b970 | |||
| 2f9a7f9a21 | |||
| c5b94e50df | |||
| 8a8dad15ef | |||
| a8d4a5b587 | |||
| a67c35257e | |||
| f9dadf5548 | |||
| 117c4f5009 | |||
| f34557337d | |||
| 9b25e623b4 | |||
| fe41824ef2 | |||
| e9755faf9a | |||
| 9dcb04b58a | |||
| d8e571d82d | |||
| 3f4027c6fa | |||
| e507f09ff9 | |||
| 63ddc31710 | |||
| 5aa5ba0aa1 | |||
| ed496bdf60 | |||
| 7201bdb9d8 | |||
| 53e3245b15 | |||
| 2c666ddde2 | |||
| 6bb2953e8d | |||
| 6a1a3eee91 | |||
| d03c7b33d3 | |||
| 355bc33f7c | |||
| bf2378ec81 | |||
| 5c999f5327 | |||
| 29a653aaeb | |||
| beabd32e6a | |||
| 77d6797d85 | |||
| fbd8f5981b | |||
| 8336207c23 | |||
| 3054f34a90 | |||
| 07a805696d | |||
| 142493ddcc | |||
| bd18b33b9d | |||
| 03373804fa | |||
| daba308440 | |||
| 1451f70b9e | |||
| 6bb8a3d935 | |||
| 95c768f444 | |||
| 2cad16d6b3 | |||
| 36a2a4709c | |||
| 6fd3a0fa8a | |||
| 56d90e6bc3 | |||
| eaab97dcbf | |||
| d8f14377f8 | |||
| 0f1f0e82c2 | |||
| 5ddc36555d | |||
| f17ce9d50b | |||
| 9e5d5c0347 | |||
| 4897f001e4 | |||
| b6d178943a | |||
| 1bf9012992 | |||
| a19fb25bea | |||
| 95b9453269 | |||
| 2333602f25 | |||
| a825925a31 | |||
| 711b8acebc | |||
| d7ee63217b | |||
| 6dae1c2a5c | |||
| 3af9861c4a | |||
| c04bedef4c | |||
| aad709eca4 | |||
| 044f5256db | |||
| 5a6bb85f65 | |||
| ef45788a0b | |||
| 4b4e593e83 | |||
| 15a9bd225f | |||
| 77579bb4f1 | |||
| 92301791f6 | |||
| 9b542c451b | |||
| 3edaf4f8ea | |||
| 1290aecbc9 |
@@ -12,7 +12,7 @@
|
||||
"legacy": true
|
||||
}
|
||||
],
|
||||
"@babel/plugin-proposal-class-properties",
|
||||
"@babel/plugin-transform-class-properties",
|
||||
[
|
||||
"transform-inline-environment-variables",
|
||||
{
|
||||
@@ -60,4 +60,4 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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>/api/discord.callback
|
||||
# https://<URL>/auth/discord.callback
|
||||
DISCORD_CLIENT_ID=
|
||||
DISCORD_CLIENT_SECRET=
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
"@typescript-eslint/no-shadow": [
|
||||
"warn",
|
||||
{
|
||||
"allow": ["transaction"],
|
||||
"hoist": "all",
|
||||
"ignoreTypeValueShadow": true
|
||||
}
|
||||
@@ -139,4 +140,4 @@
|
||||
"typescript": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: 14
|
||||
daysUntilClose: 7
|
||||
|
||||
# Label requiring a response
|
||||
responseRequiredLabel: more information needed
|
||||
|
||||
+3
-3
@@ -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"
|
||||
|
||||
@@ -6,6 +6,10 @@ 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
|
||||
|
||||
@@ -17,3 +21,6 @@ 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
|
||||
|
||||
@@ -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} />,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
PadlockIcon,
|
||||
PlusIcon,
|
||||
SearchIcon,
|
||||
ShapesIcon,
|
||||
StarredIcon,
|
||||
TrashIcon,
|
||||
UnstarredIcon,
|
||||
@@ -21,7 +22,7 @@ import { createAction } from "~/actions";
|
||||
import { CollectionSection } from "~/actions/sections";
|
||||
import { setPersistedState } from "~/hooks/usePersistedState";
|
||||
import history from "~/utils/history";
|
||||
import { searchPath } from "~/utils/routeHelpers";
|
||||
import { newTemplatePath, searchPath } from "~/utils/routeHelpers";
|
||||
|
||||
const ColorCollectionIcon = ({ collection }: { collection: Collection }) => (
|
||||
<DynamicCollectionIcon collection={collection} />
|
||||
@@ -111,6 +112,7 @@ export const editCollectionPermissions = createAction({
|
||||
|
||||
stores.dialogs.openModal({
|
||||
title: t("Share this collection"),
|
||||
style: { marginBottom: -12 },
|
||||
content: (
|
||||
<SharePopover
|
||||
collection={collection}
|
||||
@@ -127,7 +129,9 @@ export const searchInCollection = createAction({
|
||||
analyticsName: "Search collection",
|
||||
section: CollectionSection,
|
||||
icon: <SearchIcon />,
|
||||
visible: ({ activeCollectionId }) => !!activeCollectionId,
|
||||
visible: ({ activeCollectionId }) =>
|
||||
!!activeCollectionId &&
|
||||
stores.policies.abilities(activeCollectionId).readDocument,
|
||||
perform: ({ activeCollectionId }) => {
|
||||
history.push(searchPath(undefined, { collectionId: activeCollectionId }));
|
||||
},
|
||||
@@ -220,6 +224,27 @@ 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,
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
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();
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
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} />,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -51,11 +51,11 @@ import {
|
||||
documentHistoryPath,
|
||||
homePath,
|
||||
newDocumentPath,
|
||||
newNestedDocumentPath,
|
||||
searchPath,
|
||||
documentPath,
|
||||
urlify,
|
||||
trashPath,
|
||||
newTemplatePath,
|
||||
} from "~/utils/routeHelpers";
|
||||
|
||||
export const openDocument = createAction({
|
||||
@@ -141,15 +141,10 @@ export const createNestedDocument = createAction({
|
||||
!!activeDocumentId &&
|
||||
stores.policies.abilities(currentTeamId).createDocument &&
|
||||
stores.policies.abilities(activeDocumentId).createChildDocument,
|
||||
perform: ({ activeCollectionId, activeDocumentId, inStarredSection }) =>
|
||||
history.push(
|
||||
newDocumentPath(activeCollectionId, {
|
||||
parentDocumentId: activeDocumentId,
|
||||
}),
|
||||
{
|
||||
starred: inStarredSection,
|
||||
}
|
||||
),
|
||||
perform: ({ activeDocumentId, inStarredSection }) =>
|
||||
history.push(newNestedDocumentPath(activeDocumentId), {
|
||||
starred: inStarredSection,
|
||||
}),
|
||||
});
|
||||
|
||||
export const starDocument = createAction({
|
||||
@@ -353,6 +348,7 @@ export const shareDocument = createAction({
|
||||
}
|
||||
|
||||
stores.dialogs.openModal({
|
||||
style: { marginBottom: -12 },
|
||||
title: t("Share this document"),
|
||||
content: (
|
||||
<SharePopover
|
||||
@@ -677,37 +673,34 @@ export const importDocument = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const createTemplate = createAction({
|
||||
name: ({ t, activeDocumentId }) =>
|
||||
activeDocumentId ? t("Templatize") : t("New template"),
|
||||
export const createTemplateFromDocument = createAction({
|
||||
name: ({ t }) => t("Templatize"),
|
||||
analyticsName: "Templatize document",
|
||||
section: DocumentSection,
|
||||
icon: <ShapesIcon />,
|
||||
keywords: "new create template",
|
||||
visible: ({ activeCollectionId, activeDocumentId, stores }) => {
|
||||
if (activeDocumentId) {
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
if (document?.isTemplate || !document?.isActive) {
|
||||
return false;
|
||||
}
|
||||
const document = activeDocumentId
|
||||
? stores.documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
if (document?.isTemplate || !document?.isActive) {
|
||||
return false;
|
||||
}
|
||||
return !!(
|
||||
!!activeCollectionId &&
|
||||
stores.policies.abilities(activeCollectionId).update
|
||||
);
|
||||
},
|
||||
perform: ({ activeCollectionId, activeDocumentId, stores, t, event }) => {
|
||||
perform: ({ activeDocumentId, stores, t, event }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
|
||||
if (activeDocumentId) {
|
||||
stores.dialogs.openModal({
|
||||
title: t("Create template"),
|
||||
content: <DocumentTemplatizeDialog documentId={activeDocumentId} />,
|
||||
});
|
||||
} else if (activeCollectionId) {
|
||||
history.push(newTemplatePath(activeCollectionId));
|
||||
}
|
||||
stores.dialogs.openModal({
|
||||
title: t("Create template"),
|
||||
content: <DocumentTemplatizeDialog documentId={activeDocumentId} />,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -991,7 +984,7 @@ export const rootDocumentActions = [
|
||||
openDocument,
|
||||
archiveDocument,
|
||||
createDocument,
|
||||
createTemplate,
|
||||
createTemplateFromDocument,
|
||||
deleteDocument,
|
||||
importDocument,
|
||||
downloadDocument,
|
||||
|
||||
@@ -5,10 +5,11 @@ 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, ...rest }: Props,
|
||||
{ children, onEscape, items, ...rest }: Props,
|
||||
ref: React.RefObject<HTMLDivElement>
|
||||
) {
|
||||
const handleKeyDown = React.useCallback(
|
||||
@@ -36,7 +37,10 @@ function ArrowKeyNavigation(
|
||||
);
|
||||
|
||||
return (
|
||||
<RovingTabIndexProvider options={{ focusOnClick: true, direction: "both" }}>
|
||||
<RovingTabIndexProvider
|
||||
options={{ focusOnClick: true, direction: "both" }}
|
||||
items={items}
|
||||
>
|
||||
<div {...rest} onKeyDown={handleKeyDown} ref={ref}>
|
||||
{children()}
|
||||
</div>
|
||||
|
||||
@@ -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: 4px;
|
||||
border-radius: 6px;
|
||||
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.5, props.theme.accentText)};
|
||||
background: ${(props) => lighten(0.2, props.theme.accent)};
|
||||
color: ${(props) => transparentize(0.3, props.theme.accentText)};
|
||||
background: ${(props) => transparentize(0.1, props.theme.accent)};
|
||||
|
||||
svg {
|
||||
fill: ${(props) => props.theme.white50};
|
||||
|
||||
@@ -11,19 +11,22 @@ import { CollectionValidation } from "@shared/validations";
|
||||
import Collection from "~/models/Collection";
|
||||
import Button from "~/components/Button";
|
||||
import Flex from "~/components/Flex";
|
||||
import IconPicker from "~/components/IconPicker";
|
||||
import Icon from "~/components/Icon";
|
||||
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;
|
||||
color: string | null;
|
||||
sharing: boolean;
|
||||
permission: CollectionPermission | undefined;
|
||||
}
|
||||
@@ -37,7 +40,16 @@ 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,
|
||||
@@ -53,7 +65,7 @@ export const CollectionForm = observer(function CollectionForm_({
|
||||
icon: collection?.icon,
|
||||
sharing: collection?.sharing ?? true,
|
||||
permission: collection?.permission,
|
||||
color: collection?.color ?? randomElement(colorPalette),
|
||||
color: iconColor,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -70,20 +82,20 @@ export const CollectionForm = observer(function CollectionForm_({
|
||||
"collection"
|
||||
);
|
||||
}
|
||||
}, [values.name, collection]);
|
||||
}, [collection, hasOpenedIconPicker, setValue, values.name, values.icon]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setTimeout(() => setFocus("name", { shouldSelect: true }), 100);
|
||||
}, [setFocus]);
|
||||
|
||||
const handleIconPickerChange = React.useCallback(
|
||||
(color: string, icon: string) => {
|
||||
const handleIconChange = React.useCallback(
|
||||
(icon: string, color: string | null) => {
|
||||
if (icon !== values.icon) {
|
||||
setFocus("name");
|
||||
}
|
||||
|
||||
setValue("color", color);
|
||||
setValue("icon", icon);
|
||||
setValue("color", color);
|
||||
},
|
||||
[setFocus, setValue, values.icon]
|
||||
);
|
||||
@@ -105,13 +117,16 @@ export const CollectionForm = observer(function CollectionForm_({
|
||||
maxLength: CollectionValidation.maxNameLength,
|
||||
})}
|
||||
prefix={
|
||||
<StyledIconPicker
|
||||
onOpen={setHasOpenedIconPicker}
|
||||
onChange={handleIconPickerChange}
|
||||
initial={values.name[0]}
|
||||
color={values.color}
|
||||
icon={values.icon}
|
||||
/>
|
||||
<React.Suspense fallback={fallbackIcon}>
|
||||
<StyledIconPicker
|
||||
icon={values.icon}
|
||||
color={values.color ?? iconColor}
|
||||
initial={values.name[0]}
|
||||
popoverPosition="right"
|
||||
onOpen={setHasOpenedIconPicker}
|
||||
onChange={handleIconChange}
|
||||
/>
|
||||
</React.Suspense>
|
||||
}
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
@@ -128,8 +143,10 @@ export const CollectionForm = observer(function CollectionForm_({
|
||||
<InputSelectPermission
|
||||
ref={field.ref}
|
||||
value={field.value}
|
||||
onChange={(value: CollectionPermission) => {
|
||||
field.onChange(value);
|
||||
onChange={(
|
||||
value: CollectionPermission | typeof EmptySelectValue
|
||||
) => {
|
||||
field.onChange(value === EmptySelectValue ? null : value);
|
||||
}}
|
||||
note={t(
|
||||
"The default access for workspace members, you can share with more users or groups later."
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { LocationDescriptor } from "history";
|
||||
import { CheckmarkIcon } from "outline-icons";
|
||||
import { ellipsis } from "polished";
|
||||
import { ellipsis, transparentize } 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.SyntheticEvent) => void | Promise<void>;
|
||||
onClick?: (event: React.MouseEvent) => void | Promise<void>;
|
||||
active?: boolean;
|
||||
selected?: boolean;
|
||||
disabled?: boolean;
|
||||
@@ -21,7 +22,7 @@ type Props = {
|
||||
as?: string | React.ComponentType<any>;
|
||||
hide?: () => void;
|
||||
level?: number;
|
||||
icon?: React.ReactElement;
|
||||
icon?: React.ReactElement | null;
|
||||
children?: React.ReactNode;
|
||||
ref?: React.LegacyRef<HTMLButtonElement> | undefined;
|
||||
};
|
||||
@@ -42,30 +43,31 @@ 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) {
|
||||
ev.preventDefault();
|
||||
preventDefault(ev);
|
||||
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}
|
||||
onMouseDown={handleMouseDown}
|
||||
onPointerDown={preventDefault}
|
||||
onMouseDown={preventDefault}
|
||||
ref={mergeRefs([
|
||||
ref,
|
||||
props.ref as React.RefObject<HTMLAnchorElement>,
|
||||
@@ -160,6 +162,10 @@ export const MenuAnchorCSS = css<MenuAnchorProps>`
|
||||
color: ${props.theme.accentText};
|
||||
fill: ${props.theme.accentText};
|
||||
}
|
||||
|
||||
${Text} {
|
||||
color: ${transparentize(0.5, props.theme.accentText)};
|
||||
}
|
||||
}
|
||||
}
|
||||
`}
|
||||
|
||||
@@ -30,6 +30,7 @@ type Props = Omit<MenuStateReturn, "items"> & {
|
||||
actions?: (Action | MenuSeparator | MenuHeading)[];
|
||||
context?: Partial<ActionContext>;
|
||||
items?: TMenuItem[];
|
||||
showIcons?: boolean;
|
||||
};
|
||||
|
||||
const Disclosure = styled(ExpandedIcon)`
|
||||
@@ -98,7 +99,7 @@ export function filterTemplateItems(items: TMenuItem[]): TMenuItem[] {
|
||||
});
|
||||
}
|
||||
|
||||
function Template({ items, actions, context, ...menu }: Props) {
|
||||
function Template({ items, actions, context, showIcons, ...menu }: Props) {
|
||||
const ctx = useActionContext({
|
||||
isContextMenu: true,
|
||||
});
|
||||
@@ -124,7 +125,8 @@ function Template({ items, actions, context, ...menu }: Props) {
|
||||
if (
|
||||
iconIsPresentInAnyMenuItem &&
|
||||
item.type !== "separator" &&
|
||||
item.type !== "heading"
|
||||
item.type !== "heading" &&
|
||||
showIcons !== false
|
||||
) {
|
||||
item.icon = item.icon || <MenuIconWrapper aria-hidden />;
|
||||
}
|
||||
@@ -138,7 +140,7 @@ function Template({ items, actions, context, ...menu }: Props) {
|
||||
key={index}
|
||||
disabled={item.disabled}
|
||||
selected={item.selected}
|
||||
icon={item.icon}
|
||||
icon={showIcons !== false ? item.icon : undefined}
|
||||
{...menu}
|
||||
>
|
||||
{item.title}
|
||||
@@ -156,7 +158,7 @@ function Template({ items, actions, context, ...menu }: Props) {
|
||||
selected={item.selected}
|
||||
level={item.level}
|
||||
target={item.href.startsWith("#") ? undefined : "_blank"}
|
||||
icon={item.icon}
|
||||
icon={showIcons !== false ? item.icon : undefined}
|
||||
{...menu}
|
||||
>
|
||||
{item.title}
|
||||
@@ -174,7 +176,7 @@ function Template({ items, actions, context, ...menu }: Props) {
|
||||
selected={item.selected}
|
||||
dangerous={item.dangerous}
|
||||
key={index}
|
||||
icon={item.icon}
|
||||
icon={showIcons !== false ? item.icon : undefined}
|
||||
{...menu}
|
||||
>
|
||||
{item.title}
|
||||
@@ -190,7 +192,12 @@ function Template({ items, actions, context, ...menu }: Props) {
|
||||
id={`${item.title}-${index}`}
|
||||
templateItems={item.items}
|
||||
parentMenuState={menu}
|
||||
title={<Title title={item.title} icon={item.icon} />}
|
||||
title={
|
||||
<Title
|
||||
title={item.title}
|
||||
icon={showIcons !== false ? item.icon : undefined}
|
||||
/>
|
||||
}
|
||||
{...menu}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
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}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
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;
|
||||
`;
|
||||
@@ -0,0 +1,30 @@
|
||||
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} />;
|
||||
});
|
||||
@@ -25,6 +25,7 @@ function Dialogs() {
|
||||
fullscreen={modal.fullscreen ?? false}
|
||||
onRequestClose={() => dialogs.closeModal(id)}
|
||||
title={modal.title}
|
||||
style={modal.style}
|
||||
>
|
||||
{modal.content}
|
||||
</Modal>
|
||||
|
||||
@@ -6,6 +6,7 @@ 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";
|
||||
@@ -15,7 +16,6 @@ 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.emoji ? (
|
||||
title: node.icon ? (
|
||||
<>
|
||||
<EmojiIcon emoji={node.emoji} /> {node.title}
|
||||
<StyledIcon value={node.icon} color={node.color} /> {node.title}
|
||||
</>
|
||||
) : (
|
||||
node.title
|
||||
@@ -144,6 +144,10 @@ const DocumentBreadcrumb: React.FC<Props> = ({
|
||||
);
|
||||
};
|
||||
|
||||
const StyledIcon = styled(Icon)`
|
||||
margin-right: 2px;
|
||||
`;
|
||||
|
||||
const SmallSlash = styled(GoToIcon)`
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
|
||||
@@ -9,15 +9,17 @@ 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";
|
||||
|
||||
@@ -52,6 +54,8 @@ function DocumentCard(props: Props) {
|
||||
disabled: !isDraggable || !canUpdatePin,
|
||||
});
|
||||
|
||||
const hasEmojiInTitle = determineIconType(document.icon) === IconType.Emoji;
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
@@ -109,12 +113,18 @@ 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.emoji ? (
|
||||
<Squircle color={theme.slateLight}>
|
||||
<EmojiIcon emoji={document.emoji} size={24} />
|
||||
</Squircle>
|
||||
{document.icon ? (
|
||||
<DocumentSquircle
|
||||
icon={document.icon}
|
||||
color={document.color ?? undefined}
|
||||
/>
|
||||
) : (
|
||||
<Squircle color={collection?.color}>
|
||||
<Squircle
|
||||
color={
|
||||
collection?.color ??
|
||||
(!pin?.collectionId ? theme.slateLight : theme.slateDark)
|
||||
}
|
||||
>
|
||||
{collection?.icon &&
|
||||
collection?.icon !== "letter" &&
|
||||
collection?.icon !== "collection" &&
|
||||
@@ -127,8 +137,8 @@ function DocumentCard(props: Props) {
|
||||
)}
|
||||
<div>
|
||||
<Heading dir={document.dir}>
|
||||
{document.emoji
|
||||
? document.titleWithDefault.replace(document.emoji, "")
|
||||
{hasEmojiInTitle
|
||||
? document.titleWithDefault.replace(document.icon!, "")
|
||||
: document.titleWithDefault}
|
||||
</Heading>
|
||||
<DocumentMeta size="xsmall">
|
||||
@@ -159,6 +169,24 @@ 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;
|
||||
`;
|
||||
|
||||
@@ -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,25 +216,30 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
|
||||
}) => {
|
||||
const node = data[index];
|
||||
const isCollection = node.type === "collection";
|
||||
let icon, title: string, emoji: string | undefined, path;
|
||||
let renderedIcon,
|
||||
title: string,
|
||||
icon: string | undefined,
|
||||
color: string | undefined,
|
||||
path;
|
||||
|
||||
if (isCollection) {
|
||||
const col = collections.get(node.collectionId as string);
|
||||
icon = col && (
|
||||
renderedIcon = col && (
|
||||
<CollectionIcon collection={col} expanded={isExpanded(index)} />
|
||||
);
|
||||
title = node.title;
|
||||
} else {
|
||||
const doc = documents.get(node.id);
|
||||
emoji = doc?.emoji ?? node.emoji;
|
||||
icon = doc?.icon ?? node.icon ?? node.emoji;
|
||||
color = doc?.color ?? node.color;
|
||||
title = doc?.title ?? node.title;
|
||||
|
||||
if (emoji) {
|
||||
icon = <EmojiIcon emoji={emoji} />;
|
||||
if (icon) {
|
||||
renderedIcon = <Icon value={icon} color={color} />;
|
||||
} else if (doc?.isStarred) {
|
||||
icon = <StarredIcon color={theme.yellow} />;
|
||||
renderedIcon = <StarredIcon color={theme.yellow} />;
|
||||
} else {
|
||||
icon = <DocumentIcon color={theme.textSecondary} />;
|
||||
renderedIcon = <DocumentIcon color={theme.textSecondary} />;
|
||||
}
|
||||
|
||||
path = ancestors(node)
|
||||
@@ -254,7 +259,7 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
|
||||
}}
|
||||
onPointerMove={() => setActiveNode(index)}
|
||||
onClick={() => toggleSelect(index)}
|
||||
icon={icon}
|
||||
icon={renderedIcon}
|
||||
title={title}
|
||||
path={path}
|
||||
/>
|
||||
@@ -275,7 +280,7 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
|
||||
selected={isSelected(index)}
|
||||
active={activeNode === index}
|
||||
expanded={isExpanded(index)}
|
||||
icon={icon}
|
||||
icon={renderedIcon}
|
||||
title={title}
|
||||
depth={node.depth as number}
|
||||
hasChildren={hasChildren(index)}
|
||||
|
||||
@@ -120,6 +120,7 @@ export const Node = styled.span<{
|
||||
color: ${props.theme.white};
|
||||
|
||||
svg {
|
||||
color: ${props.theme.white};
|
||||
fill: ${props.theme.white};
|
||||
}
|
||||
`}
|
||||
|
||||
@@ -15,6 +15,7 @@ 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";
|
||||
@@ -23,7 +24,6 @@ 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;
|
||||
@@ -97,9 +97,9 @@ function DocumentListItem(
|
||||
>
|
||||
<Content>
|
||||
<Heading dir={document.dir}>
|
||||
{document.emoji && (
|
||||
{document.icon && (
|
||||
<>
|
||||
<EmojiIcon emoji={document.emoji} size={24} />
|
||||
<Icon value={document.icon} color={document.color ?? undefined} />
|
||||
|
||||
</>
|
||||
)}
|
||||
@@ -275,6 +275,8 @@ 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));
|
||||
|
||||
@@ -128,15 +128,6 @@ 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}>
|
||||
|
||||
@@ -28,6 +28,7 @@ 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"));
|
||||
|
||||
@@ -89,6 +90,12 @@ 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) {
|
||||
@@ -107,6 +114,9 @@ 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)
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
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` : "")}
|
||||
`;
|
||||
@@ -1,262 +0,0 @@
|
||||
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;
|
||||
@@ -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?: CollectionGroupMembership;
|
||||
membership?: GroupMembership;
|
||||
showFacepile?: boolean;
|
||||
showAvatar?: boolean;
|
||||
renderActions: (params: { openMembersModal: () => void }) => React.ReactNode;
|
||||
};
|
||||
|
||||
function GroupListItem({ group, showFacepile, renderActions }: Props) {
|
||||
const { groupMemberships } = useStores();
|
||||
const { groupUsers } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const [membersModalOpen, setMembersModalOpen, setMembersModalClosed] =
|
||||
useBoolean();
|
||||
const memberCount = group.memberCount;
|
||||
const membershipsInGroup = groupMemberships.inGroup(group.id);
|
||||
const membershipsInGroup = groupUsers.inGroup(group.id);
|
||||
const users = membershipsInGroup
|
||||
.slice(0, MAX_AVATAR_DISPLAY)
|
||||
.map((gm) => gm.user);
|
||||
|
||||
@@ -2,6 +2,7 @@ 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;
|
||||
@@ -43,7 +44,7 @@ function Highlight({
|
||||
}
|
||||
|
||||
export const Mark = styled.mark`
|
||||
color: inherit;
|
||||
color: ${s("text")};
|
||||
background: transparent;
|
||||
font-weight: 600;
|
||||
`;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
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,
|
||||
@@ -21,20 +23,23 @@ const HoverPreviewDocument = React.forwardRef(function _HoverPreviewDocument(
|
||||
<Preview to={url}>
|
||||
<Card ref={ref}>
|
||||
<CardContent>
|
||||
<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>
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Preview>
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
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;
|
||||
@@ -1,211 +0,0 @@
|
||||
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;
|
||||
@@ -0,0 +1,218 @@
|
||||
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;
|
||||
@@ -0,0 +1,8 @@
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
|
||||
export const Emoji = styled.span`
|
||||
font-family: ${s("fontFamilyEmoji")};
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
`;
|
||||
@@ -0,0 +1,245 @@
|
||||
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;
|
||||
@@ -0,0 +1,61 @@
|
||||
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);
|
||||
@@ -0,0 +1,120 @@
|
||||
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);
|
||||
@@ -0,0 +1,15 @@
|
||||
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")};
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,200 @@
|
||||
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;
|
||||
@@ -0,0 +1,20 @@
|
||||
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;
|
||||
`};
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,92 @@
|
||||
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;
|
||||
@@ -0,0 +1,312 @@
|
||||
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;
|
||||
@@ -0,0 +1,50 @@
|
||||
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));
|
||||
@@ -2,10 +2,10 @@ import { observer } from "mobx-react";
|
||||
import { CollectionIcon } from "outline-icons";
|
||||
import { getLuminance } from "polished";
|
||||
import * as React from "react";
|
||||
import { IconLibrary } from "@shared/utils/IconLibrary";
|
||||
import { colorPalette } from "@shared/utils/collections";
|
||||
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,6 +16,7 @@ type Props = {
|
||||
size?: number;
|
||||
/** The color of the icon, defaults to the collection color */
|
||||
color?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function ResolvedCollectionIcon({
|
||||
@@ -23,35 +24,41 @@ function ResolvedCollectionIcon({
|
||||
color: inputColor,
|
||||
expanded,
|
||||
size,
|
||||
className,
|
||||
}: Props) {
|
||||
const { ui } = useStores();
|
||||
|
||||
// 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);
|
||||
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 (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 (
|
||||
<CollectionIcon
|
||||
color={color}
|
||||
expanded={expanded}
|
||||
size={size}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <CollectionIcon color={color} expanded={expanded} size={size} />;
|
||||
return (
|
||||
<Icon
|
||||
value={collection.icon}
|
||||
color={inputColor ?? collection.color ?? undefined}
|
||||
size={size}
|
||||
initial={collection.initial}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(ResolvedCollectionIcon);
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
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;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -15,19 +17,28 @@ type Props = {
|
||||
export default function EmojiIcon({ size = 24, emoji, ...rest }: Props) {
|
||||
return (
|
||||
<Span $size={size} {...rest}>
|
||||
{emoji}
|
||||
<SVG size={size} emoji={emoji} />
|
||||
</Span>
|
||||
);
|
||||
}
|
||||
|
||||
const Span = styled.span<{ $size: number }>`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
font-family: ${s("fontFamilyEmoji")};
|
||||
display: inline-block;
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -143,8 +143,12 @@ export interface Props
|
||||
onRequestSubmit?: (
|
||||
ev: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>
|
||||
) => unknown;
|
||||
onFocus?: (ev: React.SyntheticEvent) => unknown;
|
||||
onBlur?: (ev: React.SyntheticEvent) => unknown;
|
||||
onFocus?: (
|
||||
ev: React.SyntheticEvent<HTMLInputElement | HTMLTextAreaElement>
|
||||
) => unknown;
|
||||
onBlur?: (
|
||||
ev: React.SyntheticEvent<HTMLInputElement | HTMLTextAreaElement>
|
||||
) => unknown;
|
||||
}
|
||||
|
||||
function Input(
|
||||
@@ -154,7 +158,9 @@ function Input(
|
||||
const internalRef = React.useRef<HTMLInputElement | HTMLTextAreaElement>();
|
||||
const [focused, setFocused] = React.useState(false);
|
||||
|
||||
const handleBlur = (ev: React.SyntheticEvent) => {
|
||||
const handleBlur = (
|
||||
ev: React.SyntheticEvent<HTMLInputElement | HTMLTextAreaElement>
|
||||
) => {
|
||||
setFocused(false);
|
||||
|
||||
if (props.onBlur) {
|
||||
@@ -162,7 +168,9 @@ function Input(
|
||||
}
|
||||
};
|
||||
|
||||
const handleFocus = (ev: React.SyntheticEvent) => {
|
||||
const handleFocus = (
|
||||
ev: React.SyntheticEvent<HTMLInputElement | HTMLTextAreaElement>
|
||||
) => {
|
||||
setFocused(true);
|
||||
|
||||
if (props.onFocus) {
|
||||
|
||||
@@ -11,13 +11,21 @@ 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;
|
||||
};
|
||||
|
||||
|
||||
@@ -50,6 +50,13 @@ 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 {
|
||||
@@ -79,6 +86,9 @@ const InputSelect = (props: Props, ref: React.RefObject<InputSelectRef>) => {
|
||||
note,
|
||||
icon,
|
||||
nude,
|
||||
skipBodyScroll,
|
||||
autoFocus,
|
||||
placeholder,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
@@ -91,7 +101,7 @@ const InputSelect = (props: Props, ref: React.RefObject<InputSelectRef>) => {
|
||||
const popover = useSelectPopover({
|
||||
...select,
|
||||
hideOnClickOutside: false,
|
||||
preventBodyScroll: true,
|
||||
preventBodyScroll: skipBodyScroll ? false : true,
|
||||
disabled,
|
||||
});
|
||||
|
||||
@@ -208,6 +218,7 @@ const InputSelect = (props: Props, ref: React.RefObject<InputSelectRef>) => {
|
||||
neutral
|
||||
disclosure
|
||||
className={className}
|
||||
autoFocus={autoFocus}
|
||||
icon={icon}
|
||||
$nude={nude}
|
||||
{...buttonProps}
|
||||
@@ -215,12 +226,19 @@ const InputSelect = (props: Props, ref: React.RefObject<InputSelectRef>) => {
|
||||
{option ? (
|
||||
labelForOption(option)
|
||||
) : (
|
||||
<Placeholder>Select a {ariaLabel.toLowerCase()}</Placeholder>
|
||||
<Placeholder>
|
||||
{placeholder ?? `Select a ${ariaLabel.toLowerCase()}`}
|
||||
</Placeholder>
|
||||
)}
|
||||
</StyledButton>
|
||||
)}
|
||||
</Select>
|
||||
<SelectPopover {...select} {...popover} aria-label={ariaLabel}>
|
||||
<SelectPopover
|
||||
{...select}
|
||||
{...popover}
|
||||
aria-label={ariaLabel}
|
||||
preventBodyScroll={skipBodyScroll ? false : true}
|
||||
>
|
||||
{(popoverProps: InnerProps) => {
|
||||
const topAnchor = popoverProps.style?.top === "0";
|
||||
const rightAnchor = popoverProps.placement === "bottom-end";
|
||||
|
||||
@@ -4,10 +4,12 @@ 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 */
|
||||
@@ -16,6 +18,8 @@ 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 */
|
||||
@@ -28,26 +32,49 @@ 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, ...rest }: Props,
|
||||
ref?: React.Ref<HTMLAnchorElement>
|
||||
{
|
||||
image,
|
||||
title,
|
||||
subtitle,
|
||||
actions,
|
||||
small,
|
||||
border,
|
||||
to,
|
||||
keyboardNavigation,
|
||||
...rest
|
||||
}: Props,
|
||||
ref: React.RefObject<HTMLAnchorElement>
|
||||
) => {
|
||||
const theme = useTheme();
|
||||
const compact = !subtitle;
|
||||
|
||||
let itemRef: React.Ref<HTMLAnchorElement> =
|
||||
let itemRef: React.RefObject<HTMLAnchorElement> =
|
||||
React.useRef<HTMLAnchorElement>(null);
|
||||
if (ref) {
|
||||
itemRef = ref;
|
||||
}
|
||||
|
||||
const { focused, ...rovingTabIndex } = useRovingTabIndex(
|
||||
itemRef as React.RefObject<HTMLAnchorElement>,
|
||||
to ? false : true
|
||||
itemRef,
|
||||
keyboardNavigation || to ? false : true
|
||||
);
|
||||
useFocusEffect(focused, itemRef as React.RefObject<HTMLAnchorElement>);
|
||||
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]);
|
||||
|
||||
const content = (selected: boolean) => (
|
||||
<>
|
||||
@@ -89,6 +116,16 @@ 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}
|
||||
>
|
||||
@@ -98,7 +135,26 @@ const ListItem = (
|
||||
}
|
||||
|
||||
return (
|
||||
<Wrapper ref={itemRef} $border={border} $small={small} {...rest}>
|
||||
<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();
|
||||
}}
|
||||
>
|
||||
{content(false)}
|
||||
</Wrapper>
|
||||
);
|
||||
@@ -107,6 +163,7 @@ const ListItem = (
|
||||
const Wrapper = styled.a<{
|
||||
$small?: boolean;
|
||||
$border?: boolean;
|
||||
$hover?: boolean;
|
||||
onClick?: React.MouseEventHandler<HTMLAnchorElement>;
|
||||
to?: LocationDescriptor;
|
||||
}>`
|
||||
@@ -123,9 +180,15 @@ const Wrapper = styled.a<{
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:${hover},
|
||||
&:focus,
|
||||
&:focus-within {
|
||||
background: ${(props) =>
|
||||
props.onClick ? props.theme.secondaryBackground : "inherit"};
|
||||
props.$hover ? props.theme.secondaryBackground : "inherit"};
|
||||
}
|
||||
|
||||
cursor: ${(props) =>
|
||||
|
||||
@@ -25,6 +25,7 @@ type Props = {
|
||||
isOpen: boolean;
|
||||
fullscreen?: boolean;
|
||||
title?: React.ReactNode;
|
||||
style?: React.CSSProperties;
|
||||
onRequestClose: () => void;
|
||||
};
|
||||
|
||||
@@ -33,6 +34,7 @@ const Modal: React.FC<Props> = ({
|
||||
isOpen,
|
||||
fullscreen = true,
|
||||
title = "Untitled",
|
||||
style,
|
||||
onRequestClose,
|
||||
}: Props) => {
|
||||
const dialog = useDialogState({
|
||||
@@ -115,7 +117,7 @@ const Modal: React.FC<Props> = ({
|
||||
column
|
||||
reverse
|
||||
>
|
||||
<SmallContent shadow>
|
||||
<SmallContent style={style} shadow>
|
||||
<ErrorBoundary component="div">{children}</ErrorBoundary>
|
||||
</SmallContent>
|
||||
<Header>
|
||||
@@ -254,7 +256,7 @@ const Header = styled(Flex)`
|
||||
const Small = styled.div`
|
||||
animation: ${fadeAndScaleIn} 250ms ease;
|
||||
|
||||
margin: auto auto;
|
||||
margin: 25vh auto auto auto;
|
||||
width: 75vw;
|
||||
min-width: 350px;
|
||||
max-width: 450px;
|
||||
@@ -269,7 +271,7 @@ const Small = styled.div`
|
||||
outline: none;
|
||||
|
||||
${NudeButton} {
|
||||
&:hover,
|
||||
&:hover:not(:disabled),
|
||||
&[aria-expanded="true"] {
|
||||
background: ${s("sidebarControlHoverBackground")};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import isEqual from "lodash/isEqual";
|
||||
import { observable, action } from "mobx";
|
||||
import { observable, action, computed } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { withTranslation, WithTranslation } from "react-i18next";
|
||||
@@ -39,7 +39,9 @@ type Props<T> = WithTranslation &
|
||||
};
|
||||
|
||||
@observer
|
||||
class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
|
||||
class PaginatedList<T extends PaginatedItem> extends React.PureComponent<
|
||||
Props<T>
|
||||
> {
|
||||
@observable
|
||||
error?: Error;
|
||||
|
||||
@@ -145,6 +147,11 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
|
||||
}
|
||||
};
|
||||
|
||||
@computed
|
||||
get itemsToRender() {
|
||||
return this.props.items?.slice(0, this.renderCount) ?? [];
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
items = [],
|
||||
@@ -188,10 +195,11 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
|
||||
aria-label={this.props["aria-label"]}
|
||||
onEscape={onEscape}
|
||||
className={this.props.className}
|
||||
items={this.itemsToRender}
|
||||
>
|
||||
{() => {
|
||||
let previousHeading = "";
|
||||
return items.slice(0, this.renderCount).map((item, index) => {
|
||||
return this.itemsToRender.map((item, index) => {
|
||||
const children = this.props.renderItem(item, index);
|
||||
|
||||
// If there is no renderHeading method passed then no date
|
||||
|
||||
+15
-10
@@ -20,15 +20,18 @@ type Props = PopoverProps & {
|
||||
hide: () => void;
|
||||
};
|
||||
|
||||
const Popover: React.FC<Props> = ({
|
||||
children,
|
||||
shrink,
|
||||
width = 380,
|
||||
scrollable = true,
|
||||
flex,
|
||||
mobilePosition,
|
||||
...rest
|
||||
}: Props) => {
|
||||
const Popover = (
|
||||
{
|
||||
children,
|
||||
shrink,
|
||||
width = 380,
|
||||
scrollable = true,
|
||||
flex,
|
||||
mobilePosition,
|
||||
...rest
|
||||
}: Props,
|
||||
ref: React.Ref<HTMLDivElement>
|
||||
) => {
|
||||
const isMobile = useMobile();
|
||||
|
||||
// Custom Escape handler rather than using hideOnEsc from reakit so we can
|
||||
@@ -50,6 +53,7 @@ const Popover: React.FC<Props> = ({
|
||||
return (
|
||||
<Dialog {...rest} modal>
|
||||
<Contents
|
||||
ref={ref}
|
||||
$shrink={shrink}
|
||||
$scrollable={scrollable}
|
||||
$flex={flex}
|
||||
@@ -64,6 +68,7 @@ const Popover: React.FC<Props> = ({
|
||||
return (
|
||||
<StyledPopover {...rest} hideOnEsc={false} hideOnClickOutside>
|
||||
<Contents
|
||||
ref={ref}
|
||||
$shrink={shrink}
|
||||
$width={width}
|
||||
$scrollable={scrollable}
|
||||
@@ -123,4 +128,4 @@ const Contents = styled.div<ContentsProps>`
|
||||
`};
|
||||
`;
|
||||
|
||||
export default Popover;
|
||||
export default React.forwardRef(Popover);
|
||||
|
||||
@@ -24,7 +24,7 @@ type Props = {
|
||||
};
|
||||
|
||||
function CollectionMemberList({ collection, invitedInSession }: Props) {
|
||||
const { memberships, collectionGroupMemberships } = useStores();
|
||||
const { memberships, groupMemberships } = 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(
|
||||
() => collectionGroupMemberships.fetchAll({ id: collectionId }),
|
||||
[collectionGroupMemberships, collectionId]
|
||||
() => groupMemberships.fetchAll({ id: collectionId }),
|
||||
[groupMemberships, collectionId]
|
||||
)
|
||||
);
|
||||
|
||||
@@ -75,7 +75,7 @@ function CollectionMemberList({ collection, invitedInSession }: Props) {
|
||||
|
||||
return (
|
||||
<>
|
||||
{collectionGroupMemberships
|
||||
{groupMemberships
|
||||
.inCollection(collection.id)
|
||||
.sort((a, b) =>
|
||||
(
|
||||
@@ -103,12 +103,12 @@ function CollectionMemberList({ collection, invitedInSession }: Props) {
|
||||
permission: CollectionPermission | typeof EmptySelectValue
|
||||
) => {
|
||||
if (permission === EmptySelectValue) {
|
||||
await collectionGroupMemberships.delete({
|
||||
await groupMemberships.delete({
|
||||
collectionId: collection.id,
|
||||
groupId: membership.groupId,
|
||||
});
|
||||
} else {
|
||||
await collectionGroupMemberships.create({
|
||||
await groupMemberships.create({
|
||||
collectionId: collection.id,
|
||||
groupId: membership.groupId,
|
||||
permission,
|
||||
|
||||
@@ -20,6 +20,7 @@ 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";
|
||||
@@ -43,8 +44,7 @@ type Props = {
|
||||
function SharePopover({ collection, visible, onRequestClose }: Props) {
|
||||
const theme = useTheme();
|
||||
const team = useCurrentTeam();
|
||||
const { collectionGroupMemberships, users, groups, memberships } =
|
||||
useStores();
|
||||
const { groupMemberships, users, groups, memberships } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const can = usePolicy(collection);
|
||||
const [query, setQuery] = React.useState("");
|
||||
@@ -56,6 +56,11 @@ 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) => {
|
||||
@@ -97,6 +102,19 @@ 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();
|
||||
@@ -119,6 +137,39 @@ 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({
|
||||
@@ -154,10 +205,10 @@ function SharePopover({ collection, visible, onRequestClose }: Props) {
|
||||
}
|
||||
|
||||
if (group) {
|
||||
await collectionGroupMemberships.create({
|
||||
await groupMemberships.create({
|
||||
collectionId: collection.id,
|
||||
groupId: group.id,
|
||||
permission: CollectionPermission.Read,
|
||||
permission,
|
||||
});
|
||||
return group;
|
||||
}
|
||||
@@ -216,7 +267,7 @@ function SharePopover({ collection, visible, onRequestClose }: Props) {
|
||||
}),
|
||||
[
|
||||
collection.id,
|
||||
collectionGroupMemberships,
|
||||
groupMemberships,
|
||||
groups,
|
||||
hidePicker,
|
||||
memberships,
|
||||
@@ -292,8 +343,10 @@ function SharePopover({ collection, visible, onRequestClose }: Props) {
|
||||
<Wrapper>
|
||||
{can.update && (
|
||||
<SearchInput
|
||||
ref={searchInputRef}
|
||||
onChange={handleQuery}
|
||||
onClick={showPicker}
|
||||
onKeyDown={handleKeyDown}
|
||||
query={query}
|
||||
back={backButton}
|
||||
action={rightButton}
|
||||
@@ -301,15 +354,16 @@ function SharePopover({ collection, visible, onRequestClose }: Props) {
|
||||
)}
|
||||
|
||||
{picker && (
|
||||
<div>
|
||||
<Suggestions
|
||||
query={query}
|
||||
collection={collection}
|
||||
pendingIds={pendingIds}
|
||||
addPendingId={handleAddPendingId}
|
||||
removePendingId={handleRemovePendingId}
|
||||
/>
|
||||
</div>
|
||||
<Suggestions
|
||||
ref={suggestionsRef}
|
||||
query={query}
|
||||
collection={collection}
|
||||
pendingIds={pendingIds}
|
||||
addPendingId={handleAddPendingId}
|
||||
removePendingId={handleRemovePendingId}
|
||||
onEscape={handleEscape}
|
||||
showGroups
|
||||
/>
|
||||
)}
|
||||
|
||||
<div style={{ display: picker ? "none" : "block" }}>
|
||||
|
||||
@@ -4,7 +4,8 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTheme } from "styled-components";
|
||||
import Squircle from "@shared/components/Squircle";
|
||||
import { CollectionPermission } from "@shared/types";
|
||||
import { CollectionPermission, IconType } from "@shared/types";
|
||||
import { determineIconType } from "@shared/utils/icon";
|
||||
import type Collection from "~/models/Collection";
|
||||
import type Document from "~/models/Document";
|
||||
import Flex from "~/components/Flex";
|
||||
@@ -54,15 +55,7 @@ export const OtherAccess = observer(({ document, children }: Props) => {
|
||||
/>
|
||||
) : usersInCollection ? (
|
||||
<ListItem
|
||||
image={
|
||||
<Squircle color={collection.color} size={AvatarSize.Medium}>
|
||||
<CollectionIcon
|
||||
collection={collection}
|
||||
color={theme.white}
|
||||
size={16}
|
||||
/>
|
||||
</Squircle>
|
||||
}
|
||||
image={<CollectionSquircle collection={collection} />}
|
||||
title={collection.name}
|
||||
subtitle={t("Everyone in the collection")}
|
||||
actions={<AccessTooltip>{t("Can view")}</AccessTooltip>}
|
||||
@@ -136,6 +129,24 @@ 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,6 +18,7 @@ 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";
|
||||
@@ -64,6 +65,11 @@ 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) => {
|
||||
@@ -107,6 +113,19 @@ 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({
|
||||
@@ -202,6 +221,39 @@ 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(
|
||||
() =>
|
||||
[
|
||||
@@ -266,8 +318,10 @@ function SharePopover({
|
||||
<Wrapper>
|
||||
{can.manageUsers && (
|
||||
<SearchInput
|
||||
ref={searchInputRef}
|
||||
onChange={handleQuery}
|
||||
onClick={showPicker}
|
||||
onKeyDown={handleKeyDown}
|
||||
query={query}
|
||||
back={backButton}
|
||||
action={rightButton}
|
||||
@@ -275,15 +329,15 @@ function SharePopover({
|
||||
)}
|
||||
|
||||
{picker && (
|
||||
<div>
|
||||
<Suggestions
|
||||
document={document}
|
||||
query={query}
|
||||
pendingIds={pendingIds}
|
||||
addPendingId={handleAddPendingId}
|
||||
removePendingId={handleRemovePendingId}
|
||||
/>
|
||||
</div>
|
||||
<Suggestions
|
||||
ref={suggestionsRef}
|
||||
document={document}
|
||||
query={query}
|
||||
pendingIds={pendingIds}
|
||||
addPendingId={handleAddPendingId}
|
||||
removePendingId={handleRemovePendingId}
|
||||
onEscape={handleEscape}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div style={{ display: picker ? "none" : "block" }}>
|
||||
|
||||
@@ -15,7 +15,9 @@ export const ListItem = styled(BaseListItem).attrs({
|
||||
padding: 6px 16px;
|
||||
border-radius: 8px;
|
||||
|
||||
&: ${hover} ${InviteIcon} {
|
||||
&: ${hover} ${InviteIcon},
|
||||
&:focus ${InviteIcon},
|
||||
&:focus-within ${InviteIcon} {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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";
|
||||
@@ -10,13 +11,18 @@ type Props = {
|
||||
query: string;
|
||||
onChange: React.ChangeEventHandler;
|
||||
onClick: React.MouseEventHandler;
|
||||
onKeyDown: React.KeyboardEventHandler;
|
||||
back: React.ReactNode;
|
||||
action: React.ReactNode;
|
||||
};
|
||||
|
||||
export function SearchInput({ onChange, onClick, query, back, action }: Props) {
|
||||
export const SearchInput = React.forwardRef(function _SearchInput(
|
||||
{ onChange, onClick, onKeyDown, query, back, action }: Props,
|
||||
ref: React.Ref<HTMLInputElement>
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const isMobile = useMobile();
|
||||
|
||||
const focusInput = React.useCallback(
|
||||
@@ -39,6 +45,7 @@ export function SearchInput({ onChange, onClick, query, back, action }: Props) {
|
||||
value={query}
|
||||
onChange={onChange}
|
||||
onClick={onClick}
|
||||
onKeyDown={onKeyDown}
|
||||
autoFocus
|
||||
margin={0}
|
||||
flex
|
||||
@@ -52,15 +59,16 @@ export function SearchInput({ onChange, onClick, query, back, action }: Props) {
|
||||
{back}
|
||||
<NativeInput
|
||||
key="input"
|
||||
ref={inputRef}
|
||||
ref={mergeRefs([inputRef, ref])}
|
||||
placeholder={`${t("Add or invite")}…`}
|
||||
value={query}
|
||||
onChange={onChange}
|
||||
onClick={onClick}
|
||||
onKeyDown={onKeyDown}
|
||||
style={{ padding: "6px 0" }}
|
||||
/>
|
||||
{action}
|
||||
</AnimatePresence>
|
||||
</HeaderInput>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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";
|
||||
@@ -11,11 +12,14 @@ 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";
|
||||
@@ -40,23 +44,34 @@ 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(
|
||||
({
|
||||
document,
|
||||
collection,
|
||||
query,
|
||||
pendingIds,
|
||||
addPendingId,
|
||||
removePendingId,
|
||||
showGroups,
|
||||
}: Props) => {
|
||||
React.forwardRef(function _Suggestions(
|
||||
{
|
||||
document,
|
||||
collection,
|
||||
query,
|
||||
pendingIds,
|
||||
addPendingId,
|
||||
removePendingId,
|
||||
showGroups,
|
||||
onEscape,
|
||||
}: Props,
|
||||
ref: React.Ref<HTMLDivElement>
|
||||
) {
|
||||
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) => {
|
||||
@@ -92,7 +107,7 @@ export const Suggestions = observer(
|
||||
: collection
|
||||
? users.notInCollection(collection.id, query)
|
||||
: users.orderedData
|
||||
).filter((u) => u.id !== user.id && !u.isSuspended);
|
||||
).filter((u) => !u.isSuspended);
|
||||
|
||||
if (isEmail(query)) {
|
||||
filtered.push(getSuggestionForEmail(query));
|
||||
@@ -107,6 +122,8 @@ export const Suggestions = observer(
|
||||
getSuggestionForEmail,
|
||||
users,
|
||||
users.orderedData,
|
||||
groups,
|
||||
groups.orderedData,
|
||||
document?.id,
|
||||
document?.members,
|
||||
collection?.id,
|
||||
@@ -174,34 +191,65 @@ export const Suggestions = observer(
|
||||
neverRenderedList.current = false;
|
||||
|
||||
return (
|
||||
<>
|
||||
{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>}
|
||||
</>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const InvitedIcon = styled(CheckmarkIcon)`
|
||||
@@ -228,3 +276,8 @@ const Separator = styled.div`
|
||||
border-top: 1px dashed ${s("divider")};
|
||||
margin: 12px 0;
|
||||
`;
|
||||
|
||||
const ScrollableContainer = styled(Scrollable)`
|
||||
padding: 12px 24px;
|
||||
margin: -12px -24px;
|
||||
`;
|
||||
|
||||
@@ -50,7 +50,8 @@ function Right({ children, border, className }: Props) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleMouseDown = React.useCallback(() => {
|
||||
const handleMouseDown = React.useCallback((event) => {
|
||||
event.preventDefault();
|
||||
setResizing(true);
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ function SharedSidebar({ rootNode, shareId }: Props) {
|
||||
|
||||
return (
|
||||
<Sidebar>
|
||||
{team && (
|
||||
{team?.name && (
|
||||
<SidebarButton
|
||||
title={team.name}
|
||||
image={<TeamLogo model={team} size={32} alt={t("Logo")} />}
|
||||
|
||||
@@ -94,6 +94,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
|
||||
const handleMouseDown = React.useCallback(
|
||||
(event) => {
|
||||
event.preventDefault();
|
||||
setOffset(event.pageX - width);
|
||||
setResizing(true);
|
||||
setAnimating(false);
|
||||
@@ -143,8 +144,11 @@ 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 () => {
|
||||
|
||||
@@ -14,13 +14,14 @@ 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 { newDocumentPath } from "~/utils/routeHelpers";
|
||||
import { newNestedDocumentPath } from "~/utils/routeHelpers";
|
||||
import DropCursor from "./DropCursor";
|
||||
import DropToImport from "./DropToImport";
|
||||
import EditableTitle, { RefHandle } from "./EditableTitle";
|
||||
@@ -142,6 +143,8 @@ 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({
|
||||
@@ -149,6 +152,7 @@ function InnerDocumentLink(
|
||||
item: () => ({
|
||||
...node,
|
||||
depth,
|
||||
icon: icon ? <Icon value={icon} color={color} /> : undefined,
|
||||
active: isActiveDocument,
|
||||
collectionId: collection?.id || "",
|
||||
}),
|
||||
@@ -279,9 +283,8 @@ function InnerDocumentLink(
|
||||
node,
|
||||
]);
|
||||
|
||||
const title =
|
||||
(activeDocument?.id === node.id ? activeDocument.title : node.title) ||
|
||||
t("Untitled");
|
||||
const doc = documents.get(node.id);
|
||||
const title = doc?.title || node.title || t("Untitled");
|
||||
|
||||
const isExpanded = expanded && !isDragging;
|
||||
const hasChildren = nodeChildren.length > 0;
|
||||
@@ -324,7 +327,7 @@ function InnerDocumentLink(
|
||||
starred: inStarredSection,
|
||||
},
|
||||
}}
|
||||
emoji={document?.emoji || node.emoji}
|
||||
icon={icon && <Icon value={icon} color={color} />}
|
||||
label={
|
||||
<EditableTitle
|
||||
title={title}
|
||||
@@ -359,9 +362,7 @@ function InnerDocumentLink(
|
||||
type={undefined}
|
||||
aria-label={t("New nested document")}
|
||||
as={Link}
|
||||
to={newDocumentPath(document.collectionId, {
|
||||
parentDocumentId: document.id,
|
||||
})}
|
||||
to={newNestedDocumentPath(document.id)}
|
||||
>
|
||||
<PlusIcon />
|
||||
</NudeButton>
|
||||
|
||||
@@ -5,6 +5,7 @@ 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";
|
||||
@@ -100,6 +101,8 @@ function DocumentLink(
|
||||
(activeDocument?.id === node.id ? activeDocument.title : node.title) ||
|
||||
t("Untitled");
|
||||
|
||||
const icon = node.icon ?? node.emoji;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarLink
|
||||
@@ -111,7 +114,7 @@ function DocumentLink(
|
||||
}}
|
||||
expanded={hasChildDocuments && depth !== 0 ? expanded : undefined}
|
||||
onDisclosureClick={handleDisclosureClick}
|
||||
emoji={node.emoji}
|
||||
icon={icon && <Icon value={icon} color={node.color} />}
|
||||
label={title}
|
||||
depth={depth}
|
||||
exact={false}
|
||||
|
||||
@@ -2,7 +2,8 @@ import fractionalIndex from "fractional-index";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { NotificationEventType } from "@shared/types";
|
||||
import { IconType, NotificationEventType } from "@shared/types";
|
||||
import { determineIconType } from "@shared/utils/icon";
|
||||
import UserMembership from "~/models/UserMembership";
|
||||
import Fade from "~/components/Fade";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
@@ -78,10 +79,11 @@ function SharedWithMeLink({ userMembership }: Props) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { emoji } = document;
|
||||
const label = emoji
|
||||
? document.title.replace(emoji, "")
|
||||
: document.titleWithDefault;
|
||||
const { icon: docIcon } = document;
|
||||
const label =
|
||||
determineIconType(docIcon) === IconType.Emoji
|
||||
? document.title.replace(docIcon!, "")
|
||||
: document.titleWithDefault;
|
||||
const collection = document.collectionId
|
||||
? collections.get(document.collectionId)
|
||||
: undefined;
|
||||
|
||||
@@ -5,7 +5,6 @@ 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";
|
||||
@@ -27,7 +26,6 @@ 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;
|
||||
@@ -52,7 +50,6 @@ function SidebarLink(
|
||||
onClick,
|
||||
onClickIntent,
|
||||
to,
|
||||
emoji,
|
||||
label,
|
||||
active,
|
||||
isActiveDrop,
|
||||
@@ -142,7 +139,6 @@ 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,7 +21,11 @@ export function useSidebarLabelAndIcon(
|
||||
if (document) {
|
||||
return {
|
||||
label: document.titleWithDefault,
|
||||
icon: document.emoji ? <EmojiIcon emoji={document.emoji} /> : icon,
|
||||
icon: document.icon ? (
|
||||
<Icon value={document.icon} color={document.color ?? undefined} />
|
||||
) : (
|
||||
icon
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ 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";
|
||||
@@ -82,6 +83,7 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
this.socket.authenticated = false;
|
||||
const {
|
||||
auth,
|
||||
dataAttributes,
|
||||
documents,
|
||||
collections,
|
||||
groups,
|
||||
@@ -290,6 +292,27 @@ 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);
|
||||
});
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
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 { isMac } from "@shared/utils/browser";
|
||||
import { search as emojiSearch } from "@shared/utils/emoji";
|
||||
import EmojiMenuItem from "./EmojiMenuItem";
|
||||
import SuggestionsMenu, {
|
||||
Props as SuggestionsMenuProps,
|
||||
@@ -19,13 +15,6 @@ 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"
|
||||
@@ -34,36 +23,26 @@ type Props = Omit<
|
||||
const EmojiMenu = (props: Props) => {
|
||||
const { search = "" } = props;
|
||||
|
||||
if (!searcher) {
|
||||
searcher = new FuzzySearch(Object.values(Data.emojis), ["search"], {
|
||||
caseSensitive: false,
|
||||
sort: true,
|
||||
});
|
||||
}
|
||||
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;
|
||||
|
||||
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 {
|
||||
name: "emoji",
|
||||
title: emoji,
|
||||
description: capitalize(item.name.toLowerCase()),
|
||||
emoji,
|
||||
attrs: { markup: shortcode, "data-name": shortcode },
|
||||
};
|
||||
})
|
||||
.slice(0, 15),
|
||||
[search]
|
||||
);
|
||||
|
||||
return (
|
||||
<SuggestionsMenu
|
||||
|
||||
@@ -5,6 +5,7 @@ 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";
|
||||
@@ -131,10 +132,10 @@ function usePosition({
|
||||
if (isImageSelection) {
|
||||
const element = view.nodeDOM(selection.from);
|
||||
|
||||
// Images are wrapped which impacts positioning - need to traverse through
|
||||
// p > span > div.image
|
||||
const imageElement = (element as HTMLElement).getElementsByTagName(
|
||||
"img"
|
||||
// Images are wrapped which impacts positioning - need to get the element
|
||||
// specifically tagged as the handle
|
||||
const imageElement = (element as HTMLElement).getElementsByClassName(
|
||||
EditorStyleHelper.imageHandle
|
||||
)[0];
|
||||
const { left, top, width } = imageElement.getBoundingClientRect();
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import Tooltip from "./Tooltip";
|
||||
export type SearchResult = {
|
||||
title: string;
|
||||
subtitle?: React.ReactNode;
|
||||
icon?: React.ReactNode;
|
||||
url: string;
|
||||
};
|
||||
|
||||
@@ -359,7 +360,7 @@ class LinkEditor extends React.Component<Props, State> {
|
||||
key={result.url}
|
||||
title={result.title}
|
||||
subtitle={result.subtitle}
|
||||
icon={<DocumentIcon />}
|
||||
icon={result.icon ?? <DocumentIcon />}
|
||||
onPointerMove={() => this.handleFocusLink(index)}
|
||||
onClick={this.handleSelectLink(result.url, result.title)}
|
||||
selected={index === selectedIndex}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react";
|
||||
import scrollIntoView from "smooth-scroll-into-view-if-needed";
|
||||
import styled from "styled-components";
|
||||
import styled, { css } from "styled-components";
|
||||
import { s, ellipsis } from "@shared/styles";
|
||||
|
||||
type Props = React.HTMLAttributes<HTMLDivElement> & {
|
||||
@@ -60,7 +60,15 @@ const IconWrapper = styled.span<{ selected: boolean }>`
|
||||
margin-right: 4px;
|
||||
height: 24px;
|
||||
opacity: 0.8;
|
||||
color: ${(props) => (props.selected ? s("accentText") : s("textSecondary"))};
|
||||
|
||||
${(props) =>
|
||||
props.selected &&
|
||||
css`
|
||||
svg {
|
||||
fill: ${s("accentText")};
|
||||
color: ${s("accentText")};
|
||||
}
|
||||
`};
|
||||
`;
|
||||
|
||||
const ListItem = styled.div<{
|
||||
|
||||
@@ -67,7 +67,7 @@ export type Props<T extends MenuItem = MenuItem> = {
|
||||
index: number,
|
||||
options: {
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
onClick: (event: React.SyntheticEvent) => void;
|
||||
}
|
||||
) => React.ReactNode;
|
||||
filterable?: boolean;
|
||||
@@ -78,6 +78,10 @@ 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);
|
||||
@@ -344,6 +348,9 @@ 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);
|
||||
@@ -576,7 +583,23 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handlePointer = () => {
|
||||
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 = () => {
|
||||
if (selectedIndex !== index) {
|
||||
setSelectedIndex(index);
|
||||
}
|
||||
@@ -585,8 +608,8 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
return (
|
||||
<ListItem
|
||||
key={index}
|
||||
onPointerMove={handlePointer}
|
||||
onPointerDown={handlePointer}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerDown={handlePointerDown}
|
||||
>
|
||||
{props.renderMenuItem(item as any, index, {
|
||||
selected: index === selectedIndex,
|
||||
|
||||
@@ -5,11 +5,17 @@ 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;
|
||||
onClick: () => void;
|
||||
/** Callback when the item is clicked */
|
||||
onClick: (event: React.SyntheticEvent) => void;
|
||||
/** An optional icon for the item */
|
||||
icon?: React.ReactElement;
|
||||
/** The title of the item */
|
||||
title: React.ReactNode;
|
||||
/** A string representing the keyboard shortcut for the item */
|
||||
shortcut?: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -40,16 +40,21 @@ function ToolbarDropdown(props: { active: boolean; item: MenuItem }) {
|
||||
};
|
||||
|
||||
return item.children
|
||||
? 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.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, commands, state]);
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ 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";
|
||||
@@ -89,7 +91,7 @@ export default class PasteHandler extends Extension {
|
||||
},
|
||||
handlePaste: (view, event: ClipboardEvent) => {
|
||||
// Do nothing if the document isn't currently editable
|
||||
if (view.props.editable && !view.props.editable(view.state)) {
|
||||
if (!view.editable) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -179,9 +181,12 @@ export default class PasteHandler extends Extension {
|
||||
if (document) {
|
||||
const { hash } = new URL(text);
|
||||
|
||||
const title = `${
|
||||
document.emoji ? document.emoji + " " : ""
|
||||
}${document.titleWithDefault}`;
|
||||
const hasEmoji =
|
||||
determineIconType(document.icon) === IconType.Emoji;
|
||||
|
||||
const title = `${hasEmoji ? document.icon + " " : ""}${
|
||||
document.titleWithDefault
|
||||
}`;
|
||||
insertLink(`${document.path}${hash}`, title);
|
||||
}
|
||||
})
|
||||
|
||||
@@ -4,8 +4,8 @@ import { InputRule } from "@shared/editor/lib/InputRule";
|
||||
|
||||
const rightArrow = new InputRule(/->$/, "→");
|
||||
const emdash = new InputRule(/--$/, "—");
|
||||
const oneHalf = new InputRule(/1\/2$/, "½");
|
||||
const threeQuarters = new InputRule(/3\/4$/, "¾");
|
||||
const oneHalf = new InputRule(/(?:^|\s)1\/2$/, "½");
|
||||
const threeQuarters = new InputRule(/(?:^|\s)3\/4$/, "¾");
|
||||
const copyright = new InputRule(/\(c\)$/, "©️");
|
||||
const registered = new InputRule(/\(r\)$/, "®️");
|
||||
const trademarked = new InputRule(/\(tm\)$/, "™️");
|
||||
|
||||
+43
-8
@@ -640,29 +640,63 @@ export class Editor extends React.PureComponent<
|
||||
public getComments = () => ProsemirrorHelper.getComments(this.view.state.doc);
|
||||
|
||||
/**
|
||||
* Remove a specific comment mark from the document.
|
||||
* Remove all marks related to a specific comment from the document.
|
||||
*
|
||||
* @param commentId The id of the comment to remove
|
||||
*/
|
||||
public removeComment = (commentId: string) => {
|
||||
const { state, dispatch } = this.view;
|
||||
let found = false;
|
||||
const tr = state.tr;
|
||||
|
||||
state.doc.descendants((node, pos) => {
|
||||
if (!node.isInline || found) {
|
||||
if (!node.isInline) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mark = node.marks.find(
|
||||
(mark) =>
|
||||
mark.type === state.schema.marks.comment &&
|
||||
mark.attrs.id === commentId
|
||||
(m) => m.type === state.schema.marks.comment && m.attrs.id === commentId
|
||||
);
|
||||
|
||||
if (mark) {
|
||||
dispatch(state.tr.removeMark(pos, pos + node.nodeSize, mark));
|
||||
found = true;
|
||||
tr.removeMark(pos, pos + node.nodeSize, mark);
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -808,6 +842,7 @@ const EditorContainer = styled(Styles)<{
|
||||
css`
|
||||
#comment-${props.focusedCommentId} {
|
||||
background: ${transparentize(0.5, props.theme.brand.marine)};
|
||||
border-bottom: 2px solid ${props.theme.commentMarkBackground};
|
||||
}
|
||||
`}
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
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";
|
||||
|
||||
@@ -12,6 +14,26 @@ 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",
|
||||
@@ -28,14 +50,24 @@ export default function codeMenuItems(
|
||||
name: "code_block",
|
||||
icon: <ExpandedIcon />,
|
||||
label: LANGUAGES[node.attrs.language ?? "none"],
|
||||
children: Object.entries(LANGUAGES).map(([value, label]) => ({
|
||||
name: "code_block",
|
||||
label,
|
||||
active: () => node.attrs.language === value,
|
||||
attrs: {
|
||||
language: value,
|
||||
},
|
||||
})),
|
||||
children: languageMenuItems,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const langToMenuItem = ({
|
||||
node,
|
||||
value,
|
||||
label,
|
||||
}: {
|
||||
node: ProseMirrorNode;
|
||||
value: string;
|
||||
label: string;
|
||||
}): MenuItem => ({
|
||||
name: "code_block",
|
||||
label,
|
||||
active: () => node.attrs.language === value,
|
||||
attrs: {
|
||||
language: value,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -209,7 +209,7 @@ export default function formattingMenuItems(
|
||||
tooltip: dictionary.comment,
|
||||
icon: <CommentIcon />,
|
||||
label: isCodeBlock ? dictionary.comment : undefined,
|
||||
active: isMarkActive(schema.marks.comment),
|
||||
active: isMarkActive(schema.marks.comment, { resolved: false }),
|
||||
visible: !isMobile || !isEmpty,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
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;
|
||||
@@ -14,11 +14,13 @@ import {
|
||||
ImportIcon,
|
||||
ShapesIcon,
|
||||
Icon,
|
||||
DatabaseIcon,
|
||||
} from "outline-icons";
|
||||
import React, { ComponentProps } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { integrationSettingsPath } from "@shared/utils/routeHelpers";
|
||||
import ZapierIcon from "~/components/Icons/ZapierIcon";
|
||||
import { Feature, FeatureFlags } from "~/utils/FeatureFlags";
|
||||
import { Hook, PluginManager } from "~/utils/PluginManager";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
import lazy from "~/utils/lazyWithRetry";
|
||||
@@ -29,6 +31,7 @@ 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"));
|
||||
@@ -119,6 +122,16 @@ const useSettingsConfig = () => {
|
||||
group: t("Workspace"),
|
||||
icon: BeakerIcon,
|
||||
},
|
||||
{
|
||||
name: t("Data Attributes"),
|
||||
path: settingsPath("attributes"),
|
||||
component: DataAttributes,
|
||||
enabled:
|
||||
can.createDataAttribute &&
|
||||
FeatureFlags.isEnabled(Feature.dataAttributes),
|
||||
group: t("Workspace"),
|
||||
icon: DatabaseIcon,
|
||||
},
|
||||
{
|
||||
name: t("Members"),
|
||||
path: settingsPath("members"),
|
||||
@@ -188,15 +201,21 @@ const useSettingsConfig = () => {
|
||||
|
||||
// Plugins
|
||||
PluginManager.getHooks(Hook.Settings).forEach((plugin) => {
|
||||
const insertIndex = items.findIndex(
|
||||
(i) => i.group === t(plugin.value.group ?? "Integrations")
|
||||
);
|
||||
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.splice(insertIndex, 0, {
|
||||
name: t(plugin.name),
|
||||
path: integrationSettingsPath(plugin.id),
|
||||
group: t(plugin.value.group),
|
||||
path:
|
||||
group === "Integrations"
|
||||
? integrationSettingsPath(plugin.id)
|
||||
: settingsPath(plugin.id),
|
||||
group: t(group),
|
||||
component: plugin.value.component,
|
||||
enabled: plugin.roles?.includes(user.role) || can.update,
|
||||
enabled: plugin.value.enabled
|
||||
? plugin.value.enabled(team, user)
|
||||
: can.update,
|
||||
icon: plugin.value.icon,
|
||||
} as ConfigItem);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NewDocumentIcon, ShapesIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import EmojiIcon from "~/components/Icons/EmojiIcon";
|
||||
import Icon from "~/components/Icon";
|
||||
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.emoji ? (
|
||||
<EmojiIcon emoji={item.emoji} />
|
||||
icon: item.icon ? (
|
||||
<Icon value={item.icon} color={item.color ?? undefined} />
|
||||
) : (
|
||||
<NewDocumentIcon />
|
||||
),
|
||||
|
||||
@@ -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";
|
||||
|
||||
+59
-29
@@ -1,16 +1,22 @@
|
||||
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 Separator from "~/components/ContextMenu/Separator";
|
||||
import Template from "~/components/ContextMenu/Template";
|
||||
import { actionToMenuItem } from "~/actions";
|
||||
import {
|
||||
deleteCommentFactory,
|
||||
resolveCommentFactory,
|
||||
unresolveCommentFactory,
|
||||
} from "~/actions/definitions/comments";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { commentPath, urlify } from "~/utils/routeHelpers";
|
||||
@@ -24,24 +30,26 @@ 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, className }: Props) {
|
||||
function CommentMenu({
|
||||
comment,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onUpdate,
|
||||
className,
|
||||
}: Props) {
|
||||
const menu = useMenuState({
|
||||
modal: true,
|
||||
});
|
||||
const { documents, dialogs } = useStores();
|
||||
const { documents } = 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)));
|
||||
@@ -58,24 +66,46 @@ function CommentMenu({ comment, onEdit, onDelete, className }: Props) {
|
||||
{...menu}
|
||||
/>
|
||||
</EventBoundary>
|
||||
|
||||
<ContextMenu {...menu} aria-label={t("Comment options")}>
|
||||
{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>
|
||||
</>
|
||||
)}
|
||||
<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
|
||||
),
|
||||
]}
|
||||
/>
|
||||
</ContextMenu>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
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);
|
||||
@@ -21,7 +21,7 @@ import Switch from "~/components/Switch";
|
||||
import { actionToMenuItem } from "~/actions";
|
||||
import {
|
||||
pinDocument,
|
||||
createTemplate,
|
||||
createTemplateFromDocument,
|
||||
subscribeDocument,
|
||||
unsubscribeDocument,
|
||||
moveDocument,
|
||||
@@ -284,7 +284,7 @@ function DocumentMenu({
|
||||
},
|
||||
actionToMenuItem(createNestedDocument, context),
|
||||
actionToMenuItem(importDocument, context),
|
||||
actionToMenuItem(createTemplate, context),
|
||||
actionToMenuItem(createTemplateFromDocument, context),
|
||||
actionToMenuItem(duplicateDocument, context),
|
||||
actionToMenuItem(publishDocument, context),
|
||||
actionToMenuItem(unpublishDocument, context),
|
||||
|
||||
@@ -5,8 +5,10 @@ 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 { newDocumentPath } from "~/utils/routeHelpers";
|
||||
import { MenuItem } from "~/types";
|
||||
import { newDocumentPath, newNestedDocumentPath } from "~/utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
label?: (props: MenuButtonHTMLProps) => React.ReactNode;
|
||||
@@ -17,58 +19,59 @@ function NewChildDocumentMenu({ document, label }: Props) {
|
||||
const menu = useMenuState({
|
||||
modal: true,
|
||||
});
|
||||
const { collections } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const collection = document.collectionId
|
||||
? collections.get(document.collectionId)
|
||||
: undefined;
|
||||
const collectionName = collection ? collection.name : t("collection");
|
||||
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),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuButton {...menu}>{label}</MenuButton>
|
||||
<ContextMenu {...menu} aria-label={t("New child document")}>
|
||||
<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,
|
||||
}),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Template {...menu} items={items} />
|
||||
</ContextMenu>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -8,7 +8,7 @@ import Button from "~/components/Button";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import MenuItem from "~/components/ContextMenu/MenuItem";
|
||||
import Separator from "~/components/ContextMenu/Separator";
|
||||
import EmojiIcon from "~/components/Icons/EmojiIcon";
|
||||
import Icon from "~/components/Icon";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { replaceTitleVariables } from "~/utils/date";
|
||||
@@ -43,7 +43,11 @@ function TemplatesMenu({ onSelectTemplate, document }: Props) {
|
||||
key={template.id}
|
||||
onClick={() => onSelectTemplate(template)}
|
||||
icon={
|
||||
template.emoji ? <EmojiIcon emoji={template.emoji} /> : <DocumentIcon />
|
||||
template.icon ? (
|
||||
<Icon value={template.icon} color={template.color ?? undefined} />
|
||||
) : (
|
||||
<DocumentIcon />
|
||||
)
|
||||
}
|
||||
{...menu}
|
||||
>
|
||||
|
||||
+26
-1
@@ -1,4 +1,5 @@
|
||||
import { observable } from "mobx";
|
||||
import { isPast } from "date-fns";
|
||||
import { computed, observable } from "mobx";
|
||||
import Model from "./base/Model";
|
||||
import Field from "./decorators/Field";
|
||||
|
||||
@@ -9,11 +10,35 @@ 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;
|
||||
|
||||
@@ -40,18 +40,18 @@ export default class Collection extends ParanoidModel {
|
||||
data: ProsemirrorData;
|
||||
|
||||
/**
|
||||
* An emoji to use as the collection icon.
|
||||
* An icon (or) emoji to use as the collection icon.
|
||||
*/
|
||||
@Field
|
||||
@observable
|
||||
icon: string;
|
||||
|
||||
/**
|
||||
* A color to use for the collection icon and other highlights.
|
||||
* The color to use for the collection icon and other highlights.
|
||||
*/
|
||||
@Field
|
||||
@observable
|
||||
color: string;
|
||||
color?: string | null;
|
||||
|
||||
/**
|
||||
* The default permission for workspace users.
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import { observable } from "mobx";
|
||||
import { CollectionPermission } from "@shared/types";
|
||||
import Collection from "./Collection";
|
||||
import Group from "./Group";
|
||||
import Model from "./base/Model";
|
||||
import Relation from "./decorators/Relation";
|
||||
|
||||
class CollectionGroupMembership extends Model {
|
||||
static modelName = "CollectionGroupMembership";
|
||||
|
||||
id: string;
|
||||
|
||||
groupId: string;
|
||||
|
||||
@Relation(() => Group, { onDelete: "cascade" })
|
||||
group: Group;
|
||||
|
||||
collectionId: string;
|
||||
|
||||
@Relation(() => Collection, { onDelete: "cascade" })
|
||||
collection: Collection;
|
||||
|
||||
@observable
|
||||
permission: CollectionPermission;
|
||||
}
|
||||
|
||||
export default CollectionGroupMembership;
|
||||
+58
-4
@@ -3,6 +3,7 @@ import { computed, observable } from "mobx";
|
||||
import { now } from "mobx-utils";
|
||||
import type { ProsemirrorData } from "@shared/types";
|
||||
import User from "~/models/User";
|
||||
import Document from "./Document";
|
||||
import Model from "./base/Model";
|
||||
import Field from "./decorators/Field";
|
||||
import Relation from "./decorators/Relation";
|
||||
@@ -34,7 +35,7 @@ class Comment extends Model {
|
||||
*/
|
||||
@Field
|
||||
@observable
|
||||
parentCommentId: string;
|
||||
parentCommentId: string | null;
|
||||
|
||||
/**
|
||||
* The comment that this comment is a reply to.
|
||||
@@ -43,33 +44,86 @@ class Comment extends Model {
|
||||
parentComment?: Comment;
|
||||
|
||||
/**
|
||||
* The document to which this comment belongs.
|
||||
* The document ID to which this comment belongs.
|
||||
*/
|
||||
@Field
|
||||
@observable
|
||||
documentId: string;
|
||||
|
||||
/**
|
||||
* The document that this comment belongs to.
|
||||
*/
|
||||
@Relation(() => Document, { onDelete: "cascade" })
|
||||
document: Document;
|
||||
|
||||
/**
|
||||
* The user who created this comment.
|
||||
*/
|
||||
@Relation(() => User)
|
||||
createdBy: User;
|
||||
|
||||
/**
|
||||
* The ID of the user who created this comment.
|
||||
*/
|
||||
createdById: string;
|
||||
|
||||
/**
|
||||
* The date and time that this comment was resolved, if it has been resolved.
|
||||
*/
|
||||
@observable
|
||||
resolvedAt: string;
|
||||
|
||||
/**
|
||||
* The user who resolved this comment, if it has been resolved.
|
||||
*/
|
||||
@Relation(() => User)
|
||||
resolvedBy: User;
|
||||
resolvedBy: User | null;
|
||||
|
||||
/**
|
||||
* The ID of the user who resolved this comment, if it has been resolved.
|
||||
*/
|
||||
resolvedById: string | null;
|
||||
|
||||
/**
|
||||
* An array of users that are currently typing a reply in this comments thread.
|
||||
*/
|
||||
@computed
|
||||
get currentlyTypingUsers(): User[] {
|
||||
public get currentlyTypingUsers(): User[] {
|
||||
return Array.from(this.typingUsers.entries())
|
||||
.filter(([, lastReceivedDate]) => lastReceivedDate > subSeconds(now(), 3))
|
||||
.map(([userId]) => this.store.rootStore.users.get(userId))
|
||||
.filter(Boolean) as User[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the comment is resolved
|
||||
*/
|
||||
@computed
|
||||
public get isResolved() {
|
||||
return !!this.resolvedAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the comment is a reply to another comment.
|
||||
*/
|
||||
@computed
|
||||
public get isReply() {
|
||||
return !!this.parentCommentId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the comment
|
||||
*/
|
||||
public resolve() {
|
||||
return this.store.rootStore.comments.resolve(this.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unresolve the comment
|
||||
*/
|
||||
public unresolve() {
|
||||
return this.store.rootStore.comments.unresolve(this.id);
|
||||
}
|
||||
}
|
||||
|
||||
export default Comment;
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { observable } from "mobx";
|
||||
import {
|
||||
DataAttributeDataType,
|
||||
type DataAttributeOptions,
|
||||
} from "@shared/models/types";
|
||||
import User from "./User";
|
||||
import ParanoidModel from "./base/ParanoidModel";
|
||||
import Field from "./decorators/Field";
|
||||
import Relation from "./decorators/Relation";
|
||||
|
||||
class DataAttribute extends ParanoidModel {
|
||||
static modelName = "DataAttribute";
|
||||
|
||||
/** The name of this data attribute. */
|
||||
@Field
|
||||
@observable
|
||||
name: string;
|
||||
|
||||
/** A user-facing description for the data attribute. */
|
||||
@Field
|
||||
@observable
|
||||
description: string | null;
|
||||
|
||||
/** The data type of this data attribute. Cannot be changed after creation. */
|
||||
@Field
|
||||
@observable
|
||||
dataType: DataAttributeDataType;
|
||||
|
||||
/** Options for `list` data type. */
|
||||
@Field
|
||||
@observable
|
||||
options: DataAttributeOptions | null;
|
||||
|
||||
/** Whether this data attribute is pinned to the top of the document. */
|
||||
@Field
|
||||
@observable
|
||||
pinned: boolean;
|
||||
|
||||
/** The sort index of this data attribute. */
|
||||
@Field
|
||||
@observable
|
||||
index: number;
|
||||
|
||||
/** The user that created this data attribute. */
|
||||
@Relation(() => User, { onDelete: "cascade" })
|
||||
createdBy?: User;
|
||||
|
||||
/** The user ID that created this data attribute. */
|
||||
createdById: string;
|
||||
}
|
||||
|
||||
export default DataAttribute;
|
||||
+60
-2
@@ -4,8 +4,13 @@ import capitalize from "lodash/capitalize";
|
||||
import floor from "lodash/floor";
|
||||
import { action, autorun, computed, observable, set } from "mobx";
|
||||
import { Node, Schema } from "prosemirror-model";
|
||||
import { Primitive } from "utility-types";
|
||||
import ExtensionManager from "@shared/editor/lib/ExtensionManager";
|
||||
import { richExtensions, withComments } from "@shared/editor/nodes";
|
||||
import {
|
||||
DataAttributeDataType,
|
||||
DocumentDataAttribute,
|
||||
} from "@shared/models/types";
|
||||
import type {
|
||||
JSONObject,
|
||||
NavigationNode,
|
||||
@@ -62,6 +67,10 @@ export default class Document extends ParanoidModel {
|
||||
@observable
|
||||
lastViewedAt: string | undefined;
|
||||
|
||||
@Field
|
||||
@observable
|
||||
dataAttributes: DocumentDataAttribute[];
|
||||
|
||||
store: DocumentsStore;
|
||||
|
||||
@Field
|
||||
@@ -129,11 +138,18 @@ export default class Document extends ParanoidModel {
|
||||
title: string;
|
||||
|
||||
/**
|
||||
* An emoji to use as the document icon.
|
||||
* An icon (or) emoji to use as the document icon.
|
||||
*/
|
||||
@Field
|
||||
@observable
|
||||
emoji: string | undefined | null;
|
||||
icon?: string | null;
|
||||
|
||||
/**
|
||||
* The color to use for the document icon.
|
||||
*/
|
||||
@Field
|
||||
@observable
|
||||
color?: string | null;
|
||||
|
||||
/**
|
||||
* Whether this is a template.
|
||||
@@ -477,6 +493,48 @@ export default class Document extends ParanoidModel {
|
||||
});
|
||||
};
|
||||
|
||||
@action
|
||||
setDataAttribute = (dataAttributeId: string, input: Primitive) => {
|
||||
const definition = this.store.rootStore.dataAttributes.get(dataAttributeId);
|
||||
if (!definition) {
|
||||
throw new Error(`Data attribute ${dataAttributeId} not found`);
|
||||
}
|
||||
|
||||
let value: Primitive = input;
|
||||
switch (definition.dataType) {
|
||||
case DataAttributeDataType.Number:
|
||||
value = Number(input);
|
||||
break;
|
||||
case DataAttributeDataType.Boolean:
|
||||
value = input === true || input === "true";
|
||||
break;
|
||||
case DataAttributeDataType.List:
|
||||
if (
|
||||
!(definition.options?.options ?? [])
|
||||
.map((i) => i.value)
|
||||
.includes(input as string)
|
||||
) {
|
||||
throw new Error(
|
||||
`Invalid value for data attribute ${dataAttributeId}`
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
this.dataAttributes = (this.dataAttributes ?? [])
|
||||
.filter((attr) => attr.dataAttributeId !== dataAttributeId)
|
||||
.concat({ dataAttributeId, value, updatedAt: new Date().toISOString() });
|
||||
return this;
|
||||
};
|
||||
|
||||
@action
|
||||
deleteDataAttribute = (dataAttributeId: string) => {
|
||||
this.dataAttributes = this.dataAttributes?.filter(
|
||||
(attr) => attr.dataAttributeId !== dataAttributeId
|
||||
);
|
||||
return this;
|
||||
};
|
||||
|
||||
@action
|
||||
updateLastViewed = (view: View) => {
|
||||
this.lastViewedAt = view.lastViewedAt;
|
||||
|
||||
@@ -1,20 +1,41 @@
|
||||
import { observable } from "mobx";
|
||||
import { CollectionPermission, DocumentPermission } from "@shared/types";
|
||||
import Collection from "./Collection";
|
||||
import Document from "./Document";
|
||||
import Group from "./Group";
|
||||
import User from "./User";
|
||||
import Model from "./base/Model";
|
||||
import Relation from "./decorators/Relation";
|
||||
|
||||
/**
|
||||
* Represents a groups's membership to a collection or document.
|
||||
*/
|
||||
class GroupMembership extends Model {
|
||||
static modelName = "GroupMembership";
|
||||
|
||||
userId: string;
|
||||
|
||||
@Relation(() => User, { onDelete: "cascade" })
|
||||
user: User;
|
||||
|
||||
/** The group ID that this membership is granted to. */
|
||||
groupId: string;
|
||||
|
||||
/** The group that this membership is granted to. */
|
||||
@Relation(() => Group, { onDelete: "cascade" })
|
||||
group: Group;
|
||||
|
||||
/** The document ID that this membership grants the group access to. */
|
||||
documentId: string | undefined;
|
||||
|
||||
/** The document that this membership grants the group access to. */
|
||||
@Relation(() => Document, { onDelete: "cascade" })
|
||||
document: Document | undefined;
|
||||
|
||||
/** The collection ID that this membership grants the group access to. */
|
||||
collectionId: string | undefined;
|
||||
|
||||
/** The collection that this membership grants the group access to. */
|
||||
@Relation(() => Collection, { onDelete: "cascade" })
|
||||
collection: Collection | undefined;
|
||||
|
||||
/** The permission level granted to the group. */
|
||||
@observable
|
||||
permission: CollectionPermission | DocumentPermission;
|
||||
}
|
||||
|
||||
export default GroupMembership;
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import Group from "./Group";
|
||||
import User from "./User";
|
||||
import Model from "./base/Model";
|
||||
import Relation from "./decorators/Relation";
|
||||
|
||||
/**
|
||||
* Represents a user's membership to a group.
|
||||
*/
|
||||
class GroupUser extends Model {
|
||||
static modelName = "GroupUser";
|
||||
|
||||
/** The ID of the user. */
|
||||
userId: string;
|
||||
|
||||
/** The user that belongs to the group. */
|
||||
@Relation(() => User, { onDelete: "cascade" })
|
||||
user: User;
|
||||
|
||||
/** The ID of the group. */
|
||||
groupId: string;
|
||||
|
||||
/** The group that the user belongs to. */
|
||||
@Relation(() => Group, { onDelete: "cascade" })
|
||||
group: Group;
|
||||
}
|
||||
|
||||
export default GroupUser;
|
||||
@@ -22,8 +22,11 @@ class Revision extends Model {
|
||||
/** Prosemirror data of the content when revision was created */
|
||||
data: ProsemirrorData;
|
||||
|
||||
/** The emoji of the document when the revision was created */
|
||||
emoji: string | null;
|
||||
/** The icon (or) emoji of the document when the revision was created */
|
||||
icon: string | null;
|
||||
|
||||
/** The color of the document icon when the revision was created */
|
||||
color: string | null;
|
||||
|
||||
/** HTML string representing the revision as a diff from the previous version */
|
||||
html: string;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user