Compare commits
4 Commits
main
...
bundle-vis
| Author | SHA1 | Date | |
|---|---|---|---|
| 11f992886b | |||
| e373a90099 | |||
| 659ae9bfbd | |||
| 212a5c4530 |
2
.babelrc
2
.babelrc
@@ -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",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
20
.env.sample
20
.env.sample
@@ -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
|
||||||
|
|||||||
@@ -41,7 +41,6 @@
|
|||||||
"@typescript-eslint/no-shadow": [
|
"@typescript-eslint/no-shadow": [
|
||||||
"warn",
|
"warn",
|
||||||
{
|
{
|
||||||
"allow": ["transaction"],
|
|
||||||
"hoist": "all",
|
"hoist": "all",
|
||||||
"ignoreTypeValueShadow": true
|
"ignoreTypeValueShadow": true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
1
__mocks__/react-medium-image-zoom.js
vendored
1
__mocks__/react-medium-image-zoom.js
vendored
@@ -1 +0,0 @@
|
|||||||
export default null;
|
|
||||||
5
app.json
5
app.json
@@ -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
|
||||||
|
|||||||
@@ -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} />,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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}</>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
@@ -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)};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -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));
|
||||||
|
|||||||
23
app/components/EmojiPicker/components.tsx
Normal file
23
app/components/EmojiPicker/components.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import styled from "styled-components";
|
||||||
|
import Button from "~/components/Button";
|
||||||
|
import { hover } from "~/styles";
|
||||||
|
import Flex from "../Flex";
|
||||||
|
|
||||||
|
export const EmojiButton = styled(Button)`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
|
||||||
|
&: ${hover},
|
||||||
|
&:active,
|
||||||
|
&[aria-expanded= "true"] {
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const Emoji = styled(Flex)<{ size?: number }>`
|
||||||
|
line-height: 1.6;
|
||||||
|
${(props) => (props.size ? `font-size: ${props.size}px` : "")}
|
||||||
|
`;
|
||||||
262
app/components/EmojiPicker/index.tsx
Normal file
262
app/components/EmojiPicker/index.tsx
Normal 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;
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
|
||||||
211
app/components/IconPicker.tsx
Normal file
211
app/components/IconPicker.tsx
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { PopoverDisclosure, usePopoverState } from "reakit";
|
||||||
|
import { MenuItem } from "reakit/Menu";
|
||||||
|
import styled, { useTheme } from "styled-components";
|
||||||
|
import { IconLibrary } from "@shared/utils/IconLibrary";
|
||||||
|
import { colorPalette } from "@shared/utils/collections";
|
||||||
|
import Flex from "~/components/Flex";
|
||||||
|
import NudeButton from "~/components/NudeButton";
|
||||||
|
import Text from "~/components/Text";
|
||||||
|
import useOnClickOutside from "~/hooks/useOnClickOutside";
|
||||||
|
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||||
|
import DelayedMount from "./DelayedMount";
|
||||||
|
import InputSearch from "./InputSearch";
|
||||||
|
import Popover from "./Popover";
|
||||||
|
|
||||||
|
const icons = IconLibrary.mapping;
|
||||||
|
|
||||||
|
const TwitterPicker = lazyWithRetry(
|
||||||
|
() => import("react-color/lib/components/twitter/Twitter")
|
||||||
|
);
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onOpen?: () => void;
|
||||||
|
onClose?: () => void;
|
||||||
|
onChange: (color: string, icon: string) => void;
|
||||||
|
initial: string;
|
||||||
|
icon: string;
|
||||||
|
color: string;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function IconPicker({
|
||||||
|
onOpen,
|
||||||
|
onClose,
|
||||||
|
icon,
|
||||||
|
initial,
|
||||||
|
color,
|
||||||
|
onChange,
|
||||||
|
className,
|
||||||
|
}: Props) {
|
||||||
|
const [query, setQuery] = React.useState("");
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const theme = useTheme();
|
||||||
|
const popover = usePopoverState({
|
||||||
|
gutter: 0,
|
||||||
|
placement: "right",
|
||||||
|
modal: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (popover.visible) {
|
||||||
|
onOpen?.();
|
||||||
|
} else {
|
||||||
|
onClose?.();
|
||||||
|
setQuery("");
|
||||||
|
}
|
||||||
|
}, [onOpen, onClose, popover.visible]);
|
||||||
|
|
||||||
|
const filteredIcons = IconLibrary.findIcons(query);
|
||||||
|
const handleFilter = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setQuery(event.target.value.toLowerCase());
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = React.useMemo(
|
||||||
|
() => ({
|
||||||
|
default: {
|
||||||
|
body: {
|
||||||
|
padding: 0,
|
||||||
|
marginRight: -8,
|
||||||
|
},
|
||||||
|
hash: {
|
||||||
|
color: theme.text,
|
||||||
|
background: theme.inputBorder,
|
||||||
|
},
|
||||||
|
swatch: {
|
||||||
|
cursor: "var(--cursor-pointer)",
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
color: theme.text,
|
||||||
|
boxShadow: `inset 0 0 0 1px ${theme.inputBorder}`,
|
||||||
|
background: "transparent",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[theme]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Custom click outside handling rather than using `hideOnClickOutside` from reakit so that we can
|
||||||
|
// prevent event bubbling.
|
||||||
|
useOnClickOutside(
|
||||||
|
popover.unstable_popoverRef,
|
||||||
|
(event) => {
|
||||||
|
if (popover.visible) {
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
popover.hide();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ capture: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
const iconNames = Object.keys(icons);
|
||||||
|
const delayPerIcon = 250 / iconNames.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PopoverDisclosure {...popover}>
|
||||||
|
{(props) => (
|
||||||
|
<NudeButton
|
||||||
|
aria-label={t("Show menu")}
|
||||||
|
className={className}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
as={IconLibrary.getComponent(icon || "collection")}
|
||||||
|
color={color}
|
||||||
|
>
|
||||||
|
{initial}
|
||||||
|
</Icon>
|
||||||
|
</NudeButton>
|
||||||
|
)}
|
||||||
|
</PopoverDisclosure>
|
||||||
|
<Popover
|
||||||
|
{...popover}
|
||||||
|
width={552}
|
||||||
|
aria-label={t("Choose an icon")}
|
||||||
|
hideOnClickOutside={false}
|
||||||
|
>
|
||||||
|
<Flex column gap={12}>
|
||||||
|
<Text size="large" weight="xbold">
|
||||||
|
{t("Choose an icon")}
|
||||||
|
</Text>
|
||||||
|
<InputSearch
|
||||||
|
value={query}
|
||||||
|
placeholder={`${t("Filter")}…`}
|
||||||
|
onChange={handleFilter}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
{iconNames.map((name, index) => (
|
||||||
|
<MenuItem key={name} onClick={() => onChange(color, name)}>
|
||||||
|
{(props) => (
|
||||||
|
<IconButton
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
opacity: query
|
||||||
|
? filteredIcons.includes(name)
|
||||||
|
? 1
|
||||||
|
: 0.3
|
||||||
|
: undefined,
|
||||||
|
"--delay": `${Math.round(index * delayPerIcon)}ms`,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
as={IconLibrary.getComponent(name)}
|
||||||
|
color={color}
|
||||||
|
size={30}
|
||||||
|
>
|
||||||
|
{initial}
|
||||||
|
</Icon>
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Flex>
|
||||||
|
<React.Suspense
|
||||||
|
fallback={
|
||||||
|
<DelayedMount>
|
||||||
|
<Text>{t("Loading")}…</Text>
|
||||||
|
</DelayedMount>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ColorPicker
|
||||||
|
color={color}
|
||||||
|
onChange={(color) => onChange(color.hex, icon)}
|
||||||
|
colors={colorPalette}
|
||||||
|
triangle="hide"
|
||||||
|
styles={styles}
|
||||||
|
/>
|
||||||
|
</React.Suspense>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
</Popover>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const Icon = styled.svg`
|
||||||
|
transition: color 150ms ease-in-out, fill 150ms ease-in-out;
|
||||||
|
transition-delay: var(--delay);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const IconButton = styled(NudeButton)`
|
||||||
|
vertical-align: top;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin: 0px 6px 6px 0px;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ColorPicker = styled(TwitterPicker)`
|
||||||
|
box-shadow: none !important;
|
||||||
|
background: transparent !important;
|
||||||
|
width: 100% !important;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default IconPicker;
|
||||||
@@ -1,218 +0,0 @@
|
|||||||
import { BackIcon } from "outline-icons";
|
|
||||||
import React from "react";
|
|
||||||
import styled from "styled-components";
|
|
||||||
import { breakpoints, s } from "@shared/styles";
|
|
||||||
import { colorPalette } from "@shared/utils/collections";
|
|
||||||
import { validateColorHex } from "@shared/utils/color";
|
|
||||||
import Flex from "~/components/Flex";
|
|
||||||
import NudeButton from "~/components/NudeButton";
|
|
||||||
import Text from "~/components/Text";
|
|
||||||
import { hover } from "~/styles";
|
|
||||||
|
|
||||||
enum Panel {
|
|
||||||
Builtin,
|
|
||||||
Hex,
|
|
||||||
}
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
width: number;
|
|
||||||
activeColor: string;
|
|
||||||
onSelect: (color: string) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const ColorPicker = ({ width, activeColor, onSelect }: Props) => {
|
|
||||||
const [localValue, setLocalValue] = React.useState(activeColor);
|
|
||||||
|
|
||||||
const [panel, setPanel] = React.useState(
|
|
||||||
colorPalette.includes(activeColor) ? Panel.Builtin : Panel.Hex
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleSwitcherClick = React.useCallback(() => {
|
|
||||||
setPanel(panel === Panel.Builtin ? Panel.Hex : Panel.Builtin);
|
|
||||||
}, [panel, setPanel]);
|
|
||||||
|
|
||||||
const isLargeMobile = width > breakpoints.mobileLarge + 12; // 12px for the Container padding
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
setLocalValue(activeColor);
|
|
||||||
setPanel(colorPalette.includes(activeColor) ? Panel.Builtin : Panel.Hex);
|
|
||||||
}, [activeColor]);
|
|
||||||
|
|
||||||
return isLargeMobile ? (
|
|
||||||
<Container justify="space-between">
|
|
||||||
<LargeMobileBuiltinColors activeColor={activeColor} onClick={onSelect} />
|
|
||||||
<LargeMobileCustomColor
|
|
||||||
value={localValue}
|
|
||||||
setLocalValue={setLocalValue}
|
|
||||||
onValidHex={onSelect}
|
|
||||||
/>
|
|
||||||
</Container>
|
|
||||||
) : (
|
|
||||||
<Container gap={12}>
|
|
||||||
<PanelSwitcher align="center">
|
|
||||||
<SwitcherButton panel={panel} onClick={handleSwitcherClick}>
|
|
||||||
{panel === Panel.Builtin ? "#" : <BackIcon />}
|
|
||||||
</SwitcherButton>
|
|
||||||
</PanelSwitcher>
|
|
||||||
{panel === Panel.Builtin ? (
|
|
||||||
<BuiltinColors activeColor={activeColor} onClick={onSelect} />
|
|
||||||
) : (
|
|
||||||
<CustomColor
|
|
||||||
value={localValue}
|
|
||||||
setLocalValue={setLocalValue}
|
|
||||||
onValidHex={onSelect}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const BuiltinColors = ({
|
|
||||||
activeColor,
|
|
||||||
onClick,
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
activeColor: string;
|
|
||||||
onClick: (color: string) => void;
|
|
||||||
className?: string;
|
|
||||||
}) => (
|
|
||||||
<Flex className={className} justify="space-between" align="center" auto>
|
|
||||||
{colorPalette.map((color) => (
|
|
||||||
<ColorButton
|
|
||||||
key={color}
|
|
||||||
color={color}
|
|
||||||
active={color === activeColor}
|
|
||||||
onClick={() => onClick(color)}
|
|
||||||
>
|
|
||||||
<Selected />
|
|
||||||
</ColorButton>
|
|
||||||
))}
|
|
||||||
</Flex>
|
|
||||||
);
|
|
||||||
|
|
||||||
const CustomColor = ({
|
|
||||||
value,
|
|
||||||
setLocalValue,
|
|
||||||
onValidHex,
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
value: string;
|
|
||||||
setLocalValue: (value: string) => void;
|
|
||||||
onValidHex: (color: string) => void;
|
|
||||||
className?: string;
|
|
||||||
}) => {
|
|
||||||
const hasHexChars = React.useCallback(
|
|
||||||
(color: string) => /(^#[0-9A-F]{1,6}$)/i.test(color),
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleInputChange = React.useCallback(
|
|
||||||
(ev: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const val = ev.target.value;
|
|
||||||
|
|
||||||
if (val === "" || val === "#") {
|
|
||||||
setLocalValue("#");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const uppercasedVal = val.toUpperCase();
|
|
||||||
|
|
||||||
if (hasHexChars(uppercasedVal)) {
|
|
||||||
setLocalValue(uppercasedVal);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (validateColorHex(uppercasedVal)) {
|
|
||||||
onValidHex(uppercasedVal);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[setLocalValue, hasHexChars, onValidHex]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Flex className={className} align="center" gap={8}>
|
|
||||||
<Text type="tertiary" size="small">
|
|
||||||
HEX
|
|
||||||
</Text>
|
|
||||||
<CustomColorInput
|
|
||||||
maxLength={7}
|
|
||||||
value={value}
|
|
||||||
onChange={handleInputChange}
|
|
||||||
/>
|
|
||||||
</Flex>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const Container = styled(Flex)`
|
|
||||||
height: 48px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
border-bottom: 1px solid ${s("inputBorder")};
|
|
||||||
`;
|
|
||||||
|
|
||||||
const Selected = styled.span`
|
|
||||||
width: 10px;
|
|
||||||
height: 5px;
|
|
||||||
border-left: 2px solid white;
|
|
||||||
border-bottom: 2px solid white;
|
|
||||||
transform: translateY(-25%) rotate(-45deg);
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ColorButton = styled(NudeButton)<{ color: string; active: boolean }>`
|
|
||||||
display: inline-flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background-color: ${({ color }) => color};
|
|
||||||
|
|
||||||
&: ${hover} {
|
|
||||||
outline: 2px solid ${s("menuBackground")} !important;
|
|
||||||
box-shadow: ${({ color }) => `0px 0px 3px 3px ${color}`};
|
|
||||||
}
|
|
||||||
|
|
||||||
& ${Selected} {
|
|
||||||
display: ${({ active }) => (active ? "block" : "none")};
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const PanelSwitcher = styled(Flex)`
|
|
||||||
width: 40px;
|
|
||||||
border-right: 1px solid ${s("inputBorder")};
|
|
||||||
`;
|
|
||||||
|
|
||||||
const SwitcherButton = styled(NudeButton)<{ panel: Panel }>`
|
|
||||||
display: inline-flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
font-size: 14px;
|
|
||||||
border: 1px solid ${s("inputBorder")};
|
|
||||||
transition: all 100ms ease-in-out;
|
|
||||||
|
|
||||||
&: ${hover} {
|
|
||||||
border-color: ${s("inputBorderFocused")};
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const LargeMobileBuiltinColors = styled(BuiltinColors)`
|
|
||||||
max-width: 380px;
|
|
||||||
padding-right: 8px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const LargeMobileCustomColor = styled(CustomColor)`
|
|
||||||
padding-left: 8px;
|
|
||||||
border-left: 1px solid ${s("inputBorder")};
|
|
||||||
width: 120px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const CustomColorInput = styled.input.attrs(() => ({
|
|
||||||
type: "text",
|
|
||||||
autocomplete: "off",
|
|
||||||
}))`
|
|
||||||
font-size: 14px;
|
|
||||||
color: ${s("textSecondary")};
|
|
||||||
background: transparent;
|
|
||||||
border: 0;
|
|
||||||
outline: 0;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export default ColorPicker;
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import styled from "styled-components";
|
|
||||||
import { s } from "@shared/styles";
|
|
||||||
|
|
||||||
export const Emoji = styled.span`
|
|
||||||
font-family: ${s("fontFamilyEmoji")};
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
`;
|
|
||||||
@@ -1,245 +0,0 @@
|
|||||||
import concat from "lodash/concat";
|
|
||||||
import React from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import styled from "styled-components";
|
|
||||||
import { EmojiCategory, EmojiSkinTone, IconType } from "@shared/types";
|
|
||||||
import { getEmojis, getEmojisWithCategory, search } from "@shared/utils/emoji";
|
|
||||||
import Flex from "~/components/Flex";
|
|
||||||
import InputSearch from "~/components/InputSearch";
|
|
||||||
import usePersistedState from "~/hooks/usePersistedState";
|
|
||||||
import {
|
|
||||||
FREQUENTLY_USED_COUNT,
|
|
||||||
DisplayCategory,
|
|
||||||
emojiSkinToneKey,
|
|
||||||
emojisFreqKey,
|
|
||||||
lastEmojiKey,
|
|
||||||
sortFrequencies,
|
|
||||||
} from "../utils";
|
|
||||||
import GridTemplate, { DataNode } from "./GridTemplate";
|
|
||||||
import SkinTonePicker from "./SkinTonePicker";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This is needed as a constant for react-window.
|
|
||||||
* Calculated from the heights of TabPanel and InputSearch.
|
|
||||||
*/
|
|
||||||
const GRID_HEIGHT = 362;
|
|
||||||
|
|
||||||
const useEmojiState = () => {
|
|
||||||
const [emojiSkinTone, setEmojiSkinTone] = usePersistedState<EmojiSkinTone>(
|
|
||||||
emojiSkinToneKey,
|
|
||||||
EmojiSkinTone.Default
|
|
||||||
);
|
|
||||||
const [emojisFreq, setEmojisFreq] = usePersistedState<Record<string, number>>(
|
|
||||||
emojisFreqKey,
|
|
||||||
{}
|
|
||||||
);
|
|
||||||
const [lastEmoji, setLastEmoji] = usePersistedState<string | undefined>(
|
|
||||||
lastEmojiKey,
|
|
||||||
undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
const incrementEmojiCount = React.useCallback(
|
|
||||||
(emoji: string) => {
|
|
||||||
emojisFreq[emoji] = (emojisFreq[emoji] ?? 0) + 1;
|
|
||||||
setEmojisFreq({ ...emojisFreq });
|
|
||||||
setLastEmoji(emoji);
|
|
||||||
},
|
|
||||||
[emojisFreq, setEmojisFreq, setLastEmoji]
|
|
||||||
);
|
|
||||||
|
|
||||||
const getFreqEmojis = React.useCallback(() => {
|
|
||||||
const freqs = Object.entries(emojisFreq);
|
|
||||||
|
|
||||||
if (freqs.length > FREQUENTLY_USED_COUNT.Track) {
|
|
||||||
sortFrequencies(freqs).splice(FREQUENTLY_USED_COUNT.Track);
|
|
||||||
setEmojisFreq(Object.fromEntries(freqs));
|
|
||||||
}
|
|
||||||
|
|
||||||
const emojis = sortFrequencies(freqs)
|
|
||||||
.slice(0, FREQUENTLY_USED_COUNT.Get)
|
|
||||||
.map(([emoji, _]) => emoji);
|
|
||||||
|
|
||||||
const isLastPresent = emojis.includes(lastEmoji ?? "");
|
|
||||||
if (lastEmoji && !isLastPresent) {
|
|
||||||
emojis.pop();
|
|
||||||
emojis.push(lastEmoji);
|
|
||||||
}
|
|
||||||
|
|
||||||
return emojis;
|
|
||||||
}, [emojisFreq, setEmojisFreq, lastEmoji]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
emojiSkinTone,
|
|
||||||
setEmojiSkinTone,
|
|
||||||
incrementEmojiCount,
|
|
||||||
getFreqEmojis,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
panelWidth: number;
|
|
||||||
query: string;
|
|
||||||
panelActive: boolean;
|
|
||||||
onEmojiChange: (emoji: string) => void;
|
|
||||||
onQueryChange: (query: string) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const EmojiPanel = ({
|
|
||||||
panelWidth,
|
|
||||||
query,
|
|
||||||
panelActive,
|
|
||||||
onEmojiChange,
|
|
||||||
onQueryChange,
|
|
||||||
}: Props) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const searchRef = React.useRef<HTMLInputElement | null>(null);
|
|
||||||
const scrollableRef = React.useRef<HTMLDivElement | null>(null);
|
|
||||||
|
|
||||||
const {
|
|
||||||
emojiSkinTone: skinTone,
|
|
||||||
setEmojiSkinTone,
|
|
||||||
incrementEmojiCount,
|
|
||||||
getFreqEmojis,
|
|
||||||
} = useEmojiState();
|
|
||||||
|
|
||||||
const freqEmojis = React.useMemo(() => getFreqEmojis(), [getFreqEmojis]);
|
|
||||||
|
|
||||||
const handleFilter = React.useCallback(
|
|
||||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
onQueryChange(event.target.value);
|
|
||||||
},
|
|
||||||
[onQueryChange]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleSkinChange = React.useCallback(
|
|
||||||
(emojiSkinTone: EmojiSkinTone) => {
|
|
||||||
setEmojiSkinTone(emojiSkinTone);
|
|
||||||
},
|
|
||||||
[setEmojiSkinTone]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleEmojiSelection = React.useCallback(
|
|
||||||
({ id, value }: { id: string; value: string }) => {
|
|
||||||
onEmojiChange(value);
|
|
||||||
incrementEmojiCount(id);
|
|
||||||
},
|
|
||||||
[onEmojiChange, incrementEmojiCount]
|
|
||||||
);
|
|
||||||
|
|
||||||
const isSearch = query !== "";
|
|
||||||
const templateData: DataNode[] = isSearch
|
|
||||||
? getSearchResults({
|
|
||||||
query,
|
|
||||||
skinTone,
|
|
||||||
})
|
|
||||||
: getAllEmojis({
|
|
||||||
skinTone,
|
|
||||||
freqEmojis,
|
|
||||||
});
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (scrollableRef.current) {
|
|
||||||
scrollableRef.current.scrollTop = 0;
|
|
||||||
}
|
|
||||||
searchRef.current?.focus();
|
|
||||||
}, [panelActive]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Flex column>
|
|
||||||
<UserInputContainer align="center" gap={12}>
|
|
||||||
<StyledInputSearch
|
|
||||||
ref={searchRef}
|
|
||||||
value={query}
|
|
||||||
placeholder={`${t("Search emoji")}…`}
|
|
||||||
onChange={handleFilter}
|
|
||||||
/>
|
|
||||||
<SkinTonePicker skinTone={skinTone} onChange={handleSkinChange} />
|
|
||||||
</UserInputContainer>
|
|
||||||
<GridTemplate
|
|
||||||
ref={scrollableRef}
|
|
||||||
width={panelWidth}
|
|
||||||
height={GRID_HEIGHT}
|
|
||||||
data={templateData}
|
|
||||||
onIconSelect={handleEmojiSelection}
|
|
||||||
/>
|
|
||||||
</Flex>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getSearchResults = ({
|
|
||||||
query,
|
|
||||||
skinTone,
|
|
||||||
}: {
|
|
||||||
query: string;
|
|
||||||
skinTone: EmojiSkinTone;
|
|
||||||
}): DataNode[] => {
|
|
||||||
const emojis = search({ query, skinTone });
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
category: DisplayCategory.Search,
|
|
||||||
icons: emojis.map((emoji) => ({
|
|
||||||
type: IconType.Emoji,
|
|
||||||
id: emoji.id,
|
|
||||||
value: emoji.value,
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
const getAllEmojis = ({
|
|
||||||
skinTone,
|
|
||||||
freqEmojis,
|
|
||||||
}: {
|
|
||||||
skinTone: EmojiSkinTone;
|
|
||||||
freqEmojis: string[];
|
|
||||||
}): DataNode[] => {
|
|
||||||
const emojisWithCategory = getEmojisWithCategory({ skinTone });
|
|
||||||
|
|
||||||
const getFrequentEmojis = (): DataNode => {
|
|
||||||
const emojis = getEmojis({ ids: freqEmojis, skinTone });
|
|
||||||
return {
|
|
||||||
category: DisplayCategory.Frequent,
|
|
||||||
icons: emojis.map((emoji) => ({
|
|
||||||
type: IconType.Emoji,
|
|
||||||
id: emoji.id,
|
|
||||||
value: emoji.value,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const getCategoryData = (emojiCategory: EmojiCategory): DataNode => {
|
|
||||||
const emojis = emojisWithCategory[emojiCategory] ?? [];
|
|
||||||
return {
|
|
||||||
category: emojiCategory,
|
|
||||||
icons: emojis.map((emoji) => ({
|
|
||||||
type: IconType.Emoji,
|
|
||||||
id: emoji.id,
|
|
||||||
value: emoji.value,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
return concat(
|
|
||||||
getFrequentEmojis(),
|
|
||||||
getCategoryData(EmojiCategory.People),
|
|
||||||
getCategoryData(EmojiCategory.Nature),
|
|
||||||
getCategoryData(EmojiCategory.Foods),
|
|
||||||
getCategoryData(EmojiCategory.Activity),
|
|
||||||
getCategoryData(EmojiCategory.Places),
|
|
||||||
getCategoryData(EmojiCategory.Objects),
|
|
||||||
getCategoryData(EmojiCategory.Symbols),
|
|
||||||
getCategoryData(EmojiCategory.Flags)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const UserInputContainer = styled(Flex)`
|
|
||||||
height: 48px;
|
|
||||||
padding: 6px 12px 0px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const StyledInputSearch = styled(InputSearch)`
|
|
||||||
flex-grow: 1;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export default EmojiPanel;
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { FixedSizeList, ListChildComponentProps } from "react-window";
|
|
||||||
import styled from "styled-components";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
data: React.ReactNode[][];
|
|
||||||
columns: number;
|
|
||||||
itemWidth: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
const Grid = (
|
|
||||||
{ width, height, data, columns, itemWidth }: Props,
|
|
||||||
ref: React.Ref<HTMLDivElement>
|
|
||||||
) => (
|
|
||||||
<Container
|
|
||||||
outerRef={ref}
|
|
||||||
width={width}
|
|
||||||
height={height}
|
|
||||||
itemCount={data.length}
|
|
||||||
itemSize={itemWidth}
|
|
||||||
itemData={{ data, columns }}
|
|
||||||
>
|
|
||||||
{Row}
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
|
|
||||||
type RowProps = {
|
|
||||||
data: React.ReactNode[][];
|
|
||||||
columns: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
const Row = ({ index, style, data }: ListChildComponentProps<RowProps>) => {
|
|
||||||
const { data: rows, columns } = data;
|
|
||||||
const row = rows[index];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<RowContainer style={style} columns={columns}>
|
|
||||||
{row}
|
|
||||||
</RowContainer>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const Container = styled(FixedSizeList<RowProps>)`
|
|
||||||
padding: 0px 12px;
|
|
||||||
|
|
||||||
// Needed for the absolutely positioned children
|
|
||||||
// to respect the VirtualList's padding
|
|
||||||
& > div {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const RowContainer = styled.div<{ columns: number }>`
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: ${({ columns }) => `repeat(${columns}, 1fr)`};
|
|
||||||
align-content: center;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export default React.forwardRef(Grid);
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
import chunk from "lodash/chunk";
|
|
||||||
import compact from "lodash/compact";
|
|
||||||
import React from "react";
|
|
||||||
import styled from "styled-components";
|
|
||||||
import { IconType } from "@shared/types";
|
|
||||||
import { IconLibrary } from "@shared/utils/IconLibrary";
|
|
||||||
import Text from "~/components/Text";
|
|
||||||
import { TRANSLATED_CATEGORIES } from "../utils";
|
|
||||||
import { Emoji } from "./Emoji";
|
|
||||||
import Grid from "./Grid";
|
|
||||||
import { IconButton } from "./IconButton";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* icon/emoji size is 24px; and we add 4px padding on all sides,
|
|
||||||
*/
|
|
||||||
const BUTTON_SIZE = 32;
|
|
||||||
|
|
||||||
type OutlineNode = {
|
|
||||||
type: IconType.SVG;
|
|
||||||
name: string;
|
|
||||||
color: string;
|
|
||||||
initial: string;
|
|
||||||
delay: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type EmojiNode = {
|
|
||||||
type: IconType.Emoji;
|
|
||||||
id: string;
|
|
||||||
value: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type DataNode = {
|
|
||||||
category: keyof typeof TRANSLATED_CATEGORIES;
|
|
||||||
icons: (OutlineNode | EmojiNode)[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
data: DataNode[];
|
|
||||||
onIconSelect: ({ id, value }: { id: string; value: string }) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const GridTemplate = (
|
|
||||||
{ width, height, data, onIconSelect }: Props,
|
|
||||||
ref: React.Ref<HTMLDivElement>
|
|
||||||
) => {
|
|
||||||
// 24px padding for the Grid Container
|
|
||||||
const itemsPerRow = Math.floor((width - 24) / BUTTON_SIZE);
|
|
||||||
|
|
||||||
const gridItems = compact(
|
|
||||||
data.flatMap((node) => {
|
|
||||||
if (node.icons.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const category = (
|
|
||||||
<CategoryName
|
|
||||||
key={node.category}
|
|
||||||
type="tertiary"
|
|
||||||
size="xsmall"
|
|
||||||
weight="bold"
|
|
||||||
>
|
|
||||||
{TRANSLATED_CATEGORIES[node.category]}
|
|
||||||
</CategoryName>
|
|
||||||
);
|
|
||||||
|
|
||||||
const items = node.icons.map((item) => {
|
|
||||||
if (item.type === IconType.SVG) {
|
|
||||||
return (
|
|
||||||
<IconButton
|
|
||||||
key={item.name}
|
|
||||||
onClick={() => onIconSelect({ id: item.name, value: item.name })}
|
|
||||||
delay={item.delay}
|
|
||||||
>
|
|
||||||
<Icon as={IconLibrary.getComponent(item.name)} color={item.color}>
|
|
||||||
{item.initial}
|
|
||||||
</Icon>
|
|
||||||
</IconButton>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<IconButton
|
|
||||||
key={item.id}
|
|
||||||
onClick={() => onIconSelect({ id: item.id, value: item.value })}
|
|
||||||
>
|
|
||||||
<Emoji>{item.value}</Emoji>
|
|
||||||
</IconButton>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const chunks = chunk(items, itemsPerRow);
|
|
||||||
return [[category], ...chunks];
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Grid
|
|
||||||
ref={ref}
|
|
||||||
width={width}
|
|
||||||
height={height}
|
|
||||||
data={gridItems}
|
|
||||||
columns={itemsPerRow}
|
|
||||||
itemWidth={BUTTON_SIZE}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const CategoryName = styled(Text)`
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
padding-left: 6px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const Icon = styled.svg`
|
|
||||||
transition: color 150ms ease-in-out, fill 150ms ease-in-out;
|
|
||||||
transition-delay: var(--delay);
|
|
||||||
`;
|
|
||||||
|
|
||||||
export default React.forwardRef(GridTemplate);
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import styled from "styled-components";
|
|
||||||
import { s } from "@shared/styles";
|
|
||||||
import NudeButton from "~/components/NudeButton";
|
|
||||||
import { hover } from "~/styles";
|
|
||||||
|
|
||||||
export const IconButton = styled(NudeButton)<{ delay?: number }>`
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
padding: 4px;
|
|
||||||
--delay: ${({ delay }) => delay && `${delay}ms`};
|
|
||||||
|
|
||||||
&: ${hover} {
|
|
||||||
background: ${s("listItemHoverBackground")};
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import styled from "styled-components";
|
|
||||||
import { IconType } from "@shared/types";
|
|
||||||
import { IconLibrary } from "@shared/utils/IconLibrary";
|
|
||||||
import Flex from "~/components/Flex";
|
|
||||||
import InputSearch from "~/components/InputSearch";
|
|
||||||
import usePersistedState from "~/hooks/usePersistedState";
|
|
||||||
import {
|
|
||||||
FREQUENTLY_USED_COUNT,
|
|
||||||
DisplayCategory,
|
|
||||||
iconsFreqKey,
|
|
||||||
lastIconKey,
|
|
||||||
sortFrequencies,
|
|
||||||
} from "../utils";
|
|
||||||
import ColorPicker from "./ColorPicker";
|
|
||||||
import GridTemplate, { DataNode } from "./GridTemplate";
|
|
||||||
|
|
||||||
const IconNames = Object.keys(IconLibrary.mapping);
|
|
||||||
const TotalIcons = IconNames.length;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This is needed as a constant for react-window.
|
|
||||||
* Calculated from the heights of TabPanel, ColorPicker and InputSearch.
|
|
||||||
*/
|
|
||||||
const GRID_HEIGHT = 314;
|
|
||||||
|
|
||||||
const useIconState = () => {
|
|
||||||
const [iconsFreq, setIconsFreq] = usePersistedState<Record<string, number>>(
|
|
||||||
iconsFreqKey,
|
|
||||||
{}
|
|
||||||
);
|
|
||||||
const [lastIcon, setLastIcon] = usePersistedState<string | undefined>(
|
|
||||||
lastIconKey,
|
|
||||||
undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
const incrementIconCount = React.useCallback(
|
|
||||||
(icon: string) => {
|
|
||||||
iconsFreq[icon] = (iconsFreq[icon] ?? 0) + 1;
|
|
||||||
setIconsFreq({ ...iconsFreq });
|
|
||||||
setLastIcon(icon);
|
|
||||||
},
|
|
||||||
[iconsFreq, setIconsFreq, setLastIcon]
|
|
||||||
);
|
|
||||||
|
|
||||||
const getFreqIcons = React.useCallback(() => {
|
|
||||||
const freqs = Object.entries(iconsFreq);
|
|
||||||
|
|
||||||
if (freqs.length > FREQUENTLY_USED_COUNT.Track) {
|
|
||||||
sortFrequencies(freqs).splice(FREQUENTLY_USED_COUNT.Track);
|
|
||||||
setIconsFreq(Object.fromEntries(freqs));
|
|
||||||
}
|
|
||||||
|
|
||||||
const icons = sortFrequencies(freqs)
|
|
||||||
.slice(0, FREQUENTLY_USED_COUNT.Get)
|
|
||||||
.map(([icon, _]) => icon);
|
|
||||||
|
|
||||||
const isLastPresent = icons.includes(lastIcon ?? "");
|
|
||||||
if (lastIcon && !isLastPresent) {
|
|
||||||
icons.pop();
|
|
||||||
icons.push(lastIcon);
|
|
||||||
}
|
|
||||||
|
|
||||||
return icons;
|
|
||||||
}, [iconsFreq, setIconsFreq, lastIcon]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
incrementIconCount,
|
|
||||||
getFreqIcons,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
panelWidth: number;
|
|
||||||
initial: string;
|
|
||||||
color: string;
|
|
||||||
query: string;
|
|
||||||
panelActive: boolean;
|
|
||||||
onIconChange: (icon: string) => void;
|
|
||||||
onColorChange: (icon: string) => void;
|
|
||||||
onQueryChange: (query: string) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const IconPanel = ({
|
|
||||||
panelWidth,
|
|
||||||
initial,
|
|
||||||
color,
|
|
||||||
query,
|
|
||||||
panelActive,
|
|
||||||
onIconChange,
|
|
||||||
onColorChange,
|
|
||||||
onQueryChange,
|
|
||||||
}: Props) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const searchRef = React.useRef<HTMLInputElement | null>(null);
|
|
||||||
const scrollableRef = React.useRef<HTMLDivElement | null>(null);
|
|
||||||
|
|
||||||
const { incrementIconCount, getFreqIcons } = useIconState();
|
|
||||||
|
|
||||||
const freqIcons = React.useMemo(() => getFreqIcons(), [getFreqIcons]);
|
|
||||||
const totalFreqIcons = freqIcons.length;
|
|
||||||
|
|
||||||
const filteredIcons = React.useMemo(
|
|
||||||
() => IconLibrary.findIcons(query),
|
|
||||||
[query]
|
|
||||||
);
|
|
||||||
|
|
||||||
const isSearch = query !== "";
|
|
||||||
const category = isSearch ? DisplayCategory.Search : DisplayCategory.All;
|
|
||||||
const delayPerIcon = 250 / (TotalIcons + totalFreqIcons);
|
|
||||||
|
|
||||||
const handleFilter = React.useCallback(
|
|
||||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
onQueryChange(event.target.value);
|
|
||||||
},
|
|
||||||
[onQueryChange]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleIconSelection = React.useCallback(
|
|
||||||
({ id, value }: { id: string; value: string }) => {
|
|
||||||
onIconChange(value);
|
|
||||||
incrementIconCount(id);
|
|
||||||
},
|
|
||||||
[onIconChange, incrementIconCount]
|
|
||||||
);
|
|
||||||
|
|
||||||
const baseIcons: DataNode = {
|
|
||||||
category,
|
|
||||||
icons: filteredIcons.map((name, index) => ({
|
|
||||||
type: IconType.SVG,
|
|
||||||
name,
|
|
||||||
color,
|
|
||||||
initial,
|
|
||||||
delay: Math.round((index + totalFreqIcons) * delayPerIcon),
|
|
||||||
onClick: handleIconSelection,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
|
|
||||||
const templateData: DataNode[] = isSearch
|
|
||||||
? [baseIcons]
|
|
||||||
: [
|
|
||||||
{
|
|
||||||
category: DisplayCategory.Frequent,
|
|
||||||
icons: freqIcons.map((name, index) => ({
|
|
||||||
type: IconType.SVG,
|
|
||||||
name,
|
|
||||||
color,
|
|
||||||
initial,
|
|
||||||
delay: Math.round((index + totalFreqIcons) * delayPerIcon),
|
|
||||||
onClick: handleIconSelection,
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
baseIcons,
|
|
||||||
];
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (scrollableRef.current) {
|
|
||||||
scrollableRef.current.scrollTop = 0;
|
|
||||||
}
|
|
||||||
searchRef.current?.focus();
|
|
||||||
}, [panelActive]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Flex column>
|
|
||||||
<InputSearchContainer align="center">
|
|
||||||
<StyledInputSearch
|
|
||||||
ref={searchRef}
|
|
||||||
value={query}
|
|
||||||
placeholder={`${t("Search icons")}…`}
|
|
||||||
onChange={handleFilter}
|
|
||||||
/>
|
|
||||||
</InputSearchContainer>
|
|
||||||
<ColorPicker
|
|
||||||
width={panelWidth}
|
|
||||||
activeColor={color}
|
|
||||||
onSelect={onColorChange}
|
|
||||||
/>
|
|
||||||
<GridTemplate
|
|
||||||
ref={scrollableRef}
|
|
||||||
width={panelWidth}
|
|
||||||
height={GRID_HEIGHT}
|
|
||||||
data={templateData}
|
|
||||||
onIconSelect={handleIconSelection}
|
|
||||||
/>
|
|
||||||
</Flex>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const InputSearchContainer = styled(Flex)`
|
|
||||||
height: 48px;
|
|
||||||
padding: 6px 12px 0px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const StyledInputSearch = styled(InputSearch)`
|
|
||||||
flex-grow: 1;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export default IconPanel;
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import styled, { css } from "styled-components";
|
|
||||||
import { s } from "@shared/styles";
|
|
||||||
import NudeButton from "~/components/NudeButton";
|
|
||||||
import { hover } from "~/styles";
|
|
||||||
|
|
||||||
export const PopoverButton = styled(NudeButton)<{ $borderOnHover?: boolean }>`
|
|
||||||
&: ${hover},
|
|
||||||
&:active,
|
|
||||||
&[aria-expanded= "true"] {
|
|
||||||
opacity: 1 !important;
|
|
||||||
|
|
||||||
${({ $borderOnHover }) =>
|
|
||||||
$borderOnHover &&
|
|
||||||
css`
|
|
||||||
background: ${s("buttonNeutralBackground")};
|
|
||||||
box-shadow: rgba(0, 0, 0, 0.07) 0px 1px 2px,
|
|
||||||
${s("buttonNeutralBorder")} 0 0 0 1px inset;
|
|
||||||
`};
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Menu, MenuButton, MenuItem, useMenuState } from "reakit";
|
|
||||||
import styled from "styled-components";
|
|
||||||
import { depths, s } from "@shared/styles";
|
|
||||||
import { EmojiSkinTone } from "@shared/types";
|
|
||||||
import { getEmojiVariants } from "@shared/utils/emoji";
|
|
||||||
import Flex from "~/components/Flex";
|
|
||||||
import NudeButton from "~/components/NudeButton";
|
|
||||||
import { hover } from "~/styles";
|
|
||||||
import { Emoji } from "./Emoji";
|
|
||||||
import { IconButton } from "./IconButton";
|
|
||||||
|
|
||||||
const SkinTonePicker = ({
|
|
||||||
skinTone,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
skinTone: EmojiSkinTone;
|
|
||||||
onChange: (skin: EmojiSkinTone) => void;
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const handEmojiVariants = React.useMemo(
|
|
||||||
() => getEmojiVariants({ id: "hand" }),
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const menu = useMenuState({
|
|
||||||
placement: "bottom",
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSkinClick = React.useCallback(
|
|
||||||
(emojiSkin) => {
|
|
||||||
menu.hide();
|
|
||||||
onChange(emojiSkin);
|
|
||||||
},
|
|
||||||
[menu, onChange]
|
|
||||||
);
|
|
||||||
|
|
||||||
const menuItems = React.useMemo(
|
|
||||||
() =>
|
|
||||||
Object.entries(handEmojiVariants).map(([eskin, emoji]) => (
|
|
||||||
<MenuItem {...menu} key={emoji.value}>
|
|
||||||
{(menuprops) => (
|
|
||||||
<IconButton {...menuprops} onClick={() => handleSkinClick(eskin)}>
|
|
||||||
<Emoji>{emoji.value}</Emoji>
|
|
||||||
</IconButton>
|
|
||||||
)}
|
|
||||||
</MenuItem>
|
|
||||||
)),
|
|
||||||
[menu, handEmojiVariants, handleSkinClick]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<MenuButton {...menu}>
|
|
||||||
{(props) => (
|
|
||||||
<StyledMenuButton
|
|
||||||
{...props}
|
|
||||||
aria-label={t("Choose default skin tone")}
|
|
||||||
>
|
|
||||||
{handEmojiVariants[skinTone]!.value}
|
|
||||||
</StyledMenuButton>
|
|
||||||
)}
|
|
||||||
</MenuButton>
|
|
||||||
<Menu {...menu} aria-label={t("Choose default skin tone")}>
|
|
||||||
{(props) => <MenuContainer {...props}>{menuItems}</MenuContainer>}
|
|
||||||
</Menu>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const MenuContainer = styled(Flex)`
|
|
||||||
z-index: ${depths.menu};
|
|
||||||
padding: 4px;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: ${s("menuBackground")};
|
|
||||||
box-shadow: ${s("menuShadow")};
|
|
||||||
`;
|
|
||||||
|
|
||||||
const StyledMenuButton = styled(NudeButton)`
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border: 1px solid ${s("inputBorder")};
|
|
||||||
padding: 4px;
|
|
||||||
|
|
||||||
&: ${hover} {
|
|
||||||
border: 1px solid ${s("inputBorderFocused")};
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export default SkinTonePicker;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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));
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
17
app/components/List/CompositeItem.tsx
Normal file
17
app/components/List/CompositeItem.tsx
Normal 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);
|
||||||
@@ -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) =>
|
||||||
|
|||||||
28
app/components/MobileScrollWrapper.tsx
Normal file
28
app/components/MobileScrollWrapper.tsx
Normal 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;
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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(() =>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
});
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
`;
|
|
||||||
|
|||||||
@@ -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")}
|
||||||
|
|||||||
@@ -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);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -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")} />}
|
||||||
|
|||||||
@@ -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` : ""};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)`,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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\)$/, "™️");
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
@@ -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};
|
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user