mirror of
https://github.com/outline/outline.git
synced 2025-12-21 10:39:41 -06:00
feat: Custom emojis in editor (#10758)
* Working pass, needs refactor * revert * fix: Copy/paste behavior * fix: Public share rendering * fixes * fix: Click around emoji atom behavior * fix: Cannot position caret next to heading * Update app/scenes/Settings/components/EmojisTable.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -89,10 +89,7 @@ const GridTemplate = (
|
||||
>
|
||||
<Emoji width={24} height={24}>
|
||||
{item.type === IconType.Custom ? (
|
||||
<CustomEmoji
|
||||
src={`/api/emojis.redirect?id=${item.value}`}
|
||||
title={item.name}
|
||||
/>
|
||||
<CustomEmoji value={item.value} title={item.name} />
|
||||
) : (
|
||||
item.value
|
||||
)}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import capitalize from "lodash/capitalize";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useCallback, useMemo, useEffect } from "react";
|
||||
import { emojiMartToGemoji, snakeCase } from "@shared/editor/lib/emoji";
|
||||
import { search as emojiSearch } from "@shared/utils/emoji";
|
||||
import EmojiMenuItem from "./EmojiMenuItem";
|
||||
import SuggestionsMenu, {
|
||||
Props as SuggestionsMenuProps,
|
||||
} from "./SuggestionsMenu";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { determineIconType } from "@shared/utils/icon";
|
||||
import { IconType } from "@shared/types";
|
||||
|
||||
type Emoji = {
|
||||
name: string;
|
||||
@@ -21,28 +24,40 @@ type Props = Omit<
|
||||
>;
|
||||
|
||||
const EmojiMenu = (props: Props) => {
|
||||
const { emojis } = useStores();
|
||||
const { search = "" } = props;
|
||||
|
||||
useEffect(() => {
|
||||
if (search) {
|
||||
void emojis.fetchPage({ query: search });
|
||||
}
|
||||
}, [emojis, search]);
|
||||
|
||||
const items = useMemo(
|
||||
() =>
|
||||
emojiSearch({ query: search })
|
||||
emojiSearch({ customEmojis: emojis.orderedData, query: search })
|
||||
.map((item) => {
|
||||
// We snake_case the shortcode for backwards compatability with gemoji to
|
||||
// avoid multiple formats being written into documents.
|
||||
// @ts-expect-error emojiMartToGemoji key
|
||||
const shortcode = snakeCase(emojiMartToGemoji[item.id] || item.id);
|
||||
const id = emojiMartToGemoji[item.id] || item.id;
|
||||
const type = determineIconType(id);
|
||||
const shortcode = type === IconType.Custom ? id : snakeCase(id);
|
||||
const emoji = item.value;
|
||||
|
||||
return {
|
||||
name: "emoji",
|
||||
title: emoji,
|
||||
description: capitalize(item.name.toLowerCase()),
|
||||
description:
|
||||
type === IconType.Custom
|
||||
? item.name
|
||||
: capitalize(item.name.toLowerCase()),
|
||||
emoji,
|
||||
attrs: { markup: shortcode, "data-name": shortcode },
|
||||
};
|
||||
})
|
||||
.slice(0, 15),
|
||||
[search]
|
||||
[search, emojis.orderedData]
|
||||
);
|
||||
|
||||
const renderMenuItem = useCallback(
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import styled from "styled-components";
|
||||
import SuggestionsMenuItem, {
|
||||
Props as SuggestionsMenuItemProps,
|
||||
} from "./SuggestionsMenuItem";
|
||||
|
||||
const Emoji = styled.span`
|
||||
font-size: 16px;
|
||||
line-height: 1.6em;
|
||||
`;
|
||||
import { Emoji } from "~/components/Emoji";
|
||||
import { CustomEmoji } from "@shared/components/CustomEmoji";
|
||||
import { isUUID } from "validator";
|
||||
|
||||
type EmojiMenuItemProps = Omit<
|
||||
SuggestionsMenuItemProps,
|
||||
@@ -19,7 +16,9 @@ export default function EmojiMenuItem({ emoji, ...rest }: EmojiMenuItemProps) {
|
||||
return (
|
||||
<SuggestionsMenuItem
|
||||
{...rest}
|
||||
icon={<Emoji className="emoji">{emoji}</Emoji>}
|
||||
icon={
|
||||
isUUID(emoji) ? <CustomEmoji value={emoji} /> : <Emoji>{emoji}</Emoji>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ const EmojisTable = observer(function EmojisTable({
|
||||
accessor: (emoji) => emoji.url,
|
||||
component: (emoji) => (
|
||||
<EmojiPreview>
|
||||
<CustomEmoji src={emoji.url} alt={emoji.name} size={28} />
|
||||
<CustomEmoji value={emoji.id} alt={emoji.name} size={28} />
|
||||
<span>:{emoji.name}:</span>
|
||||
</EmojiPreview>
|
||||
),
|
||||
|
||||
@@ -15,7 +15,6 @@ import * as T from "./schema";
|
||||
import { getTeamFromContext } from "@server/utils/passport";
|
||||
import { loadPublicShare } from "@server/commands/shareLoader";
|
||||
import { AuthorizationError } from "@server/errors";
|
||||
import { flattenTree } from "@shared/utils/tree";
|
||||
|
||||
const router = new Router();
|
||||
|
||||
@@ -87,18 +86,15 @@ router.get(
|
||||
const teamFromCtx = await getTeamFromContext(ctx, {
|
||||
includeStateCookie: false,
|
||||
});
|
||||
|
||||
const { sharedTree } = await loadPublicShare({
|
||||
const { share } = await loadPublicShare({
|
||||
id: shareId,
|
||||
teamId: teamFromCtx?.id,
|
||||
});
|
||||
|
||||
// collect all icons from sharedTree
|
||||
const isEmojiInSharedTree =
|
||||
sharedTree &&
|
||||
flattenTree(sharedTree).some((node) => node.icon === emoji.id);
|
||||
|
||||
if (!isEmojiInSharedTree) {
|
||||
// Note: This is purposefully using a somewhat looser authorization check.
|
||||
// In order to load a custom emoji you must have a valid emoji ID and a
|
||||
// valid share ID from the same team.
|
||||
if (share.teamId !== emoji.teamId) {
|
||||
throw AuthorizationError();
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import styled from "styled-components";
|
||||
import useShare from "@shared/hooks/useShare";
|
||||
|
||||
export const CustomEmoji = styled.img<{ size?: number }>`
|
||||
width: ${(props) => (props.size ? `${props.size}px` : "16px")};
|
||||
height: ${(props) => (props.size ? `${props.size}px` : "16px")};
|
||||
object-fit: contain;
|
||||
`;
|
||||
type Props = React.ImgHTMLAttributes<HTMLImageElement> & {
|
||||
value: string;
|
||||
size?: number | string;
|
||||
};
|
||||
|
||||
export const CustomEmoji = ({ value, size = 16, ...props }: Props) => {
|
||||
const { shareId } = useShare();
|
||||
return (
|
||||
<img
|
||||
src={`/api/emojis.redirect?id=${value}${shareId ? `&shareId=${shareId}` : ""}`}
|
||||
style={{ width: size, height: size, objectFit: "contain" }}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,7 +2,6 @@ import { observer } from "mobx-react";
|
||||
import { getLuminance } from "polished";
|
||||
import styled from "styled-components";
|
||||
import useStores from "../hooks/useStores";
|
||||
import useShare from "../hooks/useShare";
|
||||
import { IconType } from "../types";
|
||||
import { IconLibrary } from "../utils/IconLibrary";
|
||||
import { colorPalette } from "../utils/collections";
|
||||
@@ -37,7 +36,6 @@ const Icon = ({
|
||||
forceColor,
|
||||
className,
|
||||
}: Props) => {
|
||||
const { shareId } = useShare();
|
||||
const iconType = determineIconType(icon);
|
||||
|
||||
if (!iconType) {
|
||||
@@ -63,11 +61,9 @@ const Icon = ({
|
||||
|
||||
if (iconType === IconType.Custom) {
|
||||
return (
|
||||
<EmojiImageWrapper>
|
||||
<CustomEmoji
|
||||
src={`/api/emojis.redirect?id=${icon}${shareId ? `&shareId=${shareId}` : ""}`}
|
||||
/>
|
||||
</EmojiImageWrapper>
|
||||
<Span size={size} className={className}>
|
||||
<CustomEmoji value={icon} size={size - size / 4} />
|
||||
</Span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -128,21 +124,11 @@ export const IconTitleWrapper = styled(Flex)<{ dir?: string }>`
|
||||
props.dir === "rtl" ? "right: -44px" : "left: -44px"};
|
||||
`;
|
||||
|
||||
const EmojiImageWrapper = styled(Flex)`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
const Span = styled(Flex)<{ size: number }>`
|
||||
width: ${(props) => props.size}px;
|
||||
height: ${(props) => props.size}px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
${IconTitleWrapper} & {
|
||||
width: auto;
|
||||
height: auto;
|
||||
|
||||
${CustomEmoji} {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default Icon;
|
||||
|
||||
@@ -892,6 +892,14 @@ h6:not(.placeholder)::before {
|
||||
margin-${props.rtl ? "right" : "left"}: -1em;
|
||||
}
|
||||
|
||||
.emoji img {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
vertical-align: middle;
|
||||
position: relative;
|
||||
top: -0.1em;
|
||||
}
|
||||
|
||||
.heading-anchor,
|
||||
.heading-fold {
|
||||
display: inline-block;
|
||||
|
||||
@@ -5,12 +5,15 @@ import {
|
||||
NodeType,
|
||||
Schema,
|
||||
} from "prosemirror-model";
|
||||
import { Command, TextSelection } from "prosemirror-state";
|
||||
import { Command, Plugin, TextSelection } from "prosemirror-state";
|
||||
import { Primitive } from "utility-types";
|
||||
import Extension from "../lib/Extension";
|
||||
import { getEmojiFromName } from "../lib/emoji";
|
||||
import { MarkdownSerializerState } from "../lib/markdown/serializer";
|
||||
import emojiRule from "../rules/emoji";
|
||||
import { isUUID } from "validator";
|
||||
import { ComponentProps } from "../types";
|
||||
import { CustomEmoji } from "../../components/CustomEmoji";
|
||||
|
||||
export default class Emoji extends Extension {
|
||||
get type() {
|
||||
@@ -36,6 +39,7 @@ export default class Emoji extends Extension {
|
||||
selectable: false,
|
||||
parseDOM: [
|
||||
{
|
||||
priority: 100,
|
||||
tag: "strong.emoji",
|
||||
preserveWhitespace: "full",
|
||||
getAttrs: (dom: HTMLElement) =>
|
||||
@@ -66,6 +70,53 @@ export default class Emoji extends Extension {
|
||||
return [emojiRule];
|
||||
}
|
||||
|
||||
get plugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
props: {
|
||||
// Placing the caret infront of an emoji is tricky as click events directly
|
||||
// on the emoji will not behave the same way as clicks on text characters, this
|
||||
// plugin ensures that clicking on an emoji behaves more naturally.
|
||||
handleClickOn: (view, _pos, node, nodePos, event) => {
|
||||
if (node.type.name === this.name) {
|
||||
const element = event.target as HTMLElement;
|
||||
const rect = element.getBoundingClientRect();
|
||||
const clickX = event.clientX - rect.left;
|
||||
const side = clickX < rect.width / 2 ? -1 : 1;
|
||||
|
||||
// If the click is in the left half of the emoji, place the caret before it
|
||||
const tr = view.state.tr.setSelection(
|
||||
TextSelection.near(
|
||||
view.state.doc.resolve(
|
||||
side === -1 ? nodePos : nodePos + node.nodeSize
|
||||
),
|
||||
side
|
||||
)
|
||||
);
|
||||
view.dispatch(tr);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
component = (props: ComponentProps) => {
|
||||
const name = props.node.attrs["data-name"];
|
||||
return (
|
||||
<strong className="emoji" data-name={name}>
|
||||
{isUUID(name) ? (
|
||||
<CustomEmoji value={name} size="1em" />
|
||||
) : (
|
||||
getEmojiFromName(name)
|
||||
)}
|
||||
</strong>
|
||||
);
|
||||
};
|
||||
|
||||
commands({ type }: { type: NodeType; schema: Schema }) {
|
||||
return (attrs: Record<string, Primitive>): Command =>
|
||||
(state, dispatch) => {
|
||||
|
||||
@@ -221,12 +221,26 @@ export default class Heading extends Node {
|
||||
container.appendChild(fold);
|
||||
|
||||
decorations.push(
|
||||
// Contains the heading actions
|
||||
Decoration.widget(pos + 1, container, {
|
||||
side: -1,
|
||||
ignoreSelection: true,
|
||||
relaxedSide: true,
|
||||
key: pos.toString(),
|
||||
})
|
||||
}),
|
||||
// Creates a "space" for the caret to move to before the widget.
|
||||
// Without this it is very hard to place the caret at the beginning
|
||||
// of the heading when it begins with an atom element.
|
||||
Decoration.widget(
|
||||
pos + 1,
|
||||
() => document.createElement("span"),
|
||||
{
|
||||
side: -1,
|
||||
ignoreSelection: true,
|
||||
relaxedSide: true,
|
||||
key: "span",
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -162,7 +162,10 @@ export default class Image extends SimpleImage {
|
||||
tag: "img",
|
||||
getAttrs: (dom: HTMLImageElement) => {
|
||||
// Don't parse images from our own editor with this rule.
|
||||
if (dom.parentElement?.classList.contains("image")) {
|
||||
if (
|
||||
dom.parentElement?.classList.contains("image") ||
|
||||
dom.parentElement?.classList.contains("emoji")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -191,16 +191,25 @@ export const getEmojisWithCategory = ({
|
||||
export const getEmojiVariants = ({ id }: { id: string }) =>
|
||||
EMOJI_ID_TO_VARIANTS[id];
|
||||
|
||||
type CustomEmoji = {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export const search = ({
|
||||
query,
|
||||
skinTone,
|
||||
customEmojis = [],
|
||||
}: {
|
||||
query: string;
|
||||
skinTone?: EmojiSkinTone;
|
||||
customEmojis?: CustomEmoji[];
|
||||
}) => {
|
||||
const queryLowercase = query.toLowerCase();
|
||||
const emojiSkinTone = skinTone ?? EmojiSkinTone.Default;
|
||||
|
||||
// Search built-in emojis
|
||||
const matchedEmojis = searcher
|
||||
.search(queryLowercase)
|
||||
.map(
|
||||
@@ -208,7 +217,29 @@ export const search = ({
|
||||
EMOJI_ID_TO_VARIANTS[emoji.id][emojiSkinTone] ??
|
||||
EMOJI_ID_TO_VARIANTS[emoji.id][EmojiSkinTone.Default]
|
||||
);
|
||||
return sortBy(matchedEmojis, (emoji) => {
|
||||
|
||||
// Search custom emojis
|
||||
const matchedCustomEmojis = customEmojis
|
||||
.filter((emoji) => {
|
||||
const nameLower = emoji.name.toLowerCase();
|
||||
const idLower = emoji.id.toLowerCase();
|
||||
return (
|
||||
nameLower.includes(queryLowercase) || idLower.includes(queryLowercase)
|
||||
);
|
||||
})
|
||||
.map(
|
||||
(customEmoji) =>
|
||||
({
|
||||
id: customEmoji.id,
|
||||
name: customEmoji.name,
|
||||
value: customEmoji.id,
|
||||
}) as Emoji
|
||||
);
|
||||
|
||||
// Combine and sort all results
|
||||
const allEmojis = [...matchedEmojis, ...matchedCustomEmojis];
|
||||
|
||||
return sortBy(allEmojis, (emoji) => {
|
||||
const nlc = emoji.name.toLowerCase();
|
||||
return query === nlc ? -1 : nlc.startsWith(queryLowercase) ? 0 : 1;
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user