4 Commits

422 changed files with 10012 additions and 14956 deletions

View File

@@ -12,7 +12,7 @@
"legacy": true "legacy": true
} }
], ],
"@babel/plugin-transform-class-properties", "@babel/plugin-proposal-class-properties",
[ [
"transform-inline-environment-variables", "transform-inline-environment-variables",
{ {

View File

@@ -126,7 +126,7 @@ jobs:
docker buildx install docker buildx install
docker context create docker-multiarch docker context create docker-multiarch
docker run --rm --privileged multiarch/qemu-user-static --reset -p yes docker run --rm --privileged multiarch/qemu-user-static --reset -p yes
docker buildx create --name docker-multiarch --platform linux/amd64,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x docker-multiarch docker buildx create --name docker-multiarch --platform linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x docker-multiarch
docker buildx inspect --builder docker-multiarch --bootstrap docker buildx inspect --builder docker-multiarch --bootstrap
docker buildx use docker-multiarch docker buildx use docker-multiarch
- run: - run:
@@ -142,9 +142,9 @@ jobs:
name: Build and push Docker image name: Build and push Docker image
command: | command: |
if [[ "$CIRCLE_TAG" == *"-"* ]]; then if [[ "$CIRCLE_TAG" == *"-"* ]]; then
docker buildx build -t $IMAGE_NAME:${CIRCLE_TAG/v/''} --platform linux/amd64,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x --push . docker buildx build -t $IMAGE_NAME:${CIRCLE_TAG/v/''} --platform linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x --push .
else else
docker buildx build -t $IMAGE_NAME:latest -t $IMAGE_NAME:${CIRCLE_TAG/v/''} --platform linux/amd64,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x --push . docker buildx build -t $IMAGE_NAME:latest -t $IMAGE_NAME:${CIRCLE_TAG/v/''} --platform linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x --push .
fi fi
workflows: workflows:

View File

@@ -127,26 +127,6 @@ GITHUB_APP_NAME=
GITHUB_APP_ID= GITHUB_APP_ID=
GITHUB_APP_PRIVATE_KEY= GITHUB_APP_PRIVATE_KEY=
# To configure Discord auth, you'll need to create a Discord Application at
# => https://discord.com/developers/applications/
#
# When configuring the Client ID, add a redirect URL under "OAuth2":
# https://<URL>/auth/discord.callback
DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=
# DISCORD_SERVER_ID should be the ID of the Discord server that Outline is
# integrated with.
# Used to verify that the user is a member of the server as well as server
# metadata such as nicknames, server icon and name.
DISCORD_SERVER_ID=
# DISCORD_SERVER_ROLES should be a comma separated list of role IDs that are
# allowed to access Outline. If this is not set, all members of the server
# will be allowed to access Outline.
# DISCORD_SERVER_ID and DISCORD_SERVER_ROLES must be set together.
DISCORD_SERVER_ROLES=
# OPTIONAL # OPTIONAL
# Base64 encoded private key and certificate for HTTPS termination. This is only # Base64 encoded private key and certificate for HTTPS termination. This is only

View File

@@ -41,7 +41,6 @@
"@typescript-eslint/no-shadow": [ "@typescript-eslint/no-shadow": [
"warn", "warn",
{ {
"allow": ["transaction"],
"hoist": "all", "hoist": "all",
"ignoreTypeValueShadow": true "ignoreTypeValueShadow": true
} }

View File

@@ -7,8 +7,7 @@
"roots": ["<rootDir>/server", "<rootDir>/plugins"], "roots": ["<rootDir>/server", "<rootDir>/plugins"],
"moduleNameMapper": { "moduleNameMapper": {
"^@server/(.*)$": "<rootDir>/server/$1", "^@server/(.*)$": "<rootDir>/server/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1", "^@shared/(.*)$": "<rootDir>/shared/$1"
"react-medium-image-zoom": "<rootDir>/__mocks__/react-medium-image-zoom.js"
}, },
"setupFiles": ["<rootDir>/__mocks__/console.js"], "setupFiles": ["<rootDir>/__mocks__/console.js"],
"setupFilesAfterEnv": ["<rootDir>/server/test/setup.ts"], "setupFilesAfterEnv": ["<rootDir>/server/test/setup.ts"],
@@ -23,8 +22,7 @@
"^~/(.*)$": "<rootDir>/app/$1", "^~/(.*)$": "<rootDir>/app/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1", "^@shared/(.*)$": "<rootDir>/shared/$1",
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js", "^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js",
"^uuid$": "<rootDir>/node_modules/uuid/dist/index.js", "^uuid$": "<rootDir>/node_modules/uuid/dist/index.js"
"react-medium-image-zoom": "<rootDir>/__mocks__/react-medium-image-zoom.js"
}, },
"modulePaths": ["<rootDir>/app"], "modulePaths": ["<rootDir>/app"],
"setupFiles": ["<rootDir>/__mocks__/window.js"], "setupFiles": ["<rootDir>/__mocks__/window.js"],
@@ -39,8 +37,7 @@
"roots": ["<rootDir>/shared"], "roots": ["<rootDir>/shared"],
"moduleNameMapper": { "moduleNameMapper": {
"^@server/(.*)$": "<rootDir>/server/$1", "^@server/(.*)$": "<rootDir>/server/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1", "^@shared/(.*)$": "<rootDir>/shared/$1"
"react-medium-image-zoom": "<rootDir>/__mocks__/react-medium-image-zoom.js"
}, },
"setupFiles": ["<rootDir>/__mocks__/console.js"], "setupFiles": ["<rootDir>/__mocks__/console.js"],
"setupFilesAfterEnv": ["<rootDir>/shared/test/setup.ts"], "setupFilesAfterEnv": ["<rootDir>/shared/test/setup.ts"],
@@ -53,8 +50,7 @@
"^~/(.*)$": "<rootDir>/app/$1", "^~/(.*)$": "<rootDir>/app/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1", "^@shared/(.*)$": "<rootDir>/shared/$1",
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js", "^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js",
"^uuid$": "<rootDir>/node_modules/uuid/dist/index.js", "^uuid$": "<rootDir>/node_modules/uuid/dist/index.js"
"react-medium-image-zoom": "<rootDir>/__mocks__/react-medium-image-zoom.js"
}, },
"setupFiles": ["<rootDir>/__mocks__/window.js"], "setupFiles": ["<rootDir>/__mocks__/window.js"],
"testEnvironment": "jsdom", "testEnvironment": "jsdom",

View File

@@ -5,7 +5,9 @@ ARG APP_PATH
WORKDIR $APP_PATH WORKDIR $APP_PATH
# --- # ---
FROM node:20-slim AS runner FROM node:20-alpine AS runner
RUN apk update && apk add --no-cache curl && apk add --no-cache ca-certificates
LABEL org.opencontainers.image.source="https://github.com/outline/outline" LABEL org.opencontainers.image.source="https://github.com/outline/outline"
@@ -20,9 +22,8 @@ COPY --from=base $APP_PATH/.sequelizerc ./.sequelizerc
COPY --from=base $APP_PATH/node_modules ./node_modules COPY --from=base $APP_PATH/node_modules ./node_modules
COPY --from=base $APP_PATH/package.json ./package.json COPY --from=base $APP_PATH/package.json ./package.json
# Create a non-root user compatible with Debian and BusyBox based images RUN addgroup -g 1001 -S nodejs && \
RUN addgroup --gid 1001 nodejs && \ adduser -S nodejs -u 1001 && \
adduser --uid 1001 --ingroup nodejs nodejs && \
chown -R nodejs:nodejs $APP_PATH/build && \ chown -R nodejs:nodejs $APP_PATH/build && \
mkdir -p /var/lib/outline && \ mkdir -p /var/lib/outline && \
chown -R nodejs:nodejs /var/lib/outline chown -R nodejs:nodejs /var/lib/outline

View File

@@ -1,26 +1,19 @@
ARG APP_PATH=/opt/outline ARG APP_PATH=/opt/outline
FROM node:20-slim AS deps FROM node:20-alpine AS deps
ARG APP_PATH ARG APP_PATH
WORKDIR $APP_PATH WORKDIR $APP_PATH
COPY ./package.json ./yarn.lock ./ COPY ./package.json ./yarn.lock ./
COPY ./patches ./patches 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 && \ RUN yarn install --no-optional --frozen-lockfile --network-timeout 1000000 && \
yarn cache clean yarn cache clean
COPY . . COPY . .
ARG CDN_URL ARG CDN_URL
RUN NODE_OPTIONS="--max-old-space-size=8192" yarn build RUN yarn build
RUN rm -rf node_modules RUN rm -rf node_modules
RUN yarn install --production=true --frozen-lockfile --network-timeout 1000000 && \ RUN yarn install --production=true --frozen-lockfile --network-timeout 1000000 && \
yarn cache clean yarn cache clean
ENV PORT 3000
HEALTHCHECK CMD wget -qO- http://localhost:${PORT}/_health | grep -q "OK" || exit 1

View File

@@ -1 +0,0 @@
export default null;

View File

@@ -33,11 +33,6 @@
"generator": "secret", "generator": "secret",
"required": true "required": true
}, },
"UTILS_SECRET": {
"description": "A 32-character secret key, generate with openssl rand -hex 32",
"generator": "secret",
"required": true
},
"ENABLE_UPDATES": { "ENABLE_UPDATES": {
"value": "true", "value": "true",
"required": true "required": true

View File

@@ -1,25 +0,0 @@
import { PlusIcon } from "outline-icons";
import * as React from "react";
import stores from "~/stores";
import ApiKeyNew from "~/scenes/ApiKeyNew";
import { createAction } from "..";
import { SettingsSection } from "../sections";
export const createApiKey = createAction({
name: ({ t }) => t("New API key"),
analyticsName: "New API key",
section: SettingsSection,
icon: <PlusIcon />,
keywords: "create",
visible: () =>
stores.policies.abilities(stores.auth.team?.id || "").createApiKey,
perform: ({ t, event }) => {
event?.preventDefault();
event?.stopPropagation();
stores.dialogs.openModal({
title: t("New API key"),
content: <ApiKeyNew onSubmit={stores.dialogs.closeAllModals} />,
});
},
});

View File

@@ -4,7 +4,6 @@ import {
PadlockIcon, PadlockIcon,
PlusIcon, PlusIcon,
SearchIcon, SearchIcon,
ShapesIcon,
StarredIcon, StarredIcon,
TrashIcon, TrashIcon,
UnstarredIcon, UnstarredIcon,
@@ -12,6 +11,7 @@ import {
import * as React from "react"; import * as React from "react";
import stores from "~/stores"; import stores from "~/stores";
import Collection from "~/models/Collection"; import Collection from "~/models/Collection";
import CollectionPermissions from "~/scenes/CollectionPermissions";
import { CollectionEdit } from "~/components/Collection/CollectionEdit"; import { CollectionEdit } from "~/components/Collection/CollectionEdit";
import { CollectionNew } from "~/components/Collection/CollectionNew"; import { CollectionNew } from "~/components/Collection/CollectionNew";
import CollectionDeleteDialog from "~/components/CollectionDeleteDialog"; import CollectionDeleteDialog from "~/components/CollectionDeleteDialog";
@@ -21,8 +21,9 @@ import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
import { createAction } from "~/actions"; import { createAction } from "~/actions";
import { CollectionSection } from "~/actions/sections"; import { CollectionSection } from "~/actions/sections";
import { setPersistedState } from "~/hooks/usePersistedState"; import { setPersistedState } from "~/hooks/usePersistedState";
import { Feature, FeatureFlags } from "~/utils/FeatureFlags";
import history from "~/utils/history"; import history from "~/utils/history";
import { newTemplatePath, searchPath } from "~/utils/routeHelpers"; import { searchPath } from "~/utils/routeHelpers";
const ColorCollectionIcon = ({ collection }: { collection: Collection }) => ( const ColorCollectionIcon = ({ collection }: { collection: Collection }) => (
<DynamicCollectionIcon collection={collection} /> <DynamicCollectionIcon collection={collection} />
@@ -110,16 +111,24 @@ export const editCollectionPermissions = createAction({
return; return;
} }
stores.dialogs.openModal({ if (FeatureFlags.isEnabled(Feature.newCollectionSharing)) {
title: t("Share this collection"), stores.dialogs.openModal({
content: ( title: t("Share this collection"),
<SharePopover content: (
collection={collection} <SharePopover
onRequestClose={stores.dialogs.closeAllModals} collection={collection}
visible onRequestClose={stores.dialogs.closeAllModals}
/> visible
), />
}); ),
});
} else {
stores.dialogs.openModal({
title: t("Collection permissions"),
fullscreen: true,
content: <CollectionPermissions collectionId={activeCollectionId} />,
});
}
}, },
}); });
@@ -128,9 +137,7 @@ export const searchInCollection = createAction({
analyticsName: "Search collection", analyticsName: "Search collection",
section: CollectionSection, section: CollectionSection,
icon: <SearchIcon />, icon: <SearchIcon />,
visible: ({ activeCollectionId }) => visible: ({ activeCollectionId }) => !!activeCollectionId,
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).readDocument,
perform: ({ activeCollectionId }) => { perform: ({ activeCollectionId }) => {
history.push(searchPath(undefined, { collectionId: activeCollectionId })); history.push(searchPath(undefined, { collectionId: activeCollectionId }));
}, },
@@ -223,27 +230,6 @@ export const deleteCollection = createAction({
}, },
}); });
export const createTemplate = createAction({
name: ({ t }) => t("New template"),
analyticsName: "New template",
section: CollectionSection,
icon: <ShapesIcon />,
keywords: "new create template",
visible: ({ activeCollectionId, stores }) =>
!!(
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).createDocument
),
perform: ({ activeCollectionId, event }) => {
if (!activeCollectionId) {
return;
}
event?.preventDefault();
event?.stopPropagation();
history.push(newTemplatePath(activeCollectionId));
},
});
export const rootCollectionActions = [ export const rootCollectionActions = [
openCollection, openCollection,
createCollection, createCollection,

View File

@@ -1,90 +0,0 @@
import { DoneIcon, TrashIcon } from "outline-icons";
import * as React from "react";
import { toast } from "sonner";
import stores from "~/stores";
import Comment from "~/models/Comment";
import CommentDeleteDialog from "~/components/CommentDeleteDialog";
import history from "~/utils/history";
import { createAction } from "..";
import { DocumentSection } from "../sections";
export const deleteCommentFactory = ({
comment,
onDelete,
}: {
comment: Comment;
onDelete: () => void;
}) =>
createAction({
name: ({ t }) => `${t("Delete")}`,
analyticsName: "Delete comment",
section: DocumentSection,
icon: <TrashIcon />,
keywords: "trash",
dangerous: true,
visible: () => stores.policies.abilities(comment.id).delete,
perform: ({ t, event }) => {
event?.preventDefault();
event?.stopPropagation();
stores.dialogs.openModal({
title: t("Delete comment"),
content: <CommentDeleteDialog comment={comment} onSubmit={onDelete} />,
});
},
});
export const resolveCommentFactory = ({
comment,
onResolve,
}: {
comment: Comment;
onResolve: () => void;
}) =>
createAction({
name: ({ t }) => t("Mark as resolved"),
analyticsName: "Resolve thread",
section: DocumentSection,
icon: <DoneIcon outline />,
visible: () =>
stores.policies.abilities(comment.id).resolve &&
stores.policies.abilities(comment.documentId).update,
perform: async ({ t }) => {
await comment.resolve();
history.replace({
...history.location,
state: null,
});
onResolve();
toast.success(t("Thread resolved"));
},
});
export const unresolveCommentFactory = ({
comment,
onUnresolve,
}: {
comment: Comment;
onUnresolve: () => void;
}) =>
createAction({
name: ({ t }) => t("Mark as unresolved"),
analyticsName: "Unresolve thread",
section: DocumentSection,
icon: <DoneIcon outline />,
visible: () =>
stores.policies.abilities(comment.id).unresolve &&
stores.policies.abilities(comment.documentId).update,
perform: async () => {
await comment.unresolve();
history.replace({
...history.location,
state: null,
});
onUnresolve();
},
});

View File

@@ -51,7 +51,6 @@ import {
documentHistoryPath, documentHistoryPath,
homePath, homePath,
newDocumentPath, newDocumentPath,
newNestedDocumentPath,
searchPath, searchPath,
documentPath, documentPath,
urlify, urlify,
@@ -141,10 +140,15 @@ export const createNestedDocument = createAction({
!!activeDocumentId && !!activeDocumentId &&
stores.policies.abilities(currentTeamId).createDocument && stores.policies.abilities(currentTeamId).createDocument &&
stores.policies.abilities(activeDocumentId).createChildDocument, stores.policies.abilities(activeDocumentId).createChildDocument,
perform: ({ activeDocumentId, inStarredSection }) => perform: ({ activeCollectionId, activeDocumentId, inStarredSection }) =>
history.push(newNestedDocumentPath(activeDocumentId), { history.push(
starred: inStarredSection, newDocumentPath(activeCollectionId, {
}), parentDocumentId: activeDocumentId,
}),
{
starred: inStarredSection,
}
),
}); });
export const starDocument = createAction({ export const starDocument = createAction({
@@ -672,22 +676,22 @@ export const importDocument = createAction({
}, },
}); });
export const createTemplateFromDocument = createAction({ export const createTemplate = createAction({
name: ({ t }) => t("Templatize"), name: ({ t }) => t("Templatize"),
analyticsName: "Templatize document", analyticsName: "Templatize document",
section: DocumentSection, section: DocumentSection,
icon: <ShapesIcon />, icon: <ShapesIcon />,
keywords: "new create template", keywords: "new create template",
visible: ({ activeCollectionId, activeDocumentId, stores }) => { visible: ({ activeCollectionId, activeDocumentId, stores }) => {
const document = activeDocumentId if (!activeDocumentId) {
? stores.documents.get(activeDocumentId)
: undefined;
if (document?.isTemplate || !document?.isActive) {
return false; return false;
} }
const document = stores.documents.get(activeDocumentId);
return !!( return !!(
!!activeCollectionId && !!activeCollectionId &&
stores.policies.abilities(activeCollectionId).update stores.policies.abilities(activeCollectionId).update &&
!document?.isTemplate &&
!!document?.isActive
); );
}, },
perform: ({ activeDocumentId, stores, t, event }) => { perform: ({ activeDocumentId, stores, t, event }) => {
@@ -696,6 +700,7 @@ export const createTemplateFromDocument = createAction({
} }
event?.preventDefault(); event?.preventDefault();
event?.stopPropagation(); event?.stopPropagation();
stores.dialogs.openModal({ stores.dialogs.openModal({
title: t("Create template"), title: t("Create template"),
content: <DocumentTemplatizeDialog documentId={activeDocumentId} />, content: <DocumentTemplatizeDialog documentId={activeDocumentId} />,
@@ -983,7 +988,7 @@ export const rootDocumentActions = [
openDocument, openDocument,
archiveDocument, archiveDocument,
createDocument, createDocument,
createTemplateFromDocument, createTemplate,
deleteDocument, deleteDocument,
importDocument, importDocument,
downloadDocument, downloadDocument,

View File

@@ -3,6 +3,7 @@ import {
SearchIcon, SearchIcon,
ArchiveIcon, ArchiveIcon,
TrashIcon, TrashIcon,
EditIcon,
OpenIcon, OpenIcon,
SettingsIcon, SettingsIcon,
KeyboardIcon, KeyboardIcon,
@@ -11,7 +12,6 @@ import {
ProfileIcon, ProfileIcon,
BrowserIcon, BrowserIcon,
ShapesIcon, ShapesIcon,
DraftsIcon,
} from "outline-icons"; } from "outline-icons";
import * as React from "react"; import * as React from "react";
import { UrlHelper } from "@shared/utils/UrlHelper"; import { UrlHelper } from "@shared/utils/UrlHelper";
@@ -57,7 +57,7 @@ export const navigateToDrafts = createAction({
name: ({ t }) => t("Drafts"), name: ({ t }) => t("Drafts"),
analyticsName: "Navigate to drafts", analyticsName: "Navigate to drafts",
section: NavigationSection, section: NavigationSection,
icon: <DraftsIcon />, icon: <EditIcon />,
perform: () => history.push(draftsPath()), perform: () => history.push(draftsPath()),
visible: ({ location }) => location.pathname !== draftsPath(), visible: ({ location }) => location.pathname !== draftsPath(),
}); });

View File

@@ -2,14 +2,13 @@
/* global ga */ /* global ga */
import escape from "lodash/escape"; import escape from "lodash/escape";
import * as React from "react"; import * as React from "react";
import { IntegrationService, PublicEnv } from "@shared/types"; import { IntegrationService } from "@shared/types";
import env from "~/env"; import env from "~/env";
type Props = { type Props = {
children?: React.ReactNode; children?: React.ReactNode;
}; };
// TODO: Refactor this component to allow injection from plugins
const Analytics: React.FC = ({ children }: Props) => { const Analytics: React.FC = ({ children }: Props) => {
// Google Analytics 3 // Google Analytics 3
React.useEffect(() => { React.useEffect(() => {
@@ -44,16 +43,12 @@ const Analytics: React.FC = ({ children }: Props) => {
React.useEffect(() => { React.useEffect(() => {
const measurementIds = []; const measurementIds = [];
if (env.analytics.service === IntegrationService.GoogleAnalytics) {
measurementIds.push(escape(env.analytics.settings?.measurementId));
}
if (env.GOOGLE_ANALYTICS_ID?.startsWith("G-")) { if (env.GOOGLE_ANALYTICS_ID?.startsWith("G-")) {
measurementIds.push(env.GOOGLE_ANALYTICS_ID); measurementIds.push(env.GOOGLE_ANALYTICS_ID);
} }
(env.analytics as PublicEnv["analytics"]).forEach((integration) => {
if (integration.service === IntegrationService.GoogleAnalytics) {
measurementIds.push(escape(integration.settings?.measurementId));
}
});
if (measurementIds.length === 0) { if (measurementIds.length === 0) {
return; return;
} }
@@ -80,32 +75,6 @@ const Analytics: React.FC = ({ children }: Props) => {
document.getElementsByTagName("head")[0]?.appendChild(script); document.getElementsByTagName("head")[0]?.appendChild(script);
}, []); }, []);
// Matomo
React.useEffect(() => {
(env.analytics as PublicEnv["analytics"]).forEach((integration) => {
if (integration.service !== IntegrationService.Matomo) {
return;
}
// @ts-expect-error - Matomo global variable
const _paq = (window._paq = window._paq || []);
_paq.push(["trackPageView"]);
_paq.push(["enableLinkTracking"]);
(function () {
const u = integration.settings?.instanceUrl;
_paq.push(["setTrackerUrl", u + "matomo.php"]);
_paq.push(["setSiteId", integration.settings?.measurementId]);
const d = document,
g = d.createElement("script"),
s = d.getElementsByTagName("script")[0];
g.type = "text/javascript";
g.async = true;
g.src = u + "matomo.js";
s.parentNode?.insertBefore(g, s);
})();
});
}, []);
return <>{children}</>; return <>{children}</>;
}; };

View File

@@ -1,50 +1,54 @@
import { RovingTabIndexProvider } from "@getoutline/react-roving-tabindex";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import * as React from "react"; import * as React from "react";
import {
useCompositeState,
Composite,
CompositeStateReturn,
} from "reakit/Composite";
type Props = React.HTMLAttributes<HTMLDivElement> & { type Props = React.HTMLAttributes<HTMLDivElement> & {
children: () => React.ReactNode; children: (composite: CompositeStateReturn) => React.ReactNode;
onEscape?: (ev: React.KeyboardEvent<HTMLDivElement>) => void; onEscape?: (ev: React.KeyboardEvent<HTMLDivElement>) => void;
items: unknown[];
}; };
function ArrowKeyNavigation( function ArrowKeyNavigation(
{ children, onEscape, items, ...rest }: Props, { children, onEscape, ...rest }: Props,
ref: React.RefObject<HTMLDivElement> ref: React.RefObject<HTMLDivElement>
) { ) {
const composite = useCompositeState();
const handleKeyDown = React.useCallback( const handleKeyDown = React.useCallback(
(ev: React.KeyboardEvent<HTMLDivElement>) => { (ev) => {
if (onEscape) { if (onEscape) {
if (ev.nativeEvent.isComposing) { if (ev.nativeEvent.isComposing) {
return; return;
} }
if (ev.key === "Escape") { if (ev.key === "Escape") {
ev.preventDefault();
onEscape(ev); onEscape(ev);
} }
if ( if (
ev.key === "ArrowUp" && ev.key === "ArrowUp" &&
// If the first item is focused and the user presses ArrowUp composite.currentId === composite.items[0].id
ev.currentTarget.firstElementChild === document.activeElement
) { ) {
onEscape(ev); onEscape(ev);
} }
} }
}, },
[onEscape] [composite.currentId, composite.items, onEscape]
); );
return ( return (
<RovingTabIndexProvider <Composite
options={{ focusOnClick: true, direction: "both" }} {...rest}
items={items} {...composite}
onKeyDown={handleKeyDown}
role="menu"
ref={ref}
> >
<div {...rest} onKeyDown={handleKeyDown} ref={ref}> {children(composite)}
{children()} </Composite>
</div>
</RovingTabIndexProvider>
); );
} }

View File

@@ -49,8 +49,8 @@ const RealButton = styled(ActionButton)<RealProps>`
&:disabled { &:disabled {
cursor: default; cursor: default;
pointer-events: none; pointer-events: none;
color: ${(props) => transparentize(0.3, props.theme.accentText)}; color: ${(props) => transparentize(0.5, props.theme.accentText)};
background: ${(props) => transparentize(0.1, props.theme.accent)}; background: ${(props) => lighten(0.2, props.theme.accent)};
svg { svg {
fill: ${(props) => props.theme.white50}; fill: ${(props) => props.theme.white50};

View File

@@ -11,22 +11,19 @@ import { CollectionValidation } from "@shared/validations";
import Collection from "~/models/Collection"; import Collection from "~/models/Collection";
import Button from "~/components/Button"; import Button from "~/components/Button";
import Flex from "~/components/Flex"; import Flex from "~/components/Flex";
import Icon from "~/components/Icon"; import IconPicker from "~/components/IconPicker";
import Input from "~/components/Input"; import Input from "~/components/Input";
import InputSelectPermission from "~/components/InputSelectPermission"; import InputSelectPermission from "~/components/InputSelectPermission";
import Switch from "~/components/Switch"; import Switch from "~/components/Switch";
import Text from "~/components/Text"; import Text from "~/components/Text";
import useBoolean from "~/hooks/useBoolean"; import useBoolean from "~/hooks/useBoolean";
import useCurrentTeam from "~/hooks/useCurrentTeam"; import useCurrentTeam from "~/hooks/useCurrentTeam";
import { EmptySelectValue } from "~/types";
import { Feature, FeatureFlags } from "~/utils/FeatureFlags"; import { Feature, FeatureFlags } from "~/utils/FeatureFlags";
const IconPicker = React.lazy(() => import("~/components/IconPicker"));
export interface FormData { export interface FormData {
name: string; name: string;
icon: string; icon: string;
color: string | null; color: string;
sharing: boolean; sharing: boolean;
permission: CollectionPermission | undefined; permission: CollectionPermission | undefined;
} }
@@ -40,16 +37,7 @@ export const CollectionForm = observer(function CollectionForm_({
}) { }) {
const team = useCurrentTeam(); const team = useCurrentTeam();
const { t } = useTranslation(); const { t } = useTranslation();
const [hasOpenedIconPicker, setHasOpenedIconPicker] = useBoolean(false); const [hasOpenedIconPicker, setHasOpenedIconPicker] = useBoolean(false);
const iconColor = React.useMemo(
() => collection?.color ?? randomElement(colorPalette),
[collection?.color]
);
const fallbackIcon = <Icon value="collection" color={iconColor} />;
const { const {
register, register,
handleSubmit: formHandleSubmit, handleSubmit: formHandleSubmit,
@@ -65,7 +53,7 @@ export const CollectionForm = observer(function CollectionForm_({
icon: collection?.icon, icon: collection?.icon,
sharing: collection?.sharing ?? true, sharing: collection?.sharing ?? true,
permission: collection?.permission, permission: collection?.permission,
color: iconColor, color: collection?.color ?? randomElement(colorPalette),
}, },
}); });
@@ -82,20 +70,20 @@ export const CollectionForm = observer(function CollectionForm_({
"collection" "collection"
); );
} }
}, [collection, hasOpenedIconPicker, setValue, values.name, values.icon]); }, [values.name, collection]);
React.useEffect(() => { React.useEffect(() => {
setTimeout(() => setFocus("name", { shouldSelect: true }), 100); setTimeout(() => setFocus("name", { shouldSelect: true }), 100);
}, [setFocus]); }, [setFocus]);
const handleIconChange = React.useCallback( const handleIconPickerChange = React.useCallback(
(icon: string, color: string | null) => { (color: string, icon: string) => {
if (icon !== values.icon) { if (icon !== values.icon) {
setFocus("name"); setFocus("name");
} }
setValue("icon", icon);
setValue("color", color); setValue("color", color);
setValue("icon", icon);
}, },
[setFocus, setValue, values.icon] [setFocus, setValue, values.icon]
); );
@@ -117,16 +105,13 @@ export const CollectionForm = observer(function CollectionForm_({
maxLength: CollectionValidation.maxNameLength, maxLength: CollectionValidation.maxNameLength,
})} })}
prefix={ prefix={
<React.Suspense fallback={fallbackIcon}> <StyledIconPicker
<StyledIconPicker onOpen={setHasOpenedIconPicker}
icon={values.icon} onChange={handleIconPickerChange}
color={values.color ?? iconColor} initial={values.name[0]}
initial={values.name[0]} color={values.color}
popoverPosition="right" icon={values.icon}
onOpen={setHasOpenedIconPicker} />
onChange={handleIconChange}
/>
</React.Suspense>
} }
autoComplete="off" autoComplete="off"
autoFocus autoFocus
@@ -143,10 +128,8 @@ export const CollectionForm = observer(function CollectionForm_({
<InputSelectPermission <InputSelectPermission
ref={field.ref} ref={field.ref}
value={field.value} value={field.value}
onChange={( onChange={(value: CollectionPermission) => {
value: CollectionPermission | typeof EmptySelectValue field.onChange(value);
) => {
field.onChange(value === EmptySelectValue ? null : value);
}} }}
note={t( note={t(
"The default access for workspace members, you can share with more users or groups later." "The default access for workspace members, you can share with more users or groups later."

View File

@@ -1,12 +1,11 @@
import { LocationDescriptor } from "history"; import { LocationDescriptor } from "history";
import { CheckmarkIcon } from "outline-icons"; import { CheckmarkIcon } from "outline-icons";
import { ellipsis, transparentize } from "polished"; import { ellipsis } from "polished";
import * as React from "react"; import * as React from "react";
import { mergeRefs } from "react-merge-refs"; import { mergeRefs } from "react-merge-refs";
import { MenuItem as BaseMenuItem } from "reakit/Menu"; import { MenuItem as BaseMenuItem } from "reakit/Menu";
import styled, { css } from "styled-components"; import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint"; import breakpoint from "styled-components-breakpoint";
import Text from "../Text";
import MenuIconWrapper from "./MenuIconWrapper"; import MenuIconWrapper from "./MenuIconWrapper";
type Props = { type Props = {
@@ -161,10 +160,6 @@ export const MenuAnchorCSS = css<MenuAnchorProps>`
color: ${props.theme.accentText}; color: ${props.theme.accentText};
fill: ${props.theme.accentText}; fill: ${props.theme.accentText};
} }
${Text} {
color: ${transparentize(0.5, props.theme.accentText)};
}
} }
} }
`} `}

View File

@@ -30,7 +30,6 @@ type Props = Omit<MenuStateReturn, "items"> & {
actions?: (Action | MenuSeparator | MenuHeading)[]; actions?: (Action | MenuSeparator | MenuHeading)[];
context?: Partial<ActionContext>; context?: Partial<ActionContext>;
items?: TMenuItem[]; items?: TMenuItem[];
showIcons?: boolean;
}; };
const Disclosure = styled(ExpandedIcon)` const Disclosure = styled(ExpandedIcon)`
@@ -99,7 +98,7 @@ export function filterTemplateItems(items: TMenuItem[]): TMenuItem[] {
}); });
} }
function Template({ items, actions, context, showIcons, ...menu }: Props) { function Template({ items, actions, context, ...menu }: Props) {
const ctx = useActionContext({ const ctx = useActionContext({
isContextMenu: true, isContextMenu: true,
}); });
@@ -125,8 +124,7 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
if ( if (
iconIsPresentInAnyMenuItem && iconIsPresentInAnyMenuItem &&
item.type !== "separator" && item.type !== "separator" &&
item.type !== "heading" && item.type !== "heading"
showIcons !== false
) { ) {
item.icon = item.icon || <MenuIconWrapper aria-hidden />; item.icon = item.icon || <MenuIconWrapper aria-hidden />;
} }
@@ -140,7 +138,7 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
key={index} key={index}
disabled={item.disabled} disabled={item.disabled}
selected={item.selected} selected={item.selected}
icon={showIcons !== false ? item.icon : undefined} icon={item.icon}
{...menu} {...menu}
> >
{item.title} {item.title}
@@ -158,7 +156,7 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
selected={item.selected} selected={item.selected}
level={item.level} level={item.level}
target={item.href.startsWith("#") ? undefined : "_blank"} target={item.href.startsWith("#") ? undefined : "_blank"}
icon={showIcons !== false ? item.icon : undefined} icon={item.icon}
{...menu} {...menu}
> >
{item.title} {item.title}
@@ -176,7 +174,7 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
selected={item.selected} selected={item.selected}
dangerous={item.dangerous} dangerous={item.dangerous}
key={index} key={index}
icon={showIcons !== false ? item.icon : undefined} icon={item.icon}
{...menu} {...menu}
> >
{item.title} {item.title}
@@ -192,12 +190,7 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
id={`${item.title}-${index}`} id={`${item.title}-${index}`}
templateItems={item.items} templateItems={item.items}
parentMenuState={menu} parentMenuState={menu}
title={ title={<Title title={item.title} icon={item.icon} />}
<Title
title={item.title}
icon={showIcons !== false ? item.icon : undefined}
/>
}
{...menu} {...menu}
/> />
); );

View File

@@ -6,7 +6,6 @@ import styled from "styled-components";
import type { NavigationNode } from "@shared/types"; import type { NavigationNode } from "@shared/types";
import Document from "~/models/Document"; import Document from "~/models/Document";
import Breadcrumb from "~/components/Breadcrumb"; import Breadcrumb from "~/components/Breadcrumb";
import Icon from "~/components/Icon";
import CollectionIcon from "~/components/Icons/CollectionIcon"; import CollectionIcon from "~/components/Icons/CollectionIcon";
import useStores from "~/hooks/useStores"; import useStores from "~/hooks/useStores";
import { MenuInternalLink } from "~/types"; import { MenuInternalLink } from "~/types";
@@ -16,6 +15,7 @@ import {
settingsPath, settingsPath,
trashPath, trashPath,
} from "~/utils/routeHelpers"; } from "~/utils/routeHelpers";
import EmojiIcon from "./Icons/EmojiIcon";
type Props = { type Props = {
children?: React.ReactNode; children?: React.ReactNode;
@@ -106,9 +106,9 @@ const DocumentBreadcrumb: React.FC<Props> = ({
path.slice(0, -1).forEach((node: NavigationNode) => { path.slice(0, -1).forEach((node: NavigationNode) => {
output.push({ output.push({
type: "route", type: "route",
title: node.icon ? ( title: node.emoji ? (
<> <>
<StyledIcon value={node.icon} color={node.color} /> {node.title} <EmojiIcon emoji={node.emoji} /> {node.title}
</> </>
) : ( ) : (
node.title node.title
@@ -144,10 +144,6 @@ const DocumentBreadcrumb: React.FC<Props> = ({
); );
}; };
const StyledIcon = styled(Icon)`
margin-right: 2px;
`;
const SmallSlash = styled(GoToIcon)` const SmallSlash = styled(GoToIcon)`
width: 12px; width: 12px;
height: 12px; height: 12px;

View File

@@ -9,17 +9,15 @@ import { Link } from "react-router-dom";
import styled, { useTheme } from "styled-components"; import styled, { useTheme } from "styled-components";
import Squircle from "@shared/components/Squircle"; import Squircle from "@shared/components/Squircle";
import { s, ellipsis } from "@shared/styles"; import { s, ellipsis } from "@shared/styles";
import { IconType } from "@shared/types";
import { determineIconType } from "@shared/utils/icon";
import Document from "~/models/Document"; import Document from "~/models/Document";
import Pin from "~/models/Pin"; import Pin from "~/models/Pin";
import Flex from "~/components/Flex"; import Flex from "~/components/Flex";
import Icon from "~/components/Icon";
import NudeButton from "~/components/NudeButton"; import NudeButton from "~/components/NudeButton";
import Time from "~/components/Time"; import Time from "~/components/Time";
import useStores from "~/hooks/useStores"; import useStores from "~/hooks/useStores";
import { hover } from "~/styles"; import { hover } from "~/styles";
import CollectionIcon from "./Icons/CollectionIcon"; import CollectionIcon from "./Icons/CollectionIcon";
import EmojiIcon from "./Icons/EmojiIcon";
import Text from "./Text"; import Text from "./Text";
import Tooltip from "./Tooltip"; import Tooltip from "./Tooltip";
@@ -54,8 +52,6 @@ function DocumentCard(props: Props) {
disabled: !isDraggable || !canUpdatePin, disabled: !isDraggable || !canUpdatePin,
}); });
const hasEmojiInTitle = determineIconType(document.icon) === IconType.Emoji;
const style = { const style = {
transform: CSS.Transform.toString(transform), transform: CSS.Transform.toString(transform),
transition, transition,
@@ -113,18 +109,12 @@ function DocumentCard(props: Props) {
<path d="M19.5 19.5H6C2.96243 19.5 0.5 17.0376 0.5 14V0.5H0.792893L19.5 19.2071V19.5Z" /> <path d="M19.5 19.5H6C2.96243 19.5 0.5 17.0376 0.5 14V0.5H0.792893L19.5 19.2071V19.5Z" />
</Fold> </Fold>
{document.icon ? ( {document.emoji ? (
<DocumentSquircle <Squircle color={theme.slateLight}>
icon={document.icon} <EmojiIcon emoji={document.emoji} size={24} />
color={document.color ?? undefined} </Squircle>
/>
) : ( ) : (
<Squircle <Squircle color={collection?.color}>
color={
collection?.color ??
(!pin?.collectionId ? theme.slateLight : theme.slateDark)
}
>
{collection?.icon && {collection?.icon &&
collection?.icon !== "letter" && collection?.icon !== "letter" &&
collection?.icon !== "collection" && collection?.icon !== "collection" &&
@@ -137,8 +127,8 @@ function DocumentCard(props: Props) {
)} )}
<div> <div>
<Heading dir={document.dir}> <Heading dir={document.dir}>
{hasEmojiInTitle {document.emoji
? document.titleWithDefault.replace(document.icon!, "") ? document.titleWithDefault.replace(document.emoji, "")
: document.titleWithDefault} : document.titleWithDefault}
</Heading> </Heading>
<DocumentMeta size="xsmall"> <DocumentMeta size="xsmall">
@@ -169,24 +159,6 @@ function DocumentCard(props: Props) {
); );
} }
const DocumentSquircle = ({
icon,
color,
}: {
icon: string;
color?: string;
}) => {
const theme = useTheme();
const iconType = determineIconType(icon)!;
const squircleColor = iconType === IconType.SVG ? color : theme.slateLight;
return (
<Squircle color={squircleColor}>
<Icon value={icon} color={theme.white} />
</Squircle>
);
};
const Clock = styled(ClockIcon)` const Clock = styled(ClockIcon)`
flex-shrink: 0; flex-shrink: 0;
`; `;

View File

@@ -18,8 +18,8 @@ import { NavigationNode } from "@shared/types";
import DocumentExplorerNode from "~/components/DocumentExplorerNode"; import DocumentExplorerNode from "~/components/DocumentExplorerNode";
import DocumentExplorerSearchResult from "~/components/DocumentExplorerSearchResult"; import DocumentExplorerSearchResult from "~/components/DocumentExplorerSearchResult";
import Flex from "~/components/Flex"; import Flex from "~/components/Flex";
import Icon from "~/components/Icon";
import CollectionIcon from "~/components/Icons/CollectionIcon"; import CollectionIcon from "~/components/Icons/CollectionIcon";
import EmojiIcon from "~/components/Icons/EmojiIcon";
import { Outline } from "~/components/Input"; import { Outline } from "~/components/Input";
import InputSearch from "~/components/InputSearch"; import InputSearch from "~/components/InputSearch";
import Text from "~/components/Text"; import Text from "~/components/Text";
@@ -216,30 +216,25 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
}) => { }) => {
const node = data[index]; const node = data[index];
const isCollection = node.type === "collection"; const isCollection = node.type === "collection";
let renderedIcon, let icon, title: string, emoji: string | undefined, path;
title: string,
icon: string | undefined,
color: string | undefined,
path;
if (isCollection) { if (isCollection) {
const col = collections.get(node.collectionId as string); const col = collections.get(node.collectionId as string);
renderedIcon = col && ( icon = col && (
<CollectionIcon collection={col} expanded={isExpanded(index)} /> <CollectionIcon collection={col} expanded={isExpanded(index)} />
); );
title = node.title; title = node.title;
} else { } else {
const doc = documents.get(node.id); const doc = documents.get(node.id);
icon = doc?.icon ?? node.icon; emoji = doc?.emoji ?? node.emoji;
color = doc?.color ?? node.color;
title = doc?.title ?? node.title; title = doc?.title ?? node.title;
if (icon) { if (emoji) {
renderedIcon = <Icon value={icon} color={color} />; icon = <EmojiIcon emoji={emoji} />;
} else if (doc?.isStarred) { } else if (doc?.isStarred) {
renderedIcon = <StarredIcon color={theme.yellow} />; icon = <StarredIcon color={theme.yellow} />;
} else { } else {
renderedIcon = <DocumentIcon color={theme.textSecondary} />; icon = <DocumentIcon color={theme.textSecondary} />;
} }
path = ancestors(node) path = ancestors(node)
@@ -259,7 +254,7 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
}} }}
onPointerMove={() => setActiveNode(index)} onPointerMove={() => setActiveNode(index)}
onClick={() => toggleSelect(index)} onClick={() => toggleSelect(index)}
icon={renderedIcon} icon={icon}
title={title} title={title}
path={path} path={path}
/> />
@@ -280,7 +275,7 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
selected={isSelected(index)} selected={isSelected(index)}
active={activeNode === index} active={activeNode === index}
expanded={isExpanded(index)} expanded={isExpanded(index)}
icon={renderedIcon} icon={icon}
title={title} title={title}
depth={node.depth as number} depth={node.depth as number}
hasChildren={hasChildren(index)} hasChildren={hasChildren(index)}

View File

@@ -1,21 +1,17 @@
import {
useFocusEffect,
useRovingTabIndex,
} from "@getoutline/react-roving-tabindex";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import * as React from "react"; import * as React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { CompositeStateReturn, CompositeItem } from "reakit/Composite";
import styled, { css } from "styled-components"; import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint"; import breakpoint from "styled-components-breakpoint";
import EventBoundary from "@shared/components/EventBoundary";
import { s } from "@shared/styles"; import { s } from "@shared/styles";
import Document from "~/models/Document"; import Document from "~/models/Document";
import Badge from "~/components/Badge"; import Badge from "~/components/Badge";
import DocumentMeta from "~/components/DocumentMeta"; import DocumentMeta from "~/components/DocumentMeta";
import EventBoundary from "~/components/EventBoundary";
import Flex from "~/components/Flex"; import Flex from "~/components/Flex";
import Highlight from "~/components/Highlight"; import Highlight from "~/components/Highlight";
import Icon from "~/components/Icon";
import NudeButton from "~/components/NudeButton"; import NudeButton from "~/components/NudeButton";
import StarButton, { AnimatedStar } from "~/components/Star"; import StarButton, { AnimatedStar } from "~/components/Star";
import Tooltip from "~/components/Tooltip"; import Tooltip from "~/components/Tooltip";
@@ -24,6 +20,7 @@ import useCurrentUser from "~/hooks/useCurrentUser";
import DocumentMenu from "~/menus/DocumentMenu"; import DocumentMenu from "~/menus/DocumentMenu";
import { hover } from "~/styles"; import { hover } from "~/styles";
import { documentPath } from "~/utils/routeHelpers"; import { documentPath } from "~/utils/routeHelpers";
import EmojiIcon from "./Icons/EmojiIcon";
type Props = { type Props = {
document: Document; document: Document;
@@ -35,7 +32,7 @@ type Props = {
showPin?: boolean; showPin?: boolean;
showDraft?: boolean; showDraft?: boolean;
showTemplate?: boolean; showTemplate?: boolean;
}; } & CompositeStateReturn;
const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi; const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi;
@@ -52,15 +49,6 @@ function DocumentListItem(
const user = useCurrentUser(); const user = useCurrentUser();
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean(); const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
let itemRef: React.Ref<HTMLAnchorElement> =
React.useRef<HTMLAnchorElement>(null);
if (ref) {
itemRef = ref;
}
const { focused, ...rovingTabIndex } = useRovingTabIndex(itemRef, false);
useFocusEffect(focused, itemRef);
const { const {
document, document,
showParentDocuments, showParentDocuments,
@@ -80,8 +68,9 @@ function DocumentListItem(
!document.isDraft && !document.isArchived && !document.isTemplate; !document.isDraft && !document.isArchived && !document.isTemplate;
return ( return (
<DocumentLink <CompositeItem
ref={itemRef} as={DocumentLink}
ref={ref}
dir={document.dir} dir={document.dir}
role="menuitem" role="menuitem"
$isStarred={document.isStarred} $isStarred={document.isStarred}
@@ -100,13 +89,12 @@ function DocumentListItem(
handleMenuOpen(); handleMenuOpen();
}} }}
{...rest} {...rest}
{...rovingTabIndex}
> >
<Content> <Content>
<Heading dir={document.dir}> <Heading dir={document.dir}>
{document.icon && ( {document.emoji && (
<> <>
<Icon value={document.icon} color={document.color ?? undefined} /> <EmojiIcon emoji={document.emoji} size={24} />
&nbsp; &nbsp;
</> </>
)} )}
@@ -162,7 +150,7 @@ function DocumentListItem(
modal={false} modal={false}
/> />
</Actions> </Actions>
</DocumentLink> </CompositeItem>
); );
} }
@@ -283,8 +271,6 @@ const ResultContext = styled(Highlight)`
font-size: 15px; font-size: 15px;
margin-top: -0.25em; margin-top: -0.25em;
margin-bottom: 0.25em; margin-bottom: 0.25em;
max-height: 90px;
overflow: hidden;
`; `;
export default observer(React.forwardRef(DocumentListItem)); export default observer(React.forwardRef(DocumentListItem));

View File

@@ -0,0 +1,23 @@
import styled from "styled-components";
import Button from "~/components/Button";
import { hover } from "~/styles";
import Flex from "../Flex";
export const EmojiButton = styled(Button)`
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
&: ${hover},
&:active,
&[aria-expanded= "true"] {
opacity: 1 !important;
}
`;
export const Emoji = styled(Flex)<{ size?: number }>`
line-height: 1.6;
${(props) => (props.size ? `font-size: ${props.size}px` : "")}
`;

View File

@@ -0,0 +1,262 @@
import data from "@emoji-mart/data";
import Picker from "@emoji-mart/react";
import { SmileyIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
import styled, { useTheme } from "styled-components";
import { depths, s } from "@shared/styles";
import { toRGB } from "@shared/utils/color";
import Button from "~/components/Button";
import Popover from "~/components/Popover";
import useStores from "~/hooks/useStores";
import useUserLocale from "~/hooks/useUserLocale";
import { Emoji, EmojiButton } from "./components";
/* Locales supported by emoji-mart */
const supportedLocales = [
"en",
"ar",
"be",
"cs",
"de",
"es",
"fa",
"fi",
"fr",
"hi",
"it",
"ja",
"ko",
"nl",
"pl",
"pt",
"ru",
"sa",
"tr",
"uk",
"vi",
"zh",
];
/**
* React hook to derive emoji picker's theme from UI theme
*
* @returns {string} Theme to use for emoji picker
*/
function usePickerTheme(): string {
const { ui } = useStores();
const { theme } = ui;
if (theme === "system") {
return "auto";
}
return theme;
}
type Props = {
/** The selected emoji, if any */
value?: string | null;
/** Callback when an emoji is selected */
onChange: (emoji: string | null) => void | Promise<void>;
/** Callback when the picker is opened */
onOpen?: () => void;
/** Callback when the picker is closed */
onClose?: () => void;
/** Callback when the picker is clicked outside of */
onClickOutside: () => void;
/** Whether to auto focus the search input on open */
autoFocus?: boolean;
/** Class name to apply to the trigger button */
className?: string;
};
function EmojiPicker({
value,
onOpen,
onClose,
onChange,
onClickOutside,
autoFocus,
className,
}: Props) {
const { t } = useTranslation();
const pickerTheme = usePickerTheme();
const theme = useTheme();
const locale = useUserLocale(true) ?? "en";
const popover = usePopoverState({
placement: "bottom-start",
modal: true,
unstable_offset: [0, 0],
});
const [emojisPerLine, setEmojisPerLine] = React.useState(9);
const pickerRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (popover.visible) {
onOpen?.();
} else {
onClose?.();
}
}, [popover.visible, onOpen, onClose]);
React.useEffect(() => {
if (popover.visible && pickerRef.current) {
// 28 is picker's observed width when perLine is set to 0
// and 36 is the default emojiButtonSize
// Ref: https://github.com/missive/emoji-mart#options--props
setEmojisPerLine(Math.floor((pickerRef.current.clientWidth - 28) / 36));
}
}, [popover.visible]);
const handleEmojiChange = React.useCallback(
async (emoji) => {
popover.hide();
await onChange(emoji ? emoji.native : null);
},
[popover, onChange]
);
const handleClick = React.useCallback(
(ev: React.MouseEvent) => {
ev.stopPropagation();
if (popover.visible) {
popover.hide();
} else {
popover.show();
}
},
[popover]
);
const handleClickOutside = React.useCallback(() => {
// It was observed that onClickOutside got triggered
// even when the picker wasn't open or opened at all.
// Hence, this guard here...
if (popover.visible) {
onClickOutside();
}
}, [popover.visible, onClickOutside]);
// Auto focus search input when picker is opened
React.useLayoutEffect(() => {
if (autoFocus && popover.visible) {
requestAnimationFrame(() => {
const searchInput = pickerRef.current
?.querySelector("em-emoji-picker")
?.shadowRoot?.querySelector(
"input[type=search]"
) as HTMLInputElement | null;
searchInput?.focus();
});
}
}, [autoFocus, popover.visible]);
return (
<>
<PopoverDisclosure {...popover}>
{(props) => (
<EmojiButton
{...props}
className={className}
onClick={handleClick}
icon={
value ? (
<Emoji size={32} align="center" justify="center">
{value}
</Emoji>
) : (
<StyledSmileyIcon size={32} color={theme.textTertiary} />
)
}
neutral
borderOnHover
/>
)}
</PopoverDisclosure>
<PickerPopover
{...popover}
tabIndex={0}
// This prevents picker from closing when any of its
// children are focused, e.g, clicking on search bar or
// a click on skin tone button
onClick={(e) => e.stopPropagation()}
width={352}
aria-label={t("Emoji Picker")}
>
{popover.visible && (
<>
{value && (
<RemoveButton neutral onClick={() => handleEmojiChange(null)}>
{t("Remove")}
</RemoveButton>
)}
<PickerStyles ref={pickerRef}>
<Picker
locale={supportedLocales.includes(locale) ? locale : "en"}
data={data}
onEmojiSelect={handleEmojiChange}
theme={pickerTheme}
previewPosition="none"
perLine={emojisPerLine}
onClickOutside={handleClickOutside}
/>
</PickerStyles>
</>
)}
</PickerPopover>
</>
);
}
const StyledSmileyIcon = styled(SmileyIcon)`
flex-shrink: 0;
@media print {
display: none;
}
`;
const RemoveButton = styled(Button)`
margin-left: -12px;
margin-bottom: 8px;
border-radius: 6px;
height: 24px;
font-size: 13px;
> :first-child {
min-height: unset;
line-height: unset;
}
`;
const PickerPopover = styled(Popover)`
z-index: ${depths.popover};
> :first-child {
padding-top: 8px;
padding-bottom: 0;
max-height: 488px;
overflow: unset;
}
`;
const PickerStyles = styled.div`
margin-left: -24px;
margin-right: -24px;
em-emoji-picker {
--shadow: none;
--font-family: ${s("fontFamily")};
--rgb-background: ${(props) => toRGB(props.theme.menuBackground)};
--rgb-accent: ${(props) => toRGB(props.theme.accent)};
--border-radius: 6px;
margin-left: auto;
margin-right: auto;
min-height: 443px;
}
`;
export default EmojiPicker;

View File

@@ -11,12 +11,16 @@ import {
import * as React from "react"; import * as React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
import { CompositeStateReturn } from "reakit/Composite";
import styled, { css } from "styled-components"; import styled, { css } from "styled-components";
import { s } from "@shared/styles"; import { s } from "@shared/styles";
import Document from "~/models/Document"; import Document from "~/models/Document";
import Event from "~/models/Event"; import Event from "~/models/Event";
import Avatar from "~/components/Avatar"; import Avatar from "~/components/Avatar";
import Item, { Actions, Props as ItemProps } from "~/components/List/Item"; import CompositeItem, {
Props as ItemProps,
} from "~/components/List/CompositeItem";
import Item, { Actions } from "~/components/List/Item";
import Time from "~/components/Time"; import Time from "~/components/Time";
import useStores from "~/hooks/useStores"; import useStores from "~/hooks/useStores";
import RevisionMenu from "~/menus/RevisionMenu"; import RevisionMenu from "~/menus/RevisionMenu";
@@ -28,7 +32,7 @@ type Props = {
document: Document; document: Document;
event: Event; event: Event;
latest?: boolean; latest?: boolean;
}; } & CompositeStateReturn;
const EventListItem = ({ event, latest, document, ...rest }: Props) => { const EventListItem = ({ event, latest, document, ...rest }: Props) => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -172,7 +176,11 @@ const BaseItem = React.forwardRef(function _BaseItem(
{ to, ...rest }: ItemProps, { to, ...rest }: ItemProps,
ref?: React.Ref<HTMLAnchorElement> ref?: React.Ref<HTMLAnchorElement>
) { ) {
return <ListItem to={to} ref={ref} {...rest} />; if (to) {
return <CompositeListItem to={to} ref={ref} {...rest} />;
}
return <ListItem ref={ref} {...rest} />;
}); });
const Subtitle = styled.span` const Subtitle = styled.span`
@@ -232,4 +240,8 @@ const ListItem = styled(Item)`
${ItemStyle} ${ItemStyle}
`; `;
const CompositeListItem = styled(CompositeItem)`
${ItemStyle}
`;
export default observer(EventListItem); export default observer(EventListItem);

View File

@@ -1,9 +1,7 @@
import * as React from "react"; import * as React from "react";
import { richExtensions } from "@shared/editor/nodes";
import { UnfurlResourceType, UnfurlResponse } from "@shared/types"; import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
import Editor from "~/components/Editor"; import Editor from "~/components/Editor";
import Flex from "~/components/Flex"; import Flex from "~/components/Flex";
import ErrorBoundary from "../ErrorBoundary";
import { import {
Preview, Preview,
Title, Title,
@@ -23,23 +21,20 @@ const HoverPreviewDocument = React.forwardRef(function _HoverPreviewDocument(
<Preview to={url}> <Preview to={url}>
<Card ref={ref}> <Card ref={ref}>
<CardContent> <CardContent>
<ErrorBoundary showTitle={false} reloadOnChunkMissing={false}> <Flex column gap={2}>
<Flex column gap={2}> <Title>{title}</Title>
<Title>{title}</Title> <Info>{lastActivityByViewer}</Info>
<Info>{lastActivityByViewer}</Info> <Description as="div">
<Description as="div"> <React.Suspense fallback={<div />}>
<React.Suspense fallback={<div />}> <Editor
<Editor key={id}
key={id} defaultValue={summary}
extensions={richExtensions} embedsDisabled
defaultValue={summary} readOnly
embedsDisabled />
readOnly </React.Suspense>
/> </Description>
</React.Suspense> </Flex>
</Description>
</Flex>
</ErrorBoundary>
</CardContent> </CardContent>
</Card> </Card>
</Preview> </Preview>

View File

@@ -1,107 +0,0 @@
import { observer } from "mobx-react";
import { getLuminance } from "polished";
import * as React from "react";
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";
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 default Icon;

View File

@@ -0,0 +1,211 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import { PopoverDisclosure, usePopoverState } from "reakit";
import { MenuItem } from "reakit/Menu";
import styled, { useTheme } from "styled-components";
import { IconLibrary } from "@shared/utils/IconLibrary";
import { colorPalette } from "@shared/utils/collections";
import Flex from "~/components/Flex";
import NudeButton from "~/components/NudeButton";
import Text from "~/components/Text";
import useOnClickOutside from "~/hooks/useOnClickOutside";
import lazyWithRetry from "~/utils/lazyWithRetry";
import DelayedMount from "./DelayedMount";
import InputSearch from "./InputSearch";
import Popover from "./Popover";
const icons = IconLibrary.mapping;
const TwitterPicker = lazyWithRetry(
() => import("react-color/lib/components/twitter/Twitter")
);
type Props = {
onOpen?: () => void;
onClose?: () => void;
onChange: (color: string, icon: string) => void;
initial: string;
icon: string;
color: string;
className?: string;
};
function IconPicker({
onOpen,
onClose,
icon,
initial,
color,
onChange,
className,
}: Props) {
const [query, setQuery] = React.useState("");
const { t } = useTranslation();
const theme = useTheme();
const popover = usePopoverState({
gutter: 0,
placement: "right",
modal: true,
});
React.useEffect(() => {
if (popover.visible) {
onOpen?.();
} else {
onClose?.();
setQuery("");
}
}, [onOpen, onClose, popover.visible]);
const filteredIcons = IconLibrary.findIcons(query);
const handleFilter = (event: React.ChangeEvent<HTMLInputElement>) => {
setQuery(event.target.value.toLowerCase());
};
const styles = React.useMemo(
() => ({
default: {
body: {
padding: 0,
marginRight: -8,
},
hash: {
color: theme.text,
background: theme.inputBorder,
},
swatch: {
cursor: "var(--cursor-pointer)",
},
input: {
color: theme.text,
boxShadow: `inset 0 0 0 1px ${theme.inputBorder}`,
background: "transparent",
},
},
}),
[theme]
);
// Custom click outside handling rather than using `hideOnClickOutside` from reakit so that we can
// prevent event bubbling.
useOnClickOutside(
popover.unstable_popoverRef,
(event) => {
if (popover.visible) {
event.stopPropagation();
event.preventDefault();
popover.hide();
}
},
{ capture: true }
);
const iconNames = Object.keys(icons);
const delayPerIcon = 250 / iconNames.length;
return (
<>
<PopoverDisclosure {...popover}>
{(props) => (
<NudeButton
aria-label={t("Show menu")}
className={className}
{...props}
>
<Icon
as={IconLibrary.getComponent(icon || "collection")}
color={color}
>
{initial}
</Icon>
</NudeButton>
)}
</PopoverDisclosure>
<Popover
{...popover}
width={552}
aria-label={t("Choose an icon")}
hideOnClickOutside={false}
>
<Flex column gap={12}>
<Text size="large" weight="xbold">
{t("Choose an icon")}
</Text>
<InputSearch
value={query}
placeholder={`${t("Filter")}`}
onChange={handleFilter}
autoFocus
/>
<div>
{iconNames.map((name, index) => (
<MenuItem key={name} onClick={() => onChange(color, name)}>
{(props) => (
<IconButton
style={
{
opacity: query
? filteredIcons.includes(name)
? 1
: 0.3
: undefined,
"--delay": `${Math.round(index * delayPerIcon)}ms`,
} as React.CSSProperties
}
{...props}
>
<Icon
as={IconLibrary.getComponent(name)}
color={color}
size={30}
>
{initial}
</Icon>
</IconButton>
)}
</MenuItem>
))}
</div>
<Flex>
<React.Suspense
fallback={
<DelayedMount>
<Text>{t("Loading")}</Text>
</DelayedMount>
}
>
<ColorPicker
color={color}
onChange={(color) => onChange(color.hex, icon)}
colors={colorPalette}
triangle="hide"
styles={styles}
/>
</React.Suspense>
</Flex>
</Flex>
</Popover>
</>
);
}
const Icon = styled.svg`
transition: color 150ms ease-in-out, fill 150ms ease-in-out;
transition-delay: var(--delay);
`;
const IconButton = styled(NudeButton)`
vertical-align: top;
border-radius: 4px;
margin: 0px 6px 6px 0px;
width: 30px;
height: 30px;
`;
const ColorPicker = styled(TwitterPicker)`
box-shadow: none !important;
background: transparent !important;
width: 100% !important;
`;
export default IconPicker;

View File

@@ -1,218 +0,0 @@
import { BackIcon } from "outline-icons";
import React from "react";
import styled from "styled-components";
import { breakpoints, s } from "@shared/styles";
import { colorPalette } from "@shared/utils/collections";
import { validateColorHex } from "@shared/utils/color";
import Flex from "~/components/Flex";
import NudeButton from "~/components/NudeButton";
import Text from "~/components/Text";
import { hover } from "~/styles";
enum Panel {
Builtin,
Hex,
}
type Props = {
width: number;
activeColor: string;
onSelect: (color: string) => void;
};
const ColorPicker = ({ width, activeColor, onSelect }: Props) => {
const [localValue, setLocalValue] = React.useState(activeColor);
const [panel, setPanel] = React.useState(
colorPalette.includes(activeColor) ? Panel.Builtin : Panel.Hex
);
const handleSwitcherClick = React.useCallback(() => {
setPanel(panel === Panel.Builtin ? Panel.Hex : Panel.Builtin);
}, [panel, setPanel]);
const isLargeMobile = width > breakpoints.mobileLarge + 12; // 12px for the Container padding
React.useEffect(() => {
setLocalValue(activeColor);
setPanel(colorPalette.includes(activeColor) ? Panel.Builtin : Panel.Hex);
}, [activeColor]);
return isLargeMobile ? (
<Container justify="space-between">
<LargeMobileBuiltinColors activeColor={activeColor} onClick={onSelect} />
<LargeMobileCustomColor
value={localValue}
setLocalValue={setLocalValue}
onValidHex={onSelect}
/>
</Container>
) : (
<Container gap={12}>
<PanelSwitcher align="center">
<SwitcherButton panel={panel} onClick={handleSwitcherClick}>
{panel === Panel.Builtin ? "#" : <BackIcon />}
</SwitcherButton>
</PanelSwitcher>
{panel === Panel.Builtin ? (
<BuiltinColors activeColor={activeColor} onClick={onSelect} />
) : (
<CustomColor
value={localValue}
setLocalValue={setLocalValue}
onValidHex={onSelect}
/>
)}
</Container>
);
};
const BuiltinColors = ({
activeColor,
onClick,
className,
}: {
activeColor: string;
onClick: (color: string) => void;
className?: string;
}) => (
<Flex className={className} justify="space-between" align="center" auto>
{colorPalette.map((color) => (
<ColorButton
key={color}
color={color}
active={color === activeColor}
onClick={() => onClick(color)}
>
<Selected />
</ColorButton>
))}
</Flex>
);
const CustomColor = ({
value,
setLocalValue,
onValidHex,
className,
}: {
value: string;
setLocalValue: (value: string) => void;
onValidHex: (color: string) => void;
className?: string;
}) => {
const hasHexChars = React.useCallback(
(color: string) => /(^#[0-9A-F]{1,6}$)/i.test(color),
[]
);
const handleInputChange = React.useCallback(
(ev: React.ChangeEvent<HTMLInputElement>) => {
const val = ev.target.value;
if (val === "" || val === "#") {
setLocalValue("#");
return;
}
const uppercasedVal = val.toUpperCase();
if (hasHexChars(uppercasedVal)) {
setLocalValue(uppercasedVal);
}
if (validateColorHex(uppercasedVal)) {
onValidHex(uppercasedVal);
}
},
[setLocalValue, hasHexChars, onValidHex]
);
return (
<Flex className={className} align="center" gap={8}>
<Text type="tertiary" size="small">
HEX
</Text>
<CustomColorInput
maxLength={7}
value={value}
onChange={handleInputChange}
/>
</Flex>
);
};
const Container = styled(Flex)`
height: 48px;
padding: 8px 12px;
border-bottom: 1px solid ${s("inputBorder")};
`;
const Selected = styled.span`
width: 10px;
height: 5px;
border-left: 2px solid white;
border-bottom: 2px solid white;
transform: translateY(-25%) rotate(-45deg);
`;
const ColorButton = styled(NudeButton)<{ color: string; active: boolean }>`
display: inline-flex;
justify-content: center;
align-items: center;
width: 24px;
height: 24px;
border-radius: 50%;
background-color: ${({ color }) => color};
&: ${hover} {
outline: 2px solid ${s("menuBackground")} !important;
box-shadow: ${({ color }) => `0px 0px 3px 3px ${color}`};
}
& ${Selected} {
display: ${({ active }) => (active ? "block" : "none")};
}
`;
const PanelSwitcher = styled(Flex)`
width: 40px;
border-right: 1px solid ${s("inputBorder")};
`;
const SwitcherButton = styled(NudeButton)<{ panel: Panel }>`
display: inline-flex;
justify-content: center;
align-items: center;
font-size: 14px;
border: 1px solid ${s("inputBorder")};
transition: all 100ms ease-in-out;
&: ${hover} {
border-color: ${s("inputBorderFocused")};
}
`;
const LargeMobileBuiltinColors = styled(BuiltinColors)`
max-width: 380px;
padding-right: 8px;
`;
const LargeMobileCustomColor = styled(CustomColor)`
padding-left: 8px;
border-left: 1px solid ${s("inputBorder")};
width: 120px;
`;
const CustomColorInput = styled.input.attrs(() => ({
type: "text",
autocomplete: "off",
}))`
font-size: 14px;
color: ${s("textSecondary")};
background: transparent;
border: 0;
outline: 0;
`;
export default ColorPicker;

View File

@@ -1,8 +0,0 @@
import styled from "styled-components";
import { s } from "@shared/styles";
export const Emoji = styled.span`
font-family: ${s("fontFamilyEmoji")};
width: 24px;
height: 24px;
`;

View File

@@ -1,245 +0,0 @@
import concat from "lodash/concat";
import React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { EmojiCategory, EmojiSkinTone, IconType } from "@shared/types";
import { getEmojis, getEmojisWithCategory, search } from "@shared/utils/emoji";
import Flex from "~/components/Flex";
import InputSearch from "~/components/InputSearch";
import usePersistedState from "~/hooks/usePersistedState";
import {
FREQUENTLY_USED_COUNT,
DisplayCategory,
emojiSkinToneKey,
emojisFreqKey,
lastEmojiKey,
sortFrequencies,
} from "../utils";
import GridTemplate, { DataNode } from "./GridTemplate";
import SkinTonePicker from "./SkinTonePicker";
/**
* This is needed as a constant for react-window.
* Calculated from the heights of TabPanel and InputSearch.
*/
const GRID_HEIGHT = 362;
const useEmojiState = () => {
const [emojiSkinTone, setEmojiSkinTone] = usePersistedState<EmojiSkinTone>(
emojiSkinToneKey,
EmojiSkinTone.Default
);
const [emojisFreq, setEmojisFreq] = usePersistedState<Record<string, number>>(
emojisFreqKey,
{}
);
const [lastEmoji, setLastEmoji] = usePersistedState<string | undefined>(
lastEmojiKey,
undefined
);
const incrementEmojiCount = React.useCallback(
(emoji: string) => {
emojisFreq[emoji] = (emojisFreq[emoji] ?? 0) + 1;
setEmojisFreq({ ...emojisFreq });
setLastEmoji(emoji);
},
[emojisFreq, setEmojisFreq, setLastEmoji]
);
const getFreqEmojis = React.useCallback(() => {
const freqs = Object.entries(emojisFreq);
if (freqs.length > FREQUENTLY_USED_COUNT.Track) {
sortFrequencies(freqs).splice(FREQUENTLY_USED_COUNT.Track);
setEmojisFreq(Object.fromEntries(freqs));
}
const emojis = sortFrequencies(freqs)
.slice(0, FREQUENTLY_USED_COUNT.Get)
.map(([emoji, _]) => emoji);
const isLastPresent = emojis.includes(lastEmoji ?? "");
if (lastEmoji && !isLastPresent) {
emojis.pop();
emojis.push(lastEmoji);
}
return emojis;
}, [emojisFreq, setEmojisFreq, lastEmoji]);
return {
emojiSkinTone,
setEmojiSkinTone,
incrementEmojiCount,
getFreqEmojis,
};
};
type Props = {
panelWidth: number;
query: string;
panelActive: boolean;
onEmojiChange: (emoji: string) => void;
onQueryChange: (query: string) => void;
};
const EmojiPanel = ({
panelWidth,
query,
panelActive,
onEmojiChange,
onQueryChange,
}: Props) => {
const { t } = useTranslation();
const searchRef = React.useRef<HTMLInputElement | null>(null);
const scrollableRef = React.useRef<HTMLDivElement | null>(null);
const {
emojiSkinTone: skinTone,
setEmojiSkinTone,
incrementEmojiCount,
getFreqEmojis,
} = useEmojiState();
const freqEmojis = React.useMemo(() => getFreqEmojis(), [getFreqEmojis]);
const handleFilter = React.useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
onQueryChange(event.target.value);
},
[onQueryChange]
);
const handleSkinChange = React.useCallback(
(emojiSkinTone: EmojiSkinTone) => {
setEmojiSkinTone(emojiSkinTone);
},
[setEmojiSkinTone]
);
const handleEmojiSelection = React.useCallback(
({ id, value }: { id: string; value: string }) => {
onEmojiChange(value);
incrementEmojiCount(id);
},
[onEmojiChange, incrementEmojiCount]
);
const isSearch = query !== "";
const templateData: DataNode[] = isSearch
? getSearchResults({
query,
skinTone,
})
: getAllEmojis({
skinTone,
freqEmojis,
});
React.useEffect(() => {
if (scrollableRef.current) {
scrollableRef.current.scrollTop = 0;
}
searchRef.current?.focus();
}, [panelActive]);
return (
<Flex column>
<UserInputContainer align="center" gap={12}>
<StyledInputSearch
ref={searchRef}
value={query}
placeholder={`${t("Search emoji")}`}
onChange={handleFilter}
/>
<SkinTonePicker skinTone={skinTone} onChange={handleSkinChange} />
</UserInputContainer>
<GridTemplate
ref={scrollableRef}
width={panelWidth}
height={GRID_HEIGHT}
data={templateData}
onIconSelect={handleEmojiSelection}
/>
</Flex>
);
};
const getSearchResults = ({
query,
skinTone,
}: {
query: string;
skinTone: EmojiSkinTone;
}): DataNode[] => {
const emojis = search({ query, skinTone });
return [
{
category: DisplayCategory.Search,
icons: emojis.map((emoji) => ({
type: IconType.Emoji,
id: emoji.id,
value: emoji.value,
})),
},
];
};
const getAllEmojis = ({
skinTone,
freqEmojis,
}: {
skinTone: EmojiSkinTone;
freqEmojis: string[];
}): DataNode[] => {
const emojisWithCategory = getEmojisWithCategory({ skinTone });
const getFrequentEmojis = (): DataNode => {
const emojis = getEmojis({ ids: freqEmojis, skinTone });
return {
category: DisplayCategory.Frequent,
icons: emojis.map((emoji) => ({
type: IconType.Emoji,
id: emoji.id,
value: emoji.value,
})),
};
};
const getCategoryData = (emojiCategory: EmojiCategory): DataNode => {
const emojis = emojisWithCategory[emojiCategory] ?? [];
return {
category: emojiCategory,
icons: emojis.map((emoji) => ({
type: IconType.Emoji,
id: emoji.id,
value: emoji.value,
})),
};
};
return concat(
getFrequentEmojis(),
getCategoryData(EmojiCategory.People),
getCategoryData(EmojiCategory.Nature),
getCategoryData(EmojiCategory.Foods),
getCategoryData(EmojiCategory.Activity),
getCategoryData(EmojiCategory.Places),
getCategoryData(EmojiCategory.Objects),
getCategoryData(EmojiCategory.Symbols),
getCategoryData(EmojiCategory.Flags)
);
};
const UserInputContainer = styled(Flex)`
height: 48px;
padding: 6px 12px 0px;
`;
const StyledInputSearch = styled(InputSearch)`
flex-grow: 1;
`;
export default EmojiPanel;

View File

@@ -1,61 +0,0 @@
import React from "react";
import { FixedSizeList, ListChildComponentProps } from "react-window";
import styled from "styled-components";
type Props = {
width: number;
height: number;
data: React.ReactNode[][];
columns: number;
itemWidth: number;
};
const Grid = (
{ width, height, data, columns, itemWidth }: Props,
ref: React.Ref<HTMLDivElement>
) => (
<Container
outerRef={ref}
width={width}
height={height}
itemCount={data.length}
itemSize={itemWidth}
itemData={{ data, columns }}
>
{Row}
</Container>
);
type RowProps = {
data: React.ReactNode[][];
columns: number;
};
const Row = ({ index, style, data }: ListChildComponentProps<RowProps>) => {
const { data: rows, columns } = data;
const row = rows[index];
return (
<RowContainer style={style} columns={columns}>
{row}
</RowContainer>
);
};
const Container = styled(FixedSizeList<RowProps>)`
padding: 0px 12px;
// Needed for the absolutely positioned children
// to respect the VirtualList's padding
& > div {
position: relative;
}
`;
const RowContainer = styled.div<{ columns: number }>`
display: grid;
grid-template-columns: ${({ columns }) => `repeat(${columns}, 1fr)`};
align-content: center;
`;
export default React.forwardRef(Grid);

View File

@@ -1,120 +0,0 @@
import chunk from "lodash/chunk";
import compact from "lodash/compact";
import React from "react";
import styled from "styled-components";
import { IconType } from "@shared/types";
import { IconLibrary } from "@shared/utils/IconLibrary";
import Text from "~/components/Text";
import { TRANSLATED_CATEGORIES } from "../utils";
import { Emoji } from "./Emoji";
import Grid from "./Grid";
import { IconButton } from "./IconButton";
/**
* icon/emoji size is 24px; and we add 4px padding on all sides,
*/
const BUTTON_SIZE = 32;
type OutlineNode = {
type: IconType.SVG;
name: string;
color: string;
initial: string;
delay: number;
};
type EmojiNode = {
type: IconType.Emoji;
id: string;
value: string;
};
export type DataNode = {
category: keyof typeof TRANSLATED_CATEGORIES;
icons: (OutlineNode | EmojiNode)[];
};
type Props = {
width: number;
height: number;
data: DataNode[];
onIconSelect: ({ id, value }: { id: string; value: string }) => void;
};
const GridTemplate = (
{ width, height, data, onIconSelect }: Props,
ref: React.Ref<HTMLDivElement>
) => {
// 24px padding for the Grid Container
const itemsPerRow = Math.floor((width - 24) / BUTTON_SIZE);
const gridItems = compact(
data.flatMap((node) => {
if (node.icons.length === 0) {
return [];
}
const category = (
<CategoryName
key={node.category}
type="tertiary"
size="xsmall"
weight="bold"
>
{TRANSLATED_CATEGORIES[node.category]}
</CategoryName>
);
const items = node.icons.map((item) => {
if (item.type === IconType.SVG) {
return (
<IconButton
key={item.name}
onClick={() => onIconSelect({ id: item.name, value: item.name })}
delay={item.delay}
>
<Icon as={IconLibrary.getComponent(item.name)} color={item.color}>
{item.initial}
</Icon>
</IconButton>
);
}
return (
<IconButton
key={item.id}
onClick={() => onIconSelect({ id: item.id, value: item.value })}
>
<Emoji>{item.value}</Emoji>
</IconButton>
);
});
const chunks = chunk(items, itemsPerRow);
return [[category], ...chunks];
})
);
return (
<Grid
ref={ref}
width={width}
height={height}
data={gridItems}
columns={itemsPerRow}
itemWidth={BUTTON_SIZE}
/>
);
};
const CategoryName = styled(Text)`
grid-column: 1 / -1;
padding-left: 6px;
`;
const Icon = styled.svg`
transition: color 150ms ease-in-out, fill 150ms ease-in-out;
transition-delay: var(--delay);
`;
export default React.forwardRef(GridTemplate);

View File

@@ -1,15 +0,0 @@
import styled from "styled-components";
import { s } from "@shared/styles";
import NudeButton from "~/components/NudeButton";
import { hover } from "~/styles";
export const IconButton = styled(NudeButton)<{ delay?: number }>`
width: 32px;
height: 32px;
padding: 4px;
--delay: ${({ delay }) => delay && `${delay}ms`};
&: ${hover} {
background: ${s("listItemHoverBackground")};
}
`;

View File

@@ -1,200 +0,0 @@
import React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { IconType } from "@shared/types";
import { IconLibrary } from "@shared/utils/IconLibrary";
import Flex from "~/components/Flex";
import InputSearch from "~/components/InputSearch";
import usePersistedState from "~/hooks/usePersistedState";
import {
FREQUENTLY_USED_COUNT,
DisplayCategory,
iconsFreqKey,
lastIconKey,
sortFrequencies,
} from "../utils";
import ColorPicker from "./ColorPicker";
import GridTemplate, { DataNode } from "./GridTemplate";
const IconNames = Object.keys(IconLibrary.mapping);
const TotalIcons = IconNames.length;
/**
* This is needed as a constant for react-window.
* Calculated from the heights of TabPanel, ColorPicker and InputSearch.
*/
const GRID_HEIGHT = 314;
const useIconState = () => {
const [iconsFreq, setIconsFreq] = usePersistedState<Record<string, number>>(
iconsFreqKey,
{}
);
const [lastIcon, setLastIcon] = usePersistedState<string | undefined>(
lastIconKey,
undefined
);
const incrementIconCount = React.useCallback(
(icon: string) => {
iconsFreq[icon] = (iconsFreq[icon] ?? 0) + 1;
setIconsFreq({ ...iconsFreq });
setLastIcon(icon);
},
[iconsFreq, setIconsFreq, setLastIcon]
);
const getFreqIcons = React.useCallback(() => {
const freqs = Object.entries(iconsFreq);
if (freqs.length > FREQUENTLY_USED_COUNT.Track) {
sortFrequencies(freqs).splice(FREQUENTLY_USED_COUNT.Track);
setIconsFreq(Object.fromEntries(freqs));
}
const icons = sortFrequencies(freqs)
.slice(0, FREQUENTLY_USED_COUNT.Get)
.map(([icon, _]) => icon);
const isLastPresent = icons.includes(lastIcon ?? "");
if (lastIcon && !isLastPresent) {
icons.pop();
icons.push(lastIcon);
}
return icons;
}, [iconsFreq, setIconsFreq, lastIcon]);
return {
incrementIconCount,
getFreqIcons,
};
};
type Props = {
panelWidth: number;
initial: string;
color: string;
query: string;
panelActive: boolean;
onIconChange: (icon: string) => void;
onColorChange: (icon: string) => void;
onQueryChange: (query: string) => void;
};
const IconPanel = ({
panelWidth,
initial,
color,
query,
panelActive,
onIconChange,
onColorChange,
onQueryChange,
}: Props) => {
const { t } = useTranslation();
const searchRef = React.useRef<HTMLInputElement | null>(null);
const scrollableRef = React.useRef<HTMLDivElement | null>(null);
const { incrementIconCount, getFreqIcons } = useIconState();
const freqIcons = React.useMemo(() => getFreqIcons(), [getFreqIcons]);
const totalFreqIcons = freqIcons.length;
const filteredIcons = React.useMemo(
() => IconLibrary.findIcons(query),
[query]
);
const isSearch = query !== "";
const category = isSearch ? DisplayCategory.Search : DisplayCategory.All;
const delayPerIcon = 250 / (TotalIcons + totalFreqIcons);
const handleFilter = React.useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
onQueryChange(event.target.value);
},
[onQueryChange]
);
const handleIconSelection = React.useCallback(
({ id, value }: { id: string; value: string }) => {
onIconChange(value);
incrementIconCount(id);
},
[onIconChange, incrementIconCount]
);
const baseIcons: DataNode = {
category,
icons: filteredIcons.map((name, index) => ({
type: IconType.SVG,
name,
color,
initial,
delay: Math.round((index + totalFreqIcons) * delayPerIcon),
onClick: handleIconSelection,
})),
};
const templateData: DataNode[] = isSearch
? [baseIcons]
: [
{
category: DisplayCategory.Frequent,
icons: freqIcons.map((name, index) => ({
type: IconType.SVG,
name,
color,
initial,
delay: Math.round((index + totalFreqIcons) * delayPerIcon),
onClick: handleIconSelection,
})),
},
baseIcons,
];
React.useEffect(() => {
if (scrollableRef.current) {
scrollableRef.current.scrollTop = 0;
}
searchRef.current?.focus();
}, [panelActive]);
return (
<Flex column>
<InputSearchContainer align="center">
<StyledInputSearch
ref={searchRef}
value={query}
placeholder={`${t("Search icons")}`}
onChange={handleFilter}
/>
</InputSearchContainer>
<ColorPicker
width={panelWidth}
activeColor={color}
onSelect={onColorChange}
/>
<GridTemplate
ref={scrollableRef}
width={panelWidth}
height={GRID_HEIGHT}
data={templateData}
onIconSelect={handleIconSelection}
/>
</Flex>
);
};
const InputSearchContainer = styled(Flex)`
height: 48px;
padding: 6px 12px 0px;
`;
const StyledInputSearch = styled(InputSearch)`
flex-grow: 1;
`;
export default IconPanel;

View File

@@ -1,20 +0,0 @@
import styled, { css } from "styled-components";
import { s } from "@shared/styles";
import NudeButton from "~/components/NudeButton";
import { hover } from "~/styles";
export const PopoverButton = styled(NudeButton)<{ $borderOnHover?: boolean }>`
&: ${hover},
&:active,
&[aria-expanded= "true"] {
opacity: 1 !important;
${({ $borderOnHover }) =>
$borderOnHover &&
css`
background: ${s("buttonNeutralBackground")};
box-shadow: rgba(0, 0, 0, 0.07) 0px 1px 2px,
${s("buttonNeutralBorder")} 0 0 0 1px inset;
`};
}
`;

View File

@@ -1,92 +0,0 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Menu, MenuButton, MenuItem, useMenuState } from "reakit";
import styled from "styled-components";
import { depths, s } from "@shared/styles";
import { EmojiSkinTone } from "@shared/types";
import { getEmojiVariants } from "@shared/utils/emoji";
import Flex from "~/components/Flex";
import NudeButton from "~/components/NudeButton";
import { hover } from "~/styles";
import { Emoji } from "./Emoji";
import { IconButton } from "./IconButton";
const SkinTonePicker = ({
skinTone,
onChange,
}: {
skinTone: EmojiSkinTone;
onChange: (skin: EmojiSkinTone) => void;
}) => {
const { t } = useTranslation();
const handEmojiVariants = React.useMemo(
() => getEmojiVariants({ id: "hand" }),
[]
);
const menu = useMenuState({
placement: "bottom",
});
const handleSkinClick = React.useCallback(
(emojiSkin) => {
menu.hide();
onChange(emojiSkin);
},
[menu, onChange]
);
const menuItems = React.useMemo(
() =>
Object.entries(handEmojiVariants).map(([eskin, emoji]) => (
<MenuItem {...menu} key={emoji.value}>
{(menuprops) => (
<IconButton {...menuprops} onClick={() => handleSkinClick(eskin)}>
<Emoji>{emoji.value}</Emoji>
</IconButton>
)}
</MenuItem>
)),
[menu, handEmojiVariants, handleSkinClick]
);
return (
<>
<MenuButton {...menu}>
{(props) => (
<StyledMenuButton
{...props}
aria-label={t("Choose default skin tone")}
>
{handEmojiVariants[skinTone]!.value}
</StyledMenuButton>
)}
</MenuButton>
<Menu {...menu} aria-label={t("Choose default skin tone")}>
{(props) => <MenuContainer {...props}>{menuItems}</MenuContainer>}
</Menu>
</>
);
};
const MenuContainer = styled(Flex)`
z-index: ${depths.menu};
padding: 4px;
border-radius: 4px;
background: ${s("menuBackground")};
box-shadow: ${s("menuShadow")};
`;
const StyledMenuButton = styled(NudeButton)`
width: 32px;
height: 32px;
border: 1px solid ${s("inputBorder")};
padding: 4px;
&: ${hover} {
border: 1px solid ${s("inputBorderFocused")};
}
`;
export default SkinTonePicker;

View File

@@ -1,312 +0,0 @@
import { SmileyIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import {
PopoverDisclosure,
Tab,
TabList,
TabPanel,
usePopoverState,
useTabState,
} from "reakit";
import styled, { css } from "styled-components";
import { s } from "@shared/styles";
import theme from "@shared/styles/theme";
import { IconType } from "@shared/types";
import { determineIconType } from "@shared/utils/icon";
import Flex from "~/components/Flex";
import Icon from "~/components/Icon";
import NudeButton from "~/components/NudeButton";
import Popover from "~/components/Popover";
import useMobile from "~/hooks/useMobile";
import useOnClickOutside from "~/hooks/useOnClickOutside";
import usePrevious from "~/hooks/usePrevious";
import useWindowSize from "~/hooks/useWindowSize";
import { hover } from "~/styles";
import EmojiPanel from "./components/EmojiPanel";
import IconPanel from "./components/IconPanel";
import { PopoverButton } from "./components/PopoverButton";
const TAB_NAMES = {
Icon: "icon",
Emoji: "emoji",
} as const;
const POPOVER_WIDTH = 408;
type Props = {
icon: string | null;
color: string;
size?: number;
initial?: string;
className?: string;
popoverPosition: "bottom-start" | "right";
allowDelete?: boolean;
borderOnHover?: boolean;
onChange: (icon: string | null, color: string | null) => void;
onOpen?: () => void;
onClose?: () => void;
};
const IconPicker = ({
icon,
color,
size = 24,
initial,
className,
popoverPosition,
allowDelete,
onChange,
onOpen,
onClose,
borderOnHover,
}: Props) => {
const { t } = useTranslation();
const { width: windowWidth } = useWindowSize();
const isMobile = useMobile();
const [query, setQuery] = React.useState("");
const [chosenColor, setChosenColor] = React.useState(color);
const contentRef = React.useRef<HTMLDivElement | null>(null);
const iconType = determineIconType(icon);
const defaultTab = React.useMemo(
() =>
iconType === IconType.Emoji ? TAB_NAMES["Emoji"] : TAB_NAMES["Icon"],
[iconType]
);
const popover = usePopoverState({
placement: popoverPosition,
modal: true,
unstable_offset: [0, 0],
});
const tab = useTabState({ selectedId: defaultTab });
const previouslyVisible = usePrevious(popover.visible);
const popoverWidth = isMobile ? windowWidth : POPOVER_WIDTH;
// In mobile, popover is absolutely positioned to leave 8px on both sides.
const panelWidth = isMobile ? windowWidth - 16 : popoverWidth;
const resetDefaultTab = React.useCallback(() => {
tab.select(defaultTab);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [defaultTab]);
const handleIconChange = React.useCallback(
(ic: string) => {
popover.hide();
const icType = determineIconType(ic);
const finalColor = icType === IconType.SVG ? chosenColor : null;
onChange(ic, finalColor);
},
[popover, onChange, chosenColor]
);
const handleIconColorChange = React.useCallback(
(c: string) => {
setChosenColor(c);
const icType = determineIconType(icon);
// Outline icon set; propagate color change
if (icType === IconType.SVG) {
onChange(icon, c);
}
},
[icon, onChange]
);
const handleIconRemove = React.useCallback(() => {
popover.hide();
onChange(null, null);
}, [popover, onChange]);
const handlePopoverButtonClick = React.useCallback(
(ev: React.MouseEvent) => {
ev.stopPropagation();
if (popover.visible) {
popover.hide();
} else {
popover.show();
}
},
[popover]
);
// Popover open effect
React.useEffect(() => {
if (popover.visible && !previouslyVisible) {
onOpen?.();
} else if (!popover.visible && previouslyVisible) {
onClose?.();
setQuery("");
resetDefaultTab();
}
}, [popover.visible, previouslyVisible, onOpen, onClose, resetDefaultTab]);
// Custom click outside handling rather than using `hideOnClickOutside` from reakit so that we can
// prevent event bubbling.
useOnClickOutside(
contentRef,
(event) => {
if (
popover.visible &&
!popover.unstable_disclosureRef.current?.contains(event.target as Node)
) {
event.stopPropagation();
event.preventDefault();
popover.hide();
}
},
{ capture: true }
);
return (
<>
<PopoverDisclosure {...popover}>
{(props) => (
<PopoverButton
{...props}
aria-label={t("Show menu")}
className={className}
size={size}
onClick={handlePopoverButtonClick}
$borderOnHover={borderOnHover}
>
{iconType && icon ? (
<Icon value={icon} color={color} size={size} initial={initial} />
) : (
<StyledSmileyIcon color={theme.textTertiary} 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;

View File

@@ -1,50 +0,0 @@
import i18next from "i18next";
export enum DisplayCategory {
All = "All",
Frequent = "Frequent",
Search = "Search",
}
export const TRANSLATED_CATEGORIES = {
All: i18next.t("All"),
Frequent: i18next.t("Frequently Used"),
Search: i18next.t("Search Results"),
People: i18next.t("Smileys & People"),
Nature: i18next.t("Animals & Nature"),
Foods: i18next.t("Food & Drink"),
Activity: i18next.t("Activity"),
Places: i18next.t("Travel & Places"),
Objects: i18next.t("Objects"),
Symbols: i18next.t("Symbols"),
Flags: i18next.t("Flags"),
};
export const FREQUENTLY_USED_COUNT = {
Get: 24,
Track: 30,
};
const STORAGE_KEYS = {
Base: "icon-state",
EmojiSkinTone: "emoji-skintone",
IconsFrequency: "icons-freq",
EmojisFrequency: "emojis-freq",
LastIcon: "last-icon",
LastEmoji: "last-emoji",
};
const getStorageKey = (key: string) => `${STORAGE_KEYS.Base}.${key}`;
export const emojiSkinToneKey = getStorageKey(STORAGE_KEYS.EmojiSkinTone);
export const iconsFreqKey = getStorageKey(STORAGE_KEYS.IconsFrequency);
export const emojisFreqKey = getStorageKey(STORAGE_KEYS.EmojisFrequency);
export const lastIconKey = getStorageKey(STORAGE_KEYS.LastIcon);
export const lastEmojiKey = getStorageKey(STORAGE_KEYS.LastEmoji);
export const sortFrequencies = (freqs: [string, number][]) =>
freqs.sort((a, b) => (a[1] >= b[1] ? -1 : 1));

View File

@@ -1,31 +0,0 @@
import * as React from "react";
type Props = {
/** The size of the icon, 24px is default to match standard icons */
size?: number;
/** The color of the icon, defaults to the current text color */
color?: string;
/** If true, the icon will retain its color in selected menus and other places that attempt to override it */
retainColor?: boolean;
};
export default function CircleIcon({
size = 24,
color = "currentColor",
retainColor,
...rest
}: Props) {
return (
<svg
fill={color}
width={size}
height={size}
viewBox="0 0 24 24"
version="1.1"
style={retainColor ? { fill: color } : undefined}
{...rest}
>
<circle xmlns="http://www.w3.org/2000/svg" cx="12" cy="12" r="8" />
</svg>
);
}

View File

@@ -2,10 +2,10 @@ import { observer } from "mobx-react";
import { CollectionIcon } from "outline-icons"; import { CollectionIcon } from "outline-icons";
import { getLuminance } from "polished"; import { getLuminance } from "polished";
import * as React from "react"; import * as React from "react";
import { colorPalette } from "@shared/utils/collections"; import { IconLibrary } from "@shared/utils/IconLibrary";
import Collection from "~/models/Collection"; import Collection from "~/models/Collection";
import Icon from "~/components/Icon";
import useStores from "~/hooks/useStores"; import useStores from "~/hooks/useStores";
import Logger from "~/utils/Logger";
type Props = { type Props = {
/** The collection to show an icon for */ /** The collection to show an icon for */
@@ -16,7 +16,6 @@ type Props = {
size?: number; size?: number;
/** The color of the icon, defaults to the collection color */ /** The color of the icon, defaults to the collection color */
color?: string; color?: string;
className?: string;
}; };
function ResolvedCollectionIcon({ function ResolvedCollectionIcon({
@@ -24,41 +23,35 @@ function ResolvedCollectionIcon({
color: inputColor, color: inputColor,
expanded, expanded,
size, size,
className,
}: Props) { }: Props) {
const { ui } = useStores(); const { ui } = useStores();
if (!collection.icon || collection.icon === "collection") { // If the chosen icon color is very dark then we invert it in dark mode
// 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.
// otherwise it will be impossible to see against the dark background. const color =
const collectionColor = collection.color ?? colorPalette[0]; inputColor ||
const color = (ui.resolvedTheme === "dark" && collection.color !== "currentColor"
inputColor || ? getLuminance(collection.color) > 0.09
(ui.resolvedTheme === "dark" && collectionColor !== "currentColor" ? collection.color
? getLuminance(collectionColor) > 0.09 : "currentColor"
? collectionColor : collection.color);
: "currentColor"
: collectionColor);
return ( if (collection.icon && collection.icon !== "collection") {
<CollectionIcon try {
color={color} const Component = IconLibrary.getComponent(collection.icon);
expanded={expanded} return (
size={size} <Component color={color} size={size}>
className={className} {collection.initial}
/> </Component>
); );
} catch (error) {
Logger.warn("Failed to render custom icon", {
icon: collection.icon,
});
}
} }
return ( return <CollectionIcon color={color} expanded={expanded} size={size} />;
<Icon
value={collection.icon}
color={inputColor ?? collection.color ?? undefined}
size={size}
initial={collection.initial}
className={className}
/>
);
} }
export default observer(ResolvedCollectionIcon); export default observer(ResolvedCollectionIcon);

View File

@@ -1,13 +1,11 @@
import * as React from "react"; import * as React from "react";
import styled from "styled-components"; import styled from "styled-components";
import { s } from "@shared/styles";
type Props = { type Props = {
/** The emoji to render */ /** The emoji to render */
emoji: string; emoji: string;
/** The size of the emoji, 24px is default to match standard icons */ /** The size of the emoji, 24px is default to match standard icons */
size?: number; size?: number;
className?: string;
}; };
/** /**
@@ -17,28 +15,19 @@ type Props = {
export default function EmojiIcon({ size = 24, emoji, ...rest }: Props) { export default function EmojiIcon({ size = 24, emoji, ...rest }: Props) {
return ( return (
<Span $size={size} {...rest}> <Span $size={size} {...rest}>
<SVG size={size} emoji={emoji} /> {emoji}
</Span> </Span>
); );
} }
const Span = styled.span<{ $size: number }>` const Span = styled.span<{ $size: number }>`
font-family: ${s("fontFamilyEmoji")}; display: inline-flex;
display: inline-block; align-items: center;
justify-content: center;
text-align: center;
flex-shrink: 0;
width: ${(props) => props.$size}px; width: ${(props) => props.$size}px;
height: ${(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>
);

View File

@@ -11,21 +11,13 @@ import { searchPath } from "~/utils/routeHelpers";
import Input, { Outline } from "./Input"; import Input, { Outline } from "./Input";
type Props = { type Props = {
/** A string representing where the search started, for tracking. */
source: string; source: string;
/** Placeholder text for the input. */
placeholder?: string; placeholder?: string;
/** Label for the input. */
label?: string; label?: string;
/** Whether the label should be hidden. */
labelHidden?: boolean; labelHidden?: boolean;
/** An optional ID of a collection to search within. */
collectionId?: string; collectionId?: string;
/** The current value of the input. */
value?: string; value?: string;
/** Event handler for when the input value changes. */
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => unknown; onChange?: (event: React.ChangeEvent<HTMLInputElement>) => unknown;
/** Event handler for when a key is pressed. */
onKeyDown?: (event: React.KeyboardEvent<HTMLInputElement>) => unknown; onKeyDown?: (event: React.KeyboardEvent<HTMLInputElement>) => unknown;
}; };

View File

@@ -50,11 +50,6 @@ export type Props = {
note?: React.ReactNode; note?: React.ReactNode;
onChange?: (value: string | null) => void; onChange?: (value: string | null) => void;
style?: React.CSSProperties; 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;
}; };
export interface InputSelectRef { export interface InputSelectRef {
@@ -84,7 +79,6 @@ const InputSelect = (props: Props, ref: React.RefObject<InputSelectRef>) => {
note, note,
icon, icon,
nude, nude,
skipBodyScroll,
...rest ...rest
} = props; } = props;
@@ -97,7 +91,7 @@ const InputSelect = (props: Props, ref: React.RefObject<InputSelectRef>) => {
const popover = useSelectPopover({ const popover = useSelectPopover({
...select, ...select,
hideOnClickOutside: false, hideOnClickOutside: false,
preventBodyScroll: skipBodyScroll ? false : true, preventBodyScroll: true,
disabled, disabled,
}); });
@@ -226,12 +220,7 @@ const InputSelect = (props: Props, ref: React.RefObject<InputSelectRef>) => {
</StyledButton> </StyledButton>
)} )}
</Select> </Select>
<SelectPopover <SelectPopover {...select} {...popover} aria-label={ariaLabel}>
{...select}
{...popover}
aria-label={ariaLabel}
preventBodyScroll={skipBodyScroll ? false : true}
>
{(popoverProps: InnerProps) => { {(popoverProps: InnerProps) => {
const topAnchor = popoverProps.style?.top === "0"; const topAnchor = popoverProps.style?.top === "0";
const rightAnchor = popoverProps.placement === "bottom-end"; const rightAnchor = popoverProps.placement === "bottom-end";

View File

@@ -23,16 +23,15 @@ function InputSelectPermission(
ref={ref} ref={ref}
label={t("Permission")} label={t("Permission")}
options={[ options={[
{
label: t("View only"),
value: CollectionPermission.Read,
},
{ {
label: t("Can edit"), label: t("Can edit"),
value: CollectionPermission.ReadWrite, value: CollectionPermission.ReadWrite,
}, },
{ {
divider: true, label: t("View only"),
value: CollectionPermission.Read,
},
{
label: t("No access"), label: t("No access"),
value: EmptySelectValue, value: EmptySelectValue,
}, },

View File

@@ -0,0 +1,17 @@
import * as React from "react";
import {
CompositeStateReturn,
CompositeItem as BaseCompositeItem,
} from "reakit/Composite";
import Item, { Props as ItemProps } from "./Item";
export type Props = ItemProps & CompositeStateReturn;
function CompositeItem(
{ to, ...rest }: Props,
ref?: React.Ref<HTMLAnchorElement>
) {
return <BaseCompositeItem as={Item} to={to} {...rest} ref={ref} />;
}
export default React.forwardRef(CompositeItem);

View File

@@ -1,15 +1,9 @@
import {
useFocusEffect,
useRovingTabIndex,
} from "@getoutline/react-roving-tabindex";
import { LocationDescriptor } from "history"; import { LocationDescriptor } from "history";
import * as React from "react"; import * as React from "react";
import scrollIntoView from "smooth-scroll-into-view-if-needed";
import styled, { useTheme } from "styled-components"; import styled, { useTheme } from "styled-components";
import { s, ellipsis } from "@shared/styles"; import { s, ellipsis } from "@shared/styles";
import Flex from "~/components/Flex"; import Flex from "~/components/Flex";
import NavLink from "~/components/NavLink"; import NavLink from "~/components/NavLink";
import { hover } from "~/styles";
export type Props = Omit<React.HTMLAttributes<HTMLAnchorElement>, "title"> & { export type Props = Omit<React.HTMLAttributes<HTMLAnchorElement>, "title"> & {
/** An icon or image to display to the left of the list item */ /** An icon or image to display to the left of the list item */
@@ -18,8 +12,6 @@ export type Props = Omit<React.HTMLAttributes<HTMLAnchorElement>, "title"> & {
to?: LocationDescriptor; to?: LocationDescriptor;
/** An optional click handler, if provided the list item will have hover styles */ /** An optional click handler, if provided the list item will have hover styles */
onClick?: React.MouseEventHandler<HTMLAnchorElement>; 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 */ /** Whether to match the location exactly */
exact?: boolean; exact?: boolean;
/** The title of the list item */ /** The title of the list item */
@@ -32,50 +24,15 @@ export type Props = Omit<React.HTMLAttributes<HTMLAnchorElement>, "title"> & {
border?: boolean; border?: boolean;
/** Whether to display the list item in a compact style */ /** Whether to display the list item in a compact style */
small?: boolean; small?: boolean;
/** Whether to enable keyboard navigation */
keyboardNavigation?: boolean;
}; };
const ListItem = ( const ListItem = (
{ { image, title, subtitle, actions, small, border, to, ...rest }: Props,
image, ref?: React.Ref<HTMLAnchorElement>
title,
subtitle,
actions,
small,
border,
to,
keyboardNavigation,
...rest
}: Props,
ref: React.RefObject<HTMLAnchorElement>
) => { ) => {
const theme = useTheme(); const theme = useTheme();
const compact = !subtitle; const compact = !subtitle;
let itemRef: React.RefObject<HTMLAnchorElement> =
React.useRef<HTMLAnchorElement>(null);
if (ref) {
itemRef = ref;
}
const { focused, ...rovingTabIndex } = useRovingTabIndex(
itemRef,
keyboardNavigation || to ? false : true
);
useFocusEffect(focused, itemRef);
const handleFocus = React.useCallback(() => {
if (itemRef.current) {
scrollIntoView(itemRef.current, {
scrollMode: "if-needed",
behavior: "auto",
block: "center",
boundary: window.document.body,
});
}
}, [itemRef]);
const content = (selected: boolean) => ( const content = (selected: boolean) => (
<> <>
{image && <Image>{image}</Image>} {image && <Image>{image}</Image>}
@@ -102,30 +59,13 @@ const ListItem = (
if (to) { if (to) {
return ( return (
<Wrapper <Wrapper
ref={itemRef} ref={ref}
$border={border} $border={border}
$small={small} $small={small}
activeStyle={{ activeStyle={{
background: theme.accent, background: theme.accent,
}} }}
{...rest} {...rest}
{...rovingTabIndex}
onClick={(ev) => {
if (rest.onClick) {
rest.onClick(ev);
}
rovingTabIndex.onClick(ev);
}}
onKeyDown={(ev) => {
if (rest.onKeyDown) {
rest.onKeyDown(ev);
}
rovingTabIndex.onKeyDown(ev);
}}
onFocus={(ev) => {
rovingTabIndex.onFocus(ev);
handleFocus();
}}
as={NavLink} as={NavLink}
to={to} to={to}
> >
@@ -135,26 +75,7 @@ const ListItem = (
} }
return ( return (
<Wrapper <Wrapper ref={ref} $border={border} $small={small} {...rest}>
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)} {content(false)}
</Wrapper> </Wrapper>
); );
@@ -163,7 +84,6 @@ const ListItem = (
const Wrapper = styled.a<{ const Wrapper = styled.a<{
$small?: boolean; $small?: boolean;
$border?: boolean; $border?: boolean;
$hover?: boolean;
onClick?: React.MouseEventHandler<HTMLAnchorElement>; onClick?: React.MouseEventHandler<HTMLAnchorElement>;
to?: LocationDescriptor; to?: LocationDescriptor;
}>` }>`
@@ -180,15 +100,9 @@ const Wrapper = styled.a<{
border-bottom: 0; border-bottom: 0;
} }
&:focus-visible { &:hover {
outline: none;
}
&:${hover},
&:focus,
&:focus-within {
background: ${(props) => background: ${(props) =>
props.$hover ? props.theme.secondaryBackground : "inherit"}; props.onClick ? props.theme.secondaryBackground : "inherit"};
} }
cursor: ${(props) => cursor: ${(props) =>

View File

@@ -0,0 +1,28 @@
import * as React from "react";
import styled from "styled-components";
import useMediaQuery from "~/hooks/useMediaQuery";
import useMobile from "~/hooks/useMobile";
type Props = {
children: React.ReactNode;
};
const MobileWrapper = styled.div`
width: 100vw;
height: 100vh;
overflow: auto;
-webkit-overflow-scrolling: touch;
`;
const MobileScrollWrapper = ({ children }: Props) => {
const isMobile = useMobile();
const isPrinting = useMediaQuery("print");
return isMobile && !isPrinting ? (
<MobileWrapper>{children}</MobileWrapper>
) : (
<>{children}</>
);
};
export default MobileScrollWrapper;

View File

@@ -254,7 +254,7 @@ const Header = styled(Flex)`
const Small = styled.div` const Small = styled.div`
animation: ${fadeAndScaleIn} 250ms ease; animation: ${fadeAndScaleIn} 250ms ease;
margin: 25vh auto auto auto; margin: auto auto;
width: 75vw; width: 75vw;
min-width: 350px; min-width: 350px;
max-width: 450px; max-width: 450px;
@@ -282,7 +282,7 @@ const Small = styled.div`
`; `;
const SmallContent = styled(Scrollable)` const SmallContent = styled(Scrollable)`
padding: 12px 24px; padding: 12px 24px 24px;
`; `;
export default observer(Modal); export default observer(Modal);

View File

@@ -1,39 +0,0 @@
import * as React from "react";
import styled from "styled-components";
import useMediaQuery from "~/hooks/useMediaQuery";
import useMobile from "~/hooks/useMobile";
import ScrollContext from "./ScrollContext";
type Props = {
children: React.ReactNode;
};
const MobileWrapper = styled.div`
width: 100vw;
height: 100vh;
overflow: auto;
-webkit-overflow-scrolling: touch;
`;
/**
* A component that wraps its children in a scrollable container on mobile devices.
* This allows us to place a fixed toolbar at the bottom of the page in the document
* editor, which would otherwise be obscured by the on-screen keyboard.
*
* On desktop devices, the children are rendered directly without any wrapping.
*/
const PageScroll = ({ children }: Props) => {
const isMobile = useMobile();
const isPrinting = useMediaQuery("print");
const ref = React.useRef<HTMLDivElement>(null);
return isMobile && !isPrinting ? (
<ScrollContext.Provider value={ref}>
<MobileWrapper ref={ref}>{children}</MobileWrapper>
</ScrollContext.Provider>
) : (
<>{children}</>
);
};
export default PageScroll;

View File

@@ -42,7 +42,7 @@ const PaginatedDocumentList = React.memo<Props>(function PaginatedDocumentList({
fetch={fetch} fetch={fetch}
options={options} options={options}
renderError={(props) => <Error {...props} />} renderError={(props) => <Error {...props} />}
renderItem={(item: Document, _index) => ( renderItem={(item: Document, _index, compositeProps) => (
<DocumentListItem <DocumentListItem
key={item.id} key={item.id}
document={item} document={item}
@@ -52,6 +52,7 @@ const PaginatedDocumentList = React.memo<Props>(function PaginatedDocumentList({
showPublished={showPublished} showPublished={showPublished}
showTemplate={showTemplate} showTemplate={showTemplate}
showDraft={showDraft} showDraft={showDraft}
{...compositeProps}
/> />
)} )}
{...rest} {...rest}

View File

@@ -30,12 +30,13 @@ const PaginatedEventList = React.memo<Props>(function PaginatedEventList({
heading={heading} heading={heading}
fetch={fetch} fetch={fetch}
options={options} options={options}
renderItem={(item: Event, index) => ( renderItem={(item: Event, index, compositeProps) => (
<EventListItem <EventListItem
key={item.id} key={item.id}
event={item} event={item}
document={document} document={document}
latest={index === 0} latest={index === 0}
{...compositeProps}
/> />
)} )}
renderHeading={(name) => <Heading>{name}</Heading>} renderHeading={(name) => <Heading>{name}</Heading>}

View File

@@ -1,9 +1,10 @@
import isEqual from "lodash/isEqual"; import isEqual from "lodash/isEqual";
import { observable, action, computed } from "mobx"; import { observable, action } from "mobx";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import * as React from "react"; import * as React from "react";
import { withTranslation, WithTranslation } from "react-i18next"; import { withTranslation, WithTranslation } from "react-i18next";
import { Waypoint } from "react-waypoint"; import { Waypoint } from "react-waypoint";
import { CompositeStateReturn } from "reakit/Composite";
import { Pagination } from "@shared/constants"; import { Pagination } from "@shared/constants";
import RootStore from "~/stores/RootStore"; import RootStore from "~/stores/RootStore";
import ArrowKeyNavigation from "~/components/ArrowKeyNavigation"; import ArrowKeyNavigation from "~/components/ArrowKeyNavigation";
@@ -29,7 +30,11 @@ type Props<T> = WithTranslation &
loading?: React.ReactElement; loading?: React.ReactElement;
items?: T[]; items?: T[];
className?: string; className?: string;
renderItem: (item: T, index: number) => React.ReactNode; renderItem: (
item: T,
index: number,
compositeProps: CompositeStateReturn
) => React.ReactNode;
renderError?: (options: { renderError?: (options: {
error: Error; error: Error;
retry: () => void; retry: () => void;
@@ -39,9 +44,7 @@ type Props<T> = WithTranslation &
}; };
@observer @observer
class PaginatedList<T extends PaginatedItem> extends React.PureComponent< class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
Props<T>
> {
@observable @observable
error?: Error; error?: Error;
@@ -147,11 +150,6 @@ class PaginatedList<T extends PaginatedItem> extends React.PureComponent<
} }
}; };
@computed
get itemsToRender() {
return this.props.items?.slice(0, this.renderCount) ?? [];
}
render() { render() {
const { const {
items = [], items = [],
@@ -195,12 +193,11 @@ class PaginatedList<T extends PaginatedItem> extends React.PureComponent<
aria-label={this.props["aria-label"]} aria-label={this.props["aria-label"]}
onEscape={onEscape} onEscape={onEscape}
className={this.props.className} className={this.props.className}
items={this.itemsToRender}
> >
{() => { {(composite: CompositeStateReturn) => {
let previousHeading = ""; let previousHeading = "";
return this.itemsToRender.map((item, index) => { return items.slice(0, this.renderCount).map((item, index) => {
const children = this.props.renderItem(item, index); const children = this.props.renderItem(item, index, composite);
// If there is no renderHeading method passed then no date // If there is no renderHeading method passed then no date
// headings are rendered // headings are rendered

View File

@@ -5,17 +5,13 @@ import Fade from "~/components/Fade";
import Flex from "~/components/Flex"; import Flex from "~/components/Flex";
import PlaceholderText from "~/components/PlaceholderText"; import PlaceholderText from "~/components/PlaceholderText";
type Props = {
/** Whether to include a title placeholder. */
includeTitle?: boolean;
/** Delay before mounting the component. Defaults to 500ms */
delay?: number;
};
export default function PlaceholderDocument({ export default function PlaceholderDocument({
includeTitle, includeTitle,
delay = 500, delay,
}: Props) { }: {
includeTitle?: boolean;
delay?: number;
}) {
const content = ( const content = (
<> <>
<PlaceholderText delay={0.2} /> <PlaceholderText delay={0.2} />

View File

@@ -1,37 +1,29 @@
import { observer } from "mobx-react";
import * as React from "react"; import * as React from "react";
import styled from "styled-components"; import styled from "styled-components";
import Logger from "~/utils/Logger"; import PluginLoader from "~/utils/PluginLoader";
import { Hook, usePluginValue } from "~/utils/PluginManager";
type Props = { type Props = {
/** The ID of the plugin to render an Icon for. */
id: string; id: string;
/** The size of the icon. */
size?: number; size?: number;
/** The color of the icon. */
color?: string; color?: string;
}; };
/**
* Renders an icon defined in a plugin (Hook.Icon).
*/
function PluginIcon({ id, color, size = 24 }: Props) { function PluginIcon({ id, color, size = 24 }: Props) {
const Icon = usePluginValue(Hook.Icon, id); const plugin = PluginLoader.plugins[id];
const Icon = plugin?.icon;
if (Icon) { if (Icon) {
return ( return (
<IconPosition> <Wrapper>
<Icon size={size} fill={color} /> <Icon size={size} fill={color} />
</IconPosition> </Wrapper>
); );
} }
Logger.warn("No Icon registered for plugin", { id });
return null; return null;
} }
const IconPosition = styled.div` const Wrapper = styled.div`
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -40,4 +32,4 @@ const IconPosition = styled.div`
height: 24px; height: 24px;
`; `;
export default observer(PluginIcon); export default PluginIcon;

View File

@@ -20,18 +20,15 @@ type Props = PopoverProps & {
hide: () => void; hide: () => void;
}; };
const Popover = ( const Popover: React.FC<Props> = ({
{ children,
children, shrink,
shrink, width = 380,
width = 380, scrollable = true,
scrollable = true, flex,
flex, mobilePosition,
mobilePosition, ...rest
...rest }: Props) => {
}: Props,
ref: React.Ref<HTMLDivElement>
) => {
const isMobile = useMobile(); const isMobile = useMobile();
// Custom Escape handler rather than using hideOnEsc from reakit so we can // Custom Escape handler rather than using hideOnEsc from reakit so we can
@@ -53,7 +50,6 @@ const Popover = (
return ( return (
<Dialog {...rest} modal> <Dialog {...rest} modal>
<Contents <Contents
ref={ref}
$shrink={shrink} $shrink={shrink}
$scrollable={scrollable} $scrollable={scrollable}
$flex={flex} $flex={flex}
@@ -68,7 +64,6 @@ const Popover = (
return ( return (
<StyledPopover {...rest} hideOnEsc={false} hideOnClickOutside> <StyledPopover {...rest} hideOnEsc={false} hideOnClickOutside>
<Contents <Contents
ref={ref}
$shrink={shrink} $shrink={shrink}
$width={width} $width={width}
$scrollable={scrollable} $scrollable={scrollable}
@@ -128,4 +123,4 @@ const Contents = styled.div<ContentsProps>`
`}; `};
`; `;
export default React.forwardRef(Popover); export default Popover;

View File

@@ -1,15 +0,0 @@
import * as React from "react";
/**
* Context to provide a reference to the scrollable container
*/
const ScrollContext = React.createContext<
React.RefObject<HTMLDivElement> | undefined
>(undefined);
/**
* Hook to get the scrollable container reference
*/
export const useScrollContext = () => React.useContext(ScrollContext);
export default ScrollContext;

View File

@@ -2,7 +2,6 @@
import * as React from "react"; import * as React from "react";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
import usePrevious from "~/hooks/usePrevious"; import usePrevious from "~/hooks/usePrevious";
import { useScrollContext } from "./ScrollContext";
type Props = { type Props = {
children: JSX.Element; children: JSX.Element;
@@ -11,7 +10,6 @@ type Props = {
export default function ScrollToTop({ children }: Props) { export default function ScrollToTop({ children }: Props) {
const location = useLocation<{ retainScrollPosition?: boolean }>(); const location = useLocation<{ retainScrollPosition?: boolean }>();
const previousLocationPathname = usePrevious(location.pathname); const previousLocationPathname = usePrevious(location.pathname);
const scrollContainerRef = useScrollContext();
React.useEffect(() => { React.useEffect(() => {
if ( if (
@@ -27,9 +25,8 @@ export default function ScrollToTop({ children }: Props) {
) { ) {
return; return;
} }
(scrollContainerRef?.current || window).scrollTo(0, 0); window.scrollTo(0, 0);
}, [ }, [
scrollContainerRef,
location.pathname, location.pathname,
previousLocationPathname, previousLocationPathname,
location.state?.retainScrollPosition, location.state?.retainScrollPosition,

View File

@@ -1,10 +1,7 @@
import {
useFocusEffect,
useRovingTabIndex,
} from "@getoutline/react-roving-tabindex";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import * as React from "react"; import * as React from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { CompositeItem } from "reakit/Composite";
import styled, { css } from "styled-components"; import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint"; import breakpoint from "styled-components-breakpoint";
import { s, ellipsis } from "@shared/styles"; import { s, ellipsis } from "@shared/styles";
@@ -37,18 +34,10 @@ function DocumentListItem(
) { ) {
const { document, highlight, context, shareId, ...rest } = props; const { document, highlight, context, shareId, ...rest } = props;
let itemRef: React.Ref<HTMLAnchorElement> =
React.useRef<HTMLAnchorElement>(null);
if (ref) {
itemRef = ref;
}
const { focused, ...rovingTabIndex } = useRovingTabIndex(itemRef, false);
useFocusEffect(focused, itemRef);
return ( return (
<DocumentLink <CompositeItem
ref={itemRef} as={DocumentLink}
ref={ref}
dir={document.dir} dir={document.dir}
to={{ to={{
pathname: shareId pathname: shareId
@@ -59,13 +48,6 @@ function DocumentListItem(
}, },
}} }}
{...rest} {...rest}
{...rovingTabIndex}
onClick={(ev) => {
if (rest.onClick) {
rest.onClick(ev);
}
rovingTabIndex.onClick(ev);
}}
> >
<Content> <Content>
<Heading dir={document.dir}> <Heading dir={document.dir}>
@@ -84,7 +66,7 @@ function DocumentListItem(
/> />
} }
</Content> </Content>
</DocumentLink> </CompositeItem>
); );
} }

View File

@@ -206,7 +206,7 @@ function SearchPopover({ shareId }: Props) {
<NoResults>{t("No results for {{query}}", { query })}</NoResults> <NoResults>{t("No results for {{query}}", { query })}</NoResults>
} }
loading={<PlaceholderList count={3} header={{ height: 20 }} />} loading={<PlaceholderList count={3} header={{ height: 20 }} />}
renderItem={(item: SearchResult, index) => ( renderItem={(item: SearchResult, index, compositeProps) => (
<SearchListItem <SearchListItem
key={item.document.id} key={item.document.id}
shareId={shareId} shareId={shareId}
@@ -215,6 +215,7 @@ function SearchPopover({ shareId }: Props) {
context={item.context} context={item.context}
highlight={cachedQuery} highlight={cachedQuery}
onClick={handleSearchItemClick} onClick={handleSearchItemClick}
{...compositeProps}
/> />
)} )}
/> />

View File

@@ -53,16 +53,16 @@ function CollectionMemberList({ collection, invitedInSession }: Props) {
() => () =>
[ [
{ {
label: t("View only"), label: t("Admin"),
value: CollectionPermission.Read, value: CollectionPermission.Admin,
}, },
{ {
label: t("Can edit"), label: t("Can edit"),
value: CollectionPermission.ReadWrite, value: CollectionPermission.ReadWrite,
}, },
{ {
label: t("Manage"), label: t("View only"),
value: CollectionPermission.Admin, value: CollectionPermission.Read,
}, },
{ {
divider: true, divider: true,
@@ -99,20 +99,18 @@ function CollectionMemberList({ collection, invitedInSession }: Props) {
<InputMemberPermissionSelect <InputMemberPermissionSelect
style={{ margin: 0 }} style={{ margin: 0 }}
permissions={permissions} permissions={permissions}
onChange={async ( onChange={async (permission: CollectionPermission) => {
permission: CollectionPermission | typeof EmptySelectValue if (permission) {
) => {
if (permission === EmptySelectValue) {
await collectionGroupMemberships.delete({
collectionId: collection.id,
groupId: membership.groupId,
});
} else {
await collectionGroupMemberships.create({ await collectionGroupMemberships.create({
collectionId: collection.id, collectionId: collection.id,
groupId: membership.groupId, groupId: membership.groupId,
permission, permission,
}); });
} else {
await collectionGroupMemberships.delete({
collectionId: collection.id,
groupId: membership.groupId,
});
} }
}} }}
disabled={!can.update} disabled={!can.update}
@@ -148,20 +146,18 @@ function CollectionMemberList({ collection, invitedInSession }: Props) {
<InputMemberPermissionSelect <InputMemberPermissionSelect
style={{ margin: 0 }} style={{ margin: 0 }}
permissions={permissions} permissions={permissions}
onChange={async ( onChange={async (permission: CollectionPermission) => {
permission: CollectionPermission | typeof EmptySelectValue if (permission) {
) => {
if (permission === EmptySelectValue) {
await memberships.delete({
collectionId: collection.id,
userId: membership.userId,
});
} else {
await memberships.create({ await memberships.create({
collectionId: collection.id, collectionId: collection.id,
userId: membership.userId, userId: membership.userId,
permission, permission,
}); });
} else {
await memberships.delete({
collectionId: collection.id,
userId: membership.userId,
});
} }
}} }}
disabled={!can.update} disabled={!can.update}

View File

@@ -20,9 +20,8 @@ import useBoolean from "~/hooks/useBoolean";
import useCurrentTeam from "~/hooks/useCurrentTeam"; import useCurrentTeam from "~/hooks/useCurrentTeam";
import useKeyDown from "~/hooks/useKeyDown"; import useKeyDown from "~/hooks/useKeyDown";
import usePolicy from "~/hooks/usePolicy"; import usePolicy from "~/hooks/usePolicy";
import usePrevious from "~/hooks/usePrevious";
import useStores from "~/hooks/useStores"; import useStores from "~/hooks/useStores";
import { EmptySelectValue, Permission } from "~/types"; import { Permission } from "~/types";
import { collectionPath, urlify } from "~/utils/routeHelpers"; import { collectionPath, urlify } from "~/utils/routeHelpers";
import { Wrapper, presence } from "../components"; import { Wrapper, presence } from "../components";
import { CopyLinkButton } from "../components/CopyLinkButton"; import { CopyLinkButton } from "../components/CopyLinkButton";
@@ -57,17 +56,9 @@ function SharePopover({ collection, visible, onRequestClose }: Props) {
CollectionPermission.Read CollectionPermission.Read
); );
const prevPendingIds = usePrevious(pendingIds);
const suggestionsRef = React.useRef<HTMLDivElement | null>(null);
const searchInputRef = React.useRef<HTMLInputElement | null>(null);
useKeyDown( useKeyDown(
"Escape", "Escape",
(ev) => { (ev) => {
if (!visible) {
return;
}
ev.preventDefault(); ev.preventDefault();
ev.stopImmediatePropagation(); ev.stopImmediatePropagation();
@@ -103,19 +94,6 @@ function SharePopover({ collection, visible, onRequestClose }: Props) {
} }
}, [visible]); }, [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( const handleQuery = React.useCallback(
(event) => { (event) => {
showPicker(); showPicker();
@@ -138,39 +116,6 @@ function SharePopover({ collection, visible, onRequestClose }: Props) {
[setPendingIds] [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( const inviteAction = React.useMemo(
() => () =>
createAction({ createAction({
@@ -284,16 +229,16 @@ function SharePopover({ collection, visible, onRequestClose }: Props) {
() => () =>
[ [
{ {
label: t("View only"), label: t("Admin"),
value: CollectionPermission.Read, value: CollectionPermission.Admin,
}, },
{ {
label: t("Can edit"), label: t("Can edit"),
value: CollectionPermission.ReadWrite, value: CollectionPermission.ReadWrite,
}, },
{ {
label: t("Manage"), label: t("View only"),
value: CollectionPermission.Admin, value: CollectionPermission.Read,
}, },
] as Permission[], ] as Permission[],
[t] [t]
@@ -344,10 +289,8 @@ function SharePopover({ collection, visible, onRequestClose }: Props) {
<Wrapper> <Wrapper>
{can.update && ( {can.update && (
<SearchInput <SearchInput
ref={searchInputRef}
onChange={handleQuery} onChange={handleQuery}
onClick={showPicker} onClick={showPicker}
onKeyDown={handleKeyDown}
query={query} query={query}
back={backButton} back={backButton}
action={rightButton} action={rightButton}
@@ -355,15 +298,15 @@ function SharePopover({ collection, visible, onRequestClose }: Props) {
)} )}
{picker && ( {picker && (
<Suggestions <div>
ref={suggestionsRef} <Suggestions
query={query} query={query}
collection={collection} collection={collection}
pendingIds={pendingIds} pendingIds={pendingIds}
addPendingId={handleAddPendingId} addPendingId={handleAddPendingId}
removePendingId={handleRemovePendingId} removePendingId={handleRemovePendingId}
onEscape={handleEscape} />
/> </div>
)} )}
<div style={{ display: picker ? "none" : "block" }}> <div style={{ display: picker ? "none" : "block" }}>
@@ -379,12 +322,8 @@ function SharePopover({ collection, visible, onRequestClose }: Props) {
<div style={{ marginRight: -8 }}> <div style={{ marginRight: -8 }}>
<InputSelectPermission <InputSelectPermission
style={{ margin: 0 }} style={{ margin: 0 }}
onChange={( onChange={(permission) => {
value: CollectionPermission | typeof EmptySelectValue void collection.save({ permission });
) => {
void collection.save({
permission: value === EmptySelectValue ? null : value,
});
}} }}
disabled={!can.update} disabled={!can.update}
value={collection?.permission} value={collection?.permission}

View File

@@ -51,10 +51,6 @@ const DocumentMemberListItem = ({
label: t("Can edit"), label: t("Can edit"),
value: DocumentPermission.ReadWrite, value: DocumentPermission.ReadWrite,
}, },
{
label: t("Manage"),
value: DocumentPermission.Admin,
},
{ {
divider: true, divider: true,
label: t("Remove"), label: t("Remove"),

View File

@@ -4,8 +4,7 @@ import * as React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useTheme } from "styled-components"; import { useTheme } from "styled-components";
import Squircle from "@shared/components/Squircle"; import Squircle from "@shared/components/Squircle";
import { CollectionPermission, IconType } from "@shared/types"; import { CollectionPermission } from "@shared/types";
import { determineIconType } from "@shared/utils/icon";
import type Collection from "~/models/Collection"; import type Collection from "~/models/Collection";
import type Document from "~/models/Document"; import type Document from "~/models/Document";
import Flex from "~/components/Flex"; import Flex from "~/components/Flex";
@@ -55,7 +54,15 @@ export const OtherAccess = observer(({ document, children }: Props) => {
/> />
) : usersInCollection ? ( ) : usersInCollection ? (
<ListItem <ListItem
image={<CollectionSquircle collection={collection} />} image={
<Squircle color={collection.color} size={AvatarSize.Medium}>
<CollectionIcon
collection={collection}
color={theme.white}
size={16}
/>
</Squircle>
}
title={collection.name} title={collection.name}
subtitle={t("Everyone in the collection")} subtitle={t("Everyone in the collection")}
actions={<AccessTooltip>{t("Can view")}</AccessTooltip>} actions={<AccessTooltip>{t("Can view")}</AccessTooltip>}
@@ -129,24 +136,6 @@ const AccessTooltip = ({
); );
}; };
const CollectionSquircle = ({ collection }: { collection: Collection }) => {
const theme = useTheme();
const iconType = determineIconType(collection.icon)!;
const squircleColor =
iconType === IconType.SVG ? collection.color! : theme.slateLight;
const iconSize = iconType === IconType.SVG ? 16 : 22;
return (
<Squircle color={squircleColor} size={AvatarSize.Medium}>
<CollectionIcon
collection={collection}
color={theme.white}
size={iconSize}
/>
</Squircle>
);
};
function useUsersInCollection(collection?: Collection) { function useUsersInCollection(collection?: Collection) {
const { users, memberships } = useStores(); const { users, memberships } = useStores();
const { request } = useRequest(() => const { request } = useRequest(() =>

View File

@@ -172,9 +172,11 @@ function PublicAccess({ document, share, sharedParent }: Props) {
error={validationError} error={validationError}
defaultValue={urlId} defaultValue={urlId}
prefix={ prefix={
<DomainPrefix onClick={() => inputRef.current?.focus()}> <DomainPrefix
{env.URL.replace(/https?:\/\//, "") + "/s/"} readOnly
</DomainPrefix> onClick={() => inputRef.current?.focus()}
value={env.URL.replace(/https?:\/\//, "") + "/s/"}
/>
} }
> >
{copyButton} {copyButton}
@@ -206,9 +208,9 @@ const Wrapper = styled.div`
margin-bottom: 8px; margin-bottom: 8px;
`; `;
const DomainPrefix = styled.span` const DomainPrefix = styled(NativeInput)`
padding: 0 2px 0 8px;
flex: 0 1 auto; flex: 0 1 auto;
padding-right: 0 !important;
cursor: text; cursor: text;
color: ${s("placeholder")}; color: ${s("placeholder")};
user-select: none; user-select: none;

View File

@@ -18,7 +18,6 @@ import useBoolean from "~/hooks/useBoolean";
import useCurrentTeam from "~/hooks/useCurrentTeam"; import useCurrentTeam from "~/hooks/useCurrentTeam";
import useKeyDown from "~/hooks/useKeyDown"; import useKeyDown from "~/hooks/useKeyDown";
import usePolicy from "~/hooks/usePolicy"; import usePolicy from "~/hooks/usePolicy";
import usePrevious from "~/hooks/usePrevious";
import useStores from "~/hooks/useStores"; import useStores from "~/hooks/useStores";
import { Permission } from "~/types"; import { Permission } from "~/types";
import { documentPath, urlify } from "~/utils/routeHelpers"; import { documentPath, urlify } from "~/utils/routeHelpers";
@@ -65,17 +64,9 @@ function SharePopover({
DocumentPermission.Read DocumentPermission.Read
); );
const prevPendingIds = usePrevious(pendingIds);
const suggestionsRef = React.useRef<HTMLDivElement | null>(null);
const searchInputRef = React.useRef<HTMLInputElement | null>(null);
useKeyDown( useKeyDown(
"Escape", "Escape",
(ev) => { (ev) => {
if (!visible) {
return;
}
ev.preventDefault(); ev.preventDefault();
ev.stopImmediatePropagation(); ev.stopImmediatePropagation();
@@ -113,19 +104,6 @@ function SharePopover({
} }
}, [picker]); }, [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( const inviteAction = React.useMemo(
() => () =>
createAction({ createAction({
@@ -221,53 +199,16 @@ function SharePopover({
[setPendingIds] [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( const permissions = React.useMemo(
() => () =>
[ [
{
label: t("View only"),
value: DocumentPermission.Read,
},
{ {
label: t("Can edit"), label: t("Can edit"),
value: DocumentPermission.ReadWrite, value: DocumentPermission.ReadWrite,
}, },
{ {
label: t("Manage"), label: t("View only"),
value: DocumentPermission.Admin, value: DocumentPermission.Read,
}, },
] as Permission[], ] as Permission[],
[t] [t]
@@ -318,10 +259,8 @@ function SharePopover({
<Wrapper> <Wrapper>
{can.manageUsers && ( {can.manageUsers && (
<SearchInput <SearchInput
ref={searchInputRef}
onChange={handleQuery} onChange={handleQuery}
onClick={showPicker} onClick={showPicker}
onKeyDown={handleKeyDown}
query={query} query={query}
back={backButton} back={backButton}
action={rightButton} action={rightButton}
@@ -329,15 +268,15 @@ function SharePopover({
)} )}
{picker && ( {picker && (
<Suggestions <div>
ref={suggestionsRef} <Suggestions
document={document} document={document}
query={query} query={query}
pendingIds={pendingIds} pendingIds={pendingIds}
addPendingId={handleAddPendingId} addPendingId={handleAddPendingId}
removePendingId={handleRemovePendingId} removePendingId={handleRemovePendingId}
onEscape={handleEscape} />
/> </div>
)} )}
<div style={{ display: picker ? "none" : "block" }}> <div style={{ display: picker ? "none" : "block" }}>
@@ -348,7 +287,7 @@ function SharePopover({
/> />
</OtherAccess> </OtherAccess>
{team.sharing && can.share && !collectionSharingDisabled && visible && ( {team.sharing && can.share && !collectionSharingDisabled && (
<> <>
{document.members.length ? <Separator /> : null} {document.members.length ? <Separator /> : null}
<PublicAccess <PublicAccess

View File

@@ -15,9 +15,7 @@ export const ListItem = styled(BaseListItem).attrs({
padding: 6px 16px; padding: 6px 16px;
border-radius: 8px; border-radius: 8px;
&: ${hover} ${InviteIcon}, &: ${hover} ${InviteIcon} {
&:focus ${InviteIcon},
&:focus-within ${InviteIcon} {
opacity: 1; opacity: 1;
} }
`; `;

View File

@@ -1,7 +1,6 @@
import { AnimatePresence } from "framer-motion"; import { AnimatePresence } from "framer-motion";
import * as React from "react"; import * as React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { mergeRefs } from "react-merge-refs";
import Flex from "~/components/Flex"; import Flex from "~/components/Flex";
import useMobile from "~/hooks/useMobile"; import useMobile from "~/hooks/useMobile";
import Input, { NativeInput } from "../../Input"; import Input, { NativeInput } from "../../Input";
@@ -11,25 +10,17 @@ type Props = {
query: string; query: string;
onChange: React.ChangeEventHandler; onChange: React.ChangeEventHandler;
onClick: React.MouseEventHandler; onClick: React.MouseEventHandler;
onKeyDown: React.KeyboardEventHandler;
back: React.ReactNode; back: React.ReactNode;
action: React.ReactNode; action: React.ReactNode;
}; };
export const SearchInput = React.forwardRef(function _SearchInput( export function SearchInput({ onChange, onClick, query, back, action }: Props) {
{ onChange, onClick, onKeyDown, query, back, action }: Props,
ref: React.Ref<HTMLInputElement>
) {
const { t } = useTranslation(); const { t } = useTranslation();
const inputRef = React.useRef<HTMLInputElement>(null); const inputRef = React.useRef<HTMLInputElement>(null);
const isMobile = useMobile(); const isMobile = useMobile();
const focusInput = React.useCallback( const focusInput = React.useCallback(
(event) => { (event) => {
if (event.target.closest("button")) {
return;
}
inputRef.current?.focus(); inputRef.current?.focus();
onClick(event); onClick(event);
}, },
@@ -45,7 +36,6 @@ export const SearchInput = React.forwardRef(function _SearchInput(
value={query} value={query}
onChange={onChange} onChange={onChange}
onClick={onClick} onClick={onClick}
onKeyDown={onKeyDown}
autoFocus autoFocus
margin={0} margin={0}
flex flex
@@ -59,16 +49,15 @@ export const SearchInput = React.forwardRef(function _SearchInput(
{back} {back}
<NativeInput <NativeInput
key="input" key="input"
ref={mergeRefs([inputRef, ref])} ref={inputRef}
placeholder={`${t("Add or invite")}`} placeholder={`${t("Add or invite")}`}
value={query} value={query}
onChange={onChange} onChange={onChange}
onClick={onClick} onClick={onClick}
onKeyDown={onKeyDown}
style={{ padding: "6px 0" }} style={{ padding: "6px 0" }}
/> />
{action} {action}
</AnimatePresence> </AnimatePresence>
</HeaderInput> </HeaderInput>
); );
}); }

View File

@@ -1,5 +1,4 @@
import { isEmail } from "class-validator"; import { isEmail } from "class-validator";
import concat from "lodash/concat";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { CheckmarkIcon, CloseIcon, GroupIcon } from "outline-icons"; import { CheckmarkIcon, CloseIcon, GroupIcon } from "outline-icons";
import * as React from "react"; import * as React from "react";
@@ -12,14 +11,11 @@ import Collection from "~/models/Collection";
import Document from "~/models/Document"; import Document from "~/models/Document";
import Group from "~/models/Group"; import Group from "~/models/Group";
import User from "~/models/User"; import User from "~/models/User";
import ArrowKeyNavigation from "~/components/ArrowKeyNavigation";
import Avatar from "~/components/Avatar"; import Avatar from "~/components/Avatar";
import { AvatarSize, IAvatar } from "~/components/Avatar/Avatar"; import { AvatarSize, IAvatar } from "~/components/Avatar/Avatar";
import Empty from "~/components/Empty"; import Empty from "~/components/Empty";
import Placeholder from "~/components/List/Placeholder"; import Placeholder from "~/components/List/Placeholder";
import Scrollable from "~/components/Scrollable";
import useCurrentUser from "~/hooks/useCurrentUser"; import useCurrentUser from "~/hooks/useCurrentUser";
import useMaxHeight from "~/hooks/useMaxHeight";
import useStores from "~/hooks/useStores"; import useStores from "~/hooks/useStores";
import useThrottledCallback from "~/hooks/useThrottledCallback"; import useThrottledCallback from "~/hooks/useThrottledCallback";
import { hover } from "~/styles"; import { hover } from "~/styles";
@@ -44,41 +40,30 @@ type Props = {
removePendingId: (id: string) => void; removePendingId: (id: string) => void;
/** Show group suggestions. */ /** Show group suggestions. */
showGroups?: boolean; showGroups?: boolean;
/** Handles escape from suggestions list */
onEscape?: (ev: React.KeyboardEvent<HTMLDivElement>) => void;
}; };
export const Suggestions = observer( export const Suggestions = observer(
React.forwardRef(function _Suggestions( ({
{ document,
document, collection,
collection, query,
query, pendingIds,
pendingIds, addPendingId,
addPendingId, removePendingId,
removePendingId, showGroups,
showGroups, }: Props) => {
onEscape,
}: Props,
ref: React.Ref<HTMLDivElement>
) {
const neverRenderedList = React.useRef(false); const neverRenderedList = React.useRef(false);
const { users, groups } = useStores(); const { users, groups } = useStores();
const { t } = useTranslation(); const { t } = useTranslation();
const user = useCurrentUser(); const user = useCurrentUser();
const theme = useTheme(); const theme = useTheme();
const containerRef = React.useRef<HTMLDivElement | null>(null);
const maxHeight = useMaxHeight({
elementRef: containerRef,
maxViewportPercentage: 70,
});
const fetchUsersByQuery = useThrottledCallback( const fetchUsersByQuery = useThrottledCallback(
(query: string) => { (params) => {
void users.fetchPage({ query }); void users.fetchPage({ query: params.query });
if (showGroups) { if (showGroups) {
void groups.fetchPage({ query }); void groups.fetchPage({ query: params.query });
} }
}, },
250, 250,
@@ -107,7 +92,7 @@ export const Suggestions = observer(
: collection : collection
? users.notInCollection(collection.id, query) ? users.notInCollection(collection.id, query)
: users.orderedData : users.orderedData
).filter((u) => !u.isSuspended); ).filter((u) => u.id !== user.id && !u.isSuspended);
if (isEmail(query)) { if (isEmail(query)) {
filtered.push(getSuggestionForEmail(query)); filtered.push(getSuggestionForEmail(query));
@@ -189,65 +174,34 @@ export const Suggestions = observer(
neverRenderedList.current = false; neverRenderedList.current = false;
return ( return (
<ScrollableContainer <>
ref={containerRef} {pending.map((suggestion) => (
hiddenScrollbars <PendingListItem
style={{ maxHeight }} {...getListItemProps(suggestion)}
> key={suggestion.id}
<ArrowKeyNavigation onClick={() => removePendingId(suggestion.id)}
ref={ref} actions={
onEscape={onEscape} <>
aria-label={t("Suggestions for invitation")} <InvitedIcon />
items={concat(pending, suggestionsWithPending)} <RemoveIcon />
> </>
{() => [ }
...pending.map((suggestion) => ( />
<PendingListItem ))}
keyboardNavigation {pending.length > 0 &&
{...getListItemProps(suggestion)} (suggestionsWithPending.length > 0 || isEmpty) && <Separator />}
key={suggestion.id} {suggestionsWithPending.map((suggestion) => (
onClick={() => removePendingId(suggestion.id)} <ListItem
onKeyDown={(ev) => { {...getListItemProps(suggestion as User)}
if (ev.key === "Enter") { key={suggestion.id}
ev.preventDefault(); onClick={() => addPendingId(suggestion.id)}
ev.stopPropagation(); actions={<InviteIcon />}
removePendingId(suggestion.id); />
} ))}
}} {isEmpty && <Empty style={{ marginTop: 22 }}>{t("No matches")}</Empty>}
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)` const InvitedIcon = styled(CheckmarkIcon)`
@@ -274,8 +228,3 @@ const Separator = styled.div`
border-top: 1px dashed ${s("divider")}; border-top: 1px dashed ${s("divider")};
margin: 12px 0; margin: 12px 0;
`; `;
const ScrollableContainer = styled(Scrollable)`
padding: 12px 24px;
margin: -12px -24px;
`;

View File

@@ -1,5 +1,5 @@
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { DraftsIcon, SearchIcon, HomeIcon, SidebarIcon } from "outline-icons"; import { EditIcon, SearchIcon, HomeIcon, SidebarIcon } from "outline-icons";
import * as React from "react"; import * as React from "react";
import { DndProvider } from "react-dnd"; import { DndProvider } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend"; import { HTML5Backend } from "react-dnd-html5-backend";
@@ -55,7 +55,7 @@ function AppSidebar() {
); );
return ( return (
<Sidebar hidden={!ui.readyToShow} ref={handleSidebarRef}> <Sidebar ref={handleSidebarRef}>
<HistoryNavigation /> <HistoryNavigation />
{dndArea && ( {dndArea && (
<DndProvider backend={HTML5Backend} options={html5Options}> <DndProvider backend={HTML5Backend} options={html5Options}>
@@ -109,7 +109,7 @@ function AppSidebar() {
{can.createDocument && ( {can.createDocument && (
<SidebarLink <SidebarLink
to={draftsPath()} to={draftsPath()}
icon={<DraftsIcon />} icon={<EditIcon />}
label={ label={
<Flex align="center" justify="space-between"> <Flex align="center" justify="space-between">
{t("Drafts")} {t("Drafts")}

View File

@@ -50,8 +50,7 @@ function Right({ children, border, className }: Props) {
} }
}, []); }, []);
const handleMouseDown = React.useCallback((event) => { const handleMouseDown = React.useCallback(() => {
event.preventDefault();
setResizing(true); setResizing(true);
}, []); }, []);

View File

@@ -29,7 +29,7 @@ function SharedSidebar({ rootNode, shareId }: Props) {
return ( return (
<Sidebar> <Sidebar>
{team?.name && ( {team && (
<SidebarButton <SidebarButton
title={team.name} title={team.name}
image={<TeamLogo model={team} size={32} alt={t("Logo")} />} image={<TeamLogo model={team} size={32} alt={t("Logo")} />}

View File

@@ -24,12 +24,11 @@ const ANIMATION_MS = 250;
type Props = { type Props = {
children: React.ReactNode; children: React.ReactNode;
hidden?: boolean;
className?: string; className?: string;
}; };
const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar( const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
{ children, hidden = false, className }: Props, { children, className }: Props,
ref: React.RefObject<HTMLDivElement> ref: React.RefObject<HTMLDivElement>
) { ) {
const [isCollapsing, setCollapsing] = React.useState(false); const [isCollapsing, setCollapsing] = React.useState(false);
@@ -94,7 +93,6 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
const handleMouseDown = React.useCallback( const handleMouseDown = React.useCallback(
(event) => { (event) => {
event.preventDefault();
setOffset(event.pageX - width); setOffset(event.pageX - width);
setResizing(true); setResizing(true);
setAnimating(false); setAnimating(false);
@@ -113,7 +111,10 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
(ev) => { (ev) => {
if (hasPointerMoved) { if (hasPointerMoved) {
setHovering( setHovering(
ev.pageX < width && ev.pageY < window.innerHeight && ev.pageY > 0 ev.pageX < width &&
ev.pageX > 0 &&
ev.pageY < window.innerHeight &&
ev.pageY > 0
); );
} }
}, },
@@ -144,11 +145,8 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
React.useEffect(() => { React.useEffect(() => {
if (isResizing) { if (isResizing) {
document.body.style.cursor = "col-resize";
document.addEventListener("mousemove", handleDrag); document.addEventListener("mousemove", handleDrag);
document.addEventListener("mouseup", handleStopDrag); document.addEventListener("mouseup", handleStopDrag);
} else {
document.body.style.cursor = "initial";
} }
return () => { return () => {
@@ -183,7 +181,6 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
<Container <Container
ref={ref} ref={ref}
style={style} style={style}
$hidden={hidden}
$isHovering={isHovering} $isHovering={isHovering}
$isAnimating={isAnimating} $isAnimating={isAnimating}
$isSmallerThanMinimum={isSmallerThanMinimum} $isSmallerThanMinimum={isSmallerThanMinimum}
@@ -255,7 +252,6 @@ type ContainerProps = {
$isSmallerThanMinimum: boolean; $isSmallerThanMinimum: boolean;
$isHovering: boolean; $isHovering: boolean;
$collapsed: boolean; $collapsed: boolean;
$hidden: boolean;
}; };
const hoverStyles = (props: ContainerProps) => ` const hoverStyles = (props: ContainerProps) => `
@@ -274,14 +270,13 @@ const hoverStyles = (props: ContainerProps) => `
`; `;
const Container = styled(Flex)<ContainerProps>` const Container = styled(Flex)<ContainerProps>`
opacity: ${(props) => (props.$hidden ? 0 : 1)};
position: fixed; position: fixed;
top: 0; top: 0;
bottom: 0; bottom: 0;
width: 100%; width: 100%;
background: ${s("sidebarBackground")}; background: ${s("sidebarBackground")};
transition: box-shadow 150ms ease-in-out, opacity 150ms ease-in-out, transition: box-shadow 100ms ease-in-out, opacity 100ms ease-in-out,
transform 150ms ease-out, transform 100ms ease-out,
${s("backgroundTransition")} ${s("backgroundTransition")}
${(props: ContainerProps) => ${(props: ContainerProps) =>
props.$isAnimating ? `,width ${ANIMATION_MS}ms ease-out` : ""}; props.$isAnimating ? `,width ${ANIMATION_MS}ms ease-out` : ""};

View File

@@ -14,14 +14,13 @@ import { DocumentValidation } from "@shared/validations";
import Collection from "~/models/Collection"; import Collection from "~/models/Collection";
import Document from "~/models/Document"; import Document from "~/models/Document";
import Fade from "~/components/Fade"; import Fade from "~/components/Fade";
import Icon from "~/components/Icon";
import NudeButton from "~/components/NudeButton"; import NudeButton from "~/components/NudeButton";
import Tooltip from "~/components/Tooltip"; import Tooltip from "~/components/Tooltip";
import useBoolean from "~/hooks/useBoolean"; import useBoolean from "~/hooks/useBoolean";
import usePolicy from "~/hooks/usePolicy"; import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores"; import useStores from "~/hooks/useStores";
import DocumentMenu from "~/menus/DocumentMenu"; import DocumentMenu from "~/menus/DocumentMenu";
import { newNestedDocumentPath } from "~/utils/routeHelpers"; import { newDocumentPath } from "~/utils/routeHelpers";
import DropCursor from "./DropCursor"; import DropCursor from "./DropCursor";
import DropToImport from "./DropToImport"; import DropToImport from "./DropToImport";
import EditableTitle, { RefHandle } from "./EditableTitle"; import EditableTitle, { RefHandle } from "./EditableTitle";
@@ -283,8 +282,6 @@ function InnerDocumentLink(
const title = const title =
(activeDocument?.id === node.id ? activeDocument.title : node.title) || (activeDocument?.id === node.id ? activeDocument.title : node.title) ||
t("Untitled"); t("Untitled");
const icon = document?.icon || node.icon || node.emoji;
const color = document?.color || node.color;
const isExpanded = expanded && !isDragging; const isExpanded = expanded && !isDragging;
const hasChildren = nodeChildren.length > 0; const hasChildren = nodeChildren.length > 0;
@@ -334,7 +331,7 @@ function InnerDocumentLink(
starred: inStarredSection, starred: inStarredSection,
}, },
}} }}
icon={icon && <Icon value={icon} color={color} />} emoji={document?.emoji || node.emoji}
label={ label={
<EditableTitle <EditableTitle
title={title} title={title}
@@ -369,7 +366,9 @@ function InnerDocumentLink(
type={undefined} type={undefined}
aria-label={t("New nested document")} aria-label={t("New nested document")}
as={Link} as={Link}
to={newNestedDocumentPath(document.id)} to={newDocumentPath(document.collectionId, {
parentDocumentId: document.id,
})}
> >
<PlusIcon /> <PlusIcon />
</NudeButton> </NudeButton>

View File

@@ -5,7 +5,6 @@ import { useTranslation } from "react-i18next";
import { NavigationNode } from "@shared/types"; import { NavigationNode } from "@shared/types";
import Collection from "~/models/Collection"; import Collection from "~/models/Collection";
import Document from "~/models/Document"; import Document from "~/models/Document";
import Icon from "~/components/Icon";
import useStores from "~/hooks/useStores"; import useStores from "~/hooks/useStores";
import { sharedDocumentPath } from "~/utils/routeHelpers"; import { sharedDocumentPath } from "~/utils/routeHelpers";
import { descendants } from "~/utils/tree"; import { descendants } from "~/utils/tree";
@@ -101,8 +100,6 @@ function DocumentLink(
(activeDocument?.id === node.id ? activeDocument.title : node.title) || (activeDocument?.id === node.id ? activeDocument.title : node.title) ||
t("Untitled"); t("Untitled");
const icon = node.icon ?? node.emoji;
return ( return (
<> <>
<SidebarLink <SidebarLink
@@ -114,7 +111,7 @@ function DocumentLink(
}} }}
expanded={hasChildDocuments && depth !== 0 ? expanded : undefined} expanded={hasChildDocuments && depth !== 0 ? expanded : undefined}
onDisclosureClick={handleDisclosureClick} onDisclosureClick={handleDisclosureClick}
icon={icon && <Icon value={icon} color={node.color} />} emoji={node.emoji}
label={title} label={title}
depth={depth} depth={depth}
exact={false} exact={false}

View File

@@ -2,8 +2,7 @@ import fractionalIndex from "fractional-index";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import * as React from "react"; import * as React from "react";
import styled from "styled-components"; import styled from "styled-components";
import { IconType, NotificationEventType } from "@shared/types"; import { NotificationEventType } from "@shared/types";
import { determineIconType } from "@shared/utils/icon";
import UserMembership from "~/models/UserMembership"; import UserMembership from "~/models/UserMembership";
import Fade from "~/components/Fade"; import Fade from "~/components/Fade";
import useBoolean from "~/hooks/useBoolean"; import useBoolean from "~/hooks/useBoolean";
@@ -79,11 +78,10 @@ function SharedWithMeLink({ userMembership }: Props) {
return null; return null;
} }
const { icon: docIcon } = document; const { emoji } = document;
const label = const label = emoji
determineIconType(docIcon) === IconType.Emoji ? document.title.replace(emoji, "")
? document.title.replace(docIcon!, "") : document.titleWithDefault;
: document.titleWithDefault;
const collection = document.collectionId const collection = document.collectionId
? collections.get(document.collectionId) ? collections.get(document.collectionId)
: undefined; : undefined;

View File

@@ -2,9 +2,10 @@ import { LocationDescriptor } from "history";
import * as React from "react"; import * as React from "react";
import styled, { useTheme, css } from "styled-components"; import styled, { useTheme, css } from "styled-components";
import breakpoint from "styled-components-breakpoint"; import breakpoint from "styled-components-breakpoint";
import EventBoundary from "@shared/components/EventBoundary";
import { s } from "@shared/styles"; import { s } from "@shared/styles";
import { NavigationNode } from "@shared/types"; import { NavigationNode } from "@shared/types";
import EventBoundary from "~/components/EventBoundary";
import EmojiIcon from "~/components/Icons/EmojiIcon";
import NudeButton from "~/components/NudeButton"; import NudeButton from "~/components/NudeButton";
import { UnreadBadge } from "~/components/UnreadBadge"; import { UnreadBadge } from "~/components/UnreadBadge";
import useUnmount from "~/hooks/useUnmount"; import useUnmount from "~/hooks/useUnmount";
@@ -26,6 +27,7 @@ type Props = Omit<NavLinkProps, "to"> & {
onClickIntent?: () => void; onClickIntent?: () => void;
onDisclosureClick?: React.MouseEventHandler<HTMLButtonElement>; onDisclosureClick?: React.MouseEventHandler<HTMLButtonElement>;
icon?: React.ReactNode; icon?: React.ReactNode;
emoji?: string | null;
label?: React.ReactNode; label?: React.ReactNode;
menu?: React.ReactNode; menu?: React.ReactNode;
unreadBadge?: boolean; unreadBadge?: boolean;
@@ -50,6 +52,7 @@ function SidebarLink(
onClick, onClick,
onClickIntent, onClickIntent,
to, to,
emoji,
label, label,
active, active,
isActiveDrop, isActiveDrop,
@@ -139,6 +142,7 @@ function SidebarLink(
/> />
)} )}
{icon && <IconWrapper>{icon}</IconWrapper>} {icon && <IconWrapper>{icon}</IconWrapper>}
{emoji && <EmojiIcon emoji={emoji} />}
<Label>{label}</Label> <Label>{label}</Label>
{unreadBadge && <UnreadBadge />} {unreadBadge && <UnreadBadge />}
</Content> </Content>

View File

@@ -1,7 +1,7 @@
import { DocumentIcon } from "outline-icons"; import { DocumentIcon } from "outline-icons";
import * as React from "react"; import * as React from "react";
import Icon from "~/components/Icon";
import CollectionIcon from "~/components/Icons/CollectionIcon"; import CollectionIcon from "~/components/Icons/CollectionIcon";
import EmojiIcon from "~/components/Icons/EmojiIcon";
import useStores from "~/hooks/useStores"; import useStores from "~/hooks/useStores";
interface SidebarItem { interface SidebarItem {
@@ -21,11 +21,7 @@ export function useSidebarLabelAndIcon(
if (document) { if (document) {
return { return {
label: document.titleWithDefault, label: document.titleWithDefault,
icon: document.icon ? ( icon: document.emoji ? <EmojiIcon emoji={document.emoji} /> : icon,
<Icon value={document.icon} color={document.color ?? undefined} />
) : (
icon
),
}; };
} }
} }

View File

@@ -1,7 +1,11 @@
import data, { type Emoji as TEmoji } from "@emoji-mart/data";
import { init, Data } from "emoji-mart";
import FuzzySearch from "fuzzy-search";
import capitalize from "lodash/capitalize"; import capitalize from "lodash/capitalize";
import sortBy from "lodash/sortBy";
import React from "react"; import React from "react";
import { emojiMartToGemoji, snakeCase } from "@shared/editor/lib/emoji"; import { emojiMartToGemoji, snakeCase } from "@shared/editor/lib/emoji";
import { search as emojiSearch } from "@shared/utils/emoji"; import { isMac } from "@shared/utils/browser";
import EmojiMenuItem from "./EmojiMenuItem"; import EmojiMenuItem from "./EmojiMenuItem";
import SuggestionsMenu, { import SuggestionsMenu, {
Props as SuggestionsMenuProps, Props as SuggestionsMenuProps,
@@ -15,6 +19,13 @@ type Emoji = {
attrs: { markup: string; "data-name": string }; attrs: { markup: string; "data-name": string };
}; };
init({
data,
noCountryFlags: isMac() ? false : undefined,
});
let searcher: FuzzySearch<TEmoji>;
type Props = Omit< type Props = Omit<
SuggestionsMenuProps<Emoji>, SuggestionsMenuProps<Emoji>,
"renderMenuItem" | "items" | "embeds" | "trigger" "renderMenuItem" | "items" | "embeds" | "trigger"
@@ -23,26 +34,36 @@ type Props = Omit<
const EmojiMenu = (props: Props) => { const EmojiMenu = (props: Props) => {
const { search = "" } = props; const { search = "" } = props;
const items = React.useMemo( if (!searcher) {
() => searcher = new FuzzySearch(Object.values(Data.emojis), ["search"], {
emojiSearch({ query: search }) caseSensitive: false,
.map((item) => { sort: true,
// 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;
return { const items = React.useMemo(() => {
name: "emoji", const n = search.toLowerCase();
title: emoji,
description: capitalize(item.name.toLowerCase()), return sortBy(searcher.search(n), (item) => {
emoji, const nlc = item.name.toLowerCase();
attrs: { markup: shortcode, "data-name": shortcode }, return nlc === n ? -1 : nlc.startsWith(n) ? 0 : 1;
}; })
}) .map((item) => {
.slice(0, 15), // We snake_case the shortcode for backwards compatability with gemoji to
[search] // 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 ( return (
<SuggestionsMenu <SuggestionsMenu

View File

@@ -223,7 +223,6 @@ const FloatingToolbar = React.forwardRef(function FloatingToolbar_(
return ( return (
<ReactPortal> <ReactPortal>
<MobileWrapper <MobileWrapper
ref={menuRef}
style={{ style={{
bottom: `calc(100% - ${height - rect.y}px)`, bottom: `calc(100% - ${height - rect.y}px)`,
}} }}

View File

@@ -3,10 +3,10 @@ import { EditorState, NodeSelection, TextSelection } from "prosemirror-state";
import * as React from "react"; import * as React from "react";
import createAndInsertLink from "@shared/editor/commands/createAndInsertLink"; import createAndInsertLink from "@shared/editor/commands/createAndInsertLink";
import filterExcessSeparators from "@shared/editor/lib/filterExcessSeparators"; import filterExcessSeparators from "@shared/editor/lib/filterExcessSeparators";
import { getMarkRange } from "@shared/editor/queries/getMarkRange"; import getMarkRange from "@shared/editor/queries/getMarkRange";
import { isInCode } from "@shared/editor/queries/isInCode"; import isInCode from "@shared/editor/queries/isInCode";
import { isMarkActive } from "@shared/editor/queries/isMarkActive"; import isMarkActive from "@shared/editor/queries/isMarkActive";
import { isNodeActive } from "@shared/editor/queries/isNodeActive"; import isNodeActive from "@shared/editor/queries/isNodeActive";
import { getColumnIndex, getRowIndex } from "@shared/editor/queries/table"; import { getColumnIndex, getRowIndex } from "@shared/editor/queries/table";
import { MenuItem } from "@shared/editor/types"; import { MenuItem } from "@shared/editor/types";
import { creatingUrlPrefix } from "@shared/utils/urls"; import { creatingUrlPrefix } from "@shared/utils/urls";
@@ -100,10 +100,10 @@ export default function SelectionToolbar(props: Props) {
const { view, commands } = useEditor(); const { view, commands } = useEditor();
const dictionary = useDictionary(); const dictionary = useDictionary();
const menuRef = React.useRef<HTMLDivElement | null>(null); const menuRef = React.useRef<HTMLDivElement | null>(null);
const isMobile = useMobile(); const isActive = useIsActive(view.state);
const isActive = useIsActive(view.state) || isMobile;
const isDragging = useIsDragging(); const isDragging = useIsDragging();
const previousIsActive = usePrevious(isActive); const previousIsActive = usePrevious(isActive);
const isMobile = useMobile();
React.useEffect(() => { React.useEffect(() => {
// Trigger callbacks when the toolbar is opened or closed // Trigger callbacks when the toolbar is opened or closed
@@ -230,7 +230,7 @@ export default function SelectionToolbar(props: Props) {
if (isCodeSelection && selection.empty) { if (isCodeSelection && selection.empty) {
items = getCodeMenuItems(state, readOnly, dictionary); items = getCodeMenuItems(state, readOnly, dictionary);
} else if (isTableSelection) { } else if (isTableSelection) {
items = getTableMenuItems(state, dictionary); items = getTableMenuItems(dictionary);
} else if (colIndex !== undefined) { } else if (colIndex !== undefined) {
items = getTableColMenuItems(state, colIndex, rtl, dictionary); items = getTableColMenuItems(state, colIndex, rtl, dictionary);
} else if (rowIndex !== undefined) { } else if (rowIndex !== undefined) {

View File

@@ -78,10 +78,6 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
const { view, commands } = useEditor(); const { view, commands } = useEditor();
const dictionary = useDictionary(); const dictionary = useDictionary();
const hasActivated = React.useRef(false); const hasActivated = React.useRef(false);
const pointerRef = React.useRef<{ clientX: number; clientY: number }>({
clientX: 0,
clientY: 0,
});
const menuRef = React.useRef<HTMLDivElement>(null); const menuRef = React.useRef<HTMLDivElement>(null);
const inputRef = React.useRef<HTMLInputElement>(null); const inputRef = React.useRef<HTMLInputElement>(null);
const [position, setPosition] = React.useState<Position>(defaultPosition); const [position, setPosition] = React.useState<Position>(defaultPosition);
@@ -348,9 +344,6 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
const handleFilesPicked = async ( const handleFilesPicked = async (
event: React.ChangeEvent<HTMLInputElement> 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 { uploadFile, onFileUploadStart, onFileUploadStop } = props;
const files = getEventFiles(event); const files = getEventFiles(event);
const parent = findParentNode((node) => !!node)(view.state.selection); const parent = findParentNode((node) => !!node)(view.state.selection);
@@ -583,23 +576,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
return null; return null;
} }
const handlePointerMove = (ev: React.PointerEvent) => { const handlePointer = () => {
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) { if (selectedIndex !== index) {
setSelectedIndex(index); setSelectedIndex(index);
} }
@@ -608,8 +585,8 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
return ( return (
<ListItem <ListItem
key={index} key={index}
onPointerMove={handlePointerMove} onPointerMove={handlePointer}
onPointerDown={handlePointerDown} onPointerDown={handlePointer}
> >
{props.renderMenuItem(item as any, index, { {props.renderMenuItem(item as any, index, {
selected: index === selectedIndex, selected: index === selectedIndex,

View File

@@ -2,7 +2,6 @@ import * as React from "react";
import { useMenuState } from "reakit"; import { useMenuState } from "reakit";
import { MenuButton } from "reakit/Menu"; import { MenuButton } from "reakit/Menu";
import styled from "styled-components"; import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { MenuItem } from "@shared/editor/types"; import { MenuItem } from "@shared/editor/types";
import { s } from "@shared/styles"; import { s } from "@shared/styles";
import ContextMenu from "~/components/ContextMenu"; import ContextMenu from "~/components/ContextMenu";
@@ -20,7 +19,7 @@ type Props = {
/* /*
* Renders a dropdown menu in the floating toolbar. * Renders a dropdown menu in the floating toolbar.
*/ */
function ToolbarDropdown(props: { active: boolean; item: MenuItem }) { function ToolbarDropdown(props: { item: MenuItem }) {
const menu = useMenuState(); const menu = useMenuState();
const { commands, view } = useEditor(); const { commands, view } = useEditor();
const { item } = props; const { item } = props;
@@ -102,7 +101,7 @@ function ToolbarMenu(props: Props) {
key={index} key={index}
> >
{item.children ? ( {item.children ? (
<ToolbarDropdown active={isActive && !item.label} item={item} /> <ToolbarDropdown item={item} />
) : ( ) : (
<ToolbarButton <ToolbarButton
onClick={handleClick(item)} onClick={handleClick(item)}
@@ -124,11 +123,6 @@ const FlexibleWrapper = styled.div`
overflow: hidden; overflow: hidden;
display: flex; display: flex;
gap: 6px; gap: 6px;
${breakpoint("mobile", "tablet")`
justify-content: space-evenly;
align-items: baseline;
`}
`; `;
const Label = styled.span` const Label = styled.span`

View File

@@ -12,9 +12,8 @@ import BlockMenu from "../components/BlockMenu";
export default class BlockMenuExtension extends Suggestion { export default class BlockMenuExtension extends Suggestion {
get defaultOptions() { get defaultOptions() {
return { return {
// ported from https://github.com/tc39/proposal-regexp-unicode-property-escapes#unicode-aware-version-of-w openRegex: /^\/(\w+)?$/,
openRegex: /(?:^|\s|\()\/([\p{L}\p{M}\d]+)?$/u, closeRegex: /(^(?!\/(\w+)?)(.*)$|^\/(([\w\W]+)\s.*|\s)$|^\/((\W)+)$)/,
closeRegex: /(?:^|\s|\()\/(([\p{L}\p{M}\d]*\s+)|(\s+[\p{L}\p{M}\d]+))$/u,
}; };
} }

View File

@@ -24,6 +24,7 @@ export default class EmojiMenuExtension extends Suggestion {
), ),
closeRegex: closeRegex:
/(?:^|\s|\():(([0-9a-zA-Z_+-]*\s+)|(\s+[0-9a-zA-Z_+-]+)|[^0-9a-zA-Z_+-]+)$/, /(?:^|\s|\():(([0-9a-zA-Z_+-]*\s+)|(\s+[0-9a-zA-Z_+-]+)|[^0-9a-zA-Z_+-]+)$/,
enabledInTable: true,
}; };
} }

View File

@@ -156,10 +156,14 @@ export default class FindAndReplaceExtension extends Extension {
} }
private get findRegExp() { private get findRegExp() {
return RegExp( try {
this.searchTerm.replace(/\\+$/, ""), return RegExp(
!this.options.caseSensitive ? "gui" : "gu" this.searchTerm.replace(/\\+$/, ""),
); !this.options.caseSensitive ? "gui" : "gu"
);
} catch (err) {
return RegExp("");
}
} }
private goToMatch(direction: number): Command { private goToMatch(direction: number): Command {
@@ -246,19 +250,15 @@ export default class FindAndReplaceExtension extends Extension {
const search = this.findRegExp; const search = this.findRegExp;
let m; let m;
try { while ((m = search.exec(text))) {
while ((m = search.exec(text))) { if (m[0] === "") {
if (m[0] === "") { break;
break;
}
this.results.push({
from: pos + m.index,
to: pos + m.index + m[0].length,
});
} }
} catch (e) {
// Invalid RegExp this.results.push({
from: pos + m.index,
to: pos + m.index + m[0].length,
});
} }
}); });
} }

View File

@@ -8,7 +8,7 @@ import {
Command, Command,
} from "prosemirror-state"; } from "prosemirror-state";
import Extension from "@shared/editor/lib/Extension"; import Extension from "@shared/editor/lib/Extension";
import { isInCode } from "@shared/editor/queries/isInCode"; import isInCode from "@shared/editor/queries/isInCode";
export default class Keys extends Extension { export default class Keys extends Extension {
get name() { get name() {

View File

@@ -10,6 +10,7 @@ export default class MentionMenuExtension extends Suggestion {
// ported from https://github.com/tc39/proposal-regexp-unicode-property-escapes#unicode-aware-version-of-w // ported from https://github.com/tc39/proposal-regexp-unicode-property-escapes#unicode-aware-version-of-w
openRegex: /(?:^|\s|\()@([\p{L}\p{M}\d]+)?$/u, openRegex: /(?:^|\s|\()@([\p{L}\p{M}\d]+)?$/u,
closeRegex: /(?:^|\s|\()@(([\p{L}\p{M}\d]*\s+)|(\s+[\p{L}\p{M}\d]+))$/u, closeRegex: /(?:^|\s|\()@(([\p{L}\p{M}\d]*\s+)|(\s+[\p{L}\p{M}\d]+))$/u,
enabledInTable: true,
}; };
} }

View File

@@ -5,10 +5,8 @@ import { LANGUAGES } from "@shared/editor/extensions/Prism";
import Extension from "@shared/editor/lib/Extension"; import Extension from "@shared/editor/lib/Extension";
import isMarkdown from "@shared/editor/lib/isMarkdown"; import isMarkdown from "@shared/editor/lib/isMarkdown";
import normalizePastedMarkdown from "@shared/editor/lib/markdown/normalize"; import normalizePastedMarkdown from "@shared/editor/lib/markdown/normalize";
import { isInCode } from "@shared/editor/queries/isInCode"; import isInCode from "@shared/editor/queries/isInCode";
import { isInList } from "@shared/editor/queries/isInList"; 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 parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import { isDocumentUrl, isUrl } from "@shared/utils/urls"; import { isDocumentUrl, isUrl } from "@shared/utils/urls";
import stores from "~/stores"; import stores from "~/stores";
@@ -181,12 +179,9 @@ export default class PasteHandler extends Extension {
if (document) { if (document) {
const { hash } = new URL(text); const { hash } = new URL(text);
const hasEmoji = const title = `${
determineIconType(document.icon) === IconType.Emoji; document.emoji ? document.emoji + " " : ""
}${document.titleWithDefault}`;
const title = `${hasEmoji ? document.icon + " " : ""}${
document.titleWithDefault
}`;
insertLink(`${document.path}${hash}`, title); insertLink(`${document.path}${hash}`, title);
} }
}) })

View File

@@ -4,8 +4,8 @@ import { InputRule } from "@shared/editor/lib/InputRule";
const rightArrow = new InputRule(/->$/, "→"); const rightArrow = new InputRule(/->$/, "→");
const emdash = new InputRule(/--$/, "—"); const emdash = new InputRule(/--$/, "—");
const oneHalf = new InputRule(/(?:^|\s)1\/2$/, "½"); const oneHalf = new InputRule(/1\/2$/, "½");
const threeQuarters = new InputRule(/(?:^|\s)3\/4$/, "¾"); const threeQuarters = new InputRule(/3\/4$/, "¾");
const copyright = new InputRule(/\(c\)$/, "©️"); const copyright = new InputRule(/\(c\)$/, "©️");
const registered = new InputRule(/\(r\)$/, "®️"); const registered = new InputRule(/\(r\)$/, "®️");
const trademarked = new InputRule(/\(tm\)$/, "™️"); const trademarked = new InputRule(/\(tm\)$/, "™️");

View File

@@ -2,9 +2,10 @@ import { action, observable } from "mobx";
import { InputRule } from "prosemirror-inputrules"; import { InputRule } from "prosemirror-inputrules";
import { NodeType, Schema } from "prosemirror-model"; import { NodeType, Schema } from "prosemirror-model";
import { EditorState, Plugin } from "prosemirror-state"; import { EditorState, Plugin } from "prosemirror-state";
import { isInTable } from "prosemirror-tables";
import Extension from "@shared/editor/lib/Extension"; import Extension from "@shared/editor/lib/Extension";
import { SuggestionsMenuPlugin } from "@shared/editor/plugins/Suggestions"; import { SuggestionsMenuPlugin } from "@shared/editor/plugins/Suggestions";
import { isInCode } from "@shared/editor/queries/isInCode"; import isInCode from "@shared/editor/queries/isInCode";
export default class Suggestion extends Extension { export default class Suggestion extends Extension {
state: { state: {
@@ -49,7 +50,8 @@ export default class Suggestion extends Extension {
match && match &&
(parent.type.name === "paragraph" || (parent.type.name === "paragraph" ||
parent.type.name === "heading") && parent.type.name === "heading") &&
(!isInCode(state) || this.options.enabledInCode) (!isInCode(state) || this.options.enabledInCode) &&
(!isInTable(state) || this.options.enabledInTable)
) { ) {
this.state.open = true; this.state.open = true;
this.state.query = match[1]; this.state.query = match[1];

View File

@@ -402,8 +402,8 @@ export class Editor extends React.PureComponent<
schema: this.schema, schema: this.schema,
doc, doc,
plugins: [ plugins: [
...this.keymaps,
...this.plugins, ...this.plugins,
...this.keymaps,
dropCursor({ dropCursor({
color: this.props.theme.cursor, color: this.props.theme.cursor,
}), }),
@@ -618,13 +618,6 @@ export class Editor extends React.PureComponent<
*/ */
public getHeadings = () => ProsemirrorHelper.getHeadings(this.view.state.doc); public getHeadings = () => ProsemirrorHelper.getHeadings(this.view.state.doc);
/**
* Return the images in the current editor.
*
* @returns A list of images in the document
*/
public getImages = () => ProsemirrorHelper.getImages(this.view.state.doc);
/** /**
* Return the tasks/checkmarks in the current editor. * Return the tasks/checkmarks in the current editor.
* *
@@ -640,63 +633,29 @@ export class Editor extends React.PureComponent<
public getComments = () => ProsemirrorHelper.getComments(this.view.state.doc); public getComments = () => ProsemirrorHelper.getComments(this.view.state.doc);
/** /**
* Remove all marks related to a specific comment from the document. * Remove a specific comment mark from the document.
* *
* @param commentId The id of the comment to remove * @param commentId The id of the comment to remove
*/ */
public removeComment = (commentId: string) => { public removeComment = (commentId: string) => {
const { state, dispatch } = this.view; const { state, dispatch } = this.view;
const tr = state.tr; let found = false;
state.doc.descendants((node, pos) => { state.doc.descendants((node, pos) => {
if (!node.isInline) { if (!node.isInline || found) {
return; return;
} }
const mark = node.marks.find( const mark = node.marks.find(
(m) => m.type === state.schema.marks.comment && m.attrs.id === commentId (mark) =>
mark.type === state.schema.marks.comment &&
mark.attrs.id === commentId
); );
if (mark) { if (mark) {
tr.removeMark(pos, pos + node.nodeSize, mark); dispatch(state.tr.removeMark(pos, pos + node.nodeSize, mark));
found = true;
} }
}); });
dispatch(tr);
};
/**
* Update all marks related to a specific comment in the document.
*
* @param commentId The id of the comment to remove
* @param attrs The attributes to update
*/
public updateComment = (commentId: string, attrs: { resolved: boolean }) => {
const { state, dispatch } = this.view;
const tr = state.tr;
state.doc.descendants((node, pos) => {
if (!node.isInline) {
return;
}
const mark = node.marks.find(
(m) => m.type === state.schema.marks.comment && m.attrs.id === commentId
);
if (mark) {
const from = pos;
const to = pos + node.nodeSize;
const newMark = state.schema.marks.comment.create({
...mark.attrs,
...attrs,
});
tr.removeMark(from, to, mark).addMark(from, to, newMark);
}
});
dispatch(tr);
}; };
/** /**
@@ -842,7 +801,6 @@ const EditorContainer = styled(Styles)<{
css` css`
#comment-${props.focusedCommentId} { #comment-${props.focusedCommentId} {
background: ${transparentize(0.5, props.theme.brand.marine)}; background: ${transparentize(0.5, props.theme.brand.marine)};
border-bottom: 2px solid ${props.theme.commentMarkBackground};
} }
`} `}

View File

@@ -1,7 +1,7 @@
import { PageBreakIcon, HorizontalRuleIcon } from "outline-icons"; import { PageBreakIcon, HorizontalRuleIcon } from "outline-icons";
import { EditorState } from "prosemirror-state"; import { EditorState } from "prosemirror-state";
import * as React from "react"; import * as React from "react";
import { isNodeActive } from "@shared/editor/queries/isNodeActive"; import isNodeActive from "@shared/editor/queries/isNodeActive";
import { MenuItem } from "@shared/editor/types"; import { MenuItem } from "@shared/editor/types";
import { Dictionary } from "~/hooks/useDictionary"; import { Dictionary } from "~/hooks/useDictionary";

View File

@@ -19,15 +19,13 @@ import {
Heading3Icon, Heading3Icon,
} from "outline-icons"; } from "outline-icons";
import { EditorState } from "prosemirror-state"; import { EditorState } from "prosemirror-state";
import { isInTable } from "prosemirror-tables";
import * as React from "react"; import * as React from "react";
import Highlight from "@shared/editor/marks/Highlight"; import isInCode from "@shared/editor/queries/isInCode";
import { getMarksBetween } from "@shared/editor/queries/getMarksBetween"; import isInList from "@shared/editor/queries/isInList";
import { isInCode } from "@shared/editor/queries/isInCode"; import isMarkActive from "@shared/editor/queries/isMarkActive";
import { isInList } from "@shared/editor/queries/isInList"; import isNodeActive from "@shared/editor/queries/isNodeActive";
import { isMarkActive } from "@shared/editor/queries/isMarkActive";
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
import { MenuItem } from "@shared/editor/types"; import { MenuItem } from "@shared/editor/types";
import CircleIcon from "~/components/Icons/CircleIcon";
import { Dictionary } from "~/hooks/useDictionary"; import { Dictionary } from "~/hooks/useDictionary";
export default function formattingMenuItems( export default function formattingMenuItems(
@@ -37,15 +35,11 @@ export default function formattingMenuItems(
dictionary: Dictionary dictionary: Dictionary
): MenuItem[] { ): MenuItem[] {
const { schema } = state; const { schema } = state;
const isTable = isInTable(state);
const isList = isInList(state);
const isCode = isInCode(state); const isCode = isInCode(state);
const isCodeBlock = isInCode(state, { onlyBlock: true }); const isCodeBlock = isInCode(state, { onlyBlock: true });
const isEmpty = state.selection.empty; const allowBlocks = !isTable && !isList;
const highlight = getMarksBetween(
state.selection.from,
state.selection.to,
state
).find(({ mark }) => mark.type.name === "highlight");
return [ return [
{ {
@@ -53,60 +47,50 @@ export default function formattingMenuItems(
tooltip: dictionary.placeholder, tooltip: dictionary.placeholder,
icon: <InputIcon />, icon: <InputIcon />,
active: isMarkActive(schema.marks.placeholder), active: isMarkActive(schema.marks.placeholder),
visible: isTemplate && (!isMobile || !isEmpty), visible: isTemplate,
}, },
{ {
name: "separator", name: "separator",
visible: isTemplate && (!isMobile || !isEmpty), visible: isTemplate,
}, },
{ {
name: "strong", name: "strong",
tooltip: dictionary.strong, tooltip: dictionary.strong,
icon: <BoldIcon />, icon: <BoldIcon />,
active: isMarkActive(schema.marks.strong), active: isMarkActive(schema.marks.strong),
visible: !isCode && (!isMobile || !isEmpty), visible: !isCode,
}, },
{ {
name: "em", name: "em",
tooltip: dictionary.em, tooltip: dictionary.em,
icon: <ItalicIcon />, icon: <ItalicIcon />,
active: isMarkActive(schema.marks.em), active: isMarkActive(schema.marks.em),
visible: !isCode && (!isMobile || !isEmpty), visible: !isCode,
}, },
{ {
name: "strikethrough", name: "strikethrough",
tooltip: dictionary.strikethrough, tooltip: dictionary.strikethrough,
icon: <StrikethroughIcon />, icon: <StrikethroughIcon />,
active: isMarkActive(schema.marks.strikethrough), active: isMarkActive(schema.marks.strikethrough),
visible: !isCode && (!isMobile || !isEmpty), visible: !isCode,
}, },
{ {
name: "highlight",
tooltip: dictionary.mark, tooltip: dictionary.mark,
icon: highlight ? ( icon: <HighlightIcon />,
<CircleIcon color={highlight.mark.attrs.color} /> active: isMarkActive(schema.marks.highlight),
) : ( visible: !isTemplate && !isCode,
<HighlightIcon />
),
active: () => !!highlight,
visible: !isCode && (!isMobile || !isEmpty),
children: Highlight.colors.map((color, index) => ({
name: "highlight",
label: Highlight.colorNames[index],
icon: <CircleIcon retainColor color={color} />,
active: isMarkActive(schema.marks.highlight, { color }),
attrs: { color },
})),
}, },
{ {
name: "code_inline", name: "code_inline",
tooltip: dictionary.codeInline, tooltip: dictionary.codeInline,
icon: <CodeIcon />, icon: <CodeIcon />,
active: isMarkActive(schema.marks.code_inline), active: isMarkActive(schema.marks.code_inline),
visible: !isCodeBlock && (!isMobile || !isEmpty), visible: !isCodeBlock,
}, },
{ {
name: "separator", name: "separator",
visible: !isCodeBlock, visible: allowBlocks && !isCode,
}, },
{ {
name: "heading", name: "heading",
@@ -114,7 +98,7 @@ export default function formattingMenuItems(
icon: <Heading1Icon />, icon: <Heading1Icon />,
active: isNodeActive(schema.nodes.heading, { level: 1 }), active: isNodeActive(schema.nodes.heading, { level: 1 }),
attrs: { level: 1 }, attrs: { level: 1 },
visible: !isCodeBlock && (!isMobile || isEmpty), visible: allowBlocks && !isCode,
}, },
{ {
name: "heading", name: "heading",
@@ -122,7 +106,7 @@ export default function formattingMenuItems(
icon: <Heading2Icon />, icon: <Heading2Icon />,
active: isNodeActive(schema.nodes.heading, { level: 2 }), active: isNodeActive(schema.nodes.heading, { level: 2 }),
attrs: { level: 2 }, attrs: { level: 2 },
visible: !isCodeBlock && (!isMobile || isEmpty), visible: allowBlocks && !isCode,
}, },
{ {
name: "heading", name: "heading",
@@ -130,7 +114,7 @@ export default function formattingMenuItems(
icon: <Heading3Icon />, icon: <Heading3Icon />,
active: isNodeActive(schema.nodes.heading, { level: 3 }), active: isNodeActive(schema.nodes.heading, { level: 3 }),
attrs: { level: 3 }, attrs: { level: 3 },
visible: !isCodeBlock && (!isMobile || isEmpty), visible: allowBlocks && !isCode,
}, },
{ {
name: "blockquote", name: "blockquote",
@@ -138,11 +122,11 @@ export default function formattingMenuItems(
icon: <BlockQuoteIcon />, icon: <BlockQuoteIcon />,
active: isNodeActive(schema.nodes.blockquote), active: isNodeActive(schema.nodes.blockquote),
attrs: { level: 2 }, attrs: { level: 2 },
visible: !isCodeBlock && (!isMobile || isEmpty), visible: allowBlocks && !isCode,
}, },
{ {
name: "separator", name: "separator",
visible: !isCodeBlock, visible: (allowBlocks || isList) && !isCode,
}, },
{ {
name: "checkbox_list", name: "checkbox_list",
@@ -150,51 +134,37 @@ export default function formattingMenuItems(
icon: <TodoListIcon />, icon: <TodoListIcon />,
keywords: "checklist checkbox task", keywords: "checklist checkbox task",
active: isNodeActive(schema.nodes.checkbox_list), active: isNodeActive(schema.nodes.checkbox_list),
visible: !isCodeBlock && (!isMobile || isEmpty), visible: (allowBlocks || isList) && !isCode,
}, },
{ {
name: "bullet_list", name: "bullet_list",
tooltip: dictionary.bulletList, tooltip: dictionary.bulletList,
icon: <BulletedListIcon />, icon: <BulletedListIcon />,
active: isNodeActive(schema.nodes.bullet_list), active: isNodeActive(schema.nodes.bullet_list),
visible: !isCodeBlock && (!isMobile || isEmpty), visible: (allowBlocks || isList) && !isCode,
}, },
{ {
name: "ordered_list", name: "ordered_list",
tooltip: dictionary.orderedList, tooltip: dictionary.orderedList,
icon: <OrderedListIcon />, icon: <OrderedListIcon />,
active: isNodeActive(schema.nodes.ordered_list), active: isNodeActive(schema.nodes.ordered_list),
visible: !isCodeBlock && (!isMobile || isEmpty), visible: (allowBlocks || isList) && !isCode,
}, },
{ {
name: "outdentList", name: "outdentList",
tooltip: dictionary.outdent, tooltip: dictionary.outdent,
icon: <OutdentIcon />, icon: <OutdentIcon />,
visible: visible: isList && isMobile,
isMobile && isInList(state, { types: ["ordered_list", "bullet_list"] }),
}, },
{ {
name: "indentList", name: "indentList",
tooltip: dictionary.indent, tooltip: dictionary.indent,
icon: <IndentIcon />, icon: <IndentIcon />,
visible: visible: isList && isMobile,
isMobile && isInList(state, { types: ["ordered_list", "bullet_list"] }),
},
{
name: "outdentCheckboxList",
tooltip: dictionary.outdent,
icon: <OutdentIcon />,
visible: isMobile && isInList(state, { types: ["checkbox_list"] }),
},
{
name: "indentCheckboxList",
tooltip: dictionary.indent,
icon: <IndentIcon />,
visible: isMobile && isInList(state, { types: ["checkbox_list"] }),
}, },
{ {
name: "separator", name: "separator",
visible: !isCodeBlock, visible: !isCode,
}, },
{ {
name: "link", name: "link",
@@ -202,25 +172,24 @@ export default function formattingMenuItems(
icon: <LinkIcon />, icon: <LinkIcon />,
active: isMarkActive(schema.marks.link), active: isMarkActive(schema.marks.link),
attrs: { href: "" }, attrs: { href: "" },
visible: !isCodeBlock && (!isMobile || !isEmpty), visible: !isCode,
}, },
{ {
name: "comment", name: "comment",
tooltip: dictionary.comment, tooltip: dictionary.comment,
icon: <CommentIcon />, icon: <CommentIcon />,
label: isCodeBlock ? dictionary.comment : undefined, label: isCodeBlock ? dictionary.comment : undefined,
active: isMarkActive(schema.marks.comment, { resolved: false }), active: isMarkActive(schema.marks.comment),
visible: !isMobile || !isEmpty,
}, },
{ {
name: "separator", name: "separator",
visible: isCode && !isCodeBlock && (!isMobile || !isEmpty), visible: isCode && !isCodeBlock,
}, },
{ {
name: "copyToClipboard", name: "copyToClipboard",
icon: <CopyIcon />, icon: <CopyIcon />,
tooltip: dictionary.copy, tooltip: dictionary.copy,
visible: isCode && !isCodeBlock && (!isMobile || !isEmpty), visible: isCode && !isCodeBlock,
}, },
]; ];
} }

View File

@@ -9,7 +9,7 @@ import {
} from "outline-icons"; } from "outline-icons";
import { EditorState } from "prosemirror-state"; import { EditorState } from "prosemirror-state";
import * as React from "react"; import * as React from "react";
import { isNodeActive } from "@shared/editor/queries/isNodeActive"; import isNodeActive from "@shared/editor/queries/isNodeActive";
import { MenuItem } from "@shared/editor/types"; import { MenuItem } from "@shared/editor/types";
import { Dictionary } from "~/hooks/useDictionary"; import { Dictionary } from "~/hooks/useDictionary";

View File

@@ -1,7 +1,7 @@
import { CommentIcon } from "outline-icons"; import { CommentIcon } from "outline-icons";
import { EditorState } from "prosemirror-state"; import { EditorState } from "prosemirror-state";
import * as React from "react"; import * as React from "react";
import { isMarkActive } from "@shared/editor/queries/isMarkActive"; import isMarkActive from "@shared/editor/queries/isMarkActive";
import { MenuItem } from "@shared/editor/types"; import { MenuItem } from "@shared/editor/types";
import { Dictionary } from "~/hooks/useDictionary"; import { Dictionary } from "~/hooks/useDictionary";

View File

@@ -1,32 +1,10 @@
import { AlignFullWidthIcon, TrashIcon } from "outline-icons"; import { TrashIcon } from "outline-icons";
import { EditorState } from "prosemirror-state";
import * as React from "react"; import * as React from "react";
import { isNodeActive } from "@shared/editor/queries/isNodeActive"; import { MenuItem } from "@shared/editor/types";
import { MenuItem, TableLayout } from "@shared/editor/types";
import { Dictionary } from "~/hooks/useDictionary"; import { Dictionary } from "~/hooks/useDictionary";
export default function tableMenuItems( export default function tableMenuItems(dictionary: Dictionary): MenuItem[] {
state: EditorState,
dictionary: Dictionary
): MenuItem[] {
const { schema } = state;
const isFullWidth = isNodeActive(schema.nodes.table, {
layout: TableLayout.fullWidth,
})(state);
return [ return [
{
name: "setTableAttr",
tooltip: isFullWidth
? dictionary.alignDefaultWidth
: dictionary.alignFullWidth,
icon: <AlignFullWidthIcon />,
attrs: isFullWidth ? { layout: null } : { layout: TableLayout.fullWidth },
active: () => isFullWidth,
},
{
name: "separator",
},
{ {
name: "deleteTable", name: "deleteTable",
tooltip: dictionary.deleteTable, tooltip: dictionary.deleteTable,

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