Compare commits

...

40 Commits

Author SHA1 Message Date
Apoorv Mishra 4034f5f0e8 fix: review 2024-06-30 11:02:00 +05:30
Apoorv Mishra c4255b6cf2 fix:review 2024-06-29 15:28:19 +05:30
Apoorv Mishra 78fd524bed fix: text 2024-06-27 01:07:11 +05:30
Apoorv Mishra a98a2fb44a fix: avoid full traversal to validate comment 2024-06-27 01:05:28 +05:30
Apoorv Mishra b10e82313e fix: disallow empty comments 2024-06-26 17:52:34 +05:30
Translate-O-Tron 6bb8a3d935 New Crowdin updates (#7079) 2024-06-23 14:26:19 -07:00
Tom Moor 95c768f444 Allow direct editing of collection icon (#7120)
* Allow direct editing of collection icon

* feedback
2024-06-23 14:26:00 -07:00
Tom Moor 2cad16d6b3 fix: Disable hover background on non-interactive share list items 2024-06-23 17:25:36 -04:00
Hemachandar 36a2a4709c fix: allow user to remove document icon (#7124) 2024-06-23 15:47:25 -04:00
Hemachandar 6fd3a0fa8a feat: Unified icon picker (#7038) 2024-06-23 06:31:18 -07:00
Tom Moor 56d90e6bc3 Add HEALTHCHECK into docker image (#7085)
* Add wget into docker image

* Add healthcheck
2024-06-22 07:05:55 -07:00
Tom Moor eaab97dcbf fix: Unable to scroll until multiple comments (#7112)
* fix: Unable to scroll in comments
fix: Missing highlighted text on first comment while composing

* docs
2024-06-22 07:05:23 -07:00
Tom Moor d8f14377f8 fix: Scrollwheel can cause image zoom to get stuck, closes #7083 2024-06-21 23:39:25 -04:00
Apoorv Mishra 0f1f0e82c2 Enable keyboard navigation in member invite list (#7102)
* feat: keyboard nav in share popover

* fix: memoize
2024-06-21 17:35:05 -07:00
Tom Moor 5ddc36555d fix: Remove breaking requirement to not pass collectionId with parentDocumentId.
This was regressed as part of 95b9453269 and unfortunately is a breaking change for API consumers
2024-06-21 20:34:31 -04:00
Tom Moor f17ce9d50b fix: collectionId and parentDocumentId now mutually exclusive in payload
closes #7110
2024-06-21 19:38:05 -04:00
Tom Moor 9e5d5c0347 fix: Incorrect policies returned from documents.update (#7111)
* fix: Incorrect policies returned from documents.update

* Regression test
2024-06-21 16:37:39 -07:00
Hemachandar 4897f001e4 Add icon column to document (#7066)
* Add icon column to document

* Backfill columns

---------

Co-authored-by: Tom Moor <tom.moor@gmail.com>
2024-06-21 06:26:20 -07:00
Hemachandar b6d178943a fix: Include drafts when loading templates (#7100)
* fix: Include drafts when loading templates

* fetch drafts when mounting templates settings
2024-06-21 04:36:22 -07:00
Hemachandar 1bf9012992 feat: Add lastUsedAt to API keys (#7082)
* feat: Add lastUsedAt to API keys

* rename column to lastActiveAt

* switch order
2024-06-20 06:18:35 -07:00
Tom Moor a19fb25bea fix: Open permissions for guests that have collection manage permission (#7075)
* fix: Opens up permissions for guests that have collection manage permission

* tsc

* tests
2024-06-20 06:18:18 -07:00
Brian Krausz 95b9453269 feat: docs managers can action docs & create subdocs (#7077)
* feat: docs managers can action docs & create subdocs

* tests

---------

Co-authored-by: Tom Moor <tom.moor@gmail.com>
2024-06-19 19:22:33 -07:00
Hemachandar 2333602f25 fix: Allow user to remove team logo (#7095) 2024-06-19 17:11:54 -07:00
Tom Moor a825925a31 fix: Layout issue with full-width documents and left TOC 2024-06-19 09:04:01 -04:00
Tom Moor 711b8acebc Add Norweigan translations (#7086) 2024-06-19 05:19:16 -07:00
Tom Moor d7ee63217b fix: Improve translation strings for api key management 2024-06-18 22:32:04 -04:00
Tom Moor 6dae1c2a5c Tweak language on API key list 2024-06-18 21:51:32 -04:00
Hemachandar 3af9861c4a feat: add API key expiry options (#7064)
* feat: add API key expiry options

* review
2024-06-18 18:34:45 -07:00
Hemachandar c04bedef4c fix: remove attachment signing for document export (#7081) 2024-06-18 18:34:05 -07:00
Translate-O-Tron aad709eca4 New Crowdin updates (#7073) 2024-06-17 18:48:45 -07:00
dependabot[bot] 044f5256db chore(deps): bump @sentry/node from 7.99.0 to 7.117.0 (#7069)
Bumps [@sentry/node](https://github.com/getsentry/sentry-javascript) from 7.99.0 to 7.117.0.
- [Release notes](https://github.com/getsentry/sentry-javascript/releases)
- [Changelog](https://github.com/getsentry/sentry-javascript/blob/7.117.0/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-javascript/compare/7.99.0...7.117.0)

---
updated-dependencies:
- dependency-name: "@sentry/node"
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-17 14:03:08 -07:00
dependabot[bot] 5a6bb85f65 chore(deps-dev): bump @types/readable-stream from 4.0.12 to 4.0.14 (#7067)
Bumps [@types/readable-stream](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/readable-stream) from 4.0.12 to 4.0.14.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/readable-stream)

---
updated-dependencies:
- dependency-name: "@types/readable-stream"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-17 14:02:56 -07:00
dependabot[bot] ef45788a0b chore(deps): bump ws from 7.5.9 to 7.5.10 (#7068)
Bumps [ws](https://github.com/websockets/ws) from 7.5.9 to 7.5.10.
- [Release notes](https://github.com/websockets/ws/releases)
- [Commits](https://github.com/websockets/ws/compare/7.5.9...7.5.10)

---
updated-dependencies:
- dependency-name: ws
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-17 14:02:46 -07:00
dependabot[bot] 4b4e593e83 chore(deps): bump umzug from 3.2.1 to 3.8.1 (#7071)
Bumps [umzug](https://github.com/sequelize/umzug) from 3.2.1 to 3.8.1.
- [Release notes](https://github.com/sequelize/umzug/releases)
- [Changelog](https://github.com/sequelize/umzug/blob/main/CHANGELOG.md)
- [Commits](https://github.com/sequelize/umzug/compare/v3.2.1...v3.8.1)

---
updated-dependencies:
- dependency-name: umzug
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-17 14:02:30 -07:00
Translate-O-Tron 15a9bd225f New Crowdin updates (#7019) 2024-06-17 04:25:14 -07:00
Michael Fowler 77579bb4f1 fix: Use the default credential strategy in S3Client construction (#7061)
By omitting this option, we fall back to the hierarchy used by S3Client by
default.  When defined, the provider chain will use the values of
AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY (and AWS_SESSION_TOKEN); in their
absence, the provider chain can retrieve credentials from a range of other
sources, including e.g. ECS credentials.

Although there are no longer any application reads from `env.AWS_ACCESS_KEY_ID`
and `env.AWS_SECRET_ACCESS_KEY`, they continue to serve a useful documentary
role.
2024-06-17 03:15:15 -07:00
Tom Moor 92301791f6 fix: Styling of disabled accent buttons 2024-06-16 20:49:46 -04:00
Tom Moor 9b542c451b fix: Unreadable tertiary text on InputSelect 2024-06-16 20:41:51 -04:00
Hemachandar 3edaf4f8ea feat: TOC position for publicly shared docs (#7057)
* feat: TOC position for publicly shared docs

* remove preferences object

* comment

* fix: Allow sidebar position preference without public branding switch

---------

Co-authored-by: Tom Moor <tom.moor@gmail.com>
2024-06-16 17:26:29 -04:00
Tom Moor 1290aecbc9 Add after option to setting plugin (#7056) 2024-06-16 09:43:32 -07:00
167 changed files with 5699 additions and 2077 deletions
+7
View File
@@ -6,6 +6,10 @@ WORKDIR $APP_PATH
COPY ./package.json ./yarn.lock ./
COPY ./patches ./patches
RUN apt-get update \
&& apt-get install -y wget \
&& rm -rf /var/lib/apt/lists/*
RUN yarn install --no-optional --frozen-lockfile --network-timeout 1000000 && \
yarn cache clean
@@ -17,3 +21,6 @@ RUN rm -rf node_modules
RUN yarn install --production=true --frozen-lockfile --network-timeout 1000000 && \
yarn cache clean
ENV PORT 3000
HEALTHCHECK CMD wget -qO- http://localhost:${PORT}/_health | grep -q "OK" || exit 1
+2 -2
View File
@@ -1,7 +1,7 @@
import { PlusIcon } from "outline-icons";
import * as React from "react";
import stores from "~/stores";
import APIKeyNew from "~/scenes/APIKeyNew";
import ApiKeyNew from "~/scenes/ApiKeyNew";
import { createAction } from "..";
import { SettingsSection } from "../sections";
@@ -19,7 +19,7 @@ export const createApiKey = createAction({
stores.dialogs.openModal({
title: t("New API key"),
content: <APIKeyNew onSubmit={stores.dialogs.closeAllModals} />,
content: <ApiKeyNew onSubmit={stores.dialogs.closeAllModals} />,
});
},
});
+5 -9
View File
@@ -51,6 +51,7 @@ import {
documentHistoryPath,
homePath,
newDocumentPath,
newNestedDocumentPath,
searchPath,
documentPath,
urlify,
@@ -141,15 +142,10 @@ export const createNestedDocument = createAction({
!!activeDocumentId &&
stores.policies.abilities(currentTeamId).createDocument &&
stores.policies.abilities(activeDocumentId).createChildDocument,
perform: ({ activeCollectionId, activeDocumentId, inStarredSection }) =>
history.push(
newDocumentPath(activeCollectionId, {
parentDocumentId: activeDocumentId,
}),
{
starred: inStarredSection,
}
),
perform: ({ activeDocumentId, inStarredSection }) =>
history.push(newNestedDocumentPath(activeDocumentId), {
starred: inStarredSection,
}),
});
export const starDocument = createAction({
+6 -2
View File
@@ -5,10 +5,11 @@ import * as React from "react";
type Props = React.HTMLAttributes<HTMLDivElement> & {
children: () => React.ReactNode;
onEscape?: (ev: React.KeyboardEvent<HTMLDivElement>) => void;
items: unknown[];
};
function ArrowKeyNavigation(
{ children, onEscape, ...rest }: Props,
{ children, onEscape, items, ...rest }: Props,
ref: React.RefObject<HTMLDivElement>
) {
const handleKeyDown = React.useCallback(
@@ -36,7 +37,10 @@ function ArrowKeyNavigation(
);
return (
<RovingTabIndexProvider options={{ focusOnClick: true, direction: "both" }}>
<RovingTabIndexProvider
options={{ focusOnClick: true, direction: "both" }}
items={items}
>
<div {...rest} onKeyDown={handleKeyDown} ref={ref}>
{children()}
</div>
+2 -2
View File
@@ -49,8 +49,8 @@ const RealButton = styled(ActionButton)<RealProps>`
&:disabled {
cursor: default;
pointer-events: none;
color: ${(props) => transparentize(0.5, props.theme.accentText)};
background: ${(props) => lighten(0.2, props.theme.accent)};
color: ${(props) => transparentize(0.3, props.theme.accentText)};
background: ${(props) => transparentize(0.1, props.theme.accent)};
svg {
fill: ${(props) => props.theme.white50};
+28 -14
View File
@@ -11,7 +11,7 @@ import { CollectionValidation } from "@shared/validations";
import Collection from "~/models/Collection";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import IconPicker from "~/components/IconPicker";
import Icon from "~/components/Icon";
import Input from "~/components/Input";
import InputSelectPermission from "~/components/InputSelectPermission";
import Switch from "~/components/Switch";
@@ -20,10 +20,12 @@ import useBoolean from "~/hooks/useBoolean";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import { Feature, FeatureFlags } from "~/utils/FeatureFlags";
const IconPicker = React.lazy(() => import("~/components/IconPicker"));
export interface FormData {
name: string;
icon: string;
color: string;
color: string | null;
sharing: boolean;
permission: CollectionPermission | undefined;
}
@@ -37,7 +39,16 @@ export const CollectionForm = observer(function CollectionForm_({
}) {
const team = useCurrentTeam();
const { t } = useTranslation();
const [hasOpenedIconPicker, setHasOpenedIconPicker] = useBoolean(false);
const iconColor = React.useMemo(
() => collection?.color ?? randomElement(colorPalette),
[collection?.color]
);
const fallbackIcon = <Icon value="collection" color={iconColor} />;
const {
register,
handleSubmit: formHandleSubmit,
@@ -53,7 +64,7 @@ export const CollectionForm = observer(function CollectionForm_({
icon: collection?.icon,
sharing: collection?.sharing ?? true,
permission: collection?.permission,
color: collection?.color ?? randomElement(colorPalette),
color: iconColor,
},
});
@@ -70,20 +81,20 @@ export const CollectionForm = observer(function CollectionForm_({
"collection"
);
}
}, [values.name, collection]);
}, [collection, hasOpenedIconPicker, setValue, values.name, values.icon]);
React.useEffect(() => {
setTimeout(() => setFocus("name", { shouldSelect: true }), 100);
}, [setFocus]);
const handleIconPickerChange = React.useCallback(
(color: string, icon: string) => {
const handleIconChange = React.useCallback(
(icon: string, color: string | null) => {
if (icon !== values.icon) {
setFocus("name");
}
setValue("color", color);
setValue("icon", icon);
setValue("color", color);
},
[setFocus, setValue, values.icon]
);
@@ -105,13 +116,16 @@ export const CollectionForm = observer(function CollectionForm_({
maxLength: CollectionValidation.maxNameLength,
})}
prefix={
<StyledIconPicker
onOpen={setHasOpenedIconPicker}
onChange={handleIconPickerChange}
initial={values.name[0]}
color={values.color}
icon={values.icon}
/>
<React.Suspense fallback={fallbackIcon}>
<StyledIconPicker
icon={values.icon}
color={values.color ?? iconColor}
initial={values.name[0]}
popoverPosition="right"
onOpen={setHasOpenedIconPicker}
onChange={handleIconChange}
/>
</React.Suspense>
}
autoComplete="off"
autoFocus
+6 -1
View File
@@ -1,11 +1,12 @@
import { LocationDescriptor } from "history";
import { CheckmarkIcon } from "outline-icons";
import { ellipsis } from "polished";
import { ellipsis, transparentize } from "polished";
import * as React from "react";
import { mergeRefs } from "react-merge-refs";
import { MenuItem as BaseMenuItem } from "reakit/Menu";
import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Text from "../Text";
import MenuIconWrapper from "./MenuIconWrapper";
type Props = {
@@ -160,6 +161,10 @@ export const MenuAnchorCSS = css<MenuAnchorProps>`
color: ${props.theme.accentText};
fill: ${props.theme.accentText};
}
${Text} {
color: ${transparentize(0.5, props.theme.accentText)};
}
}
}
`}
+7 -3
View File
@@ -6,6 +6,7 @@ import styled from "styled-components";
import type { NavigationNode } from "@shared/types";
import Document from "~/models/Document";
import Breadcrumb from "~/components/Breadcrumb";
import Icon from "~/components/Icon";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import useStores from "~/hooks/useStores";
import { MenuInternalLink } from "~/types";
@@ -15,7 +16,6 @@ import {
settingsPath,
trashPath,
} from "~/utils/routeHelpers";
import EmojiIcon from "./Icons/EmojiIcon";
type Props = {
children?: React.ReactNode;
@@ -106,9 +106,9 @@ const DocumentBreadcrumb: React.FC<Props> = ({
path.slice(0, -1).forEach((node: NavigationNode) => {
output.push({
type: "route",
title: node.emoji ? (
title: node.icon ? (
<>
<EmojiIcon emoji={node.emoji} /> {node.title}
<StyledIcon value={node.icon} color={node.color} /> {node.title}
</>
) : (
node.title
@@ -144,6 +144,10 @@ const DocumentBreadcrumb: React.FC<Props> = ({
);
};
const StyledIcon = styled(Icon)`
margin-right: 2px;
`;
const SmallSlash = styled(GoToIcon)`
width: 12px;
height: 12px;
+37 -8
View File
@@ -9,15 +9,17 @@ import { Link } from "react-router-dom";
import styled, { useTheme } from "styled-components";
import Squircle from "@shared/components/Squircle";
import { s, ellipsis } from "@shared/styles";
import { IconType } from "@shared/types";
import { determineIconType } from "@shared/utils/icon";
import Document from "~/models/Document";
import Pin from "~/models/Pin";
import Flex from "~/components/Flex";
import Icon from "~/components/Icon";
import NudeButton from "~/components/NudeButton";
import Time from "~/components/Time";
import useStores from "~/hooks/useStores";
import { hover } from "~/styles";
import CollectionIcon from "./Icons/CollectionIcon";
import EmojiIcon from "./Icons/EmojiIcon";
import Text from "./Text";
import Tooltip from "./Tooltip";
@@ -52,6 +54,8 @@ function DocumentCard(props: Props) {
disabled: !isDraggable || !canUpdatePin,
});
const hasEmojiInTitle = determineIconType(document.icon) === IconType.Emoji;
const style = {
transform: CSS.Transform.toString(transform),
transition,
@@ -109,12 +113,18 @@ function DocumentCard(props: Props) {
<path d="M19.5 19.5H6C2.96243 19.5 0.5 17.0376 0.5 14V0.5H0.792893L19.5 19.2071V19.5Z" />
</Fold>
{document.emoji ? (
<Squircle color={theme.slateLight}>
<EmojiIcon emoji={document.emoji} size={24} />
</Squircle>
{document.icon ? (
<DocumentSquircle
icon={document.icon}
color={document.color ?? undefined}
/>
) : (
<Squircle color={collection?.color}>
<Squircle
color={
collection?.color ??
(!pin?.collectionId ? theme.slateLight : theme.slateDark)
}
>
{collection?.icon &&
collection?.icon !== "letter" &&
collection?.icon !== "collection" &&
@@ -127,8 +137,8 @@ function DocumentCard(props: Props) {
)}
<div>
<Heading dir={document.dir}>
{document.emoji
? document.titleWithDefault.replace(document.emoji, "")
{hasEmojiInTitle
? document.titleWithDefault.replace(document.icon!, "")
: document.titleWithDefault}
</Heading>
<DocumentMeta size="xsmall">
@@ -159,6 +169,25 @@ function DocumentCard(props: Props) {
);
}
const DocumentSquircle = ({
icon,
color,
}: {
icon: string;
color?: string;
}) => {
const theme = useTheme();
const iconType = determineIconType(icon)!;
const squircleColor =
iconType === IconType.Outline ? color : theme.slateLight;
return (
<Squircle color={squircleColor}>
<Icon value={icon} color={theme.white} />
</Squircle>
);
};
const Clock = styled(ClockIcon)`
flex-shrink: 0;
`;
+15 -10
View File
@@ -18,8 +18,8 @@ import { NavigationNode } from "@shared/types";
import DocumentExplorerNode from "~/components/DocumentExplorerNode";
import DocumentExplorerSearchResult from "~/components/DocumentExplorerSearchResult";
import Flex from "~/components/Flex";
import Icon from "~/components/Icon";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import EmojiIcon from "~/components/Icons/EmojiIcon";
import { Outline } from "~/components/Input";
import InputSearch from "~/components/InputSearch";
import Text from "~/components/Text";
@@ -216,25 +216,30 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
}) => {
const node = data[index];
const isCollection = node.type === "collection";
let icon, title: string, emoji: string | undefined, path;
let renderedIcon,
title: string,
icon: string | undefined,
color: string | undefined,
path;
if (isCollection) {
const col = collections.get(node.collectionId as string);
icon = col && (
renderedIcon = col && (
<CollectionIcon collection={col} expanded={isExpanded(index)} />
);
title = node.title;
} else {
const doc = documents.get(node.id);
emoji = doc?.emoji ?? node.emoji;
icon = doc?.icon ?? node.icon;
color = doc?.color ?? node.color;
title = doc?.title ?? node.title;
if (emoji) {
icon = <EmojiIcon emoji={emoji} />;
if (icon) {
renderedIcon = <Icon value={icon} color={color} />;
} else if (doc?.isStarred) {
icon = <StarredIcon color={theme.yellow} />;
renderedIcon = <StarredIcon color={theme.yellow} />;
} else {
icon = <DocumentIcon color={theme.textSecondary} />;
renderedIcon = <DocumentIcon color={theme.textSecondary} />;
}
path = ancestors(node)
@@ -254,7 +259,7 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
}}
onPointerMove={() => setActiveNode(index)}
onClick={() => toggleSelect(index)}
icon={icon}
icon={renderedIcon}
title={title}
path={path}
/>
@@ -275,7 +280,7 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
selected={isSelected(index)}
active={activeNode === index}
expanded={isExpanded(index)}
icon={icon}
icon={renderedIcon}
title={title}
depth={node.depth as number}
hasChildren={hasChildren(index)}
+3 -3
View File
@@ -15,6 +15,7 @@ import Badge from "~/components/Badge";
import DocumentMeta from "~/components/DocumentMeta";
import Flex from "~/components/Flex";
import Highlight from "~/components/Highlight";
import Icon from "~/components/Icon";
import NudeButton from "~/components/NudeButton";
import StarButton, { AnimatedStar } from "~/components/Star";
import Tooltip from "~/components/Tooltip";
@@ -23,7 +24,6 @@ import useCurrentUser from "~/hooks/useCurrentUser";
import DocumentMenu from "~/menus/DocumentMenu";
import { hover } from "~/styles";
import { documentPath } from "~/utils/routeHelpers";
import EmojiIcon from "./Icons/EmojiIcon";
type Props = {
document: Document;
@@ -97,9 +97,9 @@ function DocumentListItem(
>
<Content>
<Heading dir={document.dir}>
{document.emoji && (
{document.icon && (
<>
<EmojiIcon emoji={document.emoji} size={24} />
<Icon value={document.icon} color={document.color ?? undefined} />
&nbsp;
</>
)}
-23
View File
@@ -1,23 +0,0 @@
import styled from "styled-components";
import Button from "~/components/Button";
import { hover } from "~/styles";
import Flex from "../Flex";
export const EmojiButton = styled(Button)`
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
&: ${hover},
&:active,
&[aria-expanded= "true"] {
opacity: 1 !important;
}
`;
export const Emoji = styled(Flex)<{ size?: number }>`
line-height: 1.6;
${(props) => (props.size ? `font-size: ${props.size}px` : "")}
`;
-262
View File
@@ -1,262 +0,0 @@
import data from "@emoji-mart/data";
import Picker from "@emoji-mart/react";
import { SmileyIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
import styled, { useTheme } from "styled-components";
import { depths, s } from "@shared/styles";
import { toRGB } from "@shared/utils/color";
import Button from "~/components/Button";
import Popover from "~/components/Popover";
import useStores from "~/hooks/useStores";
import useUserLocale from "~/hooks/useUserLocale";
import { Emoji, EmojiButton } from "./components";
/* Locales supported by emoji-mart */
const supportedLocales = [
"en",
"ar",
"be",
"cs",
"de",
"es",
"fa",
"fi",
"fr",
"hi",
"it",
"ja",
"ko",
"nl",
"pl",
"pt",
"ru",
"sa",
"tr",
"uk",
"vi",
"zh",
];
/**
* React hook to derive emoji picker's theme from UI theme
*
* @returns {string} Theme to use for emoji picker
*/
function usePickerTheme(): string {
const { ui } = useStores();
const { theme } = ui;
if (theme === "system") {
return "auto";
}
return theme;
}
type Props = {
/** The selected emoji, if any */
value?: string | null;
/** Callback when an emoji is selected */
onChange: (emoji: string | null) => void | Promise<void>;
/** Callback when the picker is opened */
onOpen?: () => void;
/** Callback when the picker is closed */
onClose?: () => void;
/** Callback when the picker is clicked outside of */
onClickOutside: () => void;
/** Whether to auto focus the search input on open */
autoFocus?: boolean;
/** Class name to apply to the trigger button */
className?: string;
};
function EmojiPicker({
value,
onOpen,
onClose,
onChange,
onClickOutside,
autoFocus,
className,
}: Props) {
const { t } = useTranslation();
const pickerTheme = usePickerTheme();
const theme = useTheme();
const locale = useUserLocale(true) ?? "en";
const popover = usePopoverState({
placement: "bottom-start",
modal: true,
unstable_offset: [0, 0],
});
const [emojisPerLine, setEmojisPerLine] = React.useState(9);
const pickerRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (popover.visible) {
onOpen?.();
} else {
onClose?.();
}
}, [popover.visible, onOpen, onClose]);
React.useEffect(() => {
if (popover.visible && pickerRef.current) {
// 28 is picker's observed width when perLine is set to 0
// and 36 is the default emojiButtonSize
// Ref: https://github.com/missive/emoji-mart#options--props
setEmojisPerLine(Math.floor((pickerRef.current.clientWidth - 28) / 36));
}
}, [popover.visible]);
const handleEmojiChange = React.useCallback(
async (emoji) => {
popover.hide();
await onChange(emoji ? emoji.native : null);
},
[popover, onChange]
);
const handleClick = React.useCallback(
(ev: React.MouseEvent) => {
ev.stopPropagation();
if (popover.visible) {
popover.hide();
} else {
popover.show();
}
},
[popover]
);
const handleClickOutside = React.useCallback(() => {
// It was observed that onClickOutside got triggered
// even when the picker wasn't open or opened at all.
// Hence, this guard here...
if (popover.visible) {
onClickOutside();
}
}, [popover.visible, onClickOutside]);
// Auto focus search input when picker is opened
React.useLayoutEffect(() => {
if (autoFocus && popover.visible) {
requestAnimationFrame(() => {
const searchInput = pickerRef.current
?.querySelector("em-emoji-picker")
?.shadowRoot?.querySelector(
"input[type=search]"
) as HTMLInputElement | null;
searchInput?.focus();
});
}
}, [autoFocus, popover.visible]);
return (
<>
<PopoverDisclosure {...popover}>
{(props) => (
<EmojiButton
{...props}
className={className}
onClick={handleClick}
icon={
value ? (
<Emoji size={32} align="center" justify="center">
{value}
</Emoji>
) : (
<StyledSmileyIcon size={32} color={theme.textTertiary} />
)
}
neutral
borderOnHover
/>
)}
</PopoverDisclosure>
<PickerPopover
{...popover}
tabIndex={0}
// This prevents picker from closing when any of its
// children are focused, e.g, clicking on search bar or
// a click on skin tone button
onClick={(e) => e.stopPropagation()}
width={352}
aria-label={t("Emoji Picker")}
>
{popover.visible && (
<>
{value && (
<RemoveButton neutral onClick={() => handleEmojiChange(null)}>
{t("Remove")}
</RemoveButton>
)}
<PickerStyles ref={pickerRef}>
<Picker
locale={supportedLocales.includes(locale) ? locale : "en"}
data={data}
onEmojiSelect={handleEmojiChange}
theme={pickerTheme}
previewPosition="none"
perLine={emojisPerLine}
onClickOutside={handleClickOutside}
/>
</PickerStyles>
</>
)}
</PickerPopover>
</>
);
}
const StyledSmileyIcon = styled(SmileyIcon)`
flex-shrink: 0;
@media print {
display: none;
}
`;
const RemoveButton = styled(Button)`
margin-left: -12px;
margin-bottom: 8px;
border-radius: 6px;
height: 24px;
font-size: 13px;
> :first-child {
min-height: unset;
line-height: unset;
}
`;
const PickerPopover = styled(Popover)`
z-index: ${depths.popover};
> :first-child {
padding-top: 8px;
padding-bottom: 0;
max-height: 488px;
overflow: unset;
}
`;
const PickerStyles = styled.div`
margin-left: -24px;
margin-right: -24px;
em-emoji-picker {
--shadow: none;
--font-family: ${s("fontFamily")};
--rgb-background: ${(props) => toRGB(props.theme.menuBackground)};
--rgb-accent: ${(props) => toRGB(props.theme.accent)};
--border-radius: 6px;
margin-left: auto;
margin-right: auto;
min-height: 443px;
}
`;
export default EmojiPicker;
+93
View File
@@ -0,0 +1,93 @@
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";
type IconProps = {
value: string;
color?: string;
size?: number;
initial?: string;
className?: string;
};
const Icon = ({
value: icon,
color,
size = 24,
initial,
className,
}: IconProps) => {
const iconType = determineIconType(icon);
if (!iconType) {
Logger.warn("Failed to determine icon type", {
icon,
});
return null;
}
try {
if (iconType === IconType.Outline) {
return (
<OutlineIcon
value={icon}
color={color}
size={size}
initial={initial}
className={className}
/>
);
}
return <EmojiIcon emoji={icon} size={size} className={className} />;
} catch (err) {
Logger.warn("Failed to render icon", {
icon,
});
}
return null;
};
type OutlineIconProps = {
value: string;
color?: string;
size?: number;
initial?: string;
className?: string;
};
const OutlineIcon = ({
value: icon,
color: inputColor,
initial,
size,
className,
}: OutlineIconProps) => {
const { ui } = useStores();
let color = inputColor ?? randomElement(colorPalette);
// 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.
if (!inputColor && ui.resolvedTheme === "dark" && color !== "currentColor") {
color = getLuminance(color) > 0.09 ? color : "currentColor";
}
const Component = IconLibrary.getComponent(icon);
return (
<Component color={color} size={size} className={className}>
{initial}
</Component>
);
};
export default Icon;
-211
View File
@@ -1,211 +0,0 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import { PopoverDisclosure, usePopoverState } from "reakit";
import { MenuItem } from "reakit/Menu";
import styled, { useTheme } from "styled-components";
import { IconLibrary } from "@shared/utils/IconLibrary";
import { colorPalette } from "@shared/utils/collections";
import Flex from "~/components/Flex";
import NudeButton from "~/components/NudeButton";
import Text from "~/components/Text";
import useOnClickOutside from "~/hooks/useOnClickOutside";
import lazyWithRetry from "~/utils/lazyWithRetry";
import DelayedMount from "./DelayedMount";
import InputSearch from "./InputSearch";
import Popover from "./Popover";
const icons = IconLibrary.mapping;
const TwitterPicker = lazyWithRetry(
() => import("react-color/lib/components/twitter/Twitter")
);
type Props = {
onOpen?: () => void;
onClose?: () => void;
onChange: (color: string, icon: string) => void;
initial: string;
icon: string;
color: string;
className?: string;
};
function IconPicker({
onOpen,
onClose,
icon,
initial,
color,
onChange,
className,
}: Props) {
const [query, setQuery] = React.useState("");
const { t } = useTranslation();
const theme = useTheme();
const popover = usePopoverState({
gutter: 0,
placement: "right",
modal: true,
});
React.useEffect(() => {
if (popover.visible) {
onOpen?.();
} else {
onClose?.();
setQuery("");
}
}, [onOpen, onClose, popover.visible]);
const filteredIcons = IconLibrary.findIcons(query);
const handleFilter = (event: React.ChangeEvent<HTMLInputElement>) => {
setQuery(event.target.value.toLowerCase());
};
const styles = React.useMemo(
() => ({
default: {
body: {
padding: 0,
marginRight: -8,
},
hash: {
color: theme.text,
background: theme.inputBorder,
},
swatch: {
cursor: "var(--cursor-pointer)",
},
input: {
color: theme.text,
boxShadow: `inset 0 0 0 1px ${theme.inputBorder}`,
background: "transparent",
},
},
}),
[theme]
);
// Custom click outside handling rather than using `hideOnClickOutside` from reakit so that we can
// prevent event bubbling.
useOnClickOutside(
popover.unstable_popoverRef,
(event) => {
if (popover.visible) {
event.stopPropagation();
event.preventDefault();
popover.hide();
}
},
{ capture: true }
);
const iconNames = Object.keys(icons);
const delayPerIcon = 250 / iconNames.length;
return (
<>
<PopoverDisclosure {...popover}>
{(props) => (
<NudeButton
aria-label={t("Show menu")}
className={className}
{...props}
>
<Icon
as={IconLibrary.getComponent(icon || "collection")}
color={color}
>
{initial}
</Icon>
</NudeButton>
)}
</PopoverDisclosure>
<Popover
{...popover}
width={552}
aria-label={t("Choose an icon")}
hideOnClickOutside={false}
>
<Flex column gap={12}>
<Text size="large" weight="xbold">
{t("Choose an icon")}
</Text>
<InputSearch
value={query}
placeholder={`${t("Filter")}`}
onChange={handleFilter}
autoFocus
/>
<div>
{iconNames.map((name, index) => (
<MenuItem key={name} onClick={() => onChange(color, name)}>
{(props) => (
<IconButton
style={
{
opacity: query
? filteredIcons.includes(name)
? 1
: 0.3
: undefined,
"--delay": `${Math.round(index * delayPerIcon)}ms`,
} as React.CSSProperties
}
{...props}
>
<Icon
as={IconLibrary.getComponent(name)}
color={color}
size={30}
>
{initial}
</Icon>
</IconButton>
)}
</MenuItem>
))}
</div>
<Flex>
<React.Suspense
fallback={
<DelayedMount>
<Text>{t("Loading")}</Text>
</DelayedMount>
}
>
<ColorPicker
color={color}
onChange={(color) => onChange(color.hex, icon)}
colors={colorPalette}
triangle="hide"
styles={styles}
/>
</React.Suspense>
</Flex>
</Flex>
</Popover>
</>
);
}
const Icon = styled.svg`
transition: color 150ms ease-in-out, fill 150ms ease-in-out;
transition-delay: var(--delay);
`;
const IconButton = styled(NudeButton)`
vertical-align: top;
border-radius: 4px;
margin: 0px 6px 6px 0px;
width: 30px;
height: 30px;
`;
const ColorPicker = styled(TwitterPicker)`
box-shadow: none !important;
background: transparent !important;
width: 100% !important;
`;
export default IconPicker;
@@ -0,0 +1,218 @@
import { BackIcon } from "outline-icons";
import React from "react";
import styled from "styled-components";
import { breakpoints, s } from "@shared/styles";
import { colorPalette } from "@shared/utils/collections";
import { validateColorHex } from "@shared/utils/color";
import Flex from "~/components/Flex";
import NudeButton from "~/components/NudeButton";
import Text from "~/components/Text";
import { hover } from "~/styles";
enum Panel {
Builtin,
Hex,
}
type Props = {
width: number;
activeColor: string;
onSelect: (color: string) => void;
};
const ColorPicker = ({ width, activeColor, onSelect }: Props) => {
const [localValue, setLocalValue] = React.useState(activeColor);
const [panel, setPanel] = React.useState(
colorPalette.includes(activeColor) ? Panel.Builtin : Panel.Hex
);
const handleSwitcherClick = React.useCallback(() => {
setPanel(panel === Panel.Builtin ? Panel.Hex : Panel.Builtin);
}, [panel, setPanel]);
const isLargeMobile = width > breakpoints.mobileLarge + 12; // 12px for the Container padding
React.useEffect(() => {
setLocalValue(activeColor);
setPanel(colorPalette.includes(activeColor) ? Panel.Builtin : Panel.Hex);
}, [activeColor]);
return isLargeMobile ? (
<Container justify="space-between">
<LargeMobileBuiltinColors activeColor={activeColor} onClick={onSelect} />
<LargeMobileCustomColor
value={localValue}
setLocalValue={setLocalValue}
onValidHex={onSelect}
/>
</Container>
) : (
<Container gap={12}>
<PanelSwitcher align="center">
<SwitcherButton panel={panel} onClick={handleSwitcherClick}>
{panel === Panel.Builtin ? "#" : <BackIcon />}
</SwitcherButton>
</PanelSwitcher>
{panel === Panel.Builtin ? (
<BuiltinColors activeColor={activeColor} onClick={onSelect} />
) : (
<CustomColor
value={localValue}
setLocalValue={setLocalValue}
onValidHex={onSelect}
/>
)}
</Container>
);
};
const BuiltinColors = ({
activeColor,
onClick,
className,
}: {
activeColor: string;
onClick: (color: string) => void;
className?: string;
}) => (
<Flex className={className} justify="space-between" align="center" auto>
{colorPalette.map((color) => (
<ColorButton
key={color}
color={color}
active={color === activeColor}
onClick={() => onClick(color)}
>
<Selected />
</ColorButton>
))}
</Flex>
);
const CustomColor = ({
value,
setLocalValue,
onValidHex,
className,
}: {
value: string;
setLocalValue: (value: string) => void;
onValidHex: (color: string) => void;
className?: string;
}) => {
const hasHexChars = React.useCallback(
(color: string) => /(^#[0-9A-F]{1,6}$)/i.test(color),
[]
);
const handleInputChange = React.useCallback(
(ev: React.ChangeEvent<HTMLInputElement>) => {
const val = ev.target.value;
if (val === "" || val === "#") {
setLocalValue("#");
return;
}
const uppercasedVal = val.toUpperCase();
if (hasHexChars(uppercasedVal)) {
setLocalValue(uppercasedVal);
}
if (validateColorHex(uppercasedVal)) {
onValidHex(uppercasedVal);
}
},
[setLocalValue, hasHexChars, onValidHex]
);
return (
<Flex className={className} align="center" gap={8}>
<Text type="tertiary" size="small">
HEX
</Text>
<CustomColorInput
maxLength={7}
value={value}
onChange={handleInputChange}
/>
</Flex>
);
};
const Container = styled(Flex)`
height: 48px;
padding: 8px 12px;
border-bottom: 1px solid ${s("inputBorder")};
`;
const Selected = styled.span`
width: 8px;
height: 4px;
border-left: 1px solid white;
border-bottom: 1px solid white;
transform: translateY(-25%) rotate(-45deg);
`;
const ColorButton = styled(NudeButton)<{ color: string; active: boolean }>`
display: inline-flex;
justify-content: center;
align-items: center;
width: 24px;
height: 24px;
border-radius: 50%;
background-color: ${({ color }) => color};
&: ${hover} {
outline: 2px solid ${s("menuBackground")} !important;
box-shadow: ${({ color }) => `0px 0px 3px 3px ${color}`};
}
& ${Selected} {
display: ${({ active }) => (active ? "block" : "none")};
}
`;
const PanelSwitcher = styled(Flex)`
width: 40px;
border-right: 1px solid ${s("inputBorder")};
`;
const SwitcherButton = styled(NudeButton)<{ panel: Panel }>`
display: inline-flex;
justify-content: center;
align-items: center;
font-size: 14px;
border: 1px solid ${s("inputBorder")};
transition: all 100ms ease-in-out;
&: ${hover} {
border-color: ${s("inputBorderFocused")};
}
`;
const LargeMobileBuiltinColors = styled(BuiltinColors)`
max-width: 380px;
padding-right: 8px;
`;
const LargeMobileCustomColor = styled(CustomColor)`
padding-left: 8px;
border-left: 1px solid ${s("inputBorder")};
width: 120px;
`;
const CustomColorInput = styled.input.attrs(() => ({
type: "text",
autocomplete: "off",
}))`
font-size: 14px;
color: ${s("textSecondary")};
background: transparent;
border: 0;
outline: 0;
`;
export default ColorPicker;
@@ -0,0 +1,8 @@
import styled from "styled-components";
import { s } from "@shared/styles";
export const Emoji = styled.span`
font-family: ${s("fontFamilyEmoji")};
width: 24px;
height: 24px;
`;
@@ -0,0 +1,245 @@
import concat from "lodash/concat";
import React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { EmojiCategory, EmojiSkinTone, IconType } from "@shared/types";
import { getEmojis, getEmojisWithCategory, search } from "@shared/utils/emoji";
import Flex from "~/components/Flex";
import InputSearch from "~/components/InputSearch";
import usePersistedState from "~/hooks/usePersistedState";
import {
FREQUENTLY_USED_COUNT,
DisplayCategory,
emojiSkinToneKey,
emojisFreqKey,
lastEmojiKey,
sortFrequencies,
} from "../utils";
import GridTemplate, { DataNode } from "./GridTemplate";
import SkinTonePicker from "./SkinTonePicker";
/**
* This is needed as a constant for react-window.
* Calculated from the heights of TabPanel and InputSearch.
*/
const GRID_HEIGHT = 362;
const useEmojiState = () => {
const [emojiSkinTone, setEmojiSkinTone] = usePersistedState<EmojiSkinTone>(
emojiSkinToneKey,
EmojiSkinTone.Default
);
const [emojisFreq, setEmojisFreq] = usePersistedState<Record<string, number>>(
emojisFreqKey,
{}
);
const [lastEmoji, setLastEmoji] = usePersistedState<string | undefined>(
lastEmojiKey,
undefined
);
const incrementEmojiCount = React.useCallback(
(emoji: string) => {
emojisFreq[emoji] = (emojisFreq[emoji] ?? 0) + 1;
setEmojisFreq({ ...emojisFreq });
setLastEmoji(emoji);
},
[emojisFreq, setEmojisFreq, setLastEmoji]
);
const getFreqEmojis = React.useCallback(() => {
const freqs = Object.entries(emojisFreq);
if (freqs.length > FREQUENTLY_USED_COUNT.Track) {
sortFrequencies(freqs).splice(FREQUENTLY_USED_COUNT.Track);
setEmojisFreq(Object.fromEntries(freqs));
}
const emojis = sortFrequencies(freqs)
.slice(0, FREQUENTLY_USED_COUNT.Get)
.map(([emoji, _]) => emoji);
const isLastPresent = emojis.includes(lastEmoji ?? "");
if (lastEmoji && !isLastPresent) {
emojis.pop();
emojis.push(lastEmoji);
}
return emojis;
}, [emojisFreq, setEmojisFreq, lastEmoji]);
return {
emojiSkinTone,
setEmojiSkinTone,
incrementEmojiCount,
getFreqEmojis,
};
};
type Props = {
panelWidth: number;
query: string;
panelActive: boolean;
onEmojiChange: (emoji: string) => void;
onQueryChange: (query: string) => void;
};
const EmojiPanel = ({
panelWidth,
query,
panelActive,
onEmojiChange,
onQueryChange,
}: Props) => {
const { t } = useTranslation();
const searchRef = React.useRef<HTMLInputElement | null>(null);
const scrollableRef = React.useRef<HTMLDivElement | null>(null);
const {
emojiSkinTone: skinTone,
setEmojiSkinTone,
incrementEmojiCount,
getFreqEmojis,
} = useEmojiState();
const freqEmojis = React.useMemo(() => getFreqEmojis(), [getFreqEmojis]);
const handleFilter = React.useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
onQueryChange(event.target.value);
},
[onQueryChange]
);
const handleSkinChange = React.useCallback(
(emojiSkinTone: EmojiSkinTone) => {
setEmojiSkinTone(emojiSkinTone);
},
[setEmojiSkinTone]
);
const handleEmojiSelection = React.useCallback(
({ id, value }: { id: string; value: string }) => {
onEmojiChange(value);
incrementEmojiCount(id);
},
[onEmojiChange, incrementEmojiCount]
);
const isSearch = query !== "";
const templateData: DataNode[] = isSearch
? getSearchResults({
query,
skinTone,
})
: getAllEmojis({
skinTone,
freqEmojis,
});
React.useEffect(() => {
if (scrollableRef.current) {
scrollableRef.current.scrollTop = 0;
}
searchRef.current?.focus();
}, [panelActive]);
return (
<Flex column>
<UserInputContainer align="center" gap={12}>
<StyledInputSearch
ref={searchRef}
value={query}
placeholder={`${t("Search emoji")}`}
onChange={handleFilter}
/>
<SkinTonePicker skinTone={skinTone} onChange={handleSkinChange} />
</UserInputContainer>
<GridTemplate
ref={scrollableRef}
width={panelWidth}
height={GRID_HEIGHT}
data={templateData}
onIconSelect={handleEmojiSelection}
/>
</Flex>
);
};
const getSearchResults = ({
query,
skinTone,
}: {
query: string;
skinTone: EmojiSkinTone;
}): DataNode[] => {
const emojis = search({ query, skinTone });
return [
{
category: DisplayCategory.Search,
icons: emojis.map((emoji) => ({
type: IconType.Emoji,
id: emoji.id,
value: emoji.value,
})),
},
];
};
const getAllEmojis = ({
skinTone,
freqEmojis,
}: {
skinTone: EmojiSkinTone;
freqEmojis: string[];
}): DataNode[] => {
const emojisWithCategory = getEmojisWithCategory({ skinTone });
const getFrequentEmojis = (): DataNode => {
const emojis = getEmojis({ ids: freqEmojis, skinTone });
return {
category: DisplayCategory.Frequent,
icons: emojis.map((emoji) => ({
type: IconType.Emoji,
id: emoji.id,
value: emoji.value,
})),
};
};
const getCategoryData = (emojiCategory: EmojiCategory): DataNode => {
const emojis = emojisWithCategory[emojiCategory] ?? [];
return {
category: emojiCategory,
icons: emojis.map((emoji) => ({
type: IconType.Emoji,
id: emoji.id,
value: emoji.value,
})),
};
};
return concat(
getFrequentEmojis(),
getCategoryData(EmojiCategory.People),
getCategoryData(EmojiCategory.Nature),
getCategoryData(EmojiCategory.Foods),
getCategoryData(EmojiCategory.Activity),
getCategoryData(EmojiCategory.Places),
getCategoryData(EmojiCategory.Objects),
getCategoryData(EmojiCategory.Symbols),
getCategoryData(EmojiCategory.Flags)
);
};
const UserInputContainer = styled(Flex)`
height: 48px;
padding: 6px 12px 0px;
`;
const StyledInputSearch = styled(InputSearch)`
flex-grow: 1;
`;
export default EmojiPanel;
@@ -0,0 +1,61 @@
import React from "react";
import { FixedSizeList, ListChildComponentProps } from "react-window";
import styled from "styled-components";
type Props = {
width: number;
height: number;
data: React.ReactNode[][];
columns: number;
itemWidth: number;
};
const Grid = (
{ width, height, data, columns, itemWidth }: Props,
ref: React.Ref<HTMLDivElement>
) => (
<Container
outerRef={ref}
width={width}
height={height}
itemCount={data.length}
itemSize={itemWidth}
itemData={{ data, columns }}
>
{Row}
</Container>
);
type RowProps = {
data: React.ReactNode[][];
columns: number;
};
const Row = ({ index, style, data }: ListChildComponentProps<RowProps>) => {
const { data: rows, columns } = data;
const row = rows[index];
return (
<RowContainer style={style} columns={columns}>
{row}
</RowContainer>
);
};
const Container = styled(FixedSizeList<RowProps>)`
padding: 0px 12px;
// Needed for the absolutely positioned children
// to respect the VirtualList's padding
& > div {
position: relative;
}
`;
const RowContainer = styled.div<{ columns: number }>`
display: grid;
grid-template-columns: ${({ columns }) => `repeat(${columns}, 1fr)`};
align-content: center;
`;
export default React.forwardRef(Grid);
@@ -0,0 +1,120 @@
import chunk from "lodash/chunk";
import compact from "lodash/compact";
import React from "react";
import styled from "styled-components";
import { IconType } from "@shared/types";
import { IconLibrary } from "@shared/utils/IconLibrary";
import Text from "~/components/Text";
import { TRANSLATED_CATEGORIES } from "../utils";
import { Emoji } from "./Emoji";
import Grid from "./Grid";
import { IconButton } from "./IconButton";
/**
* icon/emoji size is 24px; and we add 4px padding on all sides,
*/
const BUTTON_SIZE = 32;
type OutlineNode = {
type: IconType.Outline;
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.Outline) {
return (
<IconButton
key={item.name}
onClick={() => onIconSelect({ id: item.name, value: item.name })}
delay={item.delay}
>
<Icon as={IconLibrary.getComponent(item.name)} color={item.color}>
{item.initial}
</Icon>
</IconButton>
);
}
return (
<IconButton
key={item.id}
onClick={() => onIconSelect({ id: item.id, value: item.value })}
>
<Emoji>{item.value}</Emoji>
</IconButton>
);
});
const chunks = chunk(items, itemsPerRow);
return [[category], ...chunks];
})
);
return (
<Grid
ref={ref}
width={width}
height={height}
data={gridItems}
columns={itemsPerRow}
itemWidth={BUTTON_SIZE}
/>
);
};
const CategoryName = styled(Text)`
grid-column: 1 / -1;
padding-left: 6px;
`;
const Icon = styled.svg`
transition: color 150ms ease-in-out, fill 150ms ease-in-out;
transition-delay: var(--delay);
`;
export default React.forwardRef(GridTemplate);
@@ -0,0 +1,15 @@
import styled from "styled-components";
import { s } from "@shared/styles";
import NudeButton from "~/components/NudeButton";
import { hover } from "~/styles";
export const IconButton = styled(NudeButton)<{ delay?: number }>`
width: 32px;
height: 32px;
padding: 4px;
--delay: ${({ delay }) => delay && `${delay}ms`};
&: ${hover} {
background: ${s("listItemHoverBackground")};
}
`;
@@ -0,0 +1,200 @@
import React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { IconType } from "@shared/types";
import { IconLibrary } from "@shared/utils/IconLibrary";
import Flex from "~/components/Flex";
import InputSearch from "~/components/InputSearch";
import usePersistedState from "~/hooks/usePersistedState";
import {
FREQUENTLY_USED_COUNT,
DisplayCategory,
iconsFreqKey,
lastIconKey,
sortFrequencies,
} from "../utils";
import ColorPicker from "./ColorPicker";
import GridTemplate, { DataNode } from "./GridTemplate";
const IconNames = Object.keys(IconLibrary.mapping);
const TotalIcons = IconNames.length;
/**
* This is needed as a constant for react-window.
* Calculated from the heights of TabPanel, ColorPicker and InputSearch.
*/
const GRID_HEIGHT = 314;
const useIconState = () => {
const [iconsFreq, setIconsFreq] = usePersistedState<Record<string, number>>(
iconsFreqKey,
{}
);
const [lastIcon, setLastIcon] = usePersistedState<string | undefined>(
lastIconKey,
undefined
);
const incrementIconCount = React.useCallback(
(icon: string) => {
iconsFreq[icon] = (iconsFreq[icon] ?? 0) + 1;
setIconsFreq({ ...iconsFreq });
setLastIcon(icon);
},
[iconsFreq, setIconsFreq, setLastIcon]
);
const getFreqIcons = React.useCallback(() => {
const freqs = Object.entries(iconsFreq);
if (freqs.length > FREQUENTLY_USED_COUNT.Track) {
sortFrequencies(freqs).splice(FREQUENTLY_USED_COUNT.Track);
setIconsFreq(Object.fromEntries(freqs));
}
const icons = sortFrequencies(freqs)
.slice(0, FREQUENTLY_USED_COUNT.Get)
.map(([icon, _]) => icon);
const isLastPresent = icons.includes(lastIcon ?? "");
if (lastIcon && !isLastPresent) {
icons.pop();
icons.push(lastIcon);
}
return icons;
}, [iconsFreq, setIconsFreq, lastIcon]);
return {
incrementIconCount,
getFreqIcons,
};
};
type Props = {
panelWidth: number;
initial: string;
color: string;
query: string;
panelActive: boolean;
onIconChange: (icon: string) => void;
onColorChange: (icon: string) => void;
onQueryChange: (query: string) => void;
};
const IconPanel = ({
panelWidth,
initial,
color,
query,
panelActive,
onIconChange,
onColorChange,
onQueryChange,
}: Props) => {
const { t } = useTranslation();
const searchRef = React.useRef<HTMLInputElement | null>(null);
const scrollableRef = React.useRef<HTMLDivElement | null>(null);
const { incrementIconCount, getFreqIcons } = useIconState();
const freqIcons = React.useMemo(() => getFreqIcons(), [getFreqIcons]);
const totalFreqIcons = freqIcons.length;
const filteredIcons = React.useMemo(
() => IconLibrary.findIcons(query),
[query]
);
const isSearch = query !== "";
const category = isSearch ? DisplayCategory.Search : DisplayCategory.All;
const delayPerIcon = 250 / (TotalIcons + totalFreqIcons);
const handleFilter = React.useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
onQueryChange(event.target.value);
},
[onQueryChange]
);
const handleIconSelection = React.useCallback(
({ id, value }: { id: string; value: string }) => {
onIconChange(value);
incrementIconCount(id);
},
[onIconChange, incrementIconCount]
);
const baseIcons: DataNode = {
category,
icons: filteredIcons.map((name, index) => ({
type: IconType.Outline,
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.Outline,
name,
color,
initial,
delay: Math.round((index + totalFreqIcons) * delayPerIcon),
onClick: handleIconSelection,
})),
},
baseIcons,
];
React.useEffect(() => {
if (scrollableRef.current) {
scrollableRef.current.scrollTop = 0;
}
searchRef.current?.focus();
}, [panelActive]);
return (
<Flex column>
<InputSearchContainer align="center">
<StyledInputSearch
ref={searchRef}
value={query}
placeholder={`${t("Search icons")}`}
onChange={handleFilter}
/>
</InputSearchContainer>
<ColorPicker
width={panelWidth}
activeColor={color}
onSelect={onColorChange}
/>
<GridTemplate
ref={scrollableRef}
width={panelWidth}
height={GRID_HEIGHT}
data={templateData}
onIconSelect={handleIconSelection}
/>
</Flex>
);
};
const InputSearchContainer = styled(Flex)`
height: 48px;
padding: 6px 12px 0px;
`;
const StyledInputSearch = styled(InputSearch)`
flex-grow: 1;
`;
export default IconPanel;
@@ -0,0 +1,20 @@
import styled, { css } from "styled-components";
import { s } from "@shared/styles";
import NudeButton from "~/components/NudeButton";
import { hover } from "~/styles";
export const PopoverButton = styled(NudeButton)<{ $borderOnHover?: boolean }>`
&: ${hover},
&:active,
&[aria-expanded= "true"] {
opacity: 1 !important;
${({ $borderOnHover }) =>
$borderOnHover &&
css`
background: ${s("buttonNeutralBackground")};
box-shadow: rgba(0, 0, 0, 0.07) 0px 1px 2px,
${s("buttonNeutralBorder")} 0 0 0 1px inset;
`};
}
`;
@@ -0,0 +1,92 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Menu, MenuButton, MenuItem, useMenuState } from "reakit";
import styled from "styled-components";
import { depths, s } from "@shared/styles";
import { EmojiSkinTone } from "@shared/types";
import { getEmojiVariants } from "@shared/utils/emoji";
import Flex from "~/components/Flex";
import NudeButton from "~/components/NudeButton";
import { hover } from "~/styles";
import { Emoji } from "./Emoji";
import { IconButton } from "./IconButton";
const SkinTonePicker = ({
skinTone,
onChange,
}: {
skinTone: EmojiSkinTone;
onChange: (skin: EmojiSkinTone) => void;
}) => {
const { t } = useTranslation();
const handEmojiVariants = React.useMemo(
() => getEmojiVariants({ id: "hand" }),
[]
);
const menu = useMenuState({
placement: "bottom",
});
const handleSkinClick = React.useCallback(
(emojiSkin) => {
menu.hide();
onChange(emojiSkin);
},
[menu, onChange]
);
const menuItems = React.useMemo(
() =>
Object.entries(handEmojiVariants).map(([eskin, emoji]) => (
<MenuItem {...menu} key={emoji.value}>
{(menuprops) => (
<IconButton {...menuprops} onClick={() => handleSkinClick(eskin)}>
<Emoji>{emoji.value}</Emoji>
</IconButton>
)}
</MenuItem>
)),
[menu, handEmojiVariants, handleSkinClick]
);
return (
<>
<MenuButton {...menu}>
{(props) => (
<StyledMenuButton
{...props}
aria-label={t("Choose default skin tone")}
>
{handEmojiVariants[skinTone]!.value}
</StyledMenuButton>
)}
</MenuButton>
<Menu {...menu} aria-label={t("Choose default skin tone")}>
{(props) => <MenuContainer {...props}>{menuItems}</MenuContainer>}
</Menu>
</>
);
};
const MenuContainer = styled(Flex)`
z-index: ${depths.menu};
padding: 4px;
border-radius: 4px;
background: ${s("menuBackground")};
box-shadow: ${s("menuShadow")};
`;
const StyledMenuButton = styled(NudeButton)`
width: 32px;
height: 32px;
border: 1px solid ${s("inputBorder")};
padding: 4px;
&: ${hover} {
border: 1px solid ${s("inputBorderFocused")};
}
`;
export default SkinTonePicker;
+315
View File
@@ -0,0 +1,315 @@
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 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 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.Outline ? 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.Outline) {
onChange(icon, c);
}
},
[icon, onChange]
);
const handleIconRemove = React.useCallback(() => {
popover.hide();
onChange(null, null);
}, [popover, onChange]);
const handleQueryChange = React.useCallback(
(q: string) => setQuery(q),
[setQuery]
);
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) {
onOpen?.();
} else {
onClose?.();
setQuery("");
resetDefaultTab();
}
}, [popover.visible, onOpen, onClose, setQuery, 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={handleQueryChange}
/>
</StyledTabPanel>
<StyledTabPanel {...tab}>
<EmojiPanel
panelWidth={panelWidth}
query={query}
panelActive={
popover.visible && tab.selectedId === TAB_NAMES["Emoji"]
}
onEmojiChange={handleIconChange}
onQueryChange={handleQueryChange}
/>
</StyledTabPanel>
</>
</Popover>
</>
);
};
const StyledSmileyIcon = styled(SmileyIcon)`
flex-shrink: 0;
@media print {
display: none;
}
`;
const RemoveButton = styled(NudeButton)`
width: auto;
font-weight: 500;
font-size: 14px;
color: ${s("textTertiary")};
padding: 8px 12px;
transition: color 100ms ease-in-out;
&: ${hover} {
color: ${s("textSecondary")};
}
`;
const TabActionsWrapper = styled(Flex)`
padding-left: 12px;
border-bottom: 1px solid ${s("inputBorder")};
`;
const StyledTab = styled(Tab)<{ active: boolean }>`
position: relative;
font-weight: 500;
font-size: 14px;
cursor: var(--pointer);
background: none;
border: 0;
padding: 8px 12px;
user-select: none;
color: ${({ active }) => (active ? s("textSecondary") : s("textTertiary"))};
transition: color 100ms ease-in-out;
&: ${hover} {
color: ${s("textSecondary")};
}
${({ active }) =>
active &&
css`
&:after {
content: "";
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 1px;
background: ${s("textSecondary")};
}
`}
`;
const StyledTabPanel = styled(TabPanel)`
height: 410px;
overflow-y: auto;
`;
export default IconPicker;
+50
View File
@@ -0,0 +1,50 @@
import i18next from "i18next";
export enum DisplayCategory {
All = "All",
Frequent = "Frequent",
Search = "Search",
}
export const TRANSLATED_CATEGORIES = {
All: i18next.t("All"),
Frequent: i18next.t("Frequently Used"),
Search: i18next.t("Search Results"),
People: i18next.t("Smileys & People"),
Nature: i18next.t("Animals & Nature"),
Foods: i18next.t("Food & Drink"),
Activity: i18next.t("Activity"),
Places: i18next.t("Travel & Places"),
Objects: i18next.t("Objects"),
Symbols: i18next.t("Symbols"),
Flags: i18next.t("Flags"),
};
export const FREQUENTLY_USED_COUNT = {
Get: 24,
Track: 30,
};
const STORAGE_KEYS = {
Base: "icon-state",
EmojiSkinTone: "emoji-skintone",
IconsFrequency: "icons-freq",
EmojisFrequency: "emojis-freq",
LastIcon: "last-icon",
LastEmoji: "last-emoji",
};
const getStorageKey = (key: string) => `${STORAGE_KEYS.Base}.${key}`;
export const emojiSkinToneKey = getStorageKey(STORAGE_KEYS.EmojiSkinTone);
export const iconsFreqKey = getStorageKey(STORAGE_KEYS.IconsFrequency);
export const emojisFreqKey = getStorageKey(STORAGE_KEYS.EmojisFrequency);
export const lastIconKey = getStorageKey(STORAGE_KEYS.LastIcon);
export const lastEmojiKey = getStorageKey(STORAGE_KEYS.LastEmoji);
export const sortFrequencies = (freqs: [string, number][]) =>
freqs.sort((a, b) => (a[1] >= b[1] ? -1 : 1));
+32 -25
View File
@@ -2,10 +2,10 @@ import { observer } from "mobx-react";
import { CollectionIcon } from "outline-icons";
import { getLuminance } from "polished";
import * as React from "react";
import { IconLibrary } from "@shared/utils/IconLibrary";
import { colorPalette } from "@shared/utils/collections";
import Collection from "~/models/Collection";
import Icon from "~/components/Icon";
import useStores from "~/hooks/useStores";
import Logger from "~/utils/Logger";
type Props = {
/** The collection to show an icon for */
@@ -16,6 +16,7 @@ type Props = {
size?: number;
/** The color of the icon, defaults to the collection color */
color?: string;
className?: string;
};
function ResolvedCollectionIcon({
@@ -23,35 +24,41 @@ function ResolvedCollectionIcon({
color: inputColor,
expanded,
size,
className,
}: Props) {
const { ui } = useStores();
// If the chosen icon color is very dark then we invert it in dark mode
// otherwise it will be impossible to see against the dark background.
const color =
inputColor ||
(ui.resolvedTheme === "dark" && collection.color !== "currentColor"
? getLuminance(collection.color) > 0.09
? collection.color
: "currentColor"
: collection.color);
if (!collection.icon || collection.icon === "collection") {
// If the chosen icon color is very dark then we invert it in dark mode
// otherwise it will be impossible to see against the dark background.
const collectionColor = collection.color ?? colorPalette[0];
const color =
inputColor ||
(ui.resolvedTheme === "dark" && collectionColor !== "currentColor"
? getLuminance(collectionColor) > 0.09
? collectionColor
: "currentColor"
: collectionColor);
if (collection.icon && collection.icon !== "collection") {
try {
const Component = IconLibrary.getComponent(collection.icon);
return (
<Component color={color} size={size}>
{collection.initial}
</Component>
);
} catch (error) {
Logger.warn("Failed to render custom icon", {
icon: collection.icon,
});
}
return (
<CollectionIcon
color={color}
expanded={expanded}
size={size}
className={className}
/>
);
}
return <CollectionIcon color={color} expanded={expanded} size={size} />;
return (
<Icon
value={collection.icon}
color={inputColor ?? collection.color ?? undefined}
size={size}
initial={collection.initial}
className={className}
/>
);
}
export default observer(ResolvedCollectionIcon);
+19 -8
View File
@@ -1,11 +1,13 @@
import * as React from "react";
import styled from "styled-components";
import { s } from "@shared/styles";
type Props = {
/** The emoji to render */
emoji: string;
/** The size of the emoji, 24px is default to match standard icons */
size?: number;
className?: string;
};
/**
@@ -15,19 +17,28 @@ type Props = {
export default function EmojiIcon({ size = 24, emoji, ...rest }: Props) {
return (
<Span $size={size} {...rest}>
{emoji}
<SVG size={size} emoji={emoji} />
</Span>
);
}
const Span = styled.span<{ $size: number }>`
display: inline-flex;
align-items: center;
justify-content: center;
text-align: center;
flex-shrink: 0;
font-family: ${s("fontFamilyEmoji")};
display: inline-block;
width: ${(props) => props.$size}px;
height: ${(props) => props.$size}px;
text-indent: -0.15em;
font-size: ${(props) => props.$size - 10}px;
`;
const SVG = ({ size, emoji }: { size: number; emoji: string }) => (
<svg width={size} height={size} xmlns="http://www.w3.org/2000/svg">
<text
x="50%"
y={"55%"}
dominantBaseline="middle"
textAnchor="middle"
fontSize={size * 0.7}
>
{emoji}
</text>
</svg>
);
+13 -2
View File
@@ -50,6 +50,11 @@ export type Props = {
note?: React.ReactNode;
onChange?: (value: string | null) => void;
style?: React.CSSProperties;
/**
* Set to true if this component is rendered inside a Modal.
* The Modal will take care of preventing body scroll behaviour.
*/
skipBodyScroll?: boolean;
};
export interface InputSelectRef {
@@ -79,6 +84,7 @@ const InputSelect = (props: Props, ref: React.RefObject<InputSelectRef>) => {
note,
icon,
nude,
skipBodyScroll,
...rest
} = props;
@@ -91,7 +97,7 @@ const InputSelect = (props: Props, ref: React.RefObject<InputSelectRef>) => {
const popover = useSelectPopover({
...select,
hideOnClickOutside: false,
preventBodyScroll: true,
preventBodyScroll: skipBodyScroll ? false : true,
disabled,
});
@@ -220,7 +226,12 @@ const InputSelect = (props: Props, ref: React.RefObject<InputSelectRef>) => {
</StyledButton>
)}
</Select>
<SelectPopover {...select} {...popover} aria-label={ariaLabel}>
<SelectPopover
{...select}
{...popover}
aria-label={ariaLabel}
preventBodyScroll={skipBodyScroll ? false : true}
>
{(popoverProps: InnerProps) => {
const topAnchor = popoverProps.style?.top === "0";
const rightAnchor = popoverProps.placement === "bottom-end";
+48 -5
View File
@@ -8,6 +8,7 @@ import styled, { useTheme } from "styled-components";
import { s, ellipsis } from "@shared/styles";
import Flex from "~/components/Flex";
import NavLink from "~/components/NavLink";
import { hover } from "~/styles";
export type Props = Omit<React.HTMLAttributes<HTMLAnchorElement>, "title"> & {
/** An icon or image to display to the left of the list item */
@@ -16,6 +17,8 @@ export type Props = Omit<React.HTMLAttributes<HTMLAnchorElement>, "title"> & {
to?: LocationDescriptor;
/** An optional click handler, if provided the list item will have hover styles */
onClick?: React.MouseEventHandler<HTMLAnchorElement>;
/** An optional keydown handler, if provided the list item will have hover styles */
onKeyDown?: React.KeyboardEventHandler<HTMLAnchorElement>;
/** Whether to match the location exactly */
exact?: boolean;
/** The title of the list item */
@@ -28,10 +31,22 @@ export type Props = Omit<React.HTMLAttributes<HTMLAnchorElement>, "title"> & {
border?: boolean;
/** Whether to display the list item in a compact style */
small?: boolean;
/** Whether to enable keyboard navigation */
keyboardNavigation?: boolean;
};
const ListItem = (
{ image, title, subtitle, actions, small, border, to, ...rest }: Props,
{
image,
title,
subtitle,
actions,
small,
border,
to,
keyboardNavigation,
...rest
}: Props,
ref?: React.Ref<HTMLAnchorElement>
) => {
const theme = useTheme();
@@ -45,7 +60,7 @@ const ListItem = (
const { focused, ...rovingTabIndex } = useRovingTabIndex(
itemRef as React.RefObject<HTMLAnchorElement>,
to ? false : true
keyboardNavigation || to ? false : true
);
useFocusEffect(focused, itemRef as React.RefObject<HTMLAnchorElement>);
@@ -89,6 +104,12 @@ const ListItem = (
}
rovingTabIndex.onClick(ev);
}}
onKeyDown={(ev) => {
if (rest.onKeyDown) {
rest.onKeyDown(ev);
}
rovingTabIndex.onKeyDown(ev);
}}
as={NavLink}
to={to}
>
@@ -98,7 +119,22 @@ const ListItem = (
}
return (
<Wrapper ref={itemRef} $border={border} $small={small} {...rest}>
<Wrapper
ref={itemRef}
$border={border}
$small={small}
$hover={!!rest.onClick}
{...rest}
{...rovingTabIndex}
onClick={(ev) => {
rest.onClick?.(ev);
rovingTabIndex.onClick(ev);
}}
onKeyDown={(ev) => {
rest.onKeyDown?.(ev);
rovingTabIndex.onKeyDown(ev);
}}
>
{content(false)}
</Wrapper>
);
@@ -107,6 +143,7 @@ const ListItem = (
const Wrapper = styled.a<{
$small?: boolean;
$border?: boolean;
$hover?: boolean;
onClick?: React.MouseEventHandler<HTMLAnchorElement>;
to?: LocationDescriptor;
}>`
@@ -123,9 +160,15 @@ const Wrapper = styled.a<{
border-bottom: 0;
}
&:hover {
&:focus-visible {
outline: none;
}
&:${hover},
&:focus,
&:focus-within {
background: ${(props) =>
props.onClick ? props.theme.secondaryBackground : "inherit"};
props.$hover ? props.theme.secondaryBackground : "inherit"};
}
cursor: ${(props) =>
+11 -3
View File
@@ -1,5 +1,5 @@
import isEqual from "lodash/isEqual";
import { observable, action } from "mobx";
import { observable, action, computed } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import { withTranslation, WithTranslation } from "react-i18next";
@@ -39,7 +39,9 @@ type Props<T> = WithTranslation &
};
@observer
class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
class PaginatedList<T extends PaginatedItem> extends React.PureComponent<
Props<T>
> {
@observable
error?: Error;
@@ -145,6 +147,11 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
}
};
@computed
get itemsToRender() {
return this.props.items?.slice(0, this.renderCount) ?? [];
}
render() {
const {
items = [],
@@ -188,10 +195,11 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
aria-label={this.props["aria-label"]}
onEscape={onEscape}
className={this.props.className}
items={this.itemsToRender}
>
{() => {
let previousHeading = "";
return items.slice(0, this.renderCount).map((item, index) => {
return this.itemsToRender.map((item, index) => {
const children = this.props.renderItem(item, index);
// If there is no renderHeading method passed then no date
+15 -10
View File
@@ -20,15 +20,18 @@ type Props = PopoverProps & {
hide: () => void;
};
const Popover: React.FC<Props> = ({
children,
shrink,
width = 380,
scrollable = true,
flex,
mobilePosition,
...rest
}: Props) => {
const Popover = (
{
children,
shrink,
width = 380,
scrollable = true,
flex,
mobilePosition,
...rest
}: Props,
ref: React.Ref<HTMLDivElement>
) => {
const isMobile = useMobile();
// Custom Escape handler rather than using hideOnEsc from reakit so we can
@@ -50,6 +53,7 @@ const Popover: React.FC<Props> = ({
return (
<Dialog {...rest} modal>
<Contents
ref={ref}
$shrink={shrink}
$scrollable={scrollable}
$flex={flex}
@@ -64,6 +68,7 @@ const Popover: React.FC<Props> = ({
return (
<StyledPopover {...rest} hideOnEsc={false} hideOnClickOutside>
<Contents
ref={ref}
$shrink={shrink}
$width={width}
$scrollable={scrollable}
@@ -123,4 +128,4 @@ const Contents = styled.div<ContentsProps>`
`};
`;
export default Popover;
export default React.forwardRef(Popover);
@@ -20,6 +20,7 @@ import useBoolean from "~/hooks/useBoolean";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useKeyDown from "~/hooks/useKeyDown";
import usePolicy from "~/hooks/usePolicy";
import usePrevious from "~/hooks/usePrevious";
import useStores from "~/hooks/useStores";
import { EmptySelectValue, Permission } from "~/types";
import { collectionPath, urlify } from "~/utils/routeHelpers";
@@ -56,6 +57,11 @@ function SharePopover({ collection, visible, onRequestClose }: Props) {
CollectionPermission.Read
);
const prevPendingIds = usePrevious(pendingIds);
const suggestionsRef = React.useRef<HTMLDivElement | null>(null);
const searchInputRef = React.useRef<HTMLInputElement | null>(null);
useKeyDown(
"Escape",
(ev) => {
@@ -97,6 +103,19 @@ function SharePopover({ collection, visible, onRequestClose }: Props) {
}
}, [visible]);
React.useEffect(() => {
if (prevPendingIds && pendingIds.length > prevPendingIds.length) {
setQuery("");
searchInputRef.current?.focus();
} else if (prevPendingIds && pendingIds.length < prevPendingIds.length) {
const firstPending = suggestionsRef.current?.firstElementChild;
if (firstPending) {
(firstPending as HTMLAnchorElement).focus();
}
}
}, [pendingIds, prevPendingIds]);
const handleQuery = React.useCallback(
(event) => {
showPicker();
@@ -119,6 +138,39 @@ function SharePopover({ collection, visible, onRequestClose }: Props) {
[setPendingIds]
);
const handleKeyDown = React.useCallback(
(ev: React.KeyboardEvent<HTMLInputElement>) => {
if (ev.nativeEvent.isComposing) {
return;
}
if (ev.key === "ArrowDown" && !ev.shiftKey) {
ev.preventDefault();
if (ev.currentTarget.value) {
const length = ev.currentTarget.value.length;
const selectionStart = ev.currentTarget.selectionStart || 0;
if (selectionStart < length) {
ev.currentTarget.selectionStart = length;
ev.currentTarget.selectionEnd = length;
return;
}
}
const firstSuggestion = suggestionsRef.current?.firstElementChild;
if (firstSuggestion) {
(firstSuggestion as HTMLAnchorElement).focus();
}
}
},
[]
);
const handleEscape = React.useCallback(
() => searchInputRef.current?.focus(),
[]
);
const inviteAction = React.useMemo(
() =>
createAction({
@@ -292,8 +344,10 @@ function SharePopover({ collection, visible, onRequestClose }: Props) {
<Wrapper>
{can.update && (
<SearchInput
ref={searchInputRef}
onChange={handleQuery}
onClick={showPicker}
onKeyDown={handleKeyDown}
query={query}
back={backButton}
action={rightButton}
@@ -303,11 +357,13 @@ function SharePopover({ collection, visible, onRequestClose }: Props) {
{picker && (
<div>
<Suggestions
ref={suggestionsRef}
query={query}
collection={collection}
pendingIds={pendingIds}
addPendingId={handleAddPendingId}
removePendingId={handleRemovePendingId}
onEscape={handleEscape}
/>
</div>
)}
+21 -10
View File
@@ -4,7 +4,8 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { useTheme } from "styled-components";
import Squircle from "@shared/components/Squircle";
import { CollectionPermission } from "@shared/types";
import { CollectionPermission, IconType } from "@shared/types";
import { determineIconType } from "@shared/utils/icon";
import type Collection from "~/models/Collection";
import type Document from "~/models/Document";
import Flex from "~/components/Flex";
@@ -54,15 +55,7 @@ export const OtherAccess = observer(({ document, children }: Props) => {
/>
) : usersInCollection ? (
<ListItem
image={
<Squircle color={collection.color} size={AvatarSize.Medium}>
<CollectionIcon
collection={collection}
color={theme.white}
size={16}
/>
</Squircle>
}
image={<CollectionSquircle collection={collection} />}
title={collection.name}
subtitle={t("Everyone in the collection")}
actions={<AccessTooltip>{t("Can view")}</AccessTooltip>}
@@ -136,6 +129,24 @@ const AccessTooltip = ({
);
};
const CollectionSquircle = ({ collection }: { collection: Collection }) => {
const theme = useTheme();
const iconType = determineIconType(collection.icon)!;
const squircleColor =
iconType === IconType.Outline ? collection.color! : theme.slateLight;
const iconSize = iconType === IconType.Outline ? 16 : 22;
return (
<Squircle color={squircleColor} size={AvatarSize.Medium}>
<CollectionIcon
collection={collection}
color={theme.white}
size={iconSize}
/>
</Squircle>
);
};
function useUsersInCollection(collection?: Collection) {
const { users, memberships } = useStores();
const { request } = useRequest(() =>
@@ -18,6 +18,7 @@ import useBoolean from "~/hooks/useBoolean";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useKeyDown from "~/hooks/useKeyDown";
import usePolicy from "~/hooks/usePolicy";
import usePrevious from "~/hooks/usePrevious";
import useStores from "~/hooks/useStores";
import { Permission } from "~/types";
import { documentPath, urlify } from "~/utils/routeHelpers";
@@ -64,6 +65,11 @@ function SharePopover({
DocumentPermission.Read
);
const prevPendingIds = usePrevious(pendingIds);
const suggestionsRef = React.useRef<HTMLDivElement | null>(null);
const searchInputRef = React.useRef<HTMLInputElement | null>(null);
useKeyDown(
"Escape",
(ev) => {
@@ -107,6 +113,19 @@ function SharePopover({
}
}, [picker]);
React.useEffect(() => {
if (prevPendingIds && pendingIds.length > prevPendingIds.length) {
setQuery("");
searchInputRef.current?.focus();
} else if (prevPendingIds && pendingIds.length < prevPendingIds.length) {
const firstPending = suggestionsRef.current?.firstElementChild;
if (firstPending) {
(firstPending as HTMLAnchorElement).focus();
}
}
}, [pendingIds, prevPendingIds]);
const inviteAction = React.useMemo(
() =>
createAction({
@@ -202,6 +221,39 @@ function SharePopover({
[setPendingIds]
);
const handleKeyDown = React.useCallback(
(ev: React.KeyboardEvent<HTMLInputElement>) => {
if (ev.nativeEvent.isComposing) {
return;
}
if (ev.key === "ArrowDown" && !ev.shiftKey) {
ev.preventDefault();
if (ev.currentTarget.value) {
const length = ev.currentTarget.value.length;
const selectionStart = ev.currentTarget.selectionStart || 0;
if (selectionStart < length) {
ev.currentTarget.selectionStart = length;
ev.currentTarget.selectionEnd = length;
return;
}
}
const firstSuggestion = suggestionsRef.current?.firstElementChild;
if (firstSuggestion) {
(firstSuggestion as HTMLAnchorElement).focus();
}
}
},
[]
);
const handleEscape = React.useCallback(
() => searchInputRef.current?.focus(),
[]
);
const permissions = React.useMemo(
() =>
[
@@ -266,8 +318,10 @@ function SharePopover({
<Wrapper>
{can.manageUsers && (
<SearchInput
ref={searchInputRef}
onChange={handleQuery}
onClick={showPicker}
onKeyDown={handleKeyDown}
query={query}
back={backButton}
action={rightButton}
@@ -275,15 +329,15 @@ function SharePopover({
)}
{picker && (
<div>
<Suggestions
document={document}
query={query}
pendingIds={pendingIds}
addPendingId={handleAddPendingId}
removePendingId={handleRemovePendingId}
/>
</div>
<Suggestions
ref={suggestionsRef}
document={document}
query={query}
pendingIds={pendingIds}
addPendingId={handleAddPendingId}
removePendingId={handleRemovePendingId}
onEscape={handleEscape}
/>
)}
<div style={{ display: picker ? "none" : "block" }}>
@@ -15,7 +15,9 @@ export const ListItem = styled(BaseListItem).attrs({
padding: 6px 16px;
border-radius: 8px;
&: ${hover} ${InviteIcon} {
&: ${hover} ${InviteIcon},
&:focus ${InviteIcon},
&:focus-within ${InviteIcon} {
opacity: 1;
}
`;
@@ -1,6 +1,7 @@
import { AnimatePresence } from "framer-motion";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { mergeRefs } from "react-merge-refs";
import Flex from "~/components/Flex";
import useMobile from "~/hooks/useMobile";
import Input, { NativeInput } from "../../Input";
@@ -10,13 +11,18 @@ type Props = {
query: string;
onChange: React.ChangeEventHandler;
onClick: React.MouseEventHandler;
onKeyDown: React.KeyboardEventHandler;
back: React.ReactNode;
action: React.ReactNode;
};
export function SearchInput({ onChange, onClick, query, back, action }: Props) {
export const SearchInput = React.forwardRef(function _SearchInput(
{ onChange, onClick, onKeyDown, query, back, action }: Props,
ref: React.Ref<HTMLInputElement>
) {
const { t } = useTranslation();
const inputRef = React.useRef<HTMLInputElement>(null);
const isMobile = useMobile();
const focusInput = React.useCallback(
@@ -39,6 +45,7 @@ export function SearchInput({ onChange, onClick, query, back, action }: Props) {
value={query}
onChange={onChange}
onClick={onClick}
onKeyDown={onKeyDown}
autoFocus
margin={0}
flex
@@ -52,15 +59,16 @@ export function SearchInput({ onChange, onClick, query, back, action }: Props) {
{back}
<NativeInput
key="input"
ref={inputRef}
ref={mergeRefs([inputRef, ref])}
placeholder={`${t("Add or invite")}`}
value={query}
onChange={onChange}
onClick={onClick}
onKeyDown={onKeyDown}
style={{ padding: "6px 0" }}
/>
{action}
</AnimatePresence>
</HeaderInput>
);
}
});
@@ -1,4 +1,5 @@
import { isEmail } from "class-validator";
import concat from "lodash/concat";
import { observer } from "mobx-react";
import { CheckmarkIcon, CloseIcon, GroupIcon } from "outline-icons";
import * as React from "react";
@@ -11,6 +12,7 @@ import Collection from "~/models/Collection";
import Document from "~/models/Document";
import Group from "~/models/Group";
import User from "~/models/User";
import ArrowKeyNavigation from "~/components/ArrowKeyNavigation";
import Avatar from "~/components/Avatar";
import { AvatarSize, IAvatar } from "~/components/Avatar/Avatar";
import Empty from "~/components/Empty";
@@ -40,18 +42,24 @@ type Props = {
removePendingId: (id: string) => void;
/** Show group suggestions. */
showGroups?: boolean;
/** Handles escape from suggestions list */
onEscape?: (ev: React.KeyboardEvent<HTMLDivElement>) => void;
};
export const Suggestions = observer(
({
document,
collection,
query,
pendingIds,
addPendingId,
removePendingId,
showGroups,
}: Props) => {
React.forwardRef(function _Suggestions(
{
document,
collection,
query,
pendingIds,
addPendingId,
removePendingId,
showGroups,
onEscape,
}: Props,
ref: React.Ref<HTMLDivElement>
) {
const neverRenderedList = React.useRef(false);
const { users, groups } = useStores();
const { t } = useTranslation();
@@ -174,34 +182,57 @@ export const Suggestions = observer(
neverRenderedList.current = false;
return (
<>
{pending.map((suggestion) => (
<PendingListItem
{...getListItemProps(suggestion)}
key={suggestion.id}
onClick={() => removePendingId(suggestion.id)}
actions={
<>
<InvitedIcon />
<RemoveIcon />
</>
}
/>
))}
{pending.length > 0 &&
(suggestionsWithPending.length > 0 || isEmpty) && <Separator />}
{suggestionsWithPending.map((suggestion) => (
<ListItem
{...getListItemProps(suggestion as User)}
key={suggestion.id}
onClick={() => addPendingId(suggestion.id)}
actions={<InviteIcon />}
/>
))}
{isEmpty && <Empty style={{ marginTop: 22 }}>{t("No matches")}</Empty>}
</>
<ArrowKeyNavigation
ref={ref}
onEscape={onEscape}
aria-label={t("Suggestions for invitation")}
items={concat(pending, suggestionsWithPending)}
>
{() => [
...pending.map((suggestion) => (
<PendingListItem
keyboardNavigation
{...getListItemProps(suggestion)}
key={suggestion.id}
onClick={() => removePendingId(suggestion.id)}
onKeyDown={(ev) => {
if (ev.key === "Enter") {
ev.preventDefault();
ev.stopPropagation();
removePendingId(suggestion.id);
}
}}
actions={
<>
<InvitedIcon />
<RemoveIcon />
</>
}
/>
)),
pending.length > 0 &&
(suggestionsWithPending.length > 0 || isEmpty) && <Separator />,
...suggestionsWithPending.map((suggestion) => (
<ListItem
keyboardNavigation
{...getListItemProps(suggestion as User)}
key={suggestion.id}
onClick={() => addPendingId(suggestion.id)}
onKeyDown={(ev) => {
if (ev.key === "Enter") {
ev.preventDefault();
ev.stopPropagation();
addPendingId(suggestion.id);
}
}}
actions={<InviteIcon />}
/>
)),
isEmpty && <Empty style={{ marginTop: 22 }}>{t("No matches")}</Empty>,
]}
</ArrowKeyNavigation>
);
}
})
);
const InvitedIcon = styled(CheckmarkIcon)`
@@ -14,13 +14,14 @@ import { DocumentValidation } from "@shared/validations";
import Collection from "~/models/Collection";
import Document from "~/models/Document";
import Fade from "~/components/Fade";
import Icon from "~/components/Icon";
import NudeButton from "~/components/NudeButton";
import Tooltip from "~/components/Tooltip";
import useBoolean from "~/hooks/useBoolean";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import DocumentMenu from "~/menus/DocumentMenu";
import { newDocumentPath } from "~/utils/routeHelpers";
import { newNestedDocumentPath } from "~/utils/routeHelpers";
import DropCursor from "./DropCursor";
import DropToImport from "./DropToImport";
import EditableTitle, { RefHandle } from "./EditableTitle";
@@ -282,6 +283,8 @@ function InnerDocumentLink(
const title =
(activeDocument?.id === node.id ? activeDocument.title : node.title) ||
t("Untitled");
const icon = document?.icon || node.icon;
const color = document?.color || node.color;
const isExpanded = expanded && !isDragging;
const hasChildren = nodeChildren.length > 0;
@@ -324,7 +327,7 @@ function InnerDocumentLink(
starred: inStarredSection,
},
}}
emoji={document?.emoji || node.emoji}
icon={icon && <Icon value={icon} color={color} />}
label={
<EditableTitle
title={title}
@@ -359,9 +362,7 @@ function InnerDocumentLink(
type={undefined}
aria-label={t("New nested document")}
as={Link}
to={newDocumentPath(document.collectionId, {
parentDocumentId: document.id,
})}
to={newNestedDocumentPath(document.id)}
>
<PlusIcon />
</NudeButton>
@@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next";
import { NavigationNode } from "@shared/types";
import Collection from "~/models/Collection";
import Document from "~/models/Document";
import Icon from "~/components/Icon";
import useStores from "~/hooks/useStores";
import { sharedDocumentPath } from "~/utils/routeHelpers";
import { descendants } from "~/utils/tree";
@@ -111,7 +112,7 @@ function DocumentLink(
}}
expanded={hasChildDocuments && depth !== 0 ? expanded : undefined}
onDisclosureClick={handleDisclosureClick}
emoji={node.emoji}
icon={node.icon && <Icon value={node.icon} color={node.color} />}
label={title}
depth={depth}
exact={false}
@@ -2,7 +2,8 @@ import fractionalIndex from "fractional-index";
import { observer } from "mobx-react";
import * as React from "react";
import styled from "styled-components";
import { NotificationEventType } from "@shared/types";
import { IconType, NotificationEventType } from "@shared/types";
import { determineIconType } from "@shared/utils/icon";
import UserMembership from "~/models/UserMembership";
import Fade from "~/components/Fade";
import useBoolean from "~/hooks/useBoolean";
@@ -78,10 +79,11 @@ function SharedWithMeLink({ userMembership }: Props) {
return null;
}
const { emoji } = document;
const label = emoji
? document.title.replace(emoji, "")
: document.titleWithDefault;
const { icon: docIcon } = document;
const label =
determineIconType(docIcon) === IconType.Emoji
? document.title.replace(docIcon!, "")
: document.titleWithDefault;
const collection = document.collectionId
? collections.get(document.collectionId)
: undefined;
@@ -5,7 +5,6 @@ import breakpoint from "styled-components-breakpoint";
import EventBoundary from "@shared/components/EventBoundary";
import { s } from "@shared/styles";
import { NavigationNode } from "@shared/types";
import EmojiIcon from "~/components/Icons/EmojiIcon";
import NudeButton from "~/components/NudeButton";
import { UnreadBadge } from "~/components/UnreadBadge";
import useUnmount from "~/hooks/useUnmount";
@@ -27,7 +26,6 @@ type Props = Omit<NavLinkProps, "to"> & {
onClickIntent?: () => void;
onDisclosureClick?: React.MouseEventHandler<HTMLButtonElement>;
icon?: React.ReactNode;
emoji?: string | null;
label?: React.ReactNode;
menu?: React.ReactNode;
unreadBadge?: boolean;
@@ -52,7 +50,6 @@ function SidebarLink(
onClick,
onClickIntent,
to,
emoji,
label,
active,
isActiveDrop,
@@ -142,7 +139,6 @@ function SidebarLink(
/>
)}
{icon && <IconWrapper>{icon}</IconWrapper>}
{emoji && <EmojiIcon emoji={emoji} />}
<Label>{label}</Label>
{unreadBadge && <UnreadBadge />}
</Content>
@@ -1,7 +1,7 @@
import { DocumentIcon } from "outline-icons";
import * as React from "react";
import Icon from "~/components/Icon";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import EmojiIcon from "~/components/Icons/EmojiIcon";
import useStores from "~/hooks/useStores";
interface SidebarItem {
@@ -21,7 +21,11 @@ export function useSidebarLabelAndIcon(
if (document) {
return {
label: document.titleWithDefault,
icon: document.emoji ? <EmojiIcon emoji={document.emoji} /> : icon,
icon: document.icon ? (
<Icon value={document.icon} color={document.color ?? undefined} />
) : (
icon
),
};
}
}
+20 -41
View File
@@ -1,11 +1,7 @@
import data, { type Emoji as TEmoji } from "@emoji-mart/data";
import { init, Data } from "emoji-mart";
import FuzzySearch from "fuzzy-search";
import capitalize from "lodash/capitalize";
import sortBy from "lodash/sortBy";
import React from "react";
import { emojiMartToGemoji, snakeCase } from "@shared/editor/lib/emoji";
import { isMac } from "@shared/utils/browser";
import { search as emojiSearch } from "@shared/utils/emoji";
import EmojiMenuItem from "./EmojiMenuItem";
import SuggestionsMenu, {
Props as SuggestionsMenuProps,
@@ -19,13 +15,6 @@ type Emoji = {
attrs: { markup: string; "data-name": string };
};
init({
data,
noCountryFlags: isMac() ? false : undefined,
});
let searcher: FuzzySearch<TEmoji>;
type Props = Omit<
SuggestionsMenuProps<Emoji>,
"renderMenuItem" | "items" | "embeds" | "trigger"
@@ -34,36 +23,26 @@ type Props = Omit<
const EmojiMenu = (props: Props) => {
const { search = "" } = props;
if (!searcher) {
searcher = new FuzzySearch(Object.values(Data.emojis), ["search"], {
caseSensitive: false,
sort: true,
});
}
const items = React.useMemo(
() =>
emojiSearch({ query: search })
.map((item) => {
// We snake_case the shortcode for backwards compatability with gemoji to
// avoid multiple formats being written into documents.
const shortcode = snakeCase(emojiMartToGemoji[item.id] || item.id);
const emoji = item.value;
const items = React.useMemo(() => {
const n = search.toLowerCase();
return sortBy(searcher.search(n), (item) => {
const nlc = item.name.toLowerCase();
return nlc === n ? -1 : nlc.startsWith(n) ? 0 : 1;
})
.map((item) => {
// We snake_case the shortcode for backwards compatability with gemoji to
// avoid multiple formats being written into documents.
const shortcode = snakeCase(emojiMartToGemoji[item.id] || item.id);
const emoji = item.skins[0].native;
return {
name: "emoji",
title: emoji,
description: capitalize(item.name.toLowerCase()),
emoji,
attrs: { markup: shortcode, "data-name": shortcode },
};
})
.slice(0, 15);
}, [search]);
return {
name: "emoji",
title: emoji,
description: capitalize(item.name.toLowerCase()),
emoji,
attrs: { markup: shortcode, "data-name": shortcode },
};
})
.slice(0, 15),
[search]
);
return (
<SuggestionsMenu
+8 -3
View File
@@ -7,6 +7,8 @@ import isMarkdown from "@shared/editor/lib/isMarkdown";
import normalizePastedMarkdown from "@shared/editor/lib/markdown/normalize";
import { isInCode } from "@shared/editor/queries/isInCode";
import { isInList } from "@shared/editor/queries/isInList";
import { IconType } from "@shared/types";
import { determineIconType } from "@shared/utils/icon";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import { isDocumentUrl, isUrl } from "@shared/utils/urls";
import stores from "~/stores";
@@ -179,9 +181,12 @@ export default class PasteHandler extends Extension {
if (document) {
const { hash } = new URL(text);
const title = `${
document.emoji ? document.emoji + " " : ""
}${document.titleWithDefault}`;
const hasEmoji =
determineIconType(document.icon) === IconType.Emoji;
const title = `${hasEmoji ? document.icon + " " : ""}${
document.titleWithDefault
}`;
insertLink(`${document.path}${hash}`, title);
}
})
+5 -3
View File
@@ -188,9 +188,11 @@ const useSettingsConfig = () => {
// Plugins
PluginManager.getHooks(Hook.Settings).forEach((plugin) => {
const insertIndex = items.findIndex(
(i) => i.group === t(plugin.value.group ?? "Integrations")
);
const insertIndex = plugin.value.after
? items.findIndex((i) => i.name === t(plugin.value.after!)) + 1
: items.findIndex(
(i) => i.group === t(plugin.value.group ?? "Integrations")
);
items.splice(insertIndex, 0, {
name: t(plugin.name),
path: integrationSettingsPath(plugin.id),
+3 -3
View File
@@ -1,6 +1,6 @@
import { NewDocumentIcon, ShapesIcon } from "outline-icons";
import * as React from "react";
import EmojiIcon from "~/components/Icons/EmojiIcon";
import Icon from "~/components/Icon";
import { createAction } from "~/actions";
import { DocumentSection } from "~/actions/sections";
import history from "~/utils/history";
@@ -21,8 +21,8 @@ const useTemplatesActions = () => {
name: item.titleWithDefault,
analyticsName: "New document",
section: DocumentSection,
icon: item.emoji ? (
<EmojiIcon emoji={item.emoji} />
icon: item.icon ? (
<Icon value={item.icon} color={item.color ?? undefined} />
) : (
<NewDocumentIcon />
),
+50 -47
View File
@@ -5,8 +5,10 @@ import { useMenuState, MenuButton, MenuButtonHTMLProps } from "reakit/Menu";
import Document from "~/models/Document";
import ContextMenu from "~/components/ContextMenu";
import Template from "~/components/ContextMenu/Template";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { newDocumentPath } from "~/utils/routeHelpers";
import { MenuItem } from "~/types";
import { newDocumentPath, newNestedDocumentPath } from "~/utils/routeHelpers";
type Props = {
label?: (props: MenuButtonHTMLProps) => React.ReactNode;
@@ -17,58 +19,59 @@ function NewChildDocumentMenu({ document, label }: Props) {
const menu = useMenuState({
modal: true,
});
const { collections } = useStores();
const { t } = useTranslation();
const collection = document.collectionId
? collections.get(document.collectionId)
: undefined;
const collectionName = collection ? collection.name : t("collection");
const canCollection = usePolicy(document.collectionId);
const { collections } = useStores();
const items: MenuItem[] = [];
if (canCollection.createDocument) {
const collection = document.collectionId
? collections.get(document.collectionId)
: undefined;
const collectionName = collection ? collection.name : t("collection");
items.push({
type: "route",
title: (
<span>
<Trans
defaults="New document in <em>{{ collectionName }}</em>"
values={{
collectionName,
}}
components={{
em: <strong />,
}}
/>
</span>
),
to: newDocumentPath(document.collectionId),
});
}
items.push({
type: "route",
title: (
<span>
<Trans
defaults="New document in <em>{{ collectionName }}</em>"
values={{
collectionName: document.title,
}}
components={{
em: <strong />,
}}
/>
</span>
),
to: newNestedDocumentPath(document.id),
});
return (
<>
<MenuButton {...menu}>{label}</MenuButton>
<ContextMenu {...menu} aria-label={t("New child document")}>
<Template
{...menu}
items={[
{
type: "route",
title: (
<span>
<Trans
defaults="New document in <em>{{ collectionName }}</em>"
values={{
collectionName,
}}
components={{
em: <strong />,
}}
/>
</span>
),
to: newDocumentPath(document.collectionId),
},
{
type: "route",
title: (
<span>
<Trans
defaults="New document in <em>{{ collectionName }}</em>"
values={{
collectionName: document.title,
}}
components={{
em: <strong />,
}}
/>
</span>
),
to: newDocumentPath(document.collectionId, {
parentDocumentId: document.id,
}),
},
]}
/>
<Template {...menu} items={items} />
</ContextMenu>
</>
);
+6 -2
View File
@@ -8,7 +8,7 @@ import Button from "~/components/Button";
import ContextMenu from "~/components/ContextMenu";
import MenuItem from "~/components/ContextMenu/MenuItem";
import Separator from "~/components/ContextMenu/Separator";
import EmojiIcon from "~/components/Icons/EmojiIcon";
import Icon from "~/components/Icon";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import { replaceTitleVariables } from "~/utils/date";
@@ -43,7 +43,11 @@ function TemplatesMenu({ onSelectTemplate, document }: Props) {
key={template.id}
onClick={() => onSelectTemplate(template)}
icon={
template.emoji ? <EmojiIcon emoji={template.emoji} /> : <DocumentIcon />
template.icon ? (
<Icon value={template.icon} color={template.color ?? undefined} />
) : (
<DocumentIcon />
)
}
{...menu}
>
+26 -1
View File
@@ -1,4 +1,5 @@
import { observable } from "mobx";
import { isPast } from "date-fns";
import { computed, observable } from "mobx";
import Model from "./base/Model";
import Field from "./decorators/Field";
@@ -9,11 +10,35 @@ class ApiKey extends Model {
@observable
id: string;
/**
* The user chosen name of the API key.
*/
@Field
@observable
name: string;
/**
* An optional datetime that the API key expires.
*/
@Field
@observable
expiresAt?: string;
/**
* An optional datetime that the API key was last used at.
*/
@observable
lastActiveAt?: string;
secret: string;
/**
* Whether the API key has an expiry in the past.
*/
@computed
get isExpired() {
return this.expiresAt ? isPast(new Date(this.expiresAt)) : false;
}
}
export default ApiKey;
+3 -3
View File
@@ -40,18 +40,18 @@ export default class Collection extends ParanoidModel {
data: ProsemirrorData;
/**
* An emoji to use as the collection icon.
* An icon (or) emoji to use as the collection icon.
*/
@Field
@observable
icon: string;
/**
* A color to use for the collection icon and other highlights.
* The color to use for the collection icon and other highlights.
*/
@Field
@observable
color: string;
color?: string | null;
/**
* The default permission for workspace users.
+9 -2
View File
@@ -129,11 +129,18 @@ export default class Document extends ParanoidModel {
title: string;
/**
* An emoji to use as the document icon.
* An icon (or) emoji to use as the document icon.
*/
@Field
@observable
emoji: string | undefined | null;
icon?: string | null;
/**
* The color to use for the document icon.
*/
@Field
@observable
color?: string | null;
/**
* Whether this is a template.
+5 -2
View File
@@ -22,8 +22,11 @@ class Revision extends Model {
/** Prosemirror data of the content when revision was created */
data: ProsemirrorData;
/** The emoji of the document when the revision was created */
emoji: string | null;
/** The icon (or) emoji of the document when the revision was created */
icon: string | null;
/** The color of the document icon when the revision was created */
color: string | null;
/** HTML string representing the revision as a diff from the previous version */
html: string;
-74
View File
@@ -1,74 +0,0 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { ApiKeyValidation } from "@shared/validations";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import Input from "~/components/Input";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
type Props = {
onSubmit: () => void;
};
function APIKeyNew({ onSubmit }: Props) {
const [name, setName] = React.useState("");
const [isSaving, setIsSaving] = React.useState(false);
const { apiKeys } = useStores();
const { t } = useTranslation();
const handleSubmit = React.useCallback(
async (ev: React.SyntheticEvent) => {
ev.preventDefault();
setIsSaving(true);
try {
await apiKeys.create({
name,
});
toast.success(t("API Key created"));
onSubmit();
} catch (err) {
toast.error(err.message);
} finally {
setIsSaving(false);
}
},
[t, name, onSubmit, apiKeys]
);
const handleNameChange = React.useCallback((event) => {
setName(event.target.value);
}, []);
return (
<form onSubmit={handleSubmit}>
<Text as="p" type="secondary">
{t(
`Name your key something that will help you to remember it's use in the future, for example "local development" or "continuous integration".`
)}
</Text>
<Flex>
<Input
type="text"
label={t("Name")}
onChange={handleNameChange}
value={name}
minLength={ApiKeyValidation.minNameLength}
maxLength={ApiKeyValidation.maxNameLength}
required
autoFocus
flex
/>
</Flex>
<Flex justify="flex-end">
<Button type="submit" disabled={isSaving || !name}>
{isSaving ? `${t("Creating")}` : t("Create")}
</Button>
</Flex>
</form>
);
}
export default APIKeyNew;
@@ -0,0 +1,100 @@
import { format as formatDate } from "date-fns";
import { CalendarIcon } from "outline-icons";
import React from "react";
import { DayPicker } from "react-day-picker";
import { useTranslation } from "react-i18next";
import { usePopoverState, PopoverDisclosure } from "reakit";
import styled, { useTheme } from "styled-components";
import { dateLocale } from "@shared/utils/date";
import Button from "~/components/Button";
import Popover from "~/components/Popover";
import useUserLocale from "~/hooks/useUserLocale";
type Props = {
selectedDate?: Date;
onSelect: (date: Date) => void;
};
const ExpiryDatePicker = ({ selectedDate, onSelect }: Props) => {
const { t } = useTranslation();
const theme = useTheme();
const userLocale = useUserLocale();
const locale = dateLocale(userLocale);
const popover = usePopoverState({ gutter: 0, placement: "right" });
const popoverContentRef = React.useRef<HTMLDivElement>(null);
const styles = React.useMemo(
() =>
({
"--rdp-caption-font-size": "16px",
"--rdp-cell-size": "34px",
"--rdp-selected-text": theme.accentText,
"--rdp-accent-color": theme.accent,
"--rdp-accent-color-dark": theme.accent,
"--rdp-background-color": theme.listItemHoverBackground,
"--rdp-background-color-dark": theme.listItemHoverBackground,
} as React.CSSProperties),
[theme]
);
const handleSelect = React.useCallback(
(date: Date) => {
popover.hide();
onSelect(date);
},
[popover, onSelect]
);
return (
<>
<PopoverDisclosure {...popover}>
{(props) => (
<StyledPopoverButton {...props} icon={<Icon />} neutral>
{selectedDate
? formatDate(selectedDate, "MMM dd, yyyy", { locale })
: t("Choose a date")}
</StyledPopoverButton>
)}
</PopoverDisclosure>
<Popover
{...popover}
ref={popoverContentRef}
width={280}
shrink
aria-label={t("Choose a date")}
>
<DayPicker
required
mode="single"
selected={selectedDate}
onSelect={handleSelect}
style={styles}
disabled={{ before: new Date() }}
/>
</Popover>
</>
);
};
const Icon = () => (
<IconWrapper>
<CalendarIcon />
</IconWrapper>
);
const StyledPopoverButton = styled(Button)`
margin-top: 12px;
width: 150px;
`;
const IconWrapper = styled.span`
display: flex;
justify-content: center;
align-items: center;
width: 24px;
height: 24px;
`;
export default ExpiryDatePicker;
+145
View File
@@ -0,0 +1,145 @@
import { endOfDay } from "date-fns";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import styled from "styled-components";
import { ApiKeyValidation } from "@shared/validations";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import Input from "~/components/Input";
import InputSelect, { Option } from "~/components/InputSelect";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
import useUserLocale from "~/hooks/useUserLocale";
import { dateToExpiry } from "~/utils/date";
import "react-day-picker/dist/style.css";
import ExpiryDatePicker from "./components/ExpiryDatePicker";
import { ExpiryType, ExpiryValues, calculateExpiryDate } from "./utils";
type Props = {
onSubmit: () => void;
};
function ApiKeyNew({ onSubmit }: Props) {
const [name, setName] = React.useState("");
const [expiryType, setExpiryType] = React.useState<ExpiryType>(
ExpiryType.Week
);
const currentDate = React.useRef<Date>(new Date());
const [expiresAt, setExpiresAt] = React.useState<Date | undefined>(() =>
calculateExpiryDate(currentDate.current, expiryType)
);
const [isSaving, setIsSaving] = React.useState(false);
const { apiKeys } = useStores();
const { t } = useTranslation();
const userLocale = useUserLocale();
const submitDisabled =
isSaving || !name || (!expiresAt && expiryType !== ExpiryType.NoExpiration);
const expiryOptions = React.useMemo<Option[]>(
() =>
[...ExpiryValues.entries()].map(([expType, { label }]) => ({
label,
value: expType,
})),
[]
);
const handleNameChange = React.useCallback((event) => {
setName(event.target.value);
}, []);
const handleExpiryTypeChange = React.useCallback((value: string) => {
const expiry = value as ExpiryType;
setExpiryType(expiry);
setExpiresAt(calculateExpiryDate(currentDate.current, expiry));
}, []);
const handleSelectCustomDate = React.useCallback((date: Date) => {
setExpiresAt(endOfDay(date));
}, []);
const handleSubmit = React.useCallback(
async (ev: React.SyntheticEvent) => {
ev.preventDefault();
setIsSaving(true);
try {
await apiKeys.create({
name,
expiresAt: expiresAt?.toISOString(),
});
toast.success(t("API key created"));
onSubmit();
} catch (err) {
toast.error(err.message);
} finally {
setIsSaving(false);
}
},
[t, name, expiresAt, onSubmit, apiKeys]
);
return (
<form onSubmit={handleSubmit}>
<Text as="p" type="secondary">
{t(
`Name your key something that will help you to remember it's use in the future, for example "local development" or "continuous integration".`
)}
</Text>
<Flex column>
<Input
type="text"
label={t("Name")}
onChange={handleNameChange}
value={name}
minLength={ApiKeyValidation.minNameLength}
maxLength={ApiKeyValidation.maxNameLength}
required
autoFocus
flex
/>
<Flex align="center" gap={16}>
<StyledExpirySelect
ariaLabel={t("Expiration")}
label={t("Expiration")}
value={expiryType}
options={expiryOptions}
onChange={handleExpiryTypeChange}
skipBodyScroll={true}
/>
{expiryType === ExpiryType.Custom ? (
<ExpiryDatePicker
selectedDate={expiresAt}
onSelect={handleSelectCustomDate}
/>
) : (
<StyledExpiryText type="secondary" size="small">
{expiresAt
? `${dateToExpiry(expiresAt.toString(), t, userLocale)}.`
: `${t("Never expires")}.`}
</StyledExpiryText>
)}
</Flex>
</Flex>
<Flex justify="flex-end">
<Button type="submit" disabled={submitDisabled}>
{isSaving ? `${t("Creating")}` : t("Create")}
</Button>
</Flex>
</form>
);
}
const StyledExpirySelect = styled(InputSelect)`
width: 150px;
`;
const StyledExpiryText = styled(Text)`
position: relative;
top: 4px;
`;
export default ApiKeyNew;
+37
View File
@@ -0,0 +1,37 @@
import { addDays, endOfDay } from "date-fns";
import i18next from "i18next";
export enum ExpiryType {
Week = "7 days",
Month = "30 days",
TwoMonths = "60 days",
ThreeMonths = "90 days",
Custom = "Custom",
NoExpiration = "No expiration",
}
type ExpiryValue = {
label: string;
value?: number;
};
export const ExpiryValues: Map<ExpiryType, ExpiryValue> = new Map([
[ExpiryType.Week, { label: i18next.t("7 days"), value: 7 }],
[ExpiryType.Month, { label: i18next.t("30 days"), value: 30 }],
[ExpiryType.TwoMonths, { label: i18next.t("60 days"), value: 60 }],
[ExpiryType.ThreeMonths, { label: i18next.t("90 days"), value: 90 }],
[ExpiryType.Custom, { label: i18next.t("Custom") }],
[ExpiryType.NoExpiration, { label: i18next.t("No expiration") }],
]);
export const calculateExpiryDate = (
currentDate: Date,
expiryType: ExpiryType
): Date | undefined => {
const daysToAdd = ExpiryValues.get(expiryType)?.value;
if (!daysToAdd) {
return;
}
const expiryDate = addDays(currentDate, daysToAdd);
return endOfDay(expiryDate);
};
+32 -3
View File
@@ -13,6 +13,7 @@ import {
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { s } from "@shared/styles";
import { colorPalette } from "@shared/utils/collections";
import Collection from "~/models/Collection";
import Search from "~/scenes/Search";
import { Action } from "~/components/Actions";
@@ -43,6 +44,8 @@ import Empty from "./components/Empty";
import MembershipPreview from "./components/MembershipPreview";
import ShareButton from "./components/ShareButton";
const IconPicker = React.lazy(() => import("~/components/IconPicker"));
function CollectionScene() {
const params = useParams<{ id?: string }>();
const history = useHistory();
@@ -60,6 +63,13 @@ function CollectionScene() {
collections.getByUrl(id) || collections.get(id);
const can = usePolicy(collection);
const handleIconChange = React.useCallback(
async (icon: string | null, color: string | null) => {
await collection?.save({ icon, color });
},
[collection]
);
React.useEffect(() => {
setLastVisitedPath(currentPath);
}, [currentPath, setLastVisitedPath]);
@@ -119,6 +129,10 @@ function CollectionScene() {
return <Search notFound />;
}
const fallbackIcon = collection ? (
<Icon as={CollectionIcon} collection={collection} size={40} expanded />
) : null;
return collection ? (
<Scene
// Forced mount prevents animation of pinned documents when navigating
@@ -164,7 +178,21 @@ function CollectionScene() {
) : (
<>
<HeadingWithIcon>
<HeadingIcon collection={collection} size={40} expanded />
{can.update ? (
<React.Suspense fallback={fallbackIcon}>
<Icon
icon={collection.icon ?? "collection"}
color={collection.color ?? colorPalette[0]}
initial={collection.name[0]}
size={40}
popoverPosition="bottom-start"
onChange={handleIconChange}
borderOnHover
/>
</React.Suspense>
) : (
fallbackIcon
)}
{collection.name}
{collection.isPrivate &&
!FeatureFlags.isEnabled(Feature.newCollectionSharing) && (
@@ -305,9 +333,10 @@ const HeadingWithIcon = styled(Heading)`
`};
`;
const HeadingIcon = styled(CollectionIcon)`
align-self: flex-start;
const Icon = styled(IconPicker)`
flex-shrink: 0;
margin-left: -8px;
margin-right: 8px;
`;
export default observer(CollectionScene);
+3 -1
View File
@@ -7,7 +7,7 @@ import { RouteComponentProps, useLocation } from "react-router-dom";
import styled, { ThemeProvider } from "styled-components";
import { setCookie } from "tiny-cookie";
import { s } from "@shared/styles";
import { NavigationNode, PublicTeam } from "@shared/types";
import { NavigationNode, PublicTeam, TOCPosition } from "@shared/types";
import type { Theme } from "~/stores/UiStore";
import DocumentModel from "~/models/Document";
import Error404 from "~/scenes/Error404";
@@ -102,6 +102,7 @@ function SharedDocumentScene(props: Props) {
? (searchParams.get("theme") as Theme)
: undefined;
const theme = useBuildTheme(response?.team?.customTheme, themeOverride);
const tocPosition = response?.team?.tocPosition ?? TOCPosition.Left;
React.useEffect(() => {
if (!user) {
@@ -188,6 +189,7 @@ function SharedDocumentScene(props: Props) {
document={response.document}
sharedTree={response.sharedTree}
shareId={shareId}
tocPosition={tocPosition}
readOnly
/>
</Layout>
@@ -24,6 +24,7 @@ import useOnClickOutside from "~/hooks/useOnClickOutside";
import useStores from "~/hooks/useStores";
import CommentEditor from "./CommentEditor";
import { Bubble } from "./CommentThreadItem";
import { HighlightedText } from "./HighlightText";
type Props = {
/** Callback when the draft should be saved. */
@@ -42,6 +43,8 @@ type Props = {
standalone?: boolean;
/** Whether to animate the comment form in and out */
animatePresence?: boolean;
/** Text to highlight at the top of the comment */
highlightedText?: string;
/** The text direction of the editor */
dir?: "rtl" | "ltr";
/** Callback when the user is typing in the editor */
@@ -64,6 +67,7 @@ function CommentForm({
standalone,
placeholder,
animatePresence,
highlightedText,
dir,
...rest
}: Props) {
@@ -274,6 +278,9 @@ function CommentForm({
$firstOfThread={standalone}
column
>
{highlightedText && (
<HighlightedText>{highlightedText}</HighlightedText>
)}
<CommentEditor
key={`${forceRender}`}
ref={editorRef}
@@ -210,6 +210,9 @@ function CommentThread({
standalone={commentsInThread.length === 0}
dir={document.dir}
autoFocus={autoFocus}
highlightedText={
commentsInThread.length === 0 ? highlightedText : undefined
}
/>
</Fade>
)}
@@ -19,8 +19,9 @@ import Text from "~/components/Text";
import Time from "~/components/Time";
import useBoolean from "~/hooks/useBoolean";
import CommentMenu from "~/menus/CommentMenu";
import { hover, truncateMultiline } from "~/styles";
import { hover } from "~/styles";
import CommentEditor from "./CommentEditor";
import { HighlightedText } from "./HighlightText";
/**
* Hook to calculate if we should display a timestamp on a comment
@@ -127,12 +128,12 @@ function CommentThreadItem({
const handleCancel = () => {
setData(toJS(comment.data));
setReadOnly();
setForceRender((s) => ++s);
setForceRender((i) => ++i);
};
React.useEffect(() => {
setData(toJS(comment.data));
setForceRender((s) => ++s);
setForceRender((i) => ++i);
}, [comment.data]);
return (
@@ -240,28 +241,6 @@ const Body = styled.form`
border-radius: 2px;
`;
const HighlightedText = styled(Text)`
position: relative;
color: ${s("textSecondary")};
font-size: 14px;
padding: 0 8px;
margin: 4px 0;
display: inline-block;
${truncateMultiline(3)}
&:after {
content: "";
width: 2px;
position: absolute;
left: 0;
top: 2px;
bottom: 2px;
background: ${s("commentMarkBackground")};
border-radius: 2px;
}
`;
const Menu = styled(CommentMenu)<{ dir?: "rtl" | "ltr" }>`
position: absolute;
left: ${(props) => (props.dir !== "rtl" ? "auto" : "4px")};
@@ -42,7 +42,6 @@ function Comments() {
.threadsInDocument(document.id)
.filter((thread) => !thread.isNew || thread.createdById === user.id);
const hasComments = threads.length > 0;
const hasMultipleComments = comments.inDocument(document.id).length > 1;
return (
<Sidebar
@@ -52,7 +51,6 @@ function Comments() {
>
<Scrollable
id="comments"
overflow={hasMultipleComments ? undefined : "initial"}
bottomShadow={!focusedComment}
hiddenScrollbars
topShadow
@@ -159,7 +159,7 @@ function DataLoader({ match, children }: Props) {
}
const newDocument = await documents.create({
collectionId: document.collectionId,
collectionId: nested ? undefined : document.collectionId,
parentDocumentId: nested ? document.id : document.parentDocumentId,
title,
data: ProsemirrorHelper.getEmptyDocument(),
+46 -22
View File
@@ -19,9 +19,15 @@ import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper";
import { s } from "@shared/styles";
import { NavigationNode, TOCPosition, TeamPreference } from "@shared/types";
import {
IconType,
NavigationNode,
TOCPosition,
TeamPreference,
} from "@shared/types";
import { ProsemirrorHelper, Heading } from "@shared/utils/ProsemirrorHelper";
import { parseDomain } from "@shared/utils/domains";
import { determineIconType } from "@shared/utils/icon";
import RootStore from "~/stores/RootStore";
import Document from "~/models/Document";
import Revision from "~/models/Revision";
@@ -83,6 +89,7 @@ type Props = WithTranslation &
revision?: Revision;
readOnly: boolean;
shareId?: string;
tocPosition?: TOCPosition;
onCreateLink?: (title: string, nested?: boolean) => Promise<string>;
onSearchLink?: (term: string) => Promise<SearchResult[]>;
};
@@ -168,8 +175,11 @@ class DocumentScene extends React.Component<Props> {
this.title = title;
this.props.document.title = title;
}
if (template.emoji) {
this.props.document.emoji = template.emoji;
if (template.icon) {
this.props.document.icon = template.icon;
}
if (template.color) {
this.props.document.color = template.color;
}
this.props.document.data = cloneDeep(template.data);
@@ -382,8 +392,9 @@ class DocumentScene extends React.Component<Props> {
void this.autosave();
});
handleChangeEmoji = action((value: string) => {
this.props.document.emoji = value;
handleChangeIcon = action((icon: string | null, color: string | null) => {
this.props.document.icon = icon;
this.props.document.color = color;
void this.onSave();
});
@@ -394,8 +405,17 @@ class DocumentScene extends React.Component<Props> {
};
render() {
const { document, revision, readOnly, abilities, auth, ui, shareId, t } =
this.props;
const {
document,
revision,
readOnly,
abilities,
auth,
ui,
shareId,
tocPosition,
t,
} = this.props;
const { team, user } = auth;
const isShare = !!shareId;
const embedsDisabled =
@@ -404,9 +424,10 @@ class DocumentScene extends React.Component<Props> {
const hasHeadings = this.headings.length > 0;
const showContents =
ui.tocVisible && ((readOnly && hasHeadings) || !readOnly);
const tocPosition =
(team?.getPreference(TeamPreference.TocPosition) as TOCPosition) ||
TOCPosition.Left;
const tocPos =
tocPosition ??
((team?.getPreference(TeamPreference.TocPosition) as TOCPosition) ||
TOCPosition.Left);
const multiplayerEditor =
!document.isArchived && !document.isDeleted && !revision && !isShare;
@@ -414,6 +435,12 @@ class DocumentScene extends React.Component<Props> {
? this.props.match.url
: updateDocumentPath(this.props.match.url, document);
const hasEmojiInTitle = determineIconType(document.icon) === IconType.Emoji;
const title = hasEmojiInTitle
? document.titleWithDefault.replace(document.icon!, "")
: document.titleWithDefault;
const favicon = hasEmojiInTitle ? emojiToUrl(document.icon!) : undefined;
return (
<ErrorBoundary showTitle>
{this.props.location.pathname !== canonicalUrl && (
@@ -448,10 +475,7 @@ class DocumentScene extends React.Component<Props> {
column
auto
>
<PageTitle
title={document.titleWithDefault.replace(document.emoji || "", "")}
favicon={document.emoji ? emojiToUrl(document.emoji) : undefined}
/>
<PageTitle title={title} favicon={favicon} />
{(this.isUploading || this.isSaving) && <LoadingIndicator />}
<Container column>
{!readOnly && (
@@ -487,7 +511,7 @@ class DocumentScene extends React.Component<Props> {
as={Main}
name="document"
fullWidth={document.fullWidth}
tocPosition={tocPosition}
tocPosition={tocPos}
>
<React.Suspense fallback={<PlaceholderDocument />}>
{revision ? (
@@ -503,7 +527,7 @@ class DocumentScene extends React.Component<Props> {
{showContents && (
<ContentsContainer
docFullWidth={document.fullWidth}
position={tocPosition}
position={tocPos}
>
<Contents headings={this.headings} />
</ContentsContainer>
@@ -511,7 +535,7 @@ class DocumentScene extends React.Component<Props> {
<EditorContainer
docFullWidth={document.fullWidth}
showContents={showContents}
tocPosition={tocPosition}
tocPosition={tocPos}
>
<Editor
id={document.id}
@@ -531,7 +555,7 @@ class DocumentScene extends React.Component<Props> {
onSearchLink={this.props.onSearchLink}
onCreateLink={this.props.onCreateLink}
onChangeTitle={this.handleChangeTitle}
onChangeEmoji={this.handleChangeEmoji}
onChangeIcon={this.handleChangeIcon}
onChange={this.handleChange}
onHeadingsChange={this.onHeadingsChange}
onSave={this.onSave}
@@ -616,9 +640,9 @@ type ContentsContainerProps = {
};
const ContentsContainer = styled.div<ContentsContainerProps>`
margin-top: calc(44px + 6vh);
${breakpoint("tablet")`
margin-top: calc(44px + 6vh);
grid-row: 1;
grid-column: ${({ docFullWidth, position }: ContentsContainerProps) =>
position === TOCPosition.Left ? 1 : docFullWidth ? 2 : 3};
@@ -635,7 +659,7 @@ type EditorContainerProps = {
const EditorContainer = styled.div<EditorContainerProps>`
// Adds space to the gutter to make room for icon & heading annotations
padding: 0 44px;
padding: 0 40px;
${breakpoint("tablet")`
grid-row: 1;
@@ -662,7 +686,7 @@ type RevisionContainerProps = {
const RevisionContainer = styled.div<RevisionContainerProps>`
// Adds space to the gutter to make room for icon
padding: 0 44px;
padding: 0 40px;
${breakpoint("tablet")`
grid-row: 1;
@@ -8,7 +8,7 @@ import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import isMarkdown from "@shared/editor/lib/isMarkdown";
import normalizePastedMarkdown from "@shared/editor/lib/markdown/normalize";
import { extraArea, s } from "@shared/styles";
import { s } from "@shared/styles";
import { light } from "@shared/styles/theme";
import {
getCurrentDateAsString,
@@ -18,29 +18,32 @@ import {
import { DocumentValidation } from "@shared/validations";
import ContentEditable, { RefHandle } from "~/components/ContentEditable";
import { useDocumentContext } from "~/components/DocumentContext";
import { Emoji, EmojiButton } from "~/components/EmojiPicker/components";
import Flex from "~/components/Flex";
import Icon from "~/components/Icon";
import { PopoverButton } from "~/components/IconPicker/components/PopoverButton";
import useBoolean from "~/hooks/useBoolean";
import usePolicy from "~/hooks/usePolicy";
import { isModKey } from "~/utils/keyboard";
const EmojiPicker = React.lazy(() => import("~/components/EmojiPicker"));
const IconPicker = React.lazy(() => import("~/components/IconPicker"));
type Props = {
/** ID of the associated document */
documentId: string;
/** Title to display */
title: string;
/** Emoji to display */
emoji?: string | null;
/** Icon to display */
icon?: string | null;
/** Icon color */
color: string;
/** Placeholder to display when the document has no title */
placeholder?: string;
/** Should the title be editable, policies will also be considered separately */
readOnly?: boolean;
/** Callback called on any edits to text */
onChangeTitle?: (text: string) => void;
/** Callback called when the user selects an emoji */
onChangeEmoji?: (emoji: string | null) => void;
/** Callback called when the user selects an icon */
onChangeIcon?: (icon: string | null, color: string | null) => void;
/** Callback called when the user expects to move to the "next" input */
onGoToNextInput?: (insertParagraph?: boolean) => void;
/** Callback called when the user expects to save (CMD+S) */
@@ -56,10 +59,11 @@ const DocumentTitle = React.forwardRef(function _DocumentTitle(
{
documentId,
title,
emoji,
icon,
color,
readOnly,
onChangeTitle,
onChangeEmoji,
onChangeIcon,
onSave,
onGoToNextInput,
onBlur,
@@ -68,7 +72,7 @@ const DocumentTitle = React.forwardRef(function _DocumentTitle(
externalRef: React.RefObject<RefHandle>
) {
const ref = React.useRef<RefHandle>(null);
const [emojiPickerIsOpen, handleOpen, handleClose] = useBoolean();
const [iconPickerIsOpen, handleOpen, handleClose] = useBoolean();
const { editor } = useDocumentContext();
const can = usePolicy(documentId);
@@ -134,21 +138,21 @@ const DocumentTitle = React.forwardRef(function _DocumentTitle(
);
const handleChange = React.useCallback(
(value: string) => {
let title = value;
(input: string) => {
let value = input;
if (/\/date\s$/.test(value)) {
title = getCurrentDateAsString();
if (/\/date\s$/.test(input)) {
value = getCurrentDateAsString();
ref?.current?.focusAtEnd();
} else if (/\/time$/.test(value)) {
title = getCurrentTimeAsString();
} else if (/\/time$/.test(input)) {
value = getCurrentTimeAsString();
ref?.current?.focusAtEnd();
} else if (/\/datetime$/.test(value)) {
title = getCurrentDateTimeAsString();
} else if (/\/datetime$/.test(input)) {
value = getCurrentDateTimeAsString();
ref?.current?.focusAtEnd();
}
onChangeTitle?.(title);
onChangeTitle?.(value);
},
[ref, onChangeTitle]
);
@@ -212,19 +216,26 @@ const DocumentTitle = React.forwardRef(function _DocumentTitle(
[editor]
);
const handleEmojiChange = React.useCallback(
async (value: string | null) => {
// Restore focus on title
restoreFocus();
if (emoji !== value) {
onChangeEmoji?.(value);
const handleIconChange = React.useCallback(
(chosenIcon: string | null, iconColor: string | null) => {
if (icon !== chosenIcon || color !== iconColor) {
onChangeIcon?.(chosenIcon, iconColor);
}
},
[emoji, onChangeEmoji, restoreFocus]
[icon, color, onChangeIcon]
);
React.useEffect(() => {
if (!iconPickerIsOpen) {
restoreFocus();
}
}, [iconPickerIsOpen, restoreFocus]);
const dir = ref.current?.getComputedDirection();
const emojiIcon = <Emoji size={32}>{emoji}</Emoji>;
const fallbackIcon = icon ? (
<Icon value={icon} color={color} size={40} />
) : null;
return (
<Title
@@ -235,8 +246,8 @@ const DocumentTitle = React.forwardRef(function _DocumentTitle(
onBlur={handleBlur}
placeholder={placeholder}
value={title}
$emojiPickerIsOpen={emojiPickerIsOpen}
$containsEmoji={!!emoji}
$iconPickerIsOpen={iconPickerIsOpen}
$containsIcon={!!icon}
autoFocus={!title}
maxLength={DocumentValidation.maxTitleLength}
readOnly={readOnly}
@@ -244,47 +255,33 @@ const DocumentTitle = React.forwardRef(function _DocumentTitle(
ref={mergeRefs([ref, externalRef])}
>
{can.update && !readOnly ? (
<EmojiWrapper align="center" justify="center" dir={dir}>
<React.Suspense fallback={emojiIcon}>
<StyledEmojiPicker
value={emoji}
onChange={handleEmojiChange}
<IconWrapper align="center" justify="center" dir={dir}>
<React.Suspense fallback={fallbackIcon}>
<IconPicker
icon={icon ?? null}
color={color}
size={40}
popoverPosition="bottom-start"
onChange={handleIconChange}
onOpen={handleOpen}
onClose={handleClose}
onClickOutside={restoreFocus}
autoFocus
allowDelete
borderOnHover
/>
</React.Suspense>
</EmojiWrapper>
) : emoji ? (
<EmojiWrapper align="center" justify="center" dir={dir}>
{emojiIcon}
</EmojiWrapper>
</IconWrapper>
) : icon ? (
<IconWrapper align="center" justify="center" dir={dir}>
{fallbackIcon}
</IconWrapper>
) : null}
</Title>
);
});
const StyledEmojiPicker = styled(EmojiPicker)`
${extraArea(8)}
`;
const EmojiWrapper = styled(Flex)<{ dir?: string }>`
position: absolute;
top: 8px;
height: 32px;
width: 32px;
// Always move above TOC
z-index: 1;
${(props: { dir?: string }) =>
props.dir === "rtl" ? "right: -40px" : "left: -40px"};
`;
type TitleProps = {
$containsEmoji: boolean;
$emojiPickerIsOpen: boolean;
$containsIcon: boolean;
$iconPickerIsOpen: boolean;
};
const Title = styled(ContentEditable)<TitleProps>`
@@ -293,7 +290,7 @@ const Title = styled(ContentEditable)<TitleProps>`
margin-top: 6vh;
margin-bottom: 0.5em;
margin-left: ${(props) =>
props.$containsEmoji || props.$emojiPickerIsOpen ? "40px" : "0px"};
props.$containsIcon || props.$iconPickerIsOpen ? "40px" : "0px"};
font-size: ${fontSize};
font-weight: 600;
border: 0;
@@ -314,14 +311,14 @@ const Title = styled(ContentEditable)<TitleProps>`
&:focus {
margin-left: 40px;
${EmojiButton} {
${PopoverButton} {
opacity: 1 !important;
}
}
${EmojiButton} {
${PopoverButton} {
opacity: ${(props: TitleProps) =>
props.$containsEmoji ? "1 !important" : 0};
props.$containsIcon ? "1 !important" : 0};
}
${breakpoint("tablet")`
@@ -333,7 +330,7 @@ const Title = styled(ContentEditable)<TitleProps>`
}
&:hover {
${EmojiButton} {
${PopoverButton} {
opacity: 0.5;
&:hover {
@@ -349,4 +346,17 @@ const Title = styled(ContentEditable)<TitleProps>`
}
`;
const IconWrapper = styled(Flex)<{ dir?: string }>`
position: absolute;
top: 3px;
height: 40px;
width: 40px;
// Always move above TOC
z-index: 1;
${(props: { dir?: string }) =>
props.dir === "rtl" ? "right: -48px" : "left: -48px"};
`;
export default observer(DocumentTitle);
+11 -4
View File
@@ -4,7 +4,9 @@ import { useTranslation } from "react-i18next";
import { mergeRefs } from "react-merge-refs";
import { useHistory, useRouteMatch } from "react-router-dom";
import { richExtensions, withComments } from "@shared/editor/nodes";
import { randomElement } from "@shared/random";
import { TeamPreference } from "@shared/types";
import { colorPalette } from "@shared/utils/collections";
import Comment from "~/models/Comment";
import Document from "~/models/Document";
import { RefHandle } from "~/components/ContentEditable";
@@ -52,7 +54,7 @@ const extensions = [
type Props = Omit<EditorProps, "editorStyle"> & {
onChangeTitle: (title: string) => void;
onChangeEmoji: (emoji: string | null) => void;
onChangeIcon: (icon: string | null, color: string | null) => void;
id: string;
document: Document;
isDraft: boolean;
@@ -81,7 +83,7 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
const {
document,
onChangeTitle,
onChangeEmoji,
onChangeIcon,
isDraft,
shareId,
readOnly,
@@ -91,6 +93,10 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
} = props;
const can = usePolicy(document);
const iconColor = React.useMemo(
() => document.color ?? randomElement(colorPalette),
[document.color]
);
const childRef = React.useRef<HTMLDivElement>(null);
const focusAtStart = React.useCallback(() => {
if (ref.current) {
@@ -186,9 +192,10 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
? document.titleWithDefault
: document.title
}
emoji={document.emoji}
icon={document.icon}
color={iconColor}
onChangeTitle={onChangeTitle}
onChangeEmoji={onChangeEmoji}
onChangeIcon={onChangeIcon}
onGoToNextInput={handleGoToNextInput}
onBlur={handleBlur}
placeholder={t("Untitled")}
+15 -9
View File
@@ -24,8 +24,9 @@ import {
useDocumentContext,
useEditingFocus,
} from "~/components/DocumentContext";
import Flex from "~/components/Flex";
import Header from "~/components/Header";
import EmojiIcon from "~/components/Icons/EmojiIcon";
import Icon from "~/components/Icon";
import Star from "~/components/Star";
import Tooltip from "~/components/Tooltip";
import { publishDocument } from "~/actions/definitions/documents";
@@ -189,7 +190,14 @@ function DocumentHeader({
return (
<StyledHeader
$hidden={isEditingFocus}
title={document.title}
title={
<Flex gap={4}>
{document.icon && (
<Icon value={document.icon} color={document.color ?? undefined} />
)}
{document.title}
</Flex>
}
hasSidebar={sharedTree && sharedTree.children?.length > 0}
left={
isMobile ? (
@@ -229,17 +237,15 @@ function DocumentHeader({
)
}
title={
<>
{document.emoji && (
<>
<EmojiIcon size={24} emoji={document.emoji} />{" "}
</>
<Flex gap={4}>
{document.icon && (
<Icon value={document.icon} color={document.color ?? undefined} />
)}
{document.title}{" "}
{document.title}
{document.isArchived && (
<ArchivedBadge>{t("Archived")}</ArchivedBadge>
)}
</>
</Flex>
}
actions={
<>
@@ -0,0 +1,29 @@
import styled from "styled-components";
import { s } from "@shared/styles";
import Text from "~/components/Text";
import { truncateMultiline } from "~/styles";
/**
* Highlighted text associated with a comment.
*/
export const HighlightedText = styled(Text)`
position: relative;
color: ${s("textSecondary")};
font-size: 14px;
padding: 0 8px;
margin: 4px 0;
display: inline-block;
${truncateMultiline(3)}
&:after {
content: "";
width: 2px;
position: absolute;
left: 0;
top: 2px;
bottom: 2px;
background: ${s("commentMarkBackground")};
border-radius: 2px;
}
`;
@@ -1,7 +1,7 @@
import * as React from "react";
import { NavigationNode } from "@shared/types";
import Breadcrumb from "~/components/Breadcrumb";
import EmojiIcon from "~/components/Icons/EmojiIcon";
import Icon from "~/components/Icon";
import { MenuInternalLink } from "~/types";
import { sharedDocumentPath } from "~/utils/routeHelpers";
@@ -53,13 +53,10 @@ const PublicBreadcrumb: React.FC<Props> = ({
.slice(0, -1)
.map((item) => ({
...item,
title: item.emoji ? (
<>
<EmojiIcon emoji={item.emoji} /> {item.title}
</>
) : (
item.title
),
icon: item.icon ? (
<Icon value={item.icon} color={item.color} />
) : undefined,
title: item.title,
type: "route",
to: sharedDocumentPath(shareId, item.url),
})),
@@ -4,10 +4,11 @@ import * as React from "react";
import { Link } from "react-router-dom";
import styled from "styled-components";
import { s, ellipsis } from "@shared/styles";
import { NavigationNode } from "@shared/types";
import { IconType, NavigationNode } from "@shared/types";
import { determineIconType } from "@shared/utils/icon";
import Document from "~/models/Document";
import Flex from "~/components/Flex";
import EmojiIcon from "~/components/Icons/EmojiIcon";
import Icon from "~/components/Icon";
import { hover } from "~/styles";
import { sharedDocumentPath } from "~/utils/routeHelpers";
@@ -58,7 +59,8 @@ function ReferenceListItem({
shareId,
...rest
}: Props) {
const { emoji } = document;
const { icon, color } = document;
const isEmoji = determineIconType(icon) === IconType.Emoji;
return (
<DocumentLink
@@ -74,9 +76,13 @@ function ReferenceListItem({
{...rest}
>
<Content gap={4} dir="auto">
{emoji ? <EmojiIcon emoji={emoji} /> : <DocumentIcon />}
{icon ? (
<Icon value={icon} color={color ?? undefined} />
) : (
<DocumentIcon />
)}
<Title>
{emoji ? document.title.replace(emoji, "") : document.title}
{isEmoji ? document.title.replace(icon!, "") : document.title}
</Title>
</Content>
</DocumentLink>
@@ -1,6 +1,7 @@
import { observer } from "mobx-react";
import * as React from "react";
import EditorContainer from "@shared/editor/components/Styles";
import { colorPalette } from "@shared/utils/collections";
import Document from "~/models/Document";
import Revision from "~/models/Revision";
import { Props as EditorProps } from "~/components/Editor";
@@ -30,7 +31,8 @@ function RevisionViewer(props: Props) {
<DocumentTitle
documentId={revision.documentId}
title={revision.title}
emoji={revision.emoji}
icon={revision.icon}
color={revision.color ?? colorPalette[0]}
readOnly
/>
<DocumentMeta
+1
View File
@@ -270,6 +270,7 @@ function Search(props: Props) {
ref={resultListRef}
onEscape={handleEscape}
aria-label={t("Search Results")}
items={data ?? []}
>
{() =>
data?.length
@@ -34,6 +34,7 @@ function RecentSearches(
ref={ref}
onEscape={onEscape}
aria-label={t("Recent searches")}
items={searches.recent}
>
{() =>
searches.recent.map((searchQuery) => (
+27 -4
View File
@@ -2,6 +2,7 @@ import { observer } from "mobx-react";
import { CodeIcon } from "outline-icons";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { toast } from "sonner";
import ApiKey from "~/models/ApiKey";
import { Action } from "~/components/Actions";
import Button from "~/components/Button";
@@ -23,9 +24,26 @@ function ApiKeys() {
const can = usePolicy(team);
const context = useActionContext();
const [copiedKeyId, setCopiedKeyId] = React.useState<string | null>();
const copyTimeoutIdRef = React.useRef<ReturnType<typeof setTimeout>>();
const handleCopy = React.useCallback(
(keyId: string) => {
if (copyTimeoutIdRef.current) {
clearTimeout(copyTimeoutIdRef.current);
}
setCopiedKeyId(keyId);
copyTimeoutIdRef.current = setTimeout(() => {
setCopiedKeyId(null);
}, 3000);
toast.message(t("API key copied to clipboard"));
},
[t]
);
return (
<Scene
title={t("API Keys")}
title={t("API")}
icon={<CodeIcon />}
actions={
<>
@@ -42,7 +60,7 @@ function ApiKeys() {
</>
}
>
<Heading>{t("API Keys")}</Heading>
<Heading>{t("API")}</Heading>
<Text as="p" type="secondary">
<Trans
defaults="Create personal API keys to authenticate with the API and programatically control
@@ -62,9 +80,14 @@ function ApiKeys() {
<PaginatedList
fetch={apiKeys.fetchPage}
items={apiKeys.orderedData}
heading={<h2>{t("Active")}</h2>}
heading={<h2>{t("Personal keys")}</h2>}
renderItem={(apiKey: ApiKey) => (
<ApiKeyListItem key={apiKey.id} apiKey={apiKey} />
<ApiKeyListItem
key={apiKey.id}
apiKey={apiKey}
isCopied={apiKey.id === copiedKeyId}
onCopy={handleCopy}
/>
)}
/>
</Scene>
+7 -4
View File
@@ -25,6 +25,7 @@ import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import isCloudHosted from "~/utils/isCloudHosted";
import TeamDelete from "../TeamDelete";
import { ActionRow } from "./components/ActionRow";
import ImageInput from "./components/ImageInput";
import SettingRow from "./components/SettingRow";
@@ -103,7 +104,7 @@ function Details() {
[]
);
const handleAvatarChange = async (avatarUrl: string) => {
const handleAvatarChange = async (avatarUrl: string | null) => {
await team.save({ avatarUrl });
toast.success(t("Logo updated"));
};
@@ -303,9 +304,11 @@ function Details() {
/>
</SettingRow>
<Button type="submit" disabled={team.isSaving || !isValid}>
{team.isSaving ? `${t("Saving")}` : t("Save")}
</Button>
<ActionRow>
<Button type="submit" disabled={team.isSaving || !isValid}>
{team.isSaving ? `${t("Saving")}` : t("Save")}
</Button>
</ActionRow>
{can.delete && (
<>
+4
View File
@@ -23,6 +23,10 @@ function Templates() {
const { fetchTemplates, templates, templatesAlphabetical } = documents;
const sort = param.get("sort") || "recent";
React.useEffect(() => {
void documents.fetchDrafts();
}, [documents]);
return (
<Scene
icon={<ShapesIcon />}
@@ -0,0 +1,21 @@
import { transparentize } from "polished";
import styled from "styled-components";
import { s } from "@shared/styles";
/**
* A sticky container for action buttons such as "Save" on settings screens.
*/
export const ActionRow = styled.div`
position: sticky;
bottom: 0;
padding: 16px 50vw;
margin: 0 -50vw;
background: ${s("background")};
transition: ${s("backgroundTransition")};
@supports (backdrop-filter: blur(20px)) {
backdrop-filter: blur(20px);
background: ${(props) => transparentize(0.2, props.theme.background)};
}
`;
@@ -1,45 +1,60 @@
import { CopyIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import ApiKey from "~/models/ApiKey";
import Button from "~/components/Button";
import CopyToClipboard from "~/components/CopyToClipboard";
import Flex from "~/components/Flex";
import ListItem from "~/components/List/Item";
import Text from "~/components/Text";
import Time from "~/components/Time";
import useUserLocale from "~/hooks/useUserLocale";
import ApiKeyMenu from "~/menus/ApiKeyMenu";
import { dateToExpiry } from "~/utils/date";
type Props = {
apiKey: ApiKey;
isCopied: boolean;
onCopy: (keyId: string) => void;
};
const ApiKeyListItem = ({ apiKey }: Props) => {
const ApiKeyListItem = ({ apiKey, isCopied, onCopy }: Props) => {
const { t } = useTranslation();
const [linkCopied, setLinkCopied] = React.useState<boolean>(false);
const userLocale = useUserLocale();
React.useEffect(() => {
if (linkCopied) {
setTimeout(() => {
setLinkCopied(false);
}, 3000);
}
}, [linkCopied]);
const subtitle = (
<>
<Text type="tertiary">
{t(`Created`)} <Time dateTime={apiKey.createdAt} addSuffix /> &middot;{" "}
</Text>
{apiKey.lastActiveAt && (
<Text type={"tertiary"}>
{t("Last used")} <Time dateTime={apiKey.lastActiveAt} addSuffix />{" "}
&middot;{" "}
</Text>
)}
<Text type={apiKey.isExpired ? "danger" : "tertiary"}>
{apiKey.expiresAt
? dateToExpiry(apiKey.expiresAt, t, userLocale)
: t("No expiry")}
</Text>
</>
);
const handleCopy = React.useCallback(() => {
setLinkCopied(true);
toast.message(t("API token copied to clipboard"));
}, [t]);
onCopy(apiKey.id);
}, [apiKey.id, onCopy]);
return (
<ListItem
key={apiKey.id}
title={apiKey.name}
subtitle={<code>{apiKey.secret.slice(0, 15)}</code>}
subtitle={subtitle}
actions={
<Flex align="center" gap={8}>
<CopyToClipboard text={apiKey.secret} onCopy={handleCopy}>
<Button type="button" icon={<CopyIcon />} neutral borderOnHover>
{linkCopied ? t("Copied") : t("Copy")}
{isCopied ? t("Copied") : t("Copy")}
</Button>
</CopyToClipboard>
<ApiKeyMenu apiKey={apiKey} />
-2
View File
@@ -2,8 +2,6 @@ declare module "autotrack/autotrack.js";
declare module "emoji-mart";
declare module "@emoji-mart/react";
declare module "string-replace-to-array";
declare module "sequelize-encrypted";
+1
View File
@@ -19,6 +19,7 @@ declare module "styled-components" {
scrollbarThumb: string;
fontFamily: string;
fontFamilyMono: string;
fontFamilyEmoji: string;
fontWeightRegular: number;
fontWeightMedium: number;
fontWeightBold: number;
+2
View File
@@ -20,6 +20,8 @@ type PluginValueMap = {
[Hook.Settings]: {
/** The group in settings sidebar this plugin belongs to. */
group: string;
/** An optional settings item to display this after. */
after?: string;
/** The displayed icon of the plugin. */
icon: React.ElementType;
/** The settings screen somponent, should be lazy loaded. */
+50
View File
@@ -5,6 +5,9 @@ import {
differenceInCalendarMonths,
differenceInCalendarYears,
format as formatDate,
isTomorrow,
isSameWeek,
isPast,
} from "date-fns";
import { TFunction } from "i18next";
import startCase from "lodash/startCase";
@@ -71,6 +74,53 @@ export function dateToHeading(
});
}
/**
* Converts a date string to a human-readable expiry string.
*
* @param dateTime The date string to convert
* @param t The translation function
* @param userLocale The user's locale
*/
export function dateToExpiry(
dateTime: string,
t: TFunction,
userLocale: string | null | undefined
) {
const date = Date.parse(dateTime);
const now = new Date();
const locale = dateLocale(userLocale);
if (isYesterday(date)) {
return t("Expired yesterday");
}
if (isPast(date)) {
return `${t("Expired {{ date }}", {
date: formatDate(date, "MMM dd, yyyy", { locale }),
})}`;
}
if (isToday(date)) {
return t("Expires today");
}
if (isTomorrow(date)) {
return t("Expires tomorrow");
}
if (isSameWeek(date, now)) {
return t("Expires {{ date }}", {
date: formatDate(Date.parse(dateTime), "iiii", {
locale,
}),
});
}
return t("Expires {{ date }}", {
date: formatDate(date, "MMM dd, yyyy", { locale }),
});
}
/**
* Replaces template variables in the given text with the current date and time.
*
+5 -2
View File
@@ -88,13 +88,16 @@ export function newTemplatePath(collectionId: string) {
export function newDocumentPath(
collectionId?: string | null,
params: {
parentDocumentId?: string;
templateId?: string;
} = {}
): string {
return collectionId
? `/collection/${collectionId}/new?${queryString.stringify(params)}`
: `/doc/new`;
: `/doc/new?${queryString.stringify(params)}`;
}
export function newNestedDocumentPath(parentDocumentId?: string): string {
return `/doc/new?${queryString.stringify({ parentDocumentId })}`;
}
export function searchPath(
+7 -7
View File
@@ -66,11 +66,10 @@
"@dnd-kit/modifiers": "^6.0.1",
"@dnd-kit/sortable": "^7.0.2",
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"@fortawesome/fontawesome-svg-core": "^6.5.2",
"@fortawesome/free-solid-svg-icons": "^6.5.2",
"@fortawesome/react-fontawesome": "^0.2.0",
"@getoutline/react-roving-tabindex": "^3.2.2",
"@getoutline/react-roving-tabindex": "^3.2.4",
"@getoutline/y-prosemirror": "^1.0.18",
"@hocuspocus/extension-throttle": "1.1.2",
"@hocuspocus/provider": "1.1.2",
@@ -81,7 +80,7 @@
"@outlinewiki/koa-passport": "^4.2.1",
"@outlinewiki/passport-azure-ad-oauth2": "^0.1.0",
"@renderlesskit/react": "^0.11.0",
"@sentry/node": "^7.99.0",
"@sentry/node": "^7.117.0",
"@sentry/react": "^7.99.0",
"@tippyjs/react": "^4.2.6",
"@types/form-data": "^2.5.0",
@@ -191,6 +190,7 @@
"react": "^17.0.2",
"react-avatar-editor": "^13.0.2",
"react-color": "^2.17.3",
"react-day-picker": "^8.10.1",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "^17.0.2",
@@ -198,7 +198,7 @@
"react-helmet-async": "^2.0.5",
"react-hook-form": "^7.41.5",
"react-i18next": "^12.3.1",
"react-medium-image-zoom": "^5.2.4",
"react-medium-image-zoom": "^5.2.5",
"react-merge-refs": "^2.0.2",
"react-portal": "^4.2.2",
"react-router-dom": "^5.3.4",
@@ -234,14 +234,14 @@
"tiny-cookie": "^2.5.1",
"tmp": "^0.2.3",
"turndown": "^7.2.0",
"umzug": "^3.2.1",
"umzug": "^3.8.1",
"utility-types": "^3.10.0",
"uuid": "^8.3.2",
"validator": "13.11.0",
"vite": "^5.2.11",
"vite-plugin-pwa": "^0.17.4",
"winston": "^3.13.0",
"ws": "^7.5.9",
"ws": "^7.5.10",
"y-indexeddb": "^9.0.11",
"y-protocols": "^1.0.6",
"yauzl": "^2.10.0",
@@ -304,7 +304,7 @@
"@types/react-table": "^7.7.18",
"@types/react-virtualized-auto-sizer": "^1.0.4",
"@types/react-window": "^1.8.8",
"@types/readable-stream": "^4.0.12",
"@types/readable-stream": "^4.0.14",
"@types/redis-info": "^3.0.3",
"@types/refractor": "^3.4.0",
"@types/resolve-path": "^1.4.2",
@@ -1,5 +1,6 @@
import { FetchError } from "node-fetch";
import { Op } from "sequelize";
import { colorPalette } from "@shared/utils/collections";
import WebhookDisabledEmail from "@server/emails/templates/WebhookDisabledEmail";
import env from "@server/env";
import Logger from "@server/logging/Logger";
@@ -423,12 +424,18 @@ export default class DeliverWebhookTask extends BaseTask<Props> {
paranoid: false,
});
const collection = model && (await presentCollection(undefined, model));
if (collection) {
// For backward compatibility, set a default color.
collection.color = collection.color ?? colorPalette[0];
}
await this.sendWebhook({
event,
subscription,
payload: {
id: event.collectionId,
model: model && (await presentCollection(undefined, model)),
model: collection,
},
});
}
@@ -448,14 +455,20 @@ export default class DeliverWebhookTask extends BaseTask<Props> {
paranoid: false,
});
const collection =
model && (await presentCollection(undefined, model.collection!));
if (collection) {
// For backward compatibility, set a default color.
collection.color = collection.color ?? colorPalette[0];
}
await this.sendWebhook({
event,
subscription,
payload: {
id: event.modelId,
model: model && presentMembership(model),
collection:
model && (await presentCollection(undefined, model.collection!)),
collection,
user: model && presentUser(model.user),
},
});
@@ -476,14 +489,20 @@ export default class DeliverWebhookTask extends BaseTask<Props> {
paranoid: false,
});
const collection =
model && (await presentCollection(undefined, model.collection!));
if (collection) {
// For backward compatibility, set a default color.
collection.color = collection.color ?? colorPalette[0];
}
await this.sendWebhook({
event,
subscription,
payload: {
id: event.modelId,
model: model && presentCollectionGroupMembership(model),
collection:
model && (await presentCollection(undefined, model.collection!)),
collection,
group: model && presentGroup(model.group),
},
});
+7 -3
View File
@@ -12,7 +12,8 @@ type Props = Optional<
| "title"
| "text"
| "content"
| "emoji"
| "icon"
| "color"
| "collectionId"
| "parentDocumentId"
| "importId"
@@ -36,7 +37,8 @@ type Props = Optional<
export default async function documentCreator({
title = "",
text = "",
emoji,
icon,
color,
state,
id,
urlId,
@@ -96,7 +98,9 @@ export default async function documentCreator({
importId,
sourceMetadata,
fullWidth: templateDocument ? templateDocument.fullWidth : fullWidth,
emoji: templateDocument ? templateDocument.emoji : emoji,
emoji: templateDocument ? templateDocument.emoji : icon,
icon: templateDocument ? templateDocument.emoji : icon,
color: templateDocument ? templateDocument.color : color,
title: TextHelper.replaceTemplateVariables(
templateDocument ? templateDocument.title : title,
user
+9 -3
View File
@@ -26,6 +26,8 @@ describe("documentDuplicator", () => {
expect(response[0].title).toEqual(original.title);
expect(response[0].text).toEqual(original.text);
expect(response[0].emoji).toEqual(original.emoji);
expect(response[0].icon).toEqual(original.icon);
expect(response[0].color).toEqual(original.color);
expect(response[0].publishedAt).toBeInstanceOf(Date);
});
@@ -34,7 +36,7 @@ describe("documentDuplicator", () => {
const original = await buildDocument({
userId: user.id,
teamId: user.teamId,
emoji: "👋",
icon: "👋",
});
const response = await sequelize.transaction((transaction) =>
@@ -51,7 +53,9 @@ describe("documentDuplicator", () => {
expect(response).toHaveLength(1);
expect(response[0].title).toEqual("New title");
expect(response[0].text).toEqual(original.text);
expect(response[0].emoji).toEqual(original.emoji);
expect(response[0].emoji).toEqual(original.icon);
expect(response[0].icon).toEqual(original.icon);
expect(response[0].color).toEqual(original.color);
expect(response[0].publishedAt).toBeInstanceOf(Date);
});
@@ -60,7 +64,7 @@ describe("documentDuplicator", () => {
const original = await buildDocument({
userId: user.id,
teamId: user.teamId,
emoji: "👋",
icon: "👋",
});
await buildDocument({
@@ -106,6 +110,8 @@ describe("documentDuplicator", () => {
expect(response[0].title).toEqual(original.title);
expect(response[0].text).toEqual(original.text);
expect(response[0].emoji).toEqual(original.emoji);
expect(response[0].icon).toEqual(original.icon);
expect(response[0].color).toEqual(original.color);
expect(response[0].publishedAt).toBeNull();
});
});
+4 -2
View File
@@ -45,7 +45,8 @@ export default async function documentDuplicator({
const duplicated = await documentCreator({
parentDocumentId: parentDocumentId ?? document.parentDocumentId,
emoji: document.emoji,
icon: document.icon ?? document.emoji,
color: document.color,
template: document.template,
title: title ?? document.title,
content: document.content,
@@ -78,7 +79,8 @@ export default async function documentDuplicator({
for (const childDocument of childDocuments) {
const duplicatedChildDocument = await documentCreator({
parentDocumentId: duplicated.id,
emoji: childDocument.emoji,
icon: childDocument.icon ?? childDocument.emoji,
color: childDocument.color,
title: childDocument.title,
text: childDocument.text,
...sharedProperties,
+5 -5
View File
@@ -28,7 +28,7 @@ async function documentImporter({
ip,
transaction,
}: Props): Promise<{
emoji?: string;
icon?: string;
text: string;
title: string;
state: Buffer;
@@ -43,9 +43,9 @@ async function documentImporter({
// find and extract emoji near the beginning of the document.
const regex = emojiRegex();
const matches = regex.exec(text.slice(0, 10));
const emoji = matches ? matches[0] : undefined;
if (emoji) {
text = text.replace(emoji, "");
const icon = matches ? matches[0] : undefined;
if (icon) {
text = text.replace(icon, "");
}
// If the first line of the imported text looks like a markdown heading
@@ -96,7 +96,7 @@ async function documentImporter({
text,
state,
title,
emoji,
icon,
};
}
+17 -6
View File
@@ -9,8 +9,10 @@ type Props = {
document: Document;
/** The new title */
title?: string;
/** The document emoji */
emoji?: string | null;
/** The document icon */
icon?: string | null;
/** The document icon's color */
color?: string | null;
/** The new text content */
text?: string;
/** Whether the editing session is complete */
@@ -46,7 +48,8 @@ export default async function documentUpdater({
user,
document,
title,
emoji,
icon,
color,
text,
editorVersion,
templateId,
@@ -65,8 +68,12 @@ export default async function documentUpdater({
if (title !== undefined) {
document.title = title.trim();
}
if (emoji !== undefined) {
document.emoji = emoji;
if (icon !== undefined) {
document.emoji = icon;
document.icon = icon;
}
if (color !== undefined) {
document.color = color;
}
if (editorVersion) {
document.editorVersion = editorVersion;
@@ -136,5 +143,9 @@ export default async function documentUpdater({
});
}
return document;
return await Document.findByPk(document.id, {
userId: user.id,
rejectOnEmpty: true,
transaction,
});
}
+6
View File
@@ -83,6 +83,10 @@ export default function auth(options: AuthenticationOptions = {}) {
throw AuthenticationError("Invalid API key");
}
if (apiKey.expiresAt && apiKey.expiresAt < new Date()) {
throw AuthenticationError("Invalid API key");
}
user = await User.findByPk(apiKey.userId, {
include: [
{
@@ -96,6 +100,8 @@ export default function auth(options: AuthenticationOptions = {}) {
if (!user) {
throw AuthenticationError("Invalid API key");
}
await apiKey.updateActiveAt();
} else {
type = AuthenticationType.APP;
user = await getUserForJWT(String(token));
@@ -0,0 +1,15 @@
"use strict";
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addColumn("apiKeys", "expiresAt", {
type: Sequelize.DATE,
allowNull: true,
});
},
async down(queryInterface, Sequelize) {
await queryInterface.removeColumn("apiKeys", "expiresAt");
},
};
@@ -0,0 +1,79 @@
"use strict";
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.sequelize.transaction(async transaction => {
await queryInterface.addColumn(
"documents",
"icon",
{
type: Sequelize.STRING,
allowNull: true,
},
{
transaction,
}
);
await queryInterface.addColumn(
"revisions",
"icon",
{
type: Sequelize.STRING,
allowNull: true,
},
{
transaction,
}
);
await queryInterface.addColumn(
"documents",
"color",
{
type: Sequelize.STRING,
allowNull: true,
},
{ transaction }
);
await queryInterface.addColumn(
"revisions",
"color",
{
type: Sequelize.STRING,
allowNull: true,
},
{ transaction }
);
});
if (process.env.DEPLOYMENT === "hosted") {
return;
}
await queryInterface.sequelize.transaction(async (transaction) => {
await queryInterface.sequelize.query(
`UPDATE documents SET icon = emoji`,
{
transaction,
type: queryInterface.sequelize.QueryTypes.UPDATE,
}
);
await queryInterface.sequelize.query(
`UPDATE revisions SET icon = emoji`,
{
transaction,
type: queryInterface.sequelize.QueryTypes.UPDATE,
}
);
});
},
async down(queryInterface, Sequelize) {
await queryInterface.sequelize.transaction(async transaction => {
await queryInterface.removeColumn("documents", "icon", { transaction });
await queryInterface.removeColumn("revisions", "icon", { transaction });
await queryInterface.removeColumn("documents", "color", { transaction });
await queryInterface.removeColumn("revisions", "color", { transaction });
});
},
};
@@ -0,0 +1,15 @@
"use strict";
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addColumn("apiKeys", "lastActiveAt", {
type: Sequelize.DATE,
allowNull: true,
});
},
async down(queryInterface, Sequelize) {
await queryInterface.removeColumn("apiKeys", "lastActiveAt");
},
};
+22
View File
@@ -17,4 +17,26 @@ describe("#ApiKey", () => {
expect(ApiKey.match("1234567890")).toBe(false);
});
});
describe("lastActiveAt", () => {
test("should update lastActiveAt", async () => {
const apiKey = await buildApiKey({
name: "Dev",
});
await apiKey.updateActiveAt();
expect(apiKey.lastActiveAt).toBeTruthy();
});
test("should not update lastActiveAt within 5 minutes", async () => {
const apiKey = await buildApiKey({
name: "Dev",
});
await apiKey.updateActiveAt();
expect(apiKey.lastActiveAt).toBeTruthy();
const lastActiveAt = apiKey.lastActiveAt;
await apiKey.updateActiveAt();
expect(apiKey.lastActiveAt).toEqual(lastActiveAt);
});
});
});
+22
View File
@@ -1,3 +1,4 @@
import { subMinutes } from "date-fns";
import randomstring from "randomstring";
import { InferAttributes, InferCreationAttributes } from "sequelize";
import {
@@ -7,6 +8,7 @@ import {
BeforeValidate,
BelongsTo,
ForeignKey,
IsDate,
} from "sequelize-typescript";
import { ApiKeyValidation } from "@shared/validations";
import User from "./User";
@@ -34,6 +36,14 @@ class ApiKey extends ParanoidModel<
@Column
secret: string;
@IsDate
@Column
expiresAt: Date | null;
@IsDate
@Column
lastActiveAt: Date | null;
// hooks
@BeforeValidate
@@ -62,6 +72,18 @@ class ApiKey extends ParanoidModel<
@ForeignKey(() => User)
@Column
userId: string;
updateActiveAt = async () => {
const fiveMinutesAgo = subMinutes(new Date(), 5);
// ensure this is updated only every few minutes otherwise
// we'll be constantly writing to the DB as API requests happen
if (!this.lastActiveAt || this.lastActiveAt < fiveMinutesAgo) {
this.lastActiveAt = new Date();
}
return this.save();
};
}
export default ApiKey;
+2 -4
View File
@@ -183,6 +183,7 @@ class Collection extends ParanoidModel<
@Column(DataType.JSONB)
content: ProsemirrorData | null;
/** An icon (or) emoji to use as the collection icon. */
@Length({
max: 50,
msg: `icon must be 50 characters or less`,
@@ -190,6 +191,7 @@ class Collection extends ParanoidModel<
@Column
icon: string | null;
/** The color of the icon. */
@IsHexColor
@Column
color: string | null;
@@ -270,10 +272,6 @@ class Collection extends ParanoidModel<
@BeforeSave
static async onBeforeSave(model: Collection) {
if (model.icon === "collection") {
model.icon = null;
}
if (!model.content) {
model.content = await DocumentHelper.toJSON(model);
}
+29 -4
View File
@@ -64,6 +64,7 @@ import View from "./View";
import ParanoidModel from "./base/ParanoidModel";
import Fix from "./decorators/Fix";
import { DocumentHelper } from "./helpers/DocumentHelper";
import IsHexColor from "./validators/IsHexColor";
import Length from "./validators/Length";
export const DOCUMENT_VERSION = 2;
@@ -254,14 +255,30 @@ class Document extends ParanoidModel<
@Column
editorVersion: string;
/** An emoji to use as the document icon. */
/**
* An emoji to use as the document icon,
* This is used as fallback (for backward compat) when icon is not set.
*/
@Length({
max: 1,
msg: `Emoji must be a single character`,
max: 50,
msg: `Emoji must be 50 characters or less`,
})
@Column
emoji: string | null;
/** An icon to use as the document icon. */
@Length({
max: 50,
msg: `icon must be 50 characters or less`,
})
@Column
icon: string | null;
/** The color of the icon. */
@IsHexColor
@Column
color: string | null;
/**
* The content of the document as Markdown.
*
@@ -352,7 +369,11 @@ class Document extends ParanoidModel<
model.archivedAt ||
model.template ||
!model.publishedAt ||
!(model.changed("title") || model.changed("emoji")) ||
!(
model.changed("title") ||
model.changed("icon") ||
model.changed("color")
) ||
!model.collectionId
) {
return;
@@ -708,6 +729,8 @@ class Document extends ParanoidModel<
this.text = revision.text;
this.title = revision.title;
this.emoji = revision.emoji;
this.icon = revision.icon;
this.color = revision.color;
};
/**
@@ -1070,6 +1093,8 @@ class Document extends ParanoidModel<
title: this.title,
url: this.url,
emoji: isNil(this.emoji) ? undefined : this.emoji,
icon: isNil(this.icon) ? undefined : this.icon,
color: isNil(this.color) ? undefined : this.color,
children,
};
};
+22 -2
View File
@@ -20,6 +20,7 @@ import Document from "./Document";
import User from "./User";
import IdModel from "./base/IdModel";
import Fix from "./decorators/Fix";
import IsHexColor from "./validators/IsHexColor";
import Length from "./validators/Length";
@DefaultScope(() => ({
@@ -70,13 +71,30 @@ class Revision extends IdModel<
@Column(DataType.JSONB)
content: ProsemirrorData;
/**
* An emoji to use as the document icon,
* This is used as fallback (for backward compat) when icon is not set.
*/
@Length({
max: 1,
msg: `Emoji must be a single character`,
max: 50,
msg: `Emoji must be 50 characters or less`,
})
@Column
emoji: string | null;
/** An icon to use as the document icon. */
@Length({
max: 50,
msg: `icon must be 50 characters or less`,
})
@Column
icon: string | null;
/** The color of the icon. */
@IsHexColor
@Column
color: string | null;
// associations
@BelongsTo(() => Document, "documentId")
@@ -121,6 +139,8 @@ class Revision extends IdModel<
title: document.title,
text: document.text,
emoji: document.emoji,
icon: document.icon,
color: document.color,
content: document.content,
userId: document.lastModifiedById,
editorVersion: document.editorVersion,

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