mirror of
https://github.com/outline/outline.git
synced 2026-06-13 19:35:02 +03:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 18aadd2b5a | |||
| 69e07a9c21 | |||
| 7b27b74e24 | |||
| 92db179230 | |||
| fa93092f79 | |||
| 63c5938a43 | |||
| a4f77e4438 | |||
| 7bb8ff4797 | |||
| bcdedd53d8 | |||
| 37b18ab940 | |||
| 42d699fabe | |||
| 8026dac146 | |||
| a5e1f613fc | |||
| 3d1f55b605 |
+1
-1
@@ -5,7 +5,7 @@ require("@dotenvx/dotenvx").config({
|
||||
var path = require('path');
|
||||
|
||||
module.exports = {
|
||||
'config': path.resolve('server/config', 'database.json'),
|
||||
'config': path.resolve('server/config', 'database.js'),
|
||||
'migrations-path': path.resolve('server', 'migrations'),
|
||||
'models-path': path.resolve('server', 'models'),
|
||||
}
|
||||
|
||||
@@ -13,13 +13,6 @@
|
||||
"group": ["mime-types"],
|
||||
"message": "Do not use the mime-types package in the browser."
|
||||
}
|
||||
],
|
||||
"paths": [
|
||||
{
|
||||
"name": "reakit/Menu",
|
||||
"importNames": ["useMenuState"],
|
||||
"message": "Do not use useMenuState from reakit/Menu. Use useMenuState instead."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
|
||||
const Header = styled.h3`
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: ${s("sidebarText")};
|
||||
letter-spacing: 0.04em;
|
||||
margin: 1em 12px 0.5em;
|
||||
`;
|
||||
|
||||
export default Header;
|
||||
@@ -1,13 +0,0 @@
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
|
||||
const MenuIconWrapper = styled.span`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-right: 6px;
|
||||
margin-left: -4px;
|
||||
color: ${s("textSecondary")};
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
export default MenuIconWrapper;
|
||||
@@ -1,217 +0,0 @@
|
||||
import { LocationDescriptor } from "history";
|
||||
import { CheckmarkIcon } from "outline-icons";
|
||||
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 { s } from "@shared/styles";
|
||||
import Text from "../Text";
|
||||
import MenuIconWrapper from "./MenuIconWrapper";
|
||||
|
||||
type Props = {
|
||||
id?: string;
|
||||
onClick?: (event: React.MouseEvent) => void | Promise<void>;
|
||||
onPointerMove?: (event: React.MouseEvent) => void | Promise<void>;
|
||||
active?: boolean;
|
||||
selected?: boolean;
|
||||
disabled?: boolean;
|
||||
dangerous?: boolean;
|
||||
to?: LocationDescriptor;
|
||||
href?: string;
|
||||
target?: string;
|
||||
as?: string | React.ComponentType<any>;
|
||||
hide?: () => void;
|
||||
level?: number;
|
||||
icon?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
ref?: React.LegacyRef<HTMLButtonElement> | undefined;
|
||||
};
|
||||
|
||||
const MenuItem = (
|
||||
{
|
||||
onClick,
|
||||
onPointerMove,
|
||||
children,
|
||||
active,
|
||||
selected,
|
||||
disabled,
|
||||
as,
|
||||
hide,
|
||||
icon,
|
||||
...rest
|
||||
}: Props,
|
||||
ref: React.Ref<HTMLAnchorElement>
|
||||
) => {
|
||||
const content = React.useCallback(
|
||||
(props) => {
|
||||
// Preventing default mousedown otherwise menu items do not work in Firefox,
|
||||
// which triggers the hideOnClickOutside handler first via mousedown – hiding
|
||||
// and un-rendering the menu contents.
|
||||
const preventDefault = (ev: React.MouseEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
};
|
||||
|
||||
const handleClick = async (ev: React.MouseEvent) => {
|
||||
hide?.();
|
||||
|
||||
if (onClick) {
|
||||
preventDefault(ev);
|
||||
await onClick(ev);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<MenuAnchor
|
||||
{...props}
|
||||
$active={active}
|
||||
as={onClick ? "button" : as}
|
||||
onClick={handleClick}
|
||||
onPointerDown={preventDefault}
|
||||
onMouseDown={preventDefault}
|
||||
ref={mergeRefs([
|
||||
ref,
|
||||
props.ref as React.RefObject<HTMLAnchorElement>,
|
||||
])}
|
||||
>
|
||||
{selected !== undefined && (
|
||||
<SelectedWrapper aria-hidden>
|
||||
{selected ? <CheckmarkIcon /> : <Spacer />}
|
||||
</SelectedWrapper>
|
||||
)}
|
||||
{icon && <MenuIconWrapper aria-hidden>{icon}</MenuIconWrapper>}
|
||||
<Title>{children}</Title>
|
||||
</MenuAnchor>
|
||||
);
|
||||
},
|
||||
[active, as, hide, icon, onClick, ref, children, selected]
|
||||
);
|
||||
|
||||
return (
|
||||
<BaseMenuItem
|
||||
onClick={disabled ? undefined : onClick}
|
||||
onPointerMove={disabled ? undefined : onPointerMove}
|
||||
disabled={disabled}
|
||||
hide={hide}
|
||||
{...rest}
|
||||
>
|
||||
{content}
|
||||
</BaseMenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
const Spacer = styled.svg`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const Title = styled.div`
|
||||
${ellipsis()}
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
type MenuAnchorProps = {
|
||||
level?: number;
|
||||
disabled?: boolean;
|
||||
dangerous?: boolean;
|
||||
disclosure?: boolean;
|
||||
$active?: boolean;
|
||||
};
|
||||
|
||||
export const MenuAnchorCSS = css<MenuAnchorProps>`
|
||||
display: flex;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
padding-left: ${(props) => 12 + (props.level || 0) * 10}px;
|
||||
width: 100%;
|
||||
min-height: 32px;
|
||||
background: none;
|
||||
color: ${(props) =>
|
||||
props.disabled ? props.theme.textTertiary : props.theme.textSecondary};
|
||||
justify-content: left;
|
||||
align-items: center;
|
||||
font-size: 16px;
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
position: relative;
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
opacity: ${(props) => (props.disabled ? ".5" : 1)};
|
||||
}
|
||||
|
||||
${(props) => props.disabled && "pointer-events: none;"}
|
||||
|
||||
${(props) =>
|
||||
props.$active === undefined &&
|
||||
!props.disabled &&
|
||||
`
|
||||
@media (hover: hover) {
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:focus-visible {
|
||||
color: ${props.theme.accentText};
|
||||
background: ${props.dangerous ? props.theme.danger : props.theme.accent};
|
||||
outline-color: ${
|
||||
props.dangerous ? props.theme.danger : props.theme.accent
|
||||
};
|
||||
box-shadow: none;
|
||||
cursor: var(--pointer);
|
||||
|
||||
svg {
|
||||
color: ${props.theme.accentText};
|
||||
fill: ${props.theme.accentText};
|
||||
}
|
||||
|
||||
${Text} {
|
||||
color: ${transparentize(0.5, props.theme.accentText)};
|
||||
}
|
||||
}
|
||||
}
|
||||
`}
|
||||
|
||||
${(props) =>
|
||||
props.$active &&
|
||||
!props.disabled &&
|
||||
`
|
||||
color: ${props.theme.accentText};
|
||||
background: ${props.dangerous ? props.theme.danger : props.theme.accent};
|
||||
box-shadow: none;
|
||||
cursor: var(--pointer);
|
||||
|
||||
svg {
|
||||
fill: ${props.theme.accentText};
|
||||
}
|
||||
`}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
padding: 4px 12px;
|
||||
padding-right: ${(props: MenuAnchorProps) =>
|
||||
props.disclosure ? 32 : 12}px;
|
||||
font-size: 14px;
|
||||
`}
|
||||
`;
|
||||
|
||||
export const MenuAnchor = styled.a`
|
||||
${MenuAnchorCSS}
|
||||
`;
|
||||
|
||||
const SelectedWrapper = styled.span`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-right: 4px;
|
||||
margin-left: -8px;
|
||||
flex-shrink: 0;
|
||||
color: ${s("textSecondary")};
|
||||
`;
|
||||
|
||||
export default React.forwardRef<HTMLAnchorElement, Props>(MenuItem);
|
||||
@@ -1,70 +0,0 @@
|
||||
import * as React from "react";
|
||||
import { useMousePosition } from "~/hooks/useMousePosition";
|
||||
|
||||
type Positions = {
|
||||
/** Sub-menu x */
|
||||
x: number;
|
||||
/** Sub-menu y */
|
||||
y: number;
|
||||
/** Sub-menu height */
|
||||
h: number;
|
||||
/** Sub-menu width */
|
||||
w: number;
|
||||
/** Mouse x */
|
||||
mouseX: number;
|
||||
/** Mouse y */
|
||||
mouseY: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Component to cover the area between the mouse cursor and the sub-menu, to
|
||||
* allow moving cursor to lower parts of sub-menu without the sub-menu
|
||||
* disappearing.
|
||||
*/
|
||||
export default function MouseSafeArea(props: {
|
||||
parentRef: React.RefObject<HTMLElement | null>;
|
||||
}) {
|
||||
const {
|
||||
x = 0,
|
||||
y = 0,
|
||||
height: h = 0,
|
||||
width: w = 0,
|
||||
} = props.parentRef.current?.getBoundingClientRect() || {};
|
||||
const [mouseX, mouseY] = useMousePosition();
|
||||
const positions = { x, y, h, w, mouseX, mouseY };
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
// backgroundColor: "rgba(255,0,0,0.1)", // Uncomment to debug
|
||||
right: getRight(positions),
|
||||
left: getLeft(positions),
|
||||
height: h,
|
||||
width: getWidth(positions),
|
||||
clipPath: getClipPath(positions),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const getLeft = ({ x, mouseX }: Positions) =>
|
||||
mouseX > x ? undefined : -Math.max(x - mouseX, 10) + "px";
|
||||
|
||||
const getRight = ({ x, w, mouseX }: Positions) =>
|
||||
mouseX > x ? -Math.max(mouseX - (x + w), 10) + "px" : undefined;
|
||||
|
||||
const getWidth = ({ x, w, mouseX }: Positions) =>
|
||||
mouseX > x
|
||||
? Math.max(mouseX - (x + w), 10) + "px"
|
||||
: Math.max(x - mouseX, 10) + "px";
|
||||
|
||||
const getClipPath = ({ x, y, h, mouseX, mouseY }: Positions) =>
|
||||
mouseX > x
|
||||
? `polygon(0% 0%, 0% 100%, 100% ${(100 * (mouseY - y)) / h - 10}%, 100% ${
|
||||
(100 * (mouseY - y)) / h + 5
|
||||
}%)`
|
||||
: `polygon(100% 0%, 0% ${(100 * (mouseY - y)) / h - 10}%, 0% ${
|
||||
(100 * (mouseY - y)) / h + 5
|
||||
}%, 100% 100%)`;
|
||||
@@ -1,27 +0,0 @@
|
||||
import { MoreIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MenuButton } from "reakit/Menu";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
|
||||
type Props = React.ComponentProps<typeof MenuButton> & {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function OverflowMenuButton({ className, ...rest }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<MenuButton {...rest}>
|
||||
{(props) => (
|
||||
<NudeButton
|
||||
className={className}
|
||||
aria-label={t("More options")}
|
||||
{...props}
|
||||
>
|
||||
<MoreIcon />
|
||||
</NudeButton>
|
||||
)}
|
||||
</MenuButton>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import * as React from "react";
|
||||
import { MenuSeparator } from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
|
||||
export default function Separator(rest: React.HTMLAttributes<HTMLHRElement>) {
|
||||
return (
|
||||
<MenuSeparator {...rest}>
|
||||
{(props) => <HorizontalRule {...props} />}
|
||||
</MenuSeparator>
|
||||
);
|
||||
}
|
||||
|
||||
const HorizontalRule = styled.hr`
|
||||
margin: 6px 0;
|
||||
`;
|
||||
@@ -1,264 +0,0 @@
|
||||
import { ExpandedIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
MenuButton,
|
||||
MenuItem as BaseMenuItem,
|
||||
MenuStateReturn,
|
||||
} from "reakit/Menu";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import MenuIconWrapper from "~/components/ContextMenu/MenuIconWrapper";
|
||||
import Flex from "~/components/Flex";
|
||||
import { actionToMenuItem } from "~/actions";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import { useMenuState } from "~/hooks/useMenuState";
|
||||
import {
|
||||
Action,
|
||||
ActionContext,
|
||||
MenuSeparator,
|
||||
MenuHeading,
|
||||
MenuItem as TMenuItem,
|
||||
} from "~/types";
|
||||
import Tooltip from "../Tooltip";
|
||||
import Header from "./Header";
|
||||
import MenuItem, { MenuAnchor } from "./MenuItem";
|
||||
import MouseSafeArea from "./MouseSafeArea";
|
||||
import Separator from "./Separator";
|
||||
import ContextMenu from ".";
|
||||
|
||||
type Props = Omit<MenuStateReturn, "items"> & {
|
||||
actions?: (Action | MenuSeparator | MenuHeading)[];
|
||||
context?: Partial<ActionContext>;
|
||||
items?: TMenuItem[];
|
||||
showIcons?: boolean;
|
||||
};
|
||||
|
||||
const Disclosure = styled(ExpandedIcon)`
|
||||
transform: rotate(270deg);
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
`;
|
||||
|
||||
type SubMenuProps = MenuStateReturn & {
|
||||
templateItems: TMenuItem[];
|
||||
parentMenuState: Omit<MenuStateReturn, "items">;
|
||||
title: React.ReactNode;
|
||||
};
|
||||
|
||||
const SubMenu = React.forwardRef(function _Template(
|
||||
{ templateItems, title, parentMenuState, ...rest }: SubMenuProps,
|
||||
ref: React.LegacyRef<HTMLButtonElement>
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const menu = useMenuState({
|
||||
parentId: parentMenuState.baseId,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuButton ref={ref} {...menu} {...rest}>
|
||||
{(props) => (
|
||||
<MenuAnchor disclosure {...props}>
|
||||
{title} <Disclosure color={theme.textTertiary} />
|
||||
</MenuAnchor>
|
||||
)}
|
||||
</MenuButton>
|
||||
<ContextMenu
|
||||
{...menu}
|
||||
aria-label={t("Submenu")}
|
||||
onClick={parentMenuState.hide}
|
||||
parentMenuState={parentMenuState}
|
||||
>
|
||||
<MouseSafeArea parentRef={menu.unstable_popoverRef} />
|
||||
<Template {...menu} items={templateItems} />
|
||||
</ContextMenu>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export function filterTemplateItems(items: TMenuItem[]): TMenuItem[] {
|
||||
return items
|
||||
.filter((item) => item.visible !== false)
|
||||
.reduce((acc, item) => {
|
||||
// trim separator if the previous item was a separator
|
||||
if (
|
||||
item.type === "separator" &&
|
||||
acc[acc.length - 1]?.type === "separator"
|
||||
) {
|
||||
return acc;
|
||||
}
|
||||
return [...acc, item];
|
||||
}, [] as TMenuItem[])
|
||||
.filter((item, index, arr) => {
|
||||
if (
|
||||
item.type === "separator" &&
|
||||
(index === 0 || index === arr.length - 1)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function Template({ items, actions, context, showIcons, ...menu }: Props) {
|
||||
const ctx = useActionContext({
|
||||
isMenu: true,
|
||||
});
|
||||
|
||||
const templateItems = actions
|
||||
? actions.map((item) =>
|
||||
item.type === "separator" || item.type === "heading"
|
||||
? item
|
||||
: actionToMenuItem(item, ctx)
|
||||
)
|
||||
: items || [];
|
||||
|
||||
const filteredTemplates = filterTemplateItems(templateItems);
|
||||
|
||||
const iconIsPresentInAnyMenuItem = filteredTemplates.find(
|
||||
(item) =>
|
||||
item.type !== "separator" && item.type !== "heading" && !!item.icon
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{filteredTemplates.map((item, index) => {
|
||||
if (
|
||||
iconIsPresentInAnyMenuItem &&
|
||||
item.type !== "separator" &&
|
||||
item.type !== "heading" &&
|
||||
showIcons !== false
|
||||
) {
|
||||
item.icon = item.icon || <MenuIconWrapper aria-hidden />;
|
||||
}
|
||||
|
||||
if (item.type === "route") {
|
||||
return (
|
||||
<MenuItem
|
||||
as={Link}
|
||||
id={`${item.title}-${index}`}
|
||||
to={item.to}
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
disabled={item.disabled}
|
||||
selected={item.selected}
|
||||
icon={showIcons !== false ? item.icon : undefined}
|
||||
{...menu}
|
||||
>
|
||||
{item.title}
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === "link") {
|
||||
return (
|
||||
<MenuItem
|
||||
id={`${item.title}-${index}`}
|
||||
href={typeof item.href === "string" ? item.href : item.href.url}
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
disabled={item.disabled}
|
||||
selected={item.selected}
|
||||
level={item.level}
|
||||
target={
|
||||
typeof item.href === "string" ? undefined : item.href.target
|
||||
}
|
||||
icon={showIcons !== false ? item.icon : undefined}
|
||||
{...menu}
|
||||
>
|
||||
{item.title}
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === "button") {
|
||||
const menuItem = (
|
||||
<MenuItem
|
||||
as="button"
|
||||
id={`${item.title}-${index}`}
|
||||
onClick={item.onClick}
|
||||
disabled={item.disabled}
|
||||
selected={item.selected}
|
||||
dangerous={item.dangerous}
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
icon={showIcons !== false ? item.icon : undefined}
|
||||
{...menu}
|
||||
>
|
||||
{item.title}
|
||||
</MenuItem>
|
||||
);
|
||||
|
||||
return item.tooltip ? (
|
||||
<Tooltip
|
||||
content={item.tooltip}
|
||||
placement={"bottom"}
|
||||
key={`tooltip-${item.title}-${index}`}
|
||||
>
|
||||
<div>{menuItem}</div>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<React.Fragment key={`${item.type}-${item.title}-${index}`}>
|
||||
{menuItem}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === "submenu") {
|
||||
// Skip rendering empty submenus
|
||||
return item.items.length > 0 ? (
|
||||
<BaseMenuItem
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
as={SubMenu}
|
||||
id={`${item.title}-${index}`}
|
||||
templateItems={item.items}
|
||||
parentMenuState={menu}
|
||||
title={
|
||||
<Title
|
||||
title={item.title}
|
||||
icon={showIcons !== false ? item.icon : undefined}
|
||||
/>
|
||||
}
|
||||
{...menu}
|
||||
/>
|
||||
) : null;
|
||||
}
|
||||
|
||||
if (item.type === "separator") {
|
||||
return <Separator key={`separator-${index}`} />;
|
||||
}
|
||||
|
||||
if (item.type === "heading") {
|
||||
return (
|
||||
<Header key={`heading-${item.title}-${index}`}>{item.title}</Header>
|
||||
);
|
||||
}
|
||||
|
||||
// This should never be reached for Reakit dropdown menu.
|
||||
// Added for exhaustiveness check.
|
||||
if (item.type === "group") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const _exhaustiveCheck: never = item;
|
||||
return _exhaustiveCheck;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Title({
|
||||
title,
|
||||
icon,
|
||||
}: {
|
||||
title: React.ReactNode;
|
||||
icon?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Flex align="center">
|
||||
{icon && <MenuIconWrapper aria-hidden>{icon}</MenuIconWrapper>}
|
||||
{title}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo<Props>(Template);
|
||||
@@ -1,317 +0,0 @@
|
||||
import { disableBodyScroll, enableBodyScroll } from "body-scroll-lock";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Menu, MenuStateReturn } from "reakit/Menu";
|
||||
import styled, { DefaultTheme } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import useEventListener from "~/hooks/useEventListener";
|
||||
import useMenuContext from "~/hooks/useMenuContext";
|
||||
import useMenuHeight from "~/hooks/useMenuHeight";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import usePrevious from "~/hooks/usePrevious";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useUnmount from "~/hooks/useUnmount";
|
||||
import {
|
||||
fadeIn,
|
||||
fadeAndSlideUp,
|
||||
fadeAndSlideDown,
|
||||
mobileContextMenu,
|
||||
} from "~/styles/animations";
|
||||
|
||||
export type Placement =
|
||||
| "auto-start"
|
||||
| "auto"
|
||||
| "auto-end"
|
||||
| "top-start"
|
||||
| "top"
|
||||
| "top-end"
|
||||
| "right-start"
|
||||
| "right"
|
||||
| "right-end"
|
||||
| "bottom-end"
|
||||
| "bottom"
|
||||
| "bottom-start"
|
||||
| "left-end"
|
||||
| "left"
|
||||
| "left-start";
|
||||
|
||||
type Props = MenuStateReturn & {
|
||||
"aria-label"?: string;
|
||||
/** Reference to the rendered menu div element */
|
||||
menuRef?: React.RefObject<HTMLDivElement>;
|
||||
/** The parent menu state if this is a submenu. */
|
||||
parentMenuState?: Omit<MenuStateReturn, "items">;
|
||||
/** Called when the context menu is opened. */
|
||||
onOpen?: () => void;
|
||||
/** Called when the context menu is closed. */
|
||||
onClose?: () => void;
|
||||
/** Called when the context menu is clicked. */
|
||||
onClick?: (ev: React.MouseEvent) => void;
|
||||
/** The maximum width of the context menu. */
|
||||
maxWidth?: number;
|
||||
/** The minimum height of the context menu. */
|
||||
minHeight?: number;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const ContextMenu: React.FC<Props> = ({
|
||||
menuRef,
|
||||
children,
|
||||
onOpen,
|
||||
onClose,
|
||||
parentMenuState,
|
||||
...rest
|
||||
}: Props) => {
|
||||
const previousVisible = usePrevious(rest.visible);
|
||||
const { ui } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const { setIsMenuOpen } = useMenuContext();
|
||||
const isMobile = useMobile();
|
||||
const isSubMenu = !!parentMenuState;
|
||||
|
||||
useUnmount(() => {
|
||||
setIsMenuOpen(false);
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (rest.visible && !previousVisible) {
|
||||
onOpen?.();
|
||||
|
||||
if (!isSubMenu) {
|
||||
setIsMenuOpen(true);
|
||||
}
|
||||
}
|
||||
|
||||
if (!rest.visible && previousVisible) {
|
||||
onClose?.();
|
||||
|
||||
if (!isSubMenu) {
|
||||
setIsMenuOpen(false);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
onOpen,
|
||||
onClose,
|
||||
previousVisible,
|
||||
rest.visible,
|
||||
ui.sidebarCollapsed,
|
||||
setIsMenuOpen,
|
||||
isSubMenu,
|
||||
t,
|
||||
]);
|
||||
|
||||
// Perf win – don't render anything until the menu has been opened
|
||||
if (!rest.visible && !previousVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// sets the menu height based on the available space between the disclosure/
|
||||
// trigger and the bottom of the window
|
||||
return (
|
||||
<>
|
||||
<Menu
|
||||
ref={menuRef}
|
||||
hideOnClickOutside={!isMobile}
|
||||
preventBodyScroll={false}
|
||||
{...rest}
|
||||
>
|
||||
{(props) => (
|
||||
<InnerContextMenu
|
||||
// oxlint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
menuProps={props as any}
|
||||
{...rest}
|
||||
isSubMenu={isSubMenu}
|
||||
>
|
||||
{children}
|
||||
</InnerContextMenu>
|
||||
)}
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type InnerContextMenuProps = MenuStateReturn & {
|
||||
isSubMenu: boolean;
|
||||
menuProps: { style?: React.CSSProperties; placement: string };
|
||||
children: React.ReactNode;
|
||||
maxWidth?: number;
|
||||
minHeight?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Inner context menu allows deferring expensive window measurement hooks etc
|
||||
* until the menu is actually opened.
|
||||
*/
|
||||
const InnerContextMenu = (props: InnerContextMenuProps) => {
|
||||
const { menuProps } = props;
|
||||
// kind of hacky, but this is an effective way of telling which way
|
||||
// the menu will _actually_ be placed when taking into account screen
|
||||
// positioning.
|
||||
const topAnchor =
|
||||
menuProps.style?.top === "0" || menuProps.style?.position === "fixed";
|
||||
const rightAnchor = menuProps.placement === "bottom-end";
|
||||
const backgroundRef = React.useRef<HTMLDivElement>(null);
|
||||
const isMobile = useMobile();
|
||||
|
||||
const maxHeight = useMenuHeight({
|
||||
visible: props.visible,
|
||||
elementRef: props.unstable_disclosureRef,
|
||||
});
|
||||
|
||||
// We must manually manage scroll lock for iOS support so that the scrollable
|
||||
// element can be passed into body-scroll-lock. See:
|
||||
// https://github.com/ariakit/ariakit/issues/469
|
||||
React.useEffect(() => {
|
||||
const scrollElement = backgroundRef.current;
|
||||
if (props.visible && scrollElement && !props.isSubMenu) {
|
||||
disableBodyScroll(scrollElement, {
|
||||
reserveScrollBarGap: true,
|
||||
});
|
||||
}
|
||||
return () => {
|
||||
if (scrollElement && !props.isSubMenu) {
|
||||
enableBodyScroll(scrollElement);
|
||||
}
|
||||
};
|
||||
}, [props.isSubMenu, props.visible]);
|
||||
|
||||
useEventListener(
|
||||
"animationstart",
|
||||
(event) => {
|
||||
if (event.target instanceof HTMLElement) {
|
||||
const parent = event.target.parentElement;
|
||||
if (parent) {
|
||||
parent.style.pointerEvents = "none";
|
||||
}
|
||||
}
|
||||
},
|
||||
backgroundRef.current
|
||||
);
|
||||
|
||||
useEventListener(
|
||||
"animationend",
|
||||
(event) => {
|
||||
if (event.target instanceof HTMLElement) {
|
||||
const parent = event.target.parentElement;
|
||||
if (parent) {
|
||||
parent.style.pointerEvents = "auto";
|
||||
}
|
||||
}
|
||||
},
|
||||
backgroundRef.current
|
||||
);
|
||||
|
||||
const style =
|
||||
topAnchor && !isMobile
|
||||
? {
|
||||
maxHeight,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
{isMobile && (
|
||||
<Backdrop
|
||||
onClick={(ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
props.hide?.();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Position {...menuProps}>
|
||||
<Background
|
||||
dir="auto"
|
||||
maxWidth={props.maxWidth}
|
||||
minHeight={props.minHeight}
|
||||
topAnchor={topAnchor}
|
||||
rightAnchor={rightAnchor}
|
||||
ref={backgroundRef}
|
||||
hiddenScrollbars
|
||||
style={style}
|
||||
>
|
||||
{props.visible || props.animating ? props.children : null}
|
||||
</Background>
|
||||
</Position>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContextMenu;
|
||||
|
||||
export const Backdrop = styled.div`
|
||||
animation: ${fadeIn} 200ms ease-in-out;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: ${s("backdrop")};
|
||||
z-index: ${depths.menu - 1};
|
||||
`;
|
||||
|
||||
export const Position = styled.div`
|
||||
position: absolute;
|
||||
z-index: ${depths.menu};
|
||||
|
||||
// Note: pointer events are re-enabled after the animation ends, see event listeners above
|
||||
pointer-events: none;
|
||||
|
||||
&:focus-visible {
|
||||
transition-delay: 250ms;
|
||||
transition-property: outline-width;
|
||||
transition-duration: 0;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/*
|
||||
* overrides make mobile-first coding style challenging
|
||||
* so we explicitly define mobile breakpoint here
|
||||
*/
|
||||
${breakpoint("mobile", "tablet")`
|
||||
position: fixed !important;
|
||||
transform: none !important;
|
||||
top: auto !important;
|
||||
right: 8px !important;
|
||||
bottom: 16px !important;
|
||||
left: 8px !important;
|
||||
`};
|
||||
`;
|
||||
|
||||
type BackgroundProps = {
|
||||
topAnchor?: boolean;
|
||||
rightAnchor?: boolean;
|
||||
maxWidth?: number;
|
||||
minHeight?: number;
|
||||
theme: DefaultTheme;
|
||||
};
|
||||
|
||||
export const Background = styled(Scrollable)<BackgroundProps>`
|
||||
animation: ${mobileContextMenu} 200ms ease;
|
||||
transform-origin: 50% 100%;
|
||||
max-width: 100%;
|
||||
background: ${s("menuBackground")};
|
||||
border-radius: 6px;
|
||||
padding: 6px;
|
||||
min-width: 180px;
|
||||
min-height: ${(props) => props.minHeight || 44}px;
|
||||
max-height: 75vh;
|
||||
font-weight: normal;
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
animation: ${(props: BackgroundProps) =>
|
||||
props.topAnchor ? fadeAndSlideDown : fadeAndSlideUp} 200ms ease;
|
||||
transform-origin: ${(props: BackgroundProps) =>
|
||||
props.rightAnchor ? "75%" : "25%"} 0;
|
||||
max-width: ${(props: BackgroundProps) => props.maxWidth ?? 276}px;
|
||||
max-height: 100vh;
|
||||
background: ${(props: BackgroundProps) => props.theme.menuBackground};
|
||||
box-shadow: ${(props: BackgroundProps) => props.theme.menuShadow};
|
||||
`};
|
||||
`;
|
||||
@@ -6,7 +6,6 @@ import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import Text from "~/components/Text";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import Separator from "./ContextMenu/Separator";
|
||||
import Flex from "./Flex";
|
||||
import { LabelText } from "./Input";
|
||||
import NudeButton from "./NudeButton";
|
||||
@@ -219,9 +218,9 @@ const MobileSelect = React.forwardRef<HTMLButtonElement, MobileSelectProps>(
|
||||
);
|
||||
|
||||
const renderOption = React.useCallback(
|
||||
(option: Option) => {
|
||||
(option: Option, idx: number) => {
|
||||
if (option.type === "separator") {
|
||||
return <Separator />;
|
||||
return <InputSelectSeparator key={`separator-${idx}`} />;
|
||||
}
|
||||
|
||||
const isSelected = option === selectedOption;
|
||||
|
||||
+27
-30
@@ -12,7 +12,6 @@ import SkipNavLink from "~/components/SkipNavLink";
|
||||
import env from "~/env";
|
||||
import useAutoRefresh from "~/hooks/useAutoRefresh";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
import { MenuProvider } from "~/hooks/useMenuContext";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
@@ -38,41 +37,39 @@ const Layout = React.forwardRef(function Layout_(
|
||||
});
|
||||
|
||||
return (
|
||||
<MenuProvider>
|
||||
<Container column auto ref={ref}>
|
||||
<Helmet>
|
||||
<title>{title ? title : env.APP_NAME}</title>
|
||||
</Helmet>
|
||||
<Container column auto ref={ref}>
|
||||
<Helmet>
|
||||
<title>{title ? title : env.APP_NAME}</title>
|
||||
</Helmet>
|
||||
|
||||
<SkipNavLink />
|
||||
<SkipNavLink />
|
||||
|
||||
{ui.progressBarVisible && <LoadingIndicatorBar />}
|
||||
{ui.progressBarVisible && <LoadingIndicatorBar />}
|
||||
|
||||
<Container auto>
|
||||
<MenuProvider>{sidebar}</MenuProvider>
|
||||
<Container auto>
|
||||
{sidebar}
|
||||
|
||||
<SkipNavContent />
|
||||
<Content
|
||||
auto
|
||||
justify="center"
|
||||
$isResizing={ui.sidebarIsResizing}
|
||||
$sidebarCollapsed={sidebarCollapsed}
|
||||
$hasSidebar={!!sidebar}
|
||||
style={
|
||||
sidebarCollapsed
|
||||
? undefined
|
||||
: {
|
||||
marginLeft: `${ui.sidebarWidth}px`,
|
||||
}
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</Content>
|
||||
<SkipNavContent />
|
||||
<Content
|
||||
auto
|
||||
justify="center"
|
||||
$isResizing={ui.sidebarIsResizing}
|
||||
$sidebarCollapsed={sidebarCollapsed}
|
||||
$hasSidebar={!!sidebar}
|
||||
style={
|
||||
sidebarCollapsed
|
||||
? undefined
|
||||
: {
|
||||
marginLeft: `${ui.sidebarWidth}px`,
|
||||
}
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</Content>
|
||||
|
||||
{sidebarRight}
|
||||
</Container>
|
||||
{sidebarRight}
|
||||
</Container>
|
||||
</MenuProvider>
|
||||
</Container>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import { depths, s } from "@shared/styles";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import Flex from "~/components/Flex";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useMenuContext from "~/hooks/useMenuContext";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import usePrevious from "~/hooks/usePrevious";
|
||||
import useStores from "~/hooks/useStores";
|
||||
@@ -41,11 +40,10 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
const { ui } = useStores();
|
||||
const location = useLocation();
|
||||
const previousLocation = usePrevious(location);
|
||||
const { isMenuOpen } = useMenuContext();
|
||||
const user = useCurrentUser({ rejectOnEmpty: false });
|
||||
const isMobile = useMobile();
|
||||
const width = ui.sidebarWidth;
|
||||
const collapsed = ui.sidebarIsClosed && !isMenuOpen;
|
||||
const collapsed = ui.sidebarIsClosed;
|
||||
const maxWidth = theme.sidebarMaxWidth;
|
||||
const minWidth = theme.sidebarMinWidth + 16; // padding
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import { Props as ButtonProps } from "~/components/Button";
|
||||
import Separator from "~/components/ContextMenu/Separator";
|
||||
import { fadeAndSlideDown, fadeAndSlideUp } from "~/styles/animations";
|
||||
import {
|
||||
SelectItemIndicator,
|
||||
@@ -99,6 +98,10 @@ const InputSelectSeparator = React.forwardRef<
|
||||
));
|
||||
InputSelectSeparator.displayName = InputSelectPrimitive.Separator.displayName;
|
||||
|
||||
const Separator = styled.hr`
|
||||
margin: 6px 0;
|
||||
`;
|
||||
|
||||
/** Styled components. */
|
||||
const StyledContent = styled(InputSelectPrimitive.Content)`
|
||||
z-index: ${depths.menu};
|
||||
|
||||
@@ -104,7 +104,7 @@ const MenuContent = React.forwardRef<
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Content ref={ref} {...rest} {...offsetProp} collisionPadding={6} asChild>
|
||||
<Content ref={ref} {...offsetProp} {...rest} collisionPadding={6} asChild>
|
||||
<Components.MenuContent {...contentProps} hiddenScrollbars>
|
||||
{children}
|
||||
</Components.MenuContent>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { fadeAndScaleIn } from "~/styles/animations";
|
||||
|
||||
type BaseMenuItemProps = {
|
||||
disabled?: boolean;
|
||||
$active?: boolean;
|
||||
$dangerous?: boolean;
|
||||
};
|
||||
|
||||
@@ -44,6 +45,24 @@ const BaseMenuItemCSS = css<BaseMenuItemProps>`
|
||||
outline: 0; // Disable default outline on Firefox
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
props.$active &&
|
||||
!props.disabled &&
|
||||
`
|
||||
color: ${props.theme.accentText};
|
||||
background: ${props.$dangerous ? props.theme.danger : props.theme.accent};
|
||||
outline-color: ${
|
||||
props.$dangerous ? props.theme.danger : props.theme.accent
|
||||
};
|
||||
box-shadow: none;
|
||||
cursor: var(--pointer);
|
||||
|
||||
svg {
|
||||
color: ${props.theme.accentText};
|
||||
fill: ${props.theme.accentText};
|
||||
}
|
||||
`}
|
||||
|
||||
${(props) =>
|
||||
!props.disabled &&
|
||||
`
|
||||
@@ -58,7 +77,7 @@ const BaseMenuItemCSS = css<BaseMenuItemProps>`
|
||||
};
|
||||
box-shadow: none;
|
||||
cursor: var(--pointer);
|
||||
|
||||
|
||||
svg {
|
||||
color: ${props.theme.accentText};
|
||||
fill: ${props.theme.accentText};
|
||||
|
||||
@@ -17,6 +17,7 @@ import Logger from "~/utils/Logger";
|
||||
import { useEditor } from "./EditorContext";
|
||||
|
||||
type Props = {
|
||||
align?: "start" | "end" | "center";
|
||||
active?: boolean;
|
||||
children: React.ReactNode;
|
||||
width?: number;
|
||||
@@ -35,16 +36,18 @@ const defaultPosition = {
|
||||
function usePosition({
|
||||
menuRef,
|
||||
active,
|
||||
align = "center",
|
||||
}: {
|
||||
menuRef: React.RefObject<HTMLDivElement>;
|
||||
active?: boolean;
|
||||
align?: Props["align"];
|
||||
}) {
|
||||
const { view } = useEditor();
|
||||
const { selection } = view.state;
|
||||
const menuWidth = menuRef.current?.offsetWidth;
|
||||
const menuHeight = menuRef.current?.offsetHeight;
|
||||
const menuWidth = menuRef.current?.offsetWidth ?? 0;
|
||||
const menuHeight = menuRef.current?.offsetHeight ?? 0;
|
||||
|
||||
if (!active || !menuWidth || !menuHeight || !menuRef.current) {
|
||||
if (!active || !menuRef.current) {
|
||||
return defaultPosition;
|
||||
}
|
||||
|
||||
@@ -94,7 +97,7 @@ function usePosition({
|
||||
const element = view.nodeDOM(position);
|
||||
const bounds = (element as HTMLElement).getBoundingClientRect();
|
||||
selectionBounds.top = bounds.top;
|
||||
selectionBounds.left = bounds.right - menuWidth;
|
||||
selectionBounds.left = bounds.right;
|
||||
selectionBounds.right = bounds.right;
|
||||
}
|
||||
}
|
||||
@@ -180,7 +183,11 @@ function usePosition({
|
||||
),
|
||||
Math.max(
|
||||
Math.max(offsetParent.x, margin),
|
||||
centerOfSelection - menuWidth / 2
|
||||
align === "center"
|
||||
? centerOfSelection - menuWidth / 2
|
||||
: align === "start"
|
||||
? selectionBounds.left
|
||||
: selectionBounds.right
|
||||
)
|
||||
);
|
||||
const top = Math.max(
|
||||
@@ -216,6 +223,7 @@ const FloatingToolbar = React.forwardRef(function FloatingToolbar_(
|
||||
let position = usePosition({
|
||||
menuRef,
|
||||
active: props.active,
|
||||
align: props.align,
|
||||
});
|
||||
|
||||
if (isSelectingText) {
|
||||
@@ -277,7 +285,7 @@ const FloatingToolbar = React.forwardRef(function FloatingToolbar_(
|
||||
left: `${position.left}px`,
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
<Background align={props.align}>{props.children}</Background>
|
||||
</Wrapper>
|
||||
</Portal>
|
||||
);
|
||||
@@ -302,7 +310,7 @@ const arrow = (props: WrapperProps) =>
|
||||
border-radius: 3px;
|
||||
z-index: -1;
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
bottom: -3px;
|
||||
left: calc(50% - ${props.$offset || 0}px);
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -335,22 +343,42 @@ const MobileWrapper = styled.div`
|
||||
}
|
||||
`;
|
||||
|
||||
const Wrapper = styled.div<WrapperProps>`
|
||||
will-change: opacity, transform;
|
||||
padding: 6px;
|
||||
position: absolute;
|
||||
z-index: ${depths.editorToolbar};
|
||||
opacity: 0;
|
||||
const Background = styled.div<{ align: Props["align"] }>`
|
||||
position: relative;
|
||||
background-color: ${s("menuBackground")};
|
||||
box-shadow: ${s("menuShadow")};
|
||||
border-radius: 4px;
|
||||
height: 36px;
|
||||
padding: 6px;
|
||||
|
||||
${(props) =>
|
||||
props.align === "start" &&
|
||||
`
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
`}
|
||||
|
||||
${(props) =>
|
||||
props.align === "end" &&
|
||||
`
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
`}
|
||||
`;
|
||||
|
||||
const Wrapper = styled.div<WrapperProps>`
|
||||
will-change: opacity, transform;
|
||||
position: absolute;
|
||||
z-index: ${depths.editorToolbar};
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
transition:
|
||||
opacity 150ms cubic-bezier(0.175, 0.885, 0.32, 1.275),
|
||||
transform 150ms cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
transition-delay: 150ms;
|
||||
line-height: 0;
|
||||
height: 36px;
|
||||
box-sizing: border-box;
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
|
||||
@@ -199,9 +199,11 @@ export default function SelectionToolbar(props: Props) {
|
||||
const isNoticeSelection = isInNotice(state);
|
||||
|
||||
let items: MenuItem[] = [];
|
||||
let align: "center" | "start" | "end" = "center";
|
||||
|
||||
if (isCodeSelection && selection.empty) {
|
||||
items = getCodeMenuItems(state, readOnly, dictionary);
|
||||
align = "end";
|
||||
} else if (isTableSelection) {
|
||||
items = getTableMenuItems(state, dictionary);
|
||||
} else if (colIndex !== undefined) {
|
||||
@@ -220,6 +222,7 @@ export default function SelectionToolbar(props: Props) {
|
||||
items = getReadOnlyMenuItems(state, !!canUpdate, dictionary);
|
||||
} else if (isNoticeSelection && selection.empty) {
|
||||
items = getNoticeMenuItems(state, readOnly, dictionary);
|
||||
align = "end";
|
||||
} else {
|
||||
items = getFormattingMenuItems(state, isTemplate, dictionary);
|
||||
}
|
||||
@@ -251,6 +254,7 @@ export default function SelectionToolbar(props: Props) {
|
||||
|
||||
return (
|
||||
<FloatingToolbar
|
||||
align={align}
|
||||
active={isActive}
|
||||
ref={menuRef}
|
||||
width={showLinkToolbar || isEmbedSelection ? 336 : undefined}
|
||||
|
||||
@@ -14,13 +14,13 @@ import { MenuItem } from "@shared/editor/types";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import { getEventFiles } from "@shared/utils/files";
|
||||
import { AttachmentValidation } from "@shared/validations";
|
||||
import Header from "~/components/ContextMenu/Header";
|
||||
import { Portal } from "~/components/Portal";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import useDictionary from "~/hooks/useDictionary";
|
||||
import Logger from "~/utils/Logger";
|
||||
import { useEditor } from "./EditorContext";
|
||||
import Input from "./Input";
|
||||
import { MenuHeader } from "~/components/primitives/components/Menu";
|
||||
|
||||
type TopAnchor = {
|
||||
top: number;
|
||||
@@ -647,7 +647,9 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
const response = (
|
||||
<React.Fragment key={`${index}-${item.name}`}>
|
||||
{currentHeading !== previousHeading && (
|
||||
<Header key={currentHeading}>{currentHeading}</Header>
|
||||
<MenuHeader key={currentHeading}>
|
||||
{currentHeading}
|
||||
</MenuHeader>
|
||||
)}
|
||||
<ListItem
|
||||
onPointerMove={handlePointerMove}
|
||||
|
||||
@@ -2,8 +2,8 @@ import { transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import scrollIntoView from "scroll-into-view-if-needed";
|
||||
import styled from "styled-components";
|
||||
import MenuItem from "~/components/ContextMenu/MenuItem";
|
||||
import { usePortalContext } from "~/components/Portal";
|
||||
import { MenuButton, MenuLabel } from "~/components/primitives/components/Menu";
|
||||
|
||||
export type Props = {
|
||||
/** Whether the item is selected */
|
||||
@@ -53,17 +53,22 @@ function SuggestionsMenuItem({
|
||||
);
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
<MenuButton
|
||||
ref={ref}
|
||||
active={selected}
|
||||
onClick={disabled ? undefined : onClick}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
onPointerMove={disabled ? undefined : onPointerMove}
|
||||
icon={icon}
|
||||
$active={selected}
|
||||
>
|
||||
{title}
|
||||
{subtitle && <Subtitle $active={selected}>· {subtitle}</Subtitle>}
|
||||
{shortcut && <Shortcut $active={selected}>{shortcut}</Shortcut>}
|
||||
</MenuItem>
|
||||
{icon}
|
||||
<MenuLabel>
|
||||
{title}
|
||||
{subtitle && (
|
||||
<Subtitle $active={selected}>· {subtitle}</Subtitle>
|
||||
)}
|
||||
{shortcut && <Shortcut $active={selected}>{shortcut}</Shortcut>}
|
||||
</MenuLabel>
|
||||
</MenuButton>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { useMemo } from "react";
|
||||
import { useMenuState } from "reakit";
|
||||
import { MenuButton } from "reakit/Menu";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import * as Toolbar from "@radix-ui/react-toolbar";
|
||||
import { MenuItem } from "@shared/editor/types";
|
||||
import { s } from "@shared/styles";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import Template from "~/components/ContextMenu/Template";
|
||||
import { TooltipProvider } from "~/components/TooltipContext";
|
||||
import { MenuItem as TMenuItem } from "~/types";
|
||||
import { useEditor } from "./EditorContext";
|
||||
@@ -14,6 +11,12 @@ import { MediaDimension } from "./MediaDimension";
|
||||
import ToolbarButton from "./ToolbarButton";
|
||||
import ToolbarSeparator from "./ToolbarSeparator";
|
||||
import Tooltip from "./Tooltip";
|
||||
import { toMenuItems } from "~/components/Menu/transformer";
|
||||
import { MenuContent } from "~/components/primitives/Menu";
|
||||
import { MenuProvider } from "~/components/primitives/Menu/MenuContext";
|
||||
import { Menu, MenuTrigger } from "~/components/primitives/Menu";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import EventBoundary from "@shared/components/EventBoundary";
|
||||
|
||||
type Props = {
|
||||
items: MenuItem[];
|
||||
@@ -23,8 +26,8 @@ type Props = {
|
||||
* Renders a dropdown menu in the floating toolbar.
|
||||
*/
|
||||
function ToolbarDropdown(props: { active: boolean; item: MenuItem }) {
|
||||
const menu = useMenuState();
|
||||
const { commands, view } = useEditor();
|
||||
const { t } = useTranslation();
|
||||
const { item } = props;
|
||||
const { state } = view;
|
||||
|
||||
@@ -60,24 +63,30 @@ function ToolbarDropdown(props: { active: boolean; item: MenuItem }) {
|
||||
: [];
|
||||
}, [item.children, commands, state]);
|
||||
|
||||
const handleCloseAutoFocus = useCallback((ev: Event) => {
|
||||
ev.stopImmediatePropagation();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuButton {...menu}>
|
||||
{(buttonProps) => (
|
||||
<ToolbarButton
|
||||
{...buttonProps}
|
||||
hovering={menu.visible}
|
||||
aria-label={item.tooltip}
|
||||
<EventBoundary>
|
||||
<MenuProvider variant="dropdown">
|
||||
<Menu>
|
||||
<MenuTrigger>
|
||||
<ToolbarButton aria-label={item.label ? undefined : item.tooltip}>
|
||||
{item.label && <Label>{item.label}</Label>}
|
||||
{item.icon}
|
||||
</ToolbarButton>
|
||||
</MenuTrigger>
|
||||
<MenuContent
|
||||
align="end"
|
||||
aria-label={item.tooltip || t("More options")}
|
||||
onCloseAutoFocus={handleCloseAutoFocus}
|
||||
>
|
||||
{item.label && <Label>{item.label}</Label>}
|
||||
{item.icon}
|
||||
</ToolbarButton>
|
||||
)}
|
||||
</MenuButton>
|
||||
<ContextMenu aria-label={item.label} {...menu}>
|
||||
<Template {...menu} items={items} />
|
||||
</ContextMenu>
|
||||
</>
|
||||
{toMenuItems(items)}
|
||||
</MenuContent>
|
||||
</Menu>
|
||||
</MenuProvider>
|
||||
</EventBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -98,40 +107,47 @@ function ToolbarMenu(props: Props) {
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<FlexibleWrapper>
|
||||
{items.map((item, index) => {
|
||||
if (item.name === "separator" && item.visible !== false) {
|
||||
return <ToolbarSeparator key={index} />;
|
||||
}
|
||||
if (item.visible === false || (!item.skipIcon && !item.icon)) {
|
||||
return null;
|
||||
}
|
||||
const isActive = item.active ? item.active(state) : false;
|
||||
<Toolbar.Root asChild>
|
||||
<FlexibleWrapper>
|
||||
{items.map((item, index) => {
|
||||
if (item.name === "separator" && item.visible !== false) {
|
||||
return <ToolbarSeparator key={index} />;
|
||||
}
|
||||
if (item.visible === false || (!item.skipIcon && !item.icon)) {
|
||||
return null;
|
||||
}
|
||||
const isActive = item.active ? item.active(state) : false;
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
key={index}
|
||||
shortcut={item.shortcut}
|
||||
content={item.label === item.tooltip ? undefined : item.tooltip}
|
||||
>
|
||||
{item.name === "dimensions" ? (
|
||||
<MediaDimension key={index} />
|
||||
) : item.children ? (
|
||||
<ToolbarDropdown active={isActive && !item.label} item={item} />
|
||||
) : (
|
||||
<ToolbarButton
|
||||
onClick={handleClick(item)}
|
||||
active={isActive && !item.label}
|
||||
aria-label={item.label ? undefined : item.tooltip}
|
||||
>
|
||||
{item.label && <Label>{item.label}</Label>}
|
||||
{item.icon}
|
||||
</ToolbarButton>
|
||||
)}
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</FlexibleWrapper>
|
||||
return (
|
||||
<Tooltip
|
||||
key={index}
|
||||
shortcut={item.shortcut}
|
||||
content={item.label === item.tooltip ? undefined : item.tooltip}
|
||||
>
|
||||
{item.name === "dimensions" ? (
|
||||
<MediaDimension key={index} />
|
||||
) : item.children ? (
|
||||
<ToolbarDropdown
|
||||
active={isActive && !item.label}
|
||||
item={item}
|
||||
/>
|
||||
) : (
|
||||
<Toolbar.Button asChild>
|
||||
<ToolbarButton
|
||||
onClick={handleClick(item)}
|
||||
active={isActive && !item.label}
|
||||
aria-label={item.label ? undefined : item.tooltip}
|
||||
>
|
||||
{item.label && <Label>{item.label}</Label>}
|
||||
{item.icon}
|
||||
</ToolbarButton>
|
||||
</Toolbar.Button>
|
||||
)}
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</FlexibleWrapper>
|
||||
</Toolbar.Root>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import noop from "lodash/noop";
|
||||
import * as React from "react";
|
||||
|
||||
type MenuContextType = {
|
||||
isMenuOpen: boolean;
|
||||
setIsMenuOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
registerMenu: (menuId: string, hideFunction: () => void) => void;
|
||||
unregisterMenu: (menuId: string) => void;
|
||||
closeOtherMenus: (...menuIds: (string | undefined)[]) => void;
|
||||
};
|
||||
|
||||
const MenuContext = React.createContext<MenuContextType | null>(null);
|
||||
|
||||
// Registry to track all active menu instances
|
||||
const menuRegistry = new Map();
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const MenuProvider: React.FC = ({ children }: Props) => {
|
||||
const [isMenuOpen, setIsMenuOpen] = React.useState(false);
|
||||
|
||||
const registerMenu = React.useCallback(
|
||||
(menuId: string, hideFunction: () => void) => {
|
||||
menuRegistry.set(menuId, hideFunction);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const unregisterMenu = React.useCallback((menuId: string) => {
|
||||
menuRegistry.delete(menuId);
|
||||
}, []);
|
||||
|
||||
const closeOtherMenus = React.useCallback(
|
||||
(...menuIds: (string | undefined)[]) => {
|
||||
menuRegistry.forEach((hideFunction, menuId) => {
|
||||
if (!menuIds.includes(menuId)) {
|
||||
hideFunction();
|
||||
}
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const memoized = React.useMemo(
|
||||
() => ({
|
||||
isMenuOpen,
|
||||
setIsMenuOpen,
|
||||
registerMenu,
|
||||
unregisterMenu,
|
||||
closeOtherMenus,
|
||||
}),
|
||||
[isMenuOpen, setIsMenuOpen, registerMenu, unregisterMenu, closeOtherMenus]
|
||||
);
|
||||
|
||||
return (
|
||||
<MenuContext.Provider value={memoized}>{children}</MenuContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const useMenuContext: () => MenuContextType = () => {
|
||||
const value = React.useContext(MenuContext);
|
||||
return value
|
||||
? value
|
||||
: {
|
||||
isMenuOpen: false,
|
||||
setIsMenuOpen: noop,
|
||||
registerMenu: noop,
|
||||
unregisterMenu: noop,
|
||||
closeOtherMenus: noop,
|
||||
};
|
||||
};
|
||||
|
||||
export default useMenuContext;
|
||||
@@ -1,46 +0,0 @@
|
||||
import * as React from "react";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import useWindowSize from "~/hooks/useWindowSize";
|
||||
|
||||
const useMenuHeight = ({
|
||||
visible,
|
||||
elementRef,
|
||||
maxViewportHeight = 90,
|
||||
margin = 8,
|
||||
}: {
|
||||
/** Whether the menu is visible. */
|
||||
visible: void | boolean;
|
||||
/** The maximum height of the menu as a percentage of the viewport. */
|
||||
maxViewportHeight?: number;
|
||||
/** A ref pointing to the element for the menu disclosure. */
|
||||
elementRef?: React.RefObject<HTMLElement | null>;
|
||||
/** The margin to apply to the positioning. */
|
||||
margin?: number;
|
||||
}) => {
|
||||
const [maxHeight, setMaxHeight] = React.useState<number | undefined>(10);
|
||||
const isMobile = useMobile();
|
||||
const { height: windowHeight } = useWindowSize();
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (visible && !isMobile) {
|
||||
const calculatedMaxHeight = (windowHeight / 100) * maxViewportHeight;
|
||||
|
||||
setMaxHeight(
|
||||
Math.min(
|
||||
calculatedMaxHeight,
|
||||
elementRef?.current
|
||||
? windowHeight -
|
||||
elementRef.current.getBoundingClientRect().bottom -
|
||||
margin
|
||||
: 0
|
||||
)
|
||||
);
|
||||
} else {
|
||||
setMaxHeight(0);
|
||||
}
|
||||
}, [visible, elementRef, windowHeight, margin, isMobile, maxViewportHeight]);
|
||||
|
||||
return maxHeight;
|
||||
};
|
||||
|
||||
export default useMenuHeight;
|
||||
@@ -1,48 +0,0 @@
|
||||
import * as React from "react";
|
||||
import {
|
||||
// oxlint-disable-next-line no-restricted-imports
|
||||
useMenuState as reakitUseMenuState,
|
||||
MenuStateReturn,
|
||||
} from "reakit/Menu";
|
||||
import useMenuContext from "./useMenuContext";
|
||||
|
||||
type Props = Parameters<typeof reakitUseMenuState>[0] & {
|
||||
parentId?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A hook that wraps Reakit's useMenuState with coordination logic to ensure
|
||||
* only one context menu can be open at a time across the application.
|
||||
*/
|
||||
export function useMenuState(options?: Props): MenuStateReturn {
|
||||
const menuState = reakitUseMenuState(options);
|
||||
const { registerMenu, unregisterMenu, closeOtherMenus } = useMenuContext();
|
||||
const menuId = menuState.baseId;
|
||||
const parentId = options?.parentId;
|
||||
|
||||
// Register this menu instance on mount and unregister on unmount
|
||||
React.useEffect(() => {
|
||||
registerMenu(menuId, menuState.hide);
|
||||
return () => unregisterMenu(menuId);
|
||||
}, [menuId, menuState.hide, registerMenu, unregisterMenu]);
|
||||
|
||||
const coordinatedShow = React.useCallback(() => {
|
||||
closeOtherMenus(menuId, parentId);
|
||||
menuState.show();
|
||||
}, [closeOtherMenus, menuId, menuState, parentId]);
|
||||
|
||||
const coordinatedToggle = React.useCallback(() => {
|
||||
closeOtherMenus(menuId, parentId);
|
||||
menuState.toggle();
|
||||
}, [menuId, menuState, closeOtherMenus, parentId]);
|
||||
|
||||
// Return the menu state with the coordinated show method
|
||||
return React.useMemo(
|
||||
() => ({
|
||||
...menuState,
|
||||
toggle: coordinatedToggle,
|
||||
show: coordinatedShow,
|
||||
}),
|
||||
[menuState, coordinatedToggle, coordinatedShow]
|
||||
);
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import throttle from "lodash/throttle";
|
||||
import { useState, useMemo } from "react";
|
||||
import useEventListener from "./useEventListener";
|
||||
import useIsMounted from "./useIsMounted";
|
||||
|
||||
/**
|
||||
* Mouse position as a tuple of [x, y]
|
||||
*/
|
||||
type MousePosition = [number, number];
|
||||
|
||||
/**
|
||||
* Hook to get the current mouse position
|
||||
*
|
||||
* @returns Mouse position as a tuple of [x, y]
|
||||
*/
|
||||
export const useMousePosition = () => {
|
||||
const isMounted = useIsMounted();
|
||||
const [mousePosition, setMousePosition] = useState<MousePosition>([0, 0]);
|
||||
|
||||
const updateMousePosition = useMemo(
|
||||
() =>
|
||||
throttle((ev: MouseEvent) => {
|
||||
if (isMounted()) {
|
||||
setMousePosition([ev.clientX, ev.clientY]);
|
||||
}
|
||||
}, 200),
|
||||
[isMounted]
|
||||
);
|
||||
|
||||
useEventListener("mousemove", updateMousePosition);
|
||||
|
||||
return mousePosition;
|
||||
};
|
||||
@@ -4,6 +4,7 @@ import User from "./User";
|
||||
import Model from "./base/Model";
|
||||
import Relation from "./decorators/Relation";
|
||||
import Field from "./decorators/Field";
|
||||
import { observable } from "mobx";
|
||||
|
||||
/**
|
||||
* Represents a user's membership to a group.
|
||||
@@ -27,6 +28,7 @@ class GroupUser extends Model {
|
||||
|
||||
/** The permission of the user in the group. */
|
||||
@Field
|
||||
@observable
|
||||
permission: GroupPermission;
|
||||
}
|
||||
|
||||
|
||||
@@ -59,11 +59,14 @@ export default class GroupUsersStore extends Store<GroupUser> {
|
||||
permission,
|
||||
});
|
||||
invariant(res?.data, "Group Membership data should be available");
|
||||
res.data.users.forEach(this.rootStore.users.add);
|
||||
res.data.groups.forEach(this.rootStore.groups.add);
|
||||
|
||||
const groupMemberships = res.data.groupMemberships.map(this.add);
|
||||
return groupMemberships[0];
|
||||
return runInAction(`GroupUsersStore#create`, () => {
|
||||
res.data.users.forEach(this.rootStore.users.add);
|
||||
res.data.groups.forEach(this.rootStore.groups.add);
|
||||
|
||||
const groupMemberships = res.data.groupMemberships.map(this.add);
|
||||
return groupMemberships[0];
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
@@ -96,11 +99,14 @@ export default class GroupUsersStore extends Store<GroupUser> {
|
||||
permission,
|
||||
});
|
||||
invariant(res?.data, "Group Membership data should be available");
|
||||
res.data.users.forEach(this.rootStore.users.add);
|
||||
res.data.groups.forEach(this.rootStore.groups.add);
|
||||
|
||||
const groupMemberships = res.data.groupMemberships.map(this.add);
|
||||
return groupMemberships[0];
|
||||
return runInAction(`GroupUsersStore#update`, () => {
|
||||
res.data.users.forEach(this.rootStore.users.add);
|
||||
res.data.groups.forEach(this.rootStore.groups.add);
|
||||
|
||||
const groupMemberships = res.data.groupMemberships.map(this.add);
|
||||
return groupMemberships[0];
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
|
||||
+1
-1
@@ -55,7 +55,7 @@ export function redirectTo(url: string) {
|
||||
export const isAllowedLoginRedirect = (input: string) => {
|
||||
const path = input.split("?")[0].split("#")[0];
|
||||
return (
|
||||
!["/", "/create", "/home", "/logout"].includes(path) &&
|
||||
!["/", "/create", "/home", "/logout", "/desktop-redirect"].includes(path) &&
|
||||
!path.startsWith("/auth/") &&
|
||||
!path.startsWith("/s/")
|
||||
);
|
||||
|
||||
+7
-7
@@ -51,11 +51,11 @@
|
||||
"> 0.25%, not dead"
|
||||
],
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.888.0",
|
||||
"@aws-sdk/lib-storage": "3.888.0",
|
||||
"@aws-sdk/s3-presigned-post": "3.888.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.888.0",
|
||||
"@aws-sdk/signature-v4-crt": "^3.888.0",
|
||||
"@aws-sdk/client-s3": "3.893.0",
|
||||
"@aws-sdk/lib-storage": "3.893.0",
|
||||
"@aws-sdk/s3-presigned-post": "3.893.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.893.0",
|
||||
"@aws-sdk/signature-v4-crt": "^3.893.0",
|
||||
"@babel/core": "^7.28.4",
|
||||
"@babel/plugin-proposal-decorators": "^7.28.0",
|
||||
"@babel/plugin-transform-class-properties": "^7.27.1",
|
||||
@@ -100,6 +100,7 @@
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toolbar": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@radix-ui/react-visually-hidden": "^1.2.2",
|
||||
"@sentry/node": "^7.120.4",
|
||||
@@ -227,7 +228,6 @@
|
||||
"react-virtualized-auto-sizer": "^1.0.26",
|
||||
"react-waypoint": "^10.3.0",
|
||||
"react-window": "^1.8.11",
|
||||
"reakit": "^1.3.11",
|
||||
"redlock": "^5.0.0-beta.2",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"refractor": "^3.6.0",
|
||||
@@ -327,7 +327,7 @@
|
||||
"@types/react-helmet": "^6.1.11",
|
||||
"@types/react-portal": "^4.0.7",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@types/react-virtualized-auto-sizer": "^1.0.4",
|
||||
"@types/react-virtualized-auto-sizer": "^1.0.8",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"@types/readable-stream": "^4.0.21",
|
||||
"@types/redis-info": "^3.0.3",
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { faker } from "@faker-js/faker";
|
||||
import { UserRole } from "@shared/types";
|
||||
import { buildUser } from "@server/test/factories";
|
||||
import { buildTeam, buildUser } from "@server/test/factories";
|
||||
import userInviter from "./userInviter";
|
||||
import { withAPIContext } from "@server/test/support";
|
||||
import { TeamDomain } from "@server/models";
|
||||
|
||||
describe("userInviter", () => {
|
||||
it("should return sent invites", async () => {
|
||||
@@ -37,6 +38,58 @@ describe("userInviter", () => {
|
||||
expect(response.sent.length).toEqual(0);
|
||||
});
|
||||
|
||||
it("should error on non allowed domains", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
|
||||
await TeamDomain.create({
|
||||
teamId: team.id,
|
||||
name: faker.internet.domainName(),
|
||||
createdById: user.id,
|
||||
});
|
||||
|
||||
await withAPIContext(user, (ctx) =>
|
||||
expect(
|
||||
userInviter(ctx, {
|
||||
invites: [
|
||||
{
|
||||
role: UserRole.Member,
|
||||
email: "test@example.com",
|
||||
name: "Test",
|
||||
},
|
||||
],
|
||||
})
|
||||
).rejects.toThrow("The domain is not allowed for this workspace")
|
||||
);
|
||||
});
|
||||
|
||||
it("should allow invites for allowed domains", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
const allowedDomain = "google.com";
|
||||
|
||||
await TeamDomain.create({
|
||||
teamId: team.id,
|
||||
name: allowedDomain,
|
||||
createdById: user.id,
|
||||
});
|
||||
|
||||
const response = await withAPIContext(user, (ctx) =>
|
||||
userInviter(ctx, {
|
||||
invites: [
|
||||
{
|
||||
role: UserRole.Member,
|
||||
email: `test@${allowedDomain}`,
|
||||
name: "Test User",
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
expect(response.sent.length).toEqual(1);
|
||||
expect(response.sent[0].email).toEqual(`test@${allowedDomain}`);
|
||||
});
|
||||
|
||||
it("should filter obviously bunk emails", async () => {
|
||||
const user = await buildUser();
|
||||
const response = await withAPIContext(user, (ctx) =>
|
||||
|
||||
@@ -6,6 +6,7 @@ import Logger from "@server/logging/Logger";
|
||||
import { User, Team } from "@server/models";
|
||||
import { UserFlag } from "@server/models/User";
|
||||
import { APIContext } from "@server/types";
|
||||
import { DomainNotAllowedError } from "@server/errors";
|
||||
|
||||
export type Invite = {
|
||||
name: string;
|
||||
@@ -41,6 +42,13 @@ export default async function userInviter(
|
||||
);
|
||||
// filter out any existing users in the system
|
||||
const emails = normalizedInvites.map((invite) => invite.email);
|
||||
|
||||
for (const email of emails) {
|
||||
if (!(await team.isDomainAllowed(email))) {
|
||||
throw DomainNotAllowedError();
|
||||
}
|
||||
}
|
||||
|
||||
const existingUsers = await User.findAll({
|
||||
where: {
|
||||
teamId: user.teamId,
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
const shared = {
|
||||
use_env_variable: process.env.DATABASE_URL ? "DATABASE_URL" : undefined,
|
||||
dialect: "postgres",
|
||||
host: process.env.DATABASE_HOST,
|
||||
port: process.env.DATABASE_PORT || 5432,
|
||||
username: process.env.DATABASE_USER,
|
||||
password: process.env.DATABASE_PASSWORD || undefined,
|
||||
database: process.env.DATABASE_NAME,
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
development: shared,
|
||||
test: shared,
|
||||
"production-ssl-disabled": shared,
|
||||
production: {
|
||||
...shared,
|
||||
dialectOptions: {
|
||||
ssl: {
|
||||
rejectUnauthorized: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"development": {
|
||||
"use_env_variable": "DATABASE_URL",
|
||||
"dialect": "postgres"
|
||||
},
|
||||
"test": {
|
||||
"use_env_variable": "DATABASE_URL",
|
||||
"dialect": "postgres"
|
||||
},
|
||||
"production": {
|
||||
"use_env_variable": "DATABASE_URL",
|
||||
"dialect": "postgres",
|
||||
"dialectOptions": {
|
||||
"ssl": {
|
||||
"rejectUnauthorized": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"production-ssl-disabled": {
|
||||
"use_env_variable": "DATABASE_URL",
|
||||
"dialect": "postgres"
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import ParanoidModel from "./base/ParanoidModel";
|
||||
import Encrypted from "./decorators/Encrypted";
|
||||
import Fix from "./decorators/Fix";
|
||||
import Length from "./validators/Length";
|
||||
import { randomString } from "@shared/random";
|
||||
|
||||
@DefaultScope(() => ({
|
||||
include: [
|
||||
@@ -98,7 +99,14 @@ class WebhookSubscription extends ParanoidModel<
|
||||
}
|
||||
}
|
||||
|
||||
// methods
|
||||
// instance methods
|
||||
|
||||
/**
|
||||
* Rotate the secret value. Does not persist to database.
|
||||
*/
|
||||
public rotateSecret() {
|
||||
this.secret = `ol_whs_${randomString(32)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disables the webhook subscription
|
||||
|
||||
@@ -208,7 +208,7 @@ allow(User, "delete", Document, (actor, document) =>
|
||||
)
|
||||
);
|
||||
|
||||
allow(User, ["restore", "permanentDelete"], Document, (actor, document) =>
|
||||
allow(User, "restore", Document, (actor, document) =>
|
||||
and(
|
||||
isTeamModel(actor, document),
|
||||
!actor.isGuest,
|
||||
@@ -229,6 +229,15 @@ allow(User, ["restore", "permanentDelete"], Document, (actor, document) =>
|
||||
)
|
||||
);
|
||||
|
||||
allow(User, "permanentDelete", Document, (actor, document) =>
|
||||
and(
|
||||
isTeamModel(actor, document),
|
||||
!actor.isGuest,
|
||||
!!document?.isDeleted,
|
||||
isTeamAdmin(actor, document)
|
||||
)
|
||||
);
|
||||
|
||||
allow(User, "archive", Document, (actor, document) =>
|
||||
and(
|
||||
!document?.template,
|
||||
|
||||
@@ -533,7 +533,7 @@ export default abstract class ImportsProcessor<
|
||||
const json = node.toJSON() as ProsemirrorData;
|
||||
const attrs = json.attrs ?? {};
|
||||
|
||||
attrs.size = attachmentsMap[attrs.id as string].size;
|
||||
attrs.size = attachmentsMap[attrs.id as string]?.size;
|
||||
|
||||
json.attrs = attrs;
|
||||
return Node.fromJSON(schema, json);
|
||||
|
||||
@@ -2268,7 +2268,7 @@ describe("#documents.deleted", () => {
|
||||
expect(body.data.length).toEqual(1);
|
||||
expect(body.policies[0].abilities.delete).toEqual(false);
|
||||
expect(body.policies[0].abilities.restore).toBeTruthy();
|
||||
expect(body.policies[0].abilities.permanentDelete).toBeTruthy();
|
||||
expect(body.policies[0].abilities.permanentDelete).toEqual(false);
|
||||
});
|
||||
|
||||
it("should return deleted documents, including users drafts without collection", async () => {
|
||||
@@ -2303,6 +2303,26 @@ describe("#documents.deleted", () => {
|
||||
expect(body.data.length).toEqual(2);
|
||||
expect(body.policies[0].abilities.delete).toEqual(false);
|
||||
expect(body.policies[0].abilities.restore).toBeTruthy();
|
||||
expect(body.policies[0].abilities.permanentDelete).toEqual(false);
|
||||
});
|
||||
|
||||
it("should return deleted documents with permanent delete abilities for admin users", async () => {
|
||||
const admin = await buildAdmin();
|
||||
const document = await buildDocument({
|
||||
userId: admin.id,
|
||||
teamId: admin.teamId,
|
||||
});
|
||||
await document.delete(admin);
|
||||
const res = await server.post("/api/documents.deleted", {
|
||||
body: {
|
||||
token: admin.getJwtToken(),
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.length).toEqual(1);
|
||||
expect(body.policies[0].abilities.delete).toEqual(false);
|
||||
expect(body.policies[0].abilities.restore).toBeTruthy();
|
||||
expect(body.policies[0].abilities.permanentDelete).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -4432,7 +4452,7 @@ describe("#documents.delete", () => {
|
||||
expect(deletedDoc?.deletedAt).not.toBe(null);
|
||||
});
|
||||
|
||||
it("should allow permanently deleting a document", async () => {
|
||||
it("should allow permanently deleting a document as admin", async () => {
|
||||
const user = await buildAdmin();
|
||||
const document = await buildDocument({
|
||||
userId: user.id,
|
||||
@@ -4456,6 +4476,31 @@ describe("#documents.delete", () => {
|
||||
expect(body.success).toEqual(true);
|
||||
});
|
||||
|
||||
it("should not allow permanently deleting a document as non-admin", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
const document = await buildDocument({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
await server.post("/api/documents.delete", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: document.id,
|
||||
},
|
||||
});
|
||||
const res = await server.post("/api/documents.delete", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: document.id,
|
||||
permanent: true,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(403);
|
||||
expect(body.message).toEqual("Authorization error");
|
||||
});
|
||||
|
||||
it("should allow deleting document without collection", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
|
||||
@@ -128,6 +128,82 @@ describe("#events.list", () => {
|
||||
expect(body.data[0].id).toEqual(auditEvent.id);
|
||||
});
|
||||
|
||||
it("should not allow members to filter by actorId", async () => {
|
||||
const user = await buildUser();
|
||||
const admin = await buildAdmin({ teamId: user.teamId });
|
||||
const collection = await buildCollection({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
const document = await buildDocument({
|
||||
userId: user.id,
|
||||
collectionId: collection.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
// audit event
|
||||
await buildEvent({
|
||||
name: "users.promote",
|
||||
teamId: user.teamId,
|
||||
actorId: admin.id,
|
||||
userId: user.id,
|
||||
});
|
||||
// event viewable in activity stream
|
||||
await buildEvent({
|
||||
name: "documents.publish",
|
||||
collectionId: collection.id,
|
||||
documentId: document.id,
|
||||
teamId: user.teamId,
|
||||
actorId: user.id,
|
||||
});
|
||||
const res = await server.post("/api/events.list", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
actorId: admin.id,
|
||||
},
|
||||
});
|
||||
expect(res.status).toEqual(403);
|
||||
});
|
||||
|
||||
it("should allow filtering by actorId when it's the current user", async () => {
|
||||
const user = await buildUser();
|
||||
const admin = await buildAdmin({ teamId: user.teamId });
|
||||
const collection = await buildCollection({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
const document = await buildDocument({
|
||||
userId: user.id,
|
||||
collectionId: collection.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
// event by admin
|
||||
await buildEvent({
|
||||
name: "documents.create",
|
||||
collectionId: collection.id,
|
||||
documentId: document.id,
|
||||
teamId: user.teamId,
|
||||
actorId: admin.id,
|
||||
});
|
||||
// event by user
|
||||
const userEvent = await buildEvent({
|
||||
name: "documents.publish",
|
||||
collectionId: collection.id,
|
||||
documentId: document.id,
|
||||
teamId: user.teamId,
|
||||
actorId: user.id,
|
||||
});
|
||||
const res = await server.post("/api/events.list", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
actorId: user.id,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.length).toEqual(1);
|
||||
expect(body.data[0].id).toEqual(userEvent.id);
|
||||
});
|
||||
|
||||
it("should allow filtering by documentId", async () => {
|
||||
const user = await buildUser();
|
||||
const admin = await buildAdmin({ teamId: user.teamId });
|
||||
@@ -184,9 +260,7 @@ describe("#events.list", () => {
|
||||
documentId: document.id,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.length).toEqual(0);
|
||||
expect(res.status).toEqual(403);
|
||||
});
|
||||
|
||||
it("should allow filtering by event name", async () => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Op, WhereOptions } from "sequelize";
|
||||
import { EventHelper } from "@shared/utils/EventHelper";
|
||||
import auth from "@server/middlewares/authentication";
|
||||
import validate from "@server/middlewares/validate";
|
||||
import { Event, User, Collection } from "@server/models";
|
||||
import { Event, User, Collection, Document } from "@server/models";
|
||||
import { authorize } from "@server/policies";
|
||||
import { presentEvent } from "@server/presenters";
|
||||
import { APIContext } from "@server/types";
|
||||
@@ -52,20 +52,25 @@ router.post(
|
||||
}
|
||||
|
||||
if (actorId) {
|
||||
const actor = await User.findByPk(actorId);
|
||||
authorize(user, "readDetails", actor);
|
||||
where = { ...where, actorId };
|
||||
}
|
||||
|
||||
if (documentId) {
|
||||
const document = await Document.findByPk(documentId, {
|
||||
userId: user.id,
|
||||
});
|
||||
authorize(user, "read", document);
|
||||
where = { ...where, documentId };
|
||||
}
|
||||
|
||||
if (collectionId) {
|
||||
where = { ...where, collectionId };
|
||||
|
||||
const collection = await Collection.findByPk(collectionId, {
|
||||
userId: user.id,
|
||||
});
|
||||
authorize(user, "read", collection);
|
||||
where = { ...where, collectionId };
|
||||
} else {
|
||||
const collectionIds = await user.collectionIds({
|
||||
paranoid: false,
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import "./bootstrap";
|
||||
import * as readline from "readline";
|
||||
import { Transaction } from "sequelize";
|
||||
|
||||
import {
|
||||
OAuthClient,
|
||||
User,
|
||||
UserAuthentication,
|
||||
WebhookSubscription,
|
||||
} from "@server/models";
|
||||
|
||||
import { sequelize } from "@server/storage/database";
|
||||
|
||||
// Helper function to prompt user for input
|
||||
function askQuestion(question: string): Promise<string> {
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
rl.question(question, (answer) => {
|
||||
rl.close();
|
||||
resolve(answer.trim().toLowerCase());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Helper function to pause and wait for user confirmation
|
||||
async function waitForConfirmation(message: string): Promise<boolean> {
|
||||
const answer = await askQuestion(`${message} (y/N): `);
|
||||
return answer === "y" || answer === "yes";
|
||||
}
|
||||
|
||||
export default async function main() {
|
||||
console.log("🔐 Reset Encrypted Data Script");
|
||||
console.log("This script will:");
|
||||
console.log("- Delete all user authentication tokens");
|
||||
console.log("- Rotate webhook signing secrets");
|
||||
console.log("- Rotate OAuth client secrets");
|
||||
console.log("- Rotate JWT secrets for all users (logging them out)");
|
||||
console.log("");
|
||||
|
||||
const shouldContinue = await waitForConfirmation(
|
||||
"⚠️ This will log out all users and invalidate tokens. Continue?"
|
||||
);
|
||||
if (!shouldContinue) {
|
||||
console.log("❌ Operation cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
await sequelize.transaction(async (transaction) => {
|
||||
await UserAuthentication.destroy({
|
||||
where: {},
|
||||
transaction,
|
||||
});
|
||||
|
||||
const webhooks = await WebhookSubscription.findAll({
|
||||
lock: Transaction.LOCK.UPDATE,
|
||||
transaction,
|
||||
});
|
||||
|
||||
for (const webhook of webhooks) {
|
||||
try {
|
||||
webhook.rotateSecret();
|
||||
await webhook.save({ transaction });
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`Failed to rotate webhook signing secret for webhook ${webhook.id}:`,
|
||||
err
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const oauthClients = await OAuthClient.findAll({
|
||||
lock: Transaction.LOCK.UPDATE,
|
||||
transaction,
|
||||
});
|
||||
|
||||
for (const client of oauthClients) {
|
||||
try {
|
||||
client.rotateClientSecret();
|
||||
await client.save({ transaction });
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`Failed to rotate OAuth client secret for client ${client.id}:`,
|
||||
err
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const users = await User.findAll({
|
||||
lock: Transaction.LOCK.UPDATE,
|
||||
transaction,
|
||||
});
|
||||
|
||||
for (const user of users) {
|
||||
try {
|
||||
await user.rotateJwtSecret({ transaction });
|
||||
} catch (err) {
|
||||
console.error(`Failed to rotate JWT secret for user ${user.id}:`, err);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Reset encrypted data, logged out ${users.length} users`);
|
||||
});
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// In the test suite we import the script rather than run via node CLI
|
||||
if (process.env.NODE_ENV !== "test") {
|
||||
void main();
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import fetchMock from "jest-fetch-mock";
|
||||
import OAuthClient from "./oauth";
|
||||
|
||||
class MinimalOAuthClient extends OAuthClient {
|
||||
endpoints = {
|
||||
authorize: 'http://example.com/authorize',
|
||||
token: 'http://example.com/token',
|
||||
userinfo: 'http://example.com/userinfo',
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.resetMocks();
|
||||
});
|
||||
|
||||
describe("userInfo", () => {
|
||||
it("should work with empty-body 401 Unauthorized responses", async () => {
|
||||
fetchMock.mockResponseOnce('', {
|
||||
status: 401,
|
||||
statusText: 'unauthorized',
|
||||
});
|
||||
|
||||
const client = new MinimalOAuthClient('clientid', 'clientsecret');
|
||||
try {
|
||||
expect.assertions(1);
|
||||
await client.userInfo('token');
|
||||
} catch (e) {
|
||||
expect(e.id).toBe('authentication_required');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -30,7 +30,6 @@ export default abstract class OAuthClient {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
data = await response.json();
|
||||
} catch (err) {
|
||||
throw InvalidRequestError(err.message);
|
||||
}
|
||||
@@ -40,6 +39,12 @@ export default abstract class OAuthClient {
|
||||
throw AuthenticationError();
|
||||
}
|
||||
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch (err) {
|
||||
throw InvalidRequestError(err.message);
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
|
||||
@@ -31,14 +31,14 @@ export type EmbedProps = {
|
||||
};
|
||||
};
|
||||
|
||||
const Img = styled(Image)<{ invertable?: boolean }>`
|
||||
const Img = styled(Image)<{ $invertable?: boolean }>`
|
||||
border-radius: 3px;
|
||||
margin: 3px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
|
||||
${(props) =>
|
||||
props.invertable &&
|
||||
props.$invertable &&
|
||||
props.theme.isDark &&
|
||||
`
|
||||
filter: invert(1);
|
||||
@@ -230,7 +230,7 @@ const embeds: EmbedDescriptor[] = [
|
||||
regexMatch: [new RegExp("^https://codepen.io/(.*?)/(pen|embed)/(.*)$")],
|
||||
transformMatch: (matches) =>
|
||||
`https://codepen.io/${matches[1]}/embed/${matches[3]}`,
|
||||
icon: <Img src="/images/codepen.png" alt="Codepen" invertable />,
|
||||
icon: <Img src="/images/codepen.png" alt="Codepen" $invertable />,
|
||||
}),
|
||||
new EmbedDescriptor({
|
||||
title: "DBDiagram",
|
||||
@@ -293,7 +293,7 @@ const embeds: EmbedDescriptor[] = [
|
||||
keywords: "design prototyping",
|
||||
regexMatch: [new RegExp("^https://framer.cloud/(.*)$")],
|
||||
transformMatch: (matches) => matches[0],
|
||||
icon: <Img src="/images/framer.png" alt="Framer" invertable />,
|
||||
icon: <Img src="/images/framer.png" alt="Framer" $invertable />,
|
||||
}),
|
||||
new EmbedDescriptor({
|
||||
title: "GitHub Gist",
|
||||
@@ -303,7 +303,7 @@ const embeds: EmbedDescriptor[] = [
|
||||
"^https://gist\\.github\\.com/([a-zA-Z\\d](?:[a-zA-Z\\d]|-(?=[a-zA-Z\\d])){0,38})/(.*)$"
|
||||
),
|
||||
],
|
||||
icon: <Img src="/images/github-gist.png" alt="GitHub" invertable />,
|
||||
icon: <Img src="/images/github-gist.png" alt="GitHub" $invertable />,
|
||||
component: Gist,
|
||||
}),
|
||||
new EmbedDescriptor({
|
||||
@@ -464,7 +464,7 @@ const embeds: EmbedDescriptor[] = [
|
||||
keywords: "code",
|
||||
defaultHidden: true,
|
||||
regexMatch: [new RegExp("^https?://jsfiddle\\.net/(.*)/(.*)$")],
|
||||
icon: <Img src="/images/jsfiddle.png" alt="JSFiddle" invertable />,
|
||||
icon: <Img src="/images/jsfiddle.png" alt="JSFiddle" $invertable />,
|
||||
component: JSFiddle,
|
||||
}),
|
||||
new EmbedDescriptor({
|
||||
@@ -609,7 +609,7 @@ const embeds: EmbedDescriptor[] = [
|
||||
new RegExp("^https?://(beta|www|old)\\.tldraw\\.com/[rsvopf]+/(.*)"),
|
||||
],
|
||||
transformMatch: (matches: RegExpMatchArray) => matches[0],
|
||||
icon: <Img src="/images/tldraw.png" alt="Tldraw" invertable />,
|
||||
icon: <Img src="/images/tldraw.png" alt="Tldraw" $invertable />,
|
||||
}),
|
||||
new EmbedDescriptor({
|
||||
title: "Trello",
|
||||
@@ -627,7 +627,7 @@ const embeds: EmbedDescriptor[] = [
|
||||
),
|
||||
],
|
||||
transformMatch: (matches: RegExpMatchArray) => matches[0],
|
||||
icon: <Img src="/images/typeform.png" alt="Typeform" invertable />,
|
||||
icon: <Img src="/images/typeform.png" alt="Typeform" $invertable />,
|
||||
}),
|
||||
new EmbedDescriptor({
|
||||
title: "Valtown",
|
||||
@@ -635,7 +635,7 @@ const embeds: EmbedDescriptor[] = [
|
||||
regexMatch: [/^https?:\/\/(?:www.)?val\.town\/(?:v|embed)\/(.*)$/],
|
||||
transformMatch: (matches: RegExpMatchArray) =>
|
||||
`https://www.val.town/embed/${matches[1]}`,
|
||||
icon: <Img src="/images/valtown.png" alt="Valtown" invertable />,
|
||||
icon: <Img src="/images/valtown.png" alt="Valtown" $invertable />,
|
||||
}),
|
||||
new EmbedDescriptor({
|
||||
title: "Vimeo",
|
||||
|
||||
@@ -206,8 +206,6 @@
|
||||
"Move document": "Move document",
|
||||
"Moving": "Moving",
|
||||
"Moving the document <em>{{ title }}</em> to the {{ newCollectionName }} collection will change permission for all workspace members from <em>{{ prevPermission }}</em> to <em>{{ newPermission }}</em>.": "Moving the document <em>{{ title }}</em> to the {{ newCollectionName }} collection will change permission for all workspace members from <em>{{ prevPermission }}</em> to <em>{{ newPermission }}</em>.",
|
||||
"More options": "More options",
|
||||
"Submenu": "Submenu",
|
||||
"Collections could not be loaded, please reload the app": "Collections could not be loaded, please reload the app",
|
||||
"Start view": "Start view",
|
||||
"Install now": "Install now",
|
||||
@@ -477,6 +475,7 @@
|
||||
"Keep as link": "Keep as link",
|
||||
"Mention": "Mention",
|
||||
"Embed": "Embed",
|
||||
"More options": "More options",
|
||||
"Insert after": "Insert after",
|
||||
"Insert before": "Insert before",
|
||||
"Move up": "Move up",
|
||||
|
||||
@@ -28,7 +28,7 @@ const isFlagEmojiSupported = (): boolean => {
|
||||
const CANVAS_WIDTH = 20;
|
||||
const textSize = Math.floor(CANVAS_HEIGHT / 2);
|
||||
|
||||
// Initialize convas context
|
||||
// Initialize canvas context
|
||||
ctx.font = textSize + "px Arial, Sans-Serif";
|
||||
ctx.textBaseline = "top";
|
||||
ctx.canvas.width = CANVAS_WIDTH * 2;
|
||||
@@ -56,7 +56,7 @@ const isFlagEmojiSupported = (): boolean => {
|
||||
}
|
||||
|
||||
// Emoji has immutable color, so we check the color of the emoji in two different colors
|
||||
// the result show be the same.
|
||||
// the result should be the same.
|
||||
const x = CANVAS_WIDTH + ((i / 4) % CANVAS_WIDTH);
|
||||
const y = Math.floor(i / 4 / CANVAS_WIDTH);
|
||||
const b = ctx.getImageData(x, y, 1, 1).data;
|
||||
@@ -215,7 +215,7 @@ export const search = ({
|
||||
};
|
||||
|
||||
/**
|
||||
* Get am emoji's human-readable ID from its string.
|
||||
* Get an emoji's human-readable ID from its string.
|
||||
*
|
||||
* @param emoji - The string representation of the emoji.
|
||||
* @returns The emoji id, if found.
|
||||
|
||||
Reference in New Issue
Block a user