mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b3a1cdde00 |
+1
-1
@@ -5,7 +5,7 @@ require("@dotenvx/dotenvx").config({
|
||||
var path = require('path');
|
||||
|
||||
module.exports = {
|
||||
'config': path.resolve('server/config', 'database.js'),
|
||||
'config': path.resolve('server/config', 'database.json'),
|
||||
'migrations-path': path.resolve('server', 'migrations'),
|
||||
'models-path': path.resolve('server', 'models'),
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ Business Source License 1.1
|
||||
Parameters
|
||||
|
||||
Licensor: General Outline, Inc.
|
||||
Licensed Work: Outline 0.87.4
|
||||
Licensed Work: Outline 0.87.3
|
||||
The Licensed Work is (c) 2025 General Outline, Inc.
|
||||
Additional Use Grant: You may make use of the Licensed Work, provided that
|
||||
you may not use the Licensed Work for a Document
|
||||
@@ -15,7 +15,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that
|
||||
Licensed Work by creating teams and documents
|
||||
controlled by such third parties.
|
||||
|
||||
Change Date: 2029-09-18
|
||||
Change Date: 2029-09-01
|
||||
|
||||
Change License: Apache License, Version 2.0
|
||||
|
||||
|
||||
@@ -7,13 +7,14 @@
|
||||
<img width="1640" alt="screenshot" src="https://user-images.githubusercontent.com/380914/110356468-26374600-7fef-11eb-9f6a-f2cc2c8c6590.png">
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://circleci.com/gh/outline/outline" rel="nofollow"><img src="https://circleci.com/gh/outline/outline.svg?style=shield"></a>
|
||||
<a href="http://www.typescriptlang.org" rel="nofollow"><img src="https://img.shields.io/badge/%3C%2F%3E-TypeScript-%230074c1.svg" alt="TypeScript"></a>
|
||||
<a href="https://github.com/prettier/prettier"><img src="https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat" alt="Prettier"></a>
|
||||
<a href="https://github.com/styled-components/styled-components"><img src="https://img.shields.io/badge/style-%F0%9F%92%85%20styled--components-orange.svg" alt="Styled Components"></a>
|
||||
<a href="https://translate.getoutline.com/project/outline" alt="Localized"><img src="https://badges.crowdin.net/outline/localized.svg"></a>
|
||||
</p>
|
||||
|
||||
This is the source code that runs [**Outline**](https://www.getoutline.com) and all the associated services. If you want to use Outline then you don't need to run this code, A hosted version of the app is offered at [getoutline.com](https://www.getoutline.com). You can also find documentation on using Outline in [our guide](https://docs.getoutline.com/s/guide).
|
||||
This is the source code that runs [**Outline**](https://www.getoutline.com) and all the associated services. If you want to use Outline then you don't need to run this code, we offer a hosted version of the app at [getoutline.com](https://www.getoutline.com). You can also find documentation on using Outline in [our guide](https://docs.getoutline.com/s/guide).
|
||||
|
||||
If you'd like to run your own copy of Outline or contribute to development then this is the place for you.
|
||||
|
||||
@@ -50,14 +51,13 @@ please refer to the [architecture document](docs/ARCHITECTURE.md) first for a hi
|
||||
|
||||
In development Outline outputs simple logging to the console, prefixed by categories. In production it outputs JSON logs, these can be easily parsed by your preferred log ingestion pipeline.
|
||||
|
||||
HTTP logging is disabled by default, but can be enabled by setting the `DEBUG=http` environment variable. logging
|
||||
can be enabled for all categories by setting `DEBUG=*` or for specific categories such as `DEBUG=database` and `LOG_LEVEL=debug`, or `LOG_LEVEL=silly` for very verbose logging.
|
||||
HTTP logging is disabled by default, but can be enabled by setting the `DEBUG=http` environment variable.
|
||||
|
||||
## Tests
|
||||
|
||||
We aim to have sufficient test coverage for critical parts of the application and aren't aiming for 100% unit test coverage. All API endpoints and anything authentication related should be thoroughly tested.
|
||||
|
||||
To add new tests, write your tests with [Jest](https://facebook.github.io/jest/) and add a file with `.test.ts` extension next to the tested code.
|
||||
To add new tests, write your tests with [Jest](https://facebook.github.io/jest/) and add a file with `.test.js` extension next to the tested code.
|
||||
|
||||
```shell
|
||||
# To run all tests
|
||||
@@ -68,14 +68,14 @@ make watch
|
||||
```
|
||||
|
||||
Once the test database is created with `make test` you may individually run
|
||||
frontend and backend tests directly with jest:
|
||||
frontend and backend tests directly.
|
||||
|
||||
```shell
|
||||
# To run backend tests
|
||||
yarn test:server
|
||||
|
||||
# To run a specific backend test in watch mode
|
||||
yarn test path/to/file.test.ts --watch
|
||||
# To run a specific backend test
|
||||
yarn test:server myTestFile
|
||||
|
||||
# To run frontend tests
|
||||
yarn test:app
|
||||
@@ -86,15 +86,14 @@ yarn test:app
|
||||
Sequelize is used to create and run migrations, for example:
|
||||
|
||||
```shell
|
||||
yarn db:create-migration --name my-migration
|
||||
yarn db:migrate
|
||||
yarn db:rollback
|
||||
yarn sequelize migration:generate --name my-migration
|
||||
yarn sequelize db:migrate
|
||||
```
|
||||
|
||||
Or, to run migrations on test database:
|
||||
Or to run migrations on test database:
|
||||
|
||||
```shell
|
||||
yarn db:migrate --env test
|
||||
yarn sequelize db:migrate --env test
|
||||
```
|
||||
|
||||
# Activity
|
||||
|
||||
@@ -13,6 +13,13 @@
|
||||
"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."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -13,6 +13,7 @@ import ErrorSuspended from "~/scenes/Errors/ErrorSuspended";
|
||||
import Layout from "~/components/Layout";
|
||||
import RegisterKeyDown from "~/components/RegisterKeyDown";
|
||||
import Sidebar from "~/components/Sidebar";
|
||||
import SidebarRight from "~/components/Sidebar/Right";
|
||||
import SettingsSidebar from "~/components/Sidebar/Settings";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import { usePostLoginPath } from "~/hooks/useLastVisitedPath";
|
||||
@@ -108,10 +109,12 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
|
||||
>
|
||||
{(showHistory || showComments) && (
|
||||
<Route path={`/doc/${slug}`}>
|
||||
<React.Suspense fallback={null}>
|
||||
{showHistory && <DocumentHistory />}
|
||||
{showComments && <DocumentComments />}
|
||||
</React.Suspense>
|
||||
<SidebarRight>
|
||||
<React.Suspense fallback={null}>
|
||||
{showHistory && <DocumentHistory />}
|
||||
{showComments && <DocumentComments />}
|
||||
</React.Suspense>
|
||||
</SidebarRight>
|
||||
</Route>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
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;
|
||||
@@ -0,0 +1,13 @@
|
||||
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;
|
||||
@@ -0,0 +1,217 @@
|
||||
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);
|
||||
@@ -0,0 +1,70 @@
|
||||
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%)`;
|
||||
@@ -0,0 +1,27 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
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;
|
||||
`;
|
||||
@@ -0,0 +1,264 @@
|
||||
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);
|
||||
@@ -0,0 +1,317 @@
|
||||
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};
|
||||
`};
|
||||
`;
|
||||
@@ -155,16 +155,14 @@ const DocumentMeta: React.FC<Props> = ({
|
||||
}
|
||||
return (
|
||||
<Viewed>
|
||||
<Separator />
|
||||
<Modified highlight>{t("Never viewed")}</Modified>
|
||||
• <Modified highlight>{t("Never viewed")}</Modified>
|
||||
</Viewed>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Viewed>
|
||||
<Separator />
|
||||
{t("Viewed")} <Time dateTime={lastViewedAt} addSuffix shorten />
|
||||
• {t("Viewed")} <Time dateTime={lastViewedAt} addSuffix shorten />
|
||||
</Viewed>
|
||||
);
|
||||
};
|
||||
@@ -188,17 +186,16 @@ const DocumentMeta: React.FC<Props> = ({
|
||||
)}
|
||||
{showParentDocuments && nestedDocumentsCount > 0 && (
|
||||
<span>
|
||||
<Separator />
|
||||
{nestedDocumentsCount}{" "}
|
||||
• {nestedDocumentsCount}{" "}
|
||||
{t("nested document", {
|
||||
count: nestedDocumentsCount,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
{timeSinceNow()}
|
||||
{timeSinceNow()}
|
||||
{canShowProgressBar && (
|
||||
<>
|
||||
<Separator />
|
||||
•
|
||||
<DocumentTasks document={document} />
|
||||
</>
|
||||
)}
|
||||
@@ -207,14 +204,6 @@ const DocumentMeta: React.FC<Props> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export const Separator = styled.span`
|
||||
padding: 0 0.4em;
|
||||
|
||||
&::after {
|
||||
content: "•";
|
||||
}
|
||||
`;
|
||||
|
||||
const Strong = styled.strong`
|
||||
font-weight: 550;
|
||||
`;
|
||||
|
||||
@@ -6,6 +6,7 @@ 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";
|
||||
@@ -218,9 +219,9 @@ const MobileSelect = React.forwardRef<HTMLButtonElement, MobileSelectProps>(
|
||||
);
|
||||
|
||||
const renderOption = React.useCallback(
|
||||
(option: Option, idx: number) => {
|
||||
(option: Option) => {
|
||||
if (option.type === "separator") {
|
||||
return <InputSelectSeparator key={`separator-${idx}`} />;
|
||||
return <Separator />;
|
||||
}
|
||||
|
||||
const isSelected = option === selectedOption;
|
||||
|
||||
+30
-27
@@ -12,6 +12,7 @@ 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 = {
|
||||
@@ -37,39 +38,41 @@ const Layout = React.forwardRef(function Layout_(
|
||||
});
|
||||
|
||||
return (
|
||||
<Container column auto ref={ref}>
|
||||
<Helmet>
|
||||
<title>{title ? title : env.APP_NAME}</title>
|
||||
</Helmet>
|
||||
<MenuProvider>
|
||||
<Container column auto ref={ref}>
|
||||
<Helmet>
|
||||
<title>{title ? title : env.APP_NAME}</title>
|
||||
</Helmet>
|
||||
|
||||
<SkipNavLink />
|
||||
<SkipNavLink />
|
||||
|
||||
{ui.progressBarVisible && <LoadingIndicatorBar />}
|
||||
{ui.progressBarVisible && <LoadingIndicatorBar />}
|
||||
|
||||
<Container auto>
|
||||
{sidebar}
|
||||
<Container auto>
|
||||
<MenuProvider>{sidebar}</MenuProvider>
|
||||
|
||||
<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}
|
||||
{sidebarRight}
|
||||
</Container>
|
||||
</Container>
|
||||
</Container>
|
||||
</MenuProvider>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { depths, s } from "@shared/styles";
|
||||
import ErrorBoundary from "~/components/ErrorBoundary";
|
||||
import Flex from "~/components/Flex";
|
||||
import ResizeBorder from "~/components/Sidebar/components/ResizeBorder";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { sidebarAppearDuration } from "~/styles/animations";
|
||||
|
||||
@@ -19,6 +20,7 @@ function Right({ children, border, className }: Props) {
|
||||
const theme = useTheme();
|
||||
const { ui } = useStores();
|
||||
const [isResizing, setResizing] = React.useState(false);
|
||||
const isMobile = useMobile();
|
||||
const maxWidth = theme.sidebarMaxWidth;
|
||||
const minWidth = theme.sidebarMinWidth + 16; // padding
|
||||
|
||||
@@ -98,11 +100,13 @@ function Right({ children, border, className }: Props) {
|
||||
<Sidebar {...animationProps} $border={border} className={className}>
|
||||
<Position style={style} column>
|
||||
<ErrorBoundary>{children}</ErrorBoundary>
|
||||
<ResizeBorder
|
||||
onMouseDown={handleMouseDown}
|
||||
onDoubleClick={handleReset}
|
||||
dir="right"
|
||||
/>
|
||||
{!isMobile && (
|
||||
<ResizeBorder
|
||||
onMouseDown={handleMouseDown}
|
||||
onDoubleClick={handleReset}
|
||||
dir="right"
|
||||
/>
|
||||
)}
|
||||
</Position>
|
||||
</Sidebar>
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ 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";
|
||||
@@ -40,10 +41,11 @@ 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;
|
||||
const collapsed = ui.sidebarIsClosed && !isMenuOpen;
|
||||
const maxWidth = theme.sidebarMaxWidth;
|
||||
const minWidth = theme.sidebarMinWidth + 16; // padding
|
||||
|
||||
|
||||
@@ -18,8 +18,6 @@ Drawer.displayName = "Drawer";
|
||||
/** Drawer's trigger. */
|
||||
const DrawerTrigger = DrawerPrimitive.Trigger;
|
||||
|
||||
const DrawerHandle = DrawerPrimitive.Handle;
|
||||
|
||||
/** Drawer's content - renders the overlay and the actual content. */
|
||||
const DrawerContent = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Content>,
|
||||
@@ -58,9 +56,11 @@ const DrawerTitle = React.forwardRef<
|
||||
const { hidden, children, ...rest } = props;
|
||||
|
||||
const title = (
|
||||
<Text size="medium" weight="bold" as={TitleWrapper} justify="center">
|
||||
{children}
|
||||
</Text>
|
||||
<TitleWrapper justify="center">
|
||||
<Text size="medium" weight="bold">
|
||||
{children}
|
||||
</Text>
|
||||
</TitleWrapper>
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -100,4 +100,4 @@ const TitleWrapper = styled(Flex)`
|
||||
padding: 8px 0;
|
||||
`;
|
||||
|
||||
export { Drawer, DrawerTrigger, DrawerHandle, DrawerContent, DrawerTitle };
|
||||
export { Drawer, DrawerTrigger, DrawerContent, DrawerTitle };
|
||||
|
||||
@@ -3,6 +3,7 @@ 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,
|
||||
@@ -98,10 +99,6 @@ 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} {...offsetProp} {...rest} collisionPadding={6} asChild>
|
||||
<Content ref={ref} {...rest} {...offsetProp} collisionPadding={6} asChild>
|
||||
<Components.MenuContent {...contentProps} hiddenScrollbars>
|
||||
{children}
|
||||
</Components.MenuContent>
|
||||
|
||||
@@ -90,7 +90,7 @@ type StyledContentProps = {
|
||||
|
||||
const StyledContent = styled(PopoverPrimitive.Content)<StyledContentProps>`
|
||||
z-index: ${depths.modal};
|
||||
max-height: min(85vh, var(--radix-popover-content-available-height));
|
||||
max-height: var(--radix-popover-content-available-height);
|
||||
transform-origin: var(--radix-popover-content-transform-origin);
|
||||
|
||||
background: ${s("menuBackground")};
|
||||
|
||||
@@ -9,7 +9,6 @@ import { fadeAndScaleIn } from "~/styles/animations";
|
||||
|
||||
type BaseMenuItemProps = {
|
||||
disabled?: boolean;
|
||||
$active?: boolean;
|
||||
$dangerous?: boolean;
|
||||
};
|
||||
|
||||
@@ -45,24 +44,6 @@ 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 &&
|
||||
`
|
||||
@@ -77,7 +58,7 @@ const BaseMenuItemCSS = css<BaseMenuItemProps>`
|
||||
};
|
||||
box-shadow: none;
|
||||
cursor: var(--pointer);
|
||||
|
||||
|
||||
svg {
|
||||
color: ${props.theme.accentText};
|
||||
fill: ${props.theme.accentText};
|
||||
|
||||
@@ -17,7 +17,6 @@ import Logger from "~/utils/Logger";
|
||||
import { useEditor } from "./EditorContext";
|
||||
|
||||
type Props = {
|
||||
align?: "start" | "end" | "center";
|
||||
active?: boolean;
|
||||
children: React.ReactNode;
|
||||
width?: number;
|
||||
@@ -36,18 +35,16 @@ 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 ?? 0;
|
||||
const menuHeight = menuRef.current?.offsetHeight ?? 0;
|
||||
const menuWidth = menuRef.current?.offsetWidth;
|
||||
const menuHeight = menuRef.current?.offsetHeight;
|
||||
|
||||
if (!active || !menuRef.current) {
|
||||
if (!active || !menuWidth || !menuHeight || !menuRef.current) {
|
||||
return defaultPosition;
|
||||
}
|
||||
|
||||
@@ -97,7 +94,7 @@ function usePosition({
|
||||
const element = view.nodeDOM(position);
|
||||
const bounds = (element as HTMLElement).getBoundingClientRect();
|
||||
selectionBounds.top = bounds.top;
|
||||
selectionBounds.left = bounds.right;
|
||||
selectionBounds.left = bounds.right - menuWidth;
|
||||
selectionBounds.right = bounds.right;
|
||||
}
|
||||
}
|
||||
@@ -183,11 +180,7 @@ function usePosition({
|
||||
),
|
||||
Math.max(
|
||||
Math.max(offsetParent.x, margin),
|
||||
align === "center"
|
||||
? centerOfSelection - menuWidth / 2
|
||||
: align === "start"
|
||||
? selectionBounds.left
|
||||
: selectionBounds.right
|
||||
centerOfSelection - menuWidth / 2
|
||||
)
|
||||
);
|
||||
const top = Math.max(
|
||||
@@ -223,7 +216,6 @@ const FloatingToolbar = React.forwardRef(function FloatingToolbar_(
|
||||
let position = usePosition({
|
||||
menuRef,
|
||||
active: props.active,
|
||||
align: props.align,
|
||||
});
|
||||
|
||||
if (isSelectingText) {
|
||||
@@ -285,7 +277,7 @@ const FloatingToolbar = React.forwardRef(function FloatingToolbar_(
|
||||
left: `${position.left}px`,
|
||||
}}
|
||||
>
|
||||
<Background align={props.align}>{props.children}</Background>
|
||||
{props.children}
|
||||
</Wrapper>
|
||||
</Portal>
|
||||
);
|
||||
@@ -310,7 +302,7 @@ const arrow = (props: WrapperProps) =>
|
||||
border-radius: 3px;
|
||||
z-index: -1;
|
||||
position: absolute;
|
||||
bottom: -3px;
|
||||
bottom: -2px;
|
||||
left: calc(50% - ${props.$offset || 0}px);
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -343,42 +335,22 @@ const MobileWrapper = styled.div`
|
||||
}
|
||||
`;
|
||||
|
||||
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;
|
||||
padding: 6px;
|
||||
position: absolute;
|
||||
z-index: ${depths.editorToolbar};
|
||||
opacity: 0;
|
||||
background-color: ${s("menuBackground")};
|
||||
box-shadow: ${s("menuShadow")};
|
||||
border-radius: 4px;
|
||||
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,11 +199,9 @@ 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) {
|
||||
@@ -222,7 +220,6 @@ 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);
|
||||
}
|
||||
@@ -254,7 +251,6 @@ 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,9 +647,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
const response = (
|
||||
<React.Fragment key={`${index}-${item.name}`}>
|
||||
{currentHeading !== previousHeading && (
|
||||
<MenuHeader key={currentHeading}>
|
||||
{currentHeading}
|
||||
</MenuHeader>
|
||||
<Header key={currentHeading}>{currentHeading}</Header>
|
||||
)}
|
||||
<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,22 +53,17 @@ function SuggestionsMenuItem({
|
||||
);
|
||||
|
||||
return (
|
||||
<MenuButton
|
||||
<MenuItem
|
||||
ref={ref}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
active={selected}
|
||||
onClick={disabled ? undefined : onClick}
|
||||
onPointerMove={disabled ? undefined : onPointerMove}
|
||||
$active={selected}
|
||||
icon={icon}
|
||||
>
|
||||
{icon}
|
||||
<MenuLabel>
|
||||
{title}
|
||||
{subtitle && (
|
||||
<Subtitle $active={selected}>· {subtitle}</Subtitle>
|
||||
)}
|
||||
{shortcut && <Shortcut $active={selected}>{shortcut}</Shortcut>}
|
||||
</MenuLabel>
|
||||
</MenuButton>
|
||||
{title}
|
||||
{subtitle && <Subtitle $active={selected}>· {subtitle}</Subtitle>}
|
||||
{shortcut && <Shortcut $active={selected}>{shortcut}</Shortcut>}
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { useMenuState } from "reakit";
|
||||
import { MenuButton } from "reakit/Menu";
|
||||
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";
|
||||
@@ -11,12 +14,6 @@ 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[];
|
||||
@@ -26,8 +23,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;
|
||||
|
||||
@@ -63,30 +60,24 @@ function ToolbarDropdown(props: { active: boolean; item: MenuItem }) {
|
||||
: [];
|
||||
}, [item.children, commands, state]);
|
||||
|
||||
const handleCloseAutoFocus = useCallback((ev: Event) => {
|
||||
ev.stopImmediatePropagation();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<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}
|
||||
<>
|
||||
<MenuButton {...menu}>
|
||||
{(buttonProps) => (
|
||||
<ToolbarButton
|
||||
{...buttonProps}
|
||||
hovering={menu.visible}
|
||||
aria-label={item.tooltip}
|
||||
>
|
||||
{toMenuItems(items)}
|
||||
</MenuContent>
|
||||
</Menu>
|
||||
</MenuProvider>
|
||||
</EventBoundary>
|
||||
{item.label && <Label>{item.label}</Label>}
|
||||
{item.icon}
|
||||
</ToolbarButton>
|
||||
)}
|
||||
</MenuButton>
|
||||
<ContextMenu aria-label={item.label} {...menu}>
|
||||
<Template {...menu} items={items} />
|
||||
</ContextMenu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -107,47 +98,40 @@ function ToolbarMenu(props: Props) {
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<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;
|
||||
<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}
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
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>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import { CustomTheme } from "@shared/types";
|
||||
import type { Theme } from "~/stores/UiStore";
|
||||
import useMediaQuery from "~/hooks/useMediaQuery";
|
||||
import useStores from "./useStores";
|
||||
import useQuery from "./useQuery";
|
||||
|
||||
/**
|
||||
* Builds a theme based on the current user's preferences, the current device
|
||||
@@ -24,11 +23,9 @@ export default function useBuildTheme(
|
||||
overrideTheme?: Theme
|
||||
) {
|
||||
const { ui } = useStores();
|
||||
const params = useQuery();
|
||||
const isMobile = useMediaQuery(`(max-width: ${breakpoints.tablet}px)`);
|
||||
const isPrinting = useMediaQuery("print");
|
||||
const queryTheme = (params.get("theme") as Theme) || undefined;
|
||||
const resolvedTheme = overrideTheme ?? queryTheme ?? ui.resolvedTheme;
|
||||
const resolvedTheme = overrideTheme ?? ui.resolvedTheme;
|
||||
|
||||
const theme = useMemo(
|
||||
() =>
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
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;
|
||||
@@ -0,0 +1,46 @@
|
||||
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;
|
||||
@@ -0,0 +1,48 @@
|
||||
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]
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
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
-4
@@ -53,8 +53,8 @@ if (element) {
|
||||
<HelmetProvider>
|
||||
<Provider {...stores}>
|
||||
<Analytics>
|
||||
<Router history={history}>
|
||||
<Theme>
|
||||
<Theme>
|
||||
<Router history={history}>
|
||||
<ErrorBoundary showTitle>
|
||||
<KBarProvider actions={[]} options={commandBarOptions}>
|
||||
<LazyPolyfill>
|
||||
@@ -74,8 +74,8 @@ if (element) {
|
||||
</LazyPolyfill>
|
||||
</KBarProvider>
|
||||
</ErrorBoundary>
|
||||
</Theme>
|
||||
</Router>
|
||||
</Router>
|
||||
</Theme>
|
||||
</Analytics>
|
||||
</Provider>
|
||||
</HelmetProvider>
|
||||
|
||||
@@ -4,7 +4,6 @@ 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.
|
||||
@@ -28,7 +27,6 @@ class GroupUser extends Model {
|
||||
|
||||
/** The permission of the user in the group. */
|
||||
@Field
|
||||
@observable
|
||||
permission: GroupPermission;
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,6 @@ import MembershipPreview from "./components/MembershipPreview";
|
||||
import Notices from "./components/Notices";
|
||||
import Overview from "./components/Overview";
|
||||
import ShareButton from "./components/ShareButton";
|
||||
import first from "lodash/first";
|
||||
|
||||
const IconPicker = lazy(() => import("~/components/IconPicker"));
|
||||
|
||||
@@ -207,7 +206,7 @@ const CollectionScene = observer(function _CollectionScene() {
|
||||
<Suspense fallback={fallbackIcon}>
|
||||
<IconPicker
|
||||
icon={collection.icon ?? "collection"}
|
||||
color={collection.color ?? (first(colorPalette) as string)}
|
||||
color={collection.color ?? colorPalette[0]}
|
||||
initial={collection.initial}
|
||||
size={40}
|
||||
popoverPosition="bottom-start"
|
||||
|
||||
@@ -23,7 +23,6 @@ import CommentForm from "./CommentForm";
|
||||
import CommentSortMenu from "./CommentSortMenu";
|
||||
import CommentThread from "./CommentThread";
|
||||
import Sidebar from "./SidebarLayout";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import { ArrowDownIcon } from "~/components/Icons/ArrowIcon";
|
||||
|
||||
function Comments() {
|
||||
@@ -35,8 +34,6 @@ function Comments() {
|
||||
const document = documents.get(match.params.documentSlug);
|
||||
const focusedComment = useFocusedComment();
|
||||
const can = usePolicy(document);
|
||||
const isMobile = useMobile();
|
||||
|
||||
const query = useQuery();
|
||||
const [viewingResolved, setViewingResolved] = useState(
|
||||
query.get("resolved") !== null || focusedComment?.isResolved || false
|
||||
@@ -126,73 +123,15 @@ function Comments() {
|
||||
prevThreadCount.current = threads.length;
|
||||
}, [sortOption.type, threads.length, viewingResolved]);
|
||||
|
||||
const content =
|
||||
!document || !isEditorInitialized ? null : (
|
||||
<>
|
||||
<Scrollable
|
||||
id="comments"
|
||||
bottomShadow={!focusedComment}
|
||||
hiddenScrollbars
|
||||
topShadow
|
||||
ref={scrollableRef}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<Wrapper $hasComments={hasComments}>
|
||||
{hasComments ? (
|
||||
threads.map((thread) => (
|
||||
<CommentThread
|
||||
key={thread.id}
|
||||
comment={thread}
|
||||
document={document}
|
||||
recessed={!!focusedComment && focusedComment.id !== thread.id}
|
||||
focused={focusedComment?.id === thread.id}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<NoComments align="center" justify="center" auto>
|
||||
<PositionedEmpty>
|
||||
{viewingResolved
|
||||
? t("No resolved comments")
|
||||
: t("No comments yet")}
|
||||
</PositionedEmpty>
|
||||
</NoComments>
|
||||
)}
|
||||
{showJumpToRecentBtn && (
|
||||
<Fade>
|
||||
<JumpToRecent onClick={scrollToBottom}>
|
||||
<Flex align="center">
|
||||
{t("New comments")}
|
||||
<ArrowDownIcon size={20} />
|
||||
</Flex>
|
||||
</JumpToRecent>
|
||||
</Fade>
|
||||
)}
|
||||
</Wrapper>
|
||||
</Scrollable>
|
||||
<AnimatePresence initial={false}>
|
||||
{(!focusedComment || isMobile) && can.comment && !viewingResolved && (
|
||||
<NewCommentForm
|
||||
draft={draft}
|
||||
onSaveDraft={onSaveDraft}
|
||||
documentId={document.id}
|
||||
placeholder={`${t("Add a comment")}…`}
|
||||
autoFocus={false}
|
||||
dir={document.dir}
|
||||
animatePresence
|
||||
standalone
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
if (!document || !isEditorInitialized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Sidebar
|
||||
title={
|
||||
<Flex align="center" justify="space-between" gap={8} auto>
|
||||
<div style={isMobile ? { padding: "0 8px" } : undefined}>
|
||||
{t("Comments")}
|
||||
</div>
|
||||
<Flex align="center" justify="space-between" auto>
|
||||
<span>{t("Comments")}</span>
|
||||
<CommentSortMenu
|
||||
viewingResolved={viewingResolved}
|
||||
onChange={(val) => {
|
||||
@@ -204,7 +143,60 @@ function Comments() {
|
||||
onClose={() => ui.set({ commentsExpanded: false })}
|
||||
scrollable={false}
|
||||
>
|
||||
{content}
|
||||
<Scrollable
|
||||
id="comments"
|
||||
bottomShadow={!focusedComment}
|
||||
hiddenScrollbars
|
||||
topShadow
|
||||
ref={scrollableRef}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<Wrapper $hasComments={hasComments}>
|
||||
{hasComments ? (
|
||||
threads.map((thread) => (
|
||||
<CommentThread
|
||||
key={thread.id}
|
||||
comment={thread}
|
||||
document={document}
|
||||
recessed={!!focusedComment && focusedComment.id !== thread.id}
|
||||
focused={focusedComment?.id === thread.id}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<NoComments align="center" justify="center" auto>
|
||||
<PositionedEmpty>
|
||||
{viewingResolved
|
||||
? t("No resolved comments")
|
||||
: t("No comments yet")}
|
||||
</PositionedEmpty>
|
||||
</NoComments>
|
||||
)}
|
||||
{showJumpToRecentBtn && (
|
||||
<Fade>
|
||||
<JumpToRecent onClick={scrollToBottom}>
|
||||
<Flex align="center">
|
||||
{t("New comments")}
|
||||
<ArrowDownIcon size={20} />
|
||||
</Flex>
|
||||
</JumpToRecent>
|
||||
</Fade>
|
||||
)}
|
||||
</Wrapper>
|
||||
</Scrollable>
|
||||
<AnimatePresence initial={false}>
|
||||
{!focusedComment && can.comment && !viewingResolved && (
|
||||
<NewCommentForm
|
||||
draft={draft}
|
||||
onSaveDraft={onSaveDraft}
|
||||
documentId={document.id}
|
||||
placeholder={`${t("Add a comment")}…`}
|
||||
autoFocus={false}
|
||||
dir={document.dir}
|
||||
animatePresence
|
||||
standalone
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,13 +9,12 @@ import { TeamPreference } from "@shared/types";
|
||||
import Document from "~/models/Document";
|
||||
import Revision from "~/models/Revision";
|
||||
import { openDocumentInsights } from "~/actions/definitions/documents";
|
||||
import DocumentMeta, { Separator } from "~/components/DocumentMeta";
|
||||
import DocumentMeta from "~/components/DocumentMeta";
|
||||
import Fade from "~/components/Fade";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { documentPath } from "~/utils/routeHelpers";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
|
||||
@@ -47,7 +46,7 @@ function TitleDocumentMeta({ to, document, revision, ...rest }: Props) {
|
||||
<Meta document={document} revision={revision} to={to} replace {...rest}>
|
||||
{commentingEnabled && can.comment && (
|
||||
<>
|
||||
<Separator />
|
||||
•
|
||||
<CommentLink
|
||||
to={{
|
||||
pathname: documentPath(document),
|
||||
@@ -67,7 +66,7 @@ function TitleDocumentMeta({ to, document, revision, ...rest }: Props) {
|
||||
!document.isDraft &&
|
||||
!document.isTemplate ? (
|
||||
<Wrapper>
|
||||
<Separator />
|
||||
•
|
||||
<InsightsButton action={openDocumentInsights}>
|
||||
{t("Viewed by")}{" "}
|
||||
{onlyYou
|
||||
@@ -109,16 +108,6 @@ export const Meta = styled(DocumentMeta)<{ rtl?: boolean }>`
|
||||
user-select: none;
|
||||
z-index: 1;
|
||||
|
||||
${breakpoint("mobile", "tablet")`
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
line-height: 1.6;
|
||||
|
||||
${Separator} {
|
||||
display: none;
|
||||
}
|
||||
`}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
cursor: var(--pointer);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import last from "lodash/last";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -32,7 +33,6 @@ import { decodeURIComponentSafe } from "~/utils/urls";
|
||||
import MultiplayerEditor from "./AsyncMultiplayerEditor";
|
||||
import DocumentMeta from "./DocumentMeta";
|
||||
import DocumentTitle from "./DocumentTitle";
|
||||
import first from "lodash/first";
|
||||
|
||||
const extensions = withUIExtensions(withComments(richExtensions));
|
||||
|
||||
@@ -80,7 +80,7 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
||||
const can = usePolicy(document);
|
||||
const commentingEnabled = !!team?.getPreference(TeamPreference.Commenting);
|
||||
|
||||
const iconColor = document.color ?? (first(colorPalette) as string);
|
||||
const iconColor = document.color ?? (last(colorPalette) as string);
|
||||
const childRef = React.useRef<HTMLDivElement>(null);
|
||||
const focusAtStart = React.useCallback(() => {
|
||||
if (ref.current) {
|
||||
|
||||
@@ -15,7 +15,6 @@ import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { documentPath } from "~/utils/routeHelpers";
|
||||
import Sidebar from "./SidebarLayout";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
|
||||
const DocumentEvents = [
|
||||
"documents.publish",
|
||||
@@ -38,7 +37,6 @@ function History() {
|
||||
const document = documents.get(match.params.documentSlug);
|
||||
const [revisionsOffset, setRevisionsOffset] = React.useState(0);
|
||||
const [eventsOffset, setEventsOffset] = React.useState(0);
|
||||
const isMobile = useMobile();
|
||||
|
||||
const fetchHistory = React.useCallback(async () => {
|
||||
if (!document) {
|
||||
@@ -127,10 +125,6 @@ function History() {
|
||||
}, [revisions, document, revisionEvents, nonRevisionEvents]);
|
||||
|
||||
const onCloseHistory = React.useCallback(() => {
|
||||
if (isMobile) {
|
||||
// Allow closing the history drawer on mobile to view revision content
|
||||
return;
|
||||
}
|
||||
if (document) {
|
||||
history.push({
|
||||
pathname: documentPath(document),
|
||||
|
||||
@@ -3,19 +3,15 @@ import { BackIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { s, ellipsis } from "@shared/styles";
|
||||
import { depths, s, ellipsis } from "@shared/styles";
|
||||
import Button from "~/components/Button";
|
||||
import Flex from "~/components/Flex";
|
||||
import { Portal } from "~/components/Portal";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import { draggableOnDesktop } from "~/styles";
|
||||
import RightSidebar from "~/components/Sidebar/Right";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerTitle,
|
||||
} from "~/components/primitives/Drawer";
|
||||
import { fadeIn } from "~/styles/animations";
|
||||
|
||||
type Props = Omit<React.HTMLAttributes<HTMLDivElement>, "title"> & {
|
||||
/* The title of the sidebar */
|
||||
@@ -23,7 +19,7 @@ type Props = Omit<React.HTMLAttributes<HTMLDivElement>, "title"> & {
|
||||
/* The content of the sidebar */
|
||||
children: React.ReactNode;
|
||||
/* Called when the sidebar is closed */
|
||||
onClose: () => void;
|
||||
onClose: React.MouseEventHandler;
|
||||
/* Whether the sidebar should be scrollable */
|
||||
scrollable?: boolean;
|
||||
};
|
||||
@@ -32,23 +28,8 @@ function SidebarLayout({ title, onClose, children, scrollable = true }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useMobile();
|
||||
|
||||
const content = scrollable ? (
|
||||
<Scrollable hiddenScrollbars topShadow>
|
||||
{children}
|
||||
</Scrollable>
|
||||
) : (
|
||||
children
|
||||
);
|
||||
|
||||
return isMobile ? (
|
||||
<Drawer onClose={onClose} defaultOpen>
|
||||
<DrawerContent>
|
||||
<DrawerTitle>{title}</DrawerTitle>
|
||||
{content}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
) : (
|
||||
<RightSidebar>
|
||||
return (
|
||||
<>
|
||||
<Header>
|
||||
<Title>{title}</Title>
|
||||
<Tooltip content={t("Close")} shortcut="Esc">
|
||||
@@ -60,11 +41,35 @@ function SidebarLayout({ title, onClose, children, scrollable = true }: Props) {
|
||||
/>
|
||||
</Tooltip>
|
||||
</Header>
|
||||
{content}
|
||||
</RightSidebar>
|
||||
{scrollable ? (
|
||||
<Scrollable hiddenScrollbars topShadow>
|
||||
{children}
|
||||
</Scrollable>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
|
||||
{isMobile && (
|
||||
<Portal>
|
||||
<Backdrop onClick={onClose} />
|
||||
</Portal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const Backdrop = styled.a`
|
||||
animation: ${fadeIn} 250ms ease-in-out;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
cursor: default;
|
||||
z-index: ${depths.mobileSidebar - 1};
|
||||
background: ${s("backdrop")};
|
||||
`;
|
||||
|
||||
const ForwardIcon = styled(BackIcon)`
|
||||
transform: rotate(180deg);
|
||||
flex-shrink: 0;
|
||||
|
||||
@@ -59,14 +59,11 @@ 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);
|
||||
|
||||
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];
|
||||
});
|
||||
const groupMemberships = res.data.groupMemberships.map(this.add);
|
||||
return groupMemberships[0];
|
||||
}
|
||||
|
||||
@action
|
||||
@@ -99,14 +96,11 @@ 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);
|
||||
|
||||
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];
|
||||
});
|
||||
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", "/desktop-redirect"].includes(path) &&
|
||||
!["/", "/create", "/home", "/logout"].includes(path) &&
|
||||
!path.startsWith("/auth/") &&
|
||||
!path.startsWith("/s/")
|
||||
);
|
||||
|
||||
+15
-15
@@ -51,16 +51,16 @@
|
||||
"> 0.25%, not dead"
|
||||
],
|
||||
"dependencies": {
|
||||
"@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",
|
||||
"@aws-sdk/client-s3": "3.879.0",
|
||||
"@aws-sdk/lib-storage": "3.879.0",
|
||||
"@aws-sdk/s3-presigned-post": "3.879.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.879.0",
|
||||
"@aws-sdk/signature-v4-crt": "^3.879.0",
|
||||
"@babel/core": "^7.28.3",
|
||||
"@babel/plugin-proposal-decorators": "^7.28.0",
|
||||
"@babel/plugin-transform-class-properties": "^7.27.1",
|
||||
"@babel/plugin-transform-destructuring": "^7.28.0",
|
||||
"@babel/plugin-transform-regenerator": "^7.28.4",
|
||||
"@babel/plugin-transform-regenerator": "^7.28.3",
|
||||
"@babel/preset-env": "^7.28.3",
|
||||
"@babel/preset-react": "^7.27.1",
|
||||
"@benrbray/prosemirror-math": "^0.2.2",
|
||||
@@ -73,9 +73,9 @@
|
||||
"@dotenvx/dotenvx": "^1.49.0",
|
||||
"@emoji-mart/data": "^1.2.1",
|
||||
"@fast-csv/parse": "^5.0.5",
|
||||
"@fortawesome/fontawesome-svg-core": "^7.0.1",
|
||||
"@fortawesome/free-brands-svg-icons": "^7.0.1",
|
||||
"@fortawesome/free-solid-svg-icons": "^7.0.1",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.7.2",
|
||||
"@fortawesome/free-brands-svg-icons": "^6.7.2",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.7.2",
|
||||
"@fortawesome/react-fontawesome": "^0.2.6",
|
||||
"@getoutline/react-roving-tabindex": "^3.2.4",
|
||||
"@hocuspocus/extension-redis": "1.1.2",
|
||||
@@ -100,7 +100,6 @@
|
||||
"@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",
|
||||
@@ -149,7 +148,7 @@
|
||||
"i18next-fs-backend": "^2.6.0",
|
||||
"i18next-http-backend": "^2.7.3",
|
||||
"invariant": "^2.2.4",
|
||||
"ioredis": "^5.7.0",
|
||||
"ioredis": "^5.6.0",
|
||||
"is-printable-key-event": "^1.0.0",
|
||||
"jsdom": "^22.1.0",
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
@@ -228,6 +227,7 @@
|
||||
"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",
|
||||
@@ -315,7 +315,7 @@
|
||||
"@types/node": "20.17.30",
|
||||
"@types/node-fetch": "^2.6.9",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@types/passport-oauth2": "^1.8.0",
|
||||
"@types/passport-oauth2": "^1.4.17",
|
||||
"@types/pluralize": "^0.0.33",
|
||||
"@types/png-chunks-extract": "^1.0.2",
|
||||
"@types/proxy-from-env": "^1.0.4",
|
||||
@@ -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.8",
|
||||
"@types/react-virtualized-auto-sizer": "^1.0.4",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"@types/readable-stream": "^4.0.21",
|
||||
"@types/redis-info": "^3.0.3",
|
||||
@@ -381,6 +381,6 @@
|
||||
"qs": "6.9.7",
|
||||
"prismjs": "1.30.0"
|
||||
},
|
||||
"version": "0.87.4",
|
||||
"version": "0.87.3",
|
||||
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
|
||||
}
|
||||
|
||||
@@ -106,70 +106,4 @@ describe("documentDuplicator", () => {
|
||||
expect(response[0].color).toEqual(original.color);
|
||||
expect(response[0].publishedAt).toBeNull();
|
||||
});
|
||||
|
||||
it("should set originalDocumentId in sourceMetadata when duplicating", async () => {
|
||||
const user = await buildUser();
|
||||
const original = await buildDocument({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
sourceMetadata: { fileName: "test.md", externalId: "ext123" },
|
||||
});
|
||||
|
||||
const response = await sequelize.transaction((transaction) =>
|
||||
documentDuplicator({
|
||||
document: original,
|
||||
collection: original.collection,
|
||||
user,
|
||||
ctx: createContext({ user, transaction }),
|
||||
})
|
||||
);
|
||||
|
||||
expect(response).toHaveLength(1);
|
||||
expect(response[0].sourceMetadata).toEqual({
|
||||
fileName: "test.md",
|
||||
externalId: "ext123",
|
||||
originalDocumentId: original.id,
|
||||
});
|
||||
});
|
||||
|
||||
it("should set originalDocumentId for child documents when duplicating recursively", async () => {
|
||||
const user = await buildUser();
|
||||
const original = await buildDocument({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
|
||||
const childDocument = await buildDocument({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
parentDocumentId: original.id,
|
||||
collection: original.collection,
|
||||
sourceMetadata: { fileName: "child.md" },
|
||||
});
|
||||
|
||||
const response = await sequelize.transaction((transaction) =>
|
||||
documentDuplicator({
|
||||
document: original,
|
||||
collection: original.collection,
|
||||
user,
|
||||
recursive: true,
|
||||
ctx: createContext({ user, transaction }),
|
||||
})
|
||||
);
|
||||
|
||||
expect(response).toHaveLength(2);
|
||||
|
||||
// Check parent document
|
||||
const duplicatedParent = response.find((doc) => !doc.parentDocumentId);
|
||||
expect(duplicatedParent?.sourceMetadata?.originalDocumentId).toEqual(
|
||||
original.id
|
||||
);
|
||||
|
||||
// Check child document
|
||||
const duplicatedChild = response.find((doc) => doc.parentDocumentId);
|
||||
expect(duplicatedChild?.sourceMetadata?.originalDocumentId).toEqual(
|
||||
childDocument.id
|
||||
);
|
||||
expect(duplicatedChild?.sourceMetadata?.fileName).toEqual("child.md");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,10 +52,6 @@ export default async function documentDuplicator({
|
||||
DocumentHelper.toProsemirror(document),
|
||||
["comment"]
|
||||
),
|
||||
sourceMetadata: {
|
||||
...document.sourceMetadata,
|
||||
originalDocumentId: document.id,
|
||||
},
|
||||
...sharedProperties,
|
||||
});
|
||||
|
||||
@@ -89,10 +85,6 @@ export default async function documentDuplicator({
|
||||
DocumentHelper.toProsemirror(childDocument),
|
||||
["comment"]
|
||||
),
|
||||
sourceMetadata: {
|
||||
...childDocument.sourceMetadata,
|
||||
originalDocumentId: childDocument.id,
|
||||
},
|
||||
...sharedProperties,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { faker } from "@faker-js/faker";
|
||||
import { UserRole } from "@shared/types";
|
||||
import { buildTeam, buildUser } from "@server/test/factories";
|
||||
import { 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 () => {
|
||||
@@ -38,58 +37,6 @@ 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,7 +6,6 @@ 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;
|
||||
@@ -42,13 +41,6 @@ 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,
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
/** @type {import('sequelize-cli').Migration} */
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
// Drop the existing foreign key constraint
|
||||
await queryInterface.sequelize.query(
|
||||
`ALTER TABLE "shares" DROP CONSTRAINT "shares_collectionId_fkey"`
|
||||
);
|
||||
|
||||
// Add the foreign key constraint with CASCADE delete
|
||||
await queryInterface.sequelize.query(`
|
||||
ALTER TABLE "shares"
|
||||
ADD CONSTRAINT "shares_collectionId_fkey"
|
||||
FOREIGN KEY("collectionId")
|
||||
REFERENCES "collections" ("id")
|
||||
ON DELETE CASCADE
|
||||
`);
|
||||
},
|
||||
|
||||
async down(queryInterface, Sequelize) {
|
||||
// Drop the cascade constraint
|
||||
await queryInterface.sequelize.query(
|
||||
`ALTER TABLE "shares" DROP CONSTRAINT "shares_collectionId_fkey"`
|
||||
);
|
||||
|
||||
// Add back the original constraint without cascade
|
||||
await queryInterface.sequelize.query(`
|
||||
ALTER TABLE "shares"
|
||||
ADD CONSTRAINT "shares_collectionId_fkey"
|
||||
FOREIGN KEY("collectionId")
|
||||
REFERENCES "collections" ("id")
|
||||
ON DELETE NO ACTION
|
||||
`);
|
||||
},
|
||||
};
|
||||
@@ -26,7 +26,6 @@ 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: [
|
||||
@@ -99,14 +98,7 @@ class WebhookSubscription extends ParanoidModel<
|
||||
}
|
||||
}
|
||||
|
||||
// instance methods
|
||||
|
||||
/**
|
||||
* Rotate the secret value. Does not persist to database.
|
||||
*/
|
||||
public rotateSecret() {
|
||||
this.secret = `ol_whs_${randomString(32)}`;
|
||||
}
|
||||
// methods
|
||||
|
||||
/**
|
||||
* Disables the webhook subscription
|
||||
|
||||
@@ -208,7 +208,7 @@ allow(User, "delete", Document, (actor, document) =>
|
||||
)
|
||||
);
|
||||
|
||||
allow(User, "restore", Document, (actor, document) =>
|
||||
allow(User, ["restore", "permanentDelete"], Document, (actor, document) =>
|
||||
and(
|
||||
isTeamModel(actor, document),
|
||||
!actor.isGuest,
|
||||
@@ -229,15 +229,6 @@ allow(User, "restore", 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,
|
||||
|
||||
@@ -99,7 +99,6 @@ async function presentDocument(
|
||||
importType: source?.format,
|
||||
createdByName: document.sourceMetadata.createdByName,
|
||||
fileName: document.sourceMetadata?.fileName,
|
||||
originalDocumentId: document.sourceMetadata?.originalDocumentId,
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
|
||||
@@ -308,15 +308,16 @@ export default abstract class ImportsProcessor<
|
||||
|
||||
for (const input of importTask.input) {
|
||||
const externalId = input.externalId;
|
||||
const internalId = await this.getInternalId(externalId, idMap);
|
||||
const internalId = this.getInternalId(externalId, idMap);
|
||||
|
||||
const parentExternalId = input.parentExternalId;
|
||||
const parentInternalId = parentExternalId
|
||||
? await this.getInternalId(parentExternalId, idMap)
|
||||
? this.getInternalId(parentExternalId, idMap)
|
||||
: undefined;
|
||||
|
||||
const collectionExternalId = input.collectionExternalId;
|
||||
const collectionInternalId = collectionExternalId
|
||||
? await this.getInternalId(collectionExternalId, idMap)
|
||||
? this.getInternalId(collectionExternalId, idMap)
|
||||
: undefined;
|
||||
|
||||
const output = outputMap[externalId];
|
||||
@@ -338,13 +339,12 @@ export default abstract class ImportsProcessor<
|
||||
transaction,
|
||||
});
|
||||
|
||||
const transformedContent = await this.updateMentionsAndAttachments({
|
||||
const transformedContent = this.updateMentionsAndAttachments({
|
||||
content: output.content,
|
||||
attachments,
|
||||
importInput,
|
||||
idMap,
|
||||
actorId: importModel.createdById,
|
||||
teamId: importModel.teamId,
|
||||
});
|
||||
|
||||
if (collectionItem) {
|
||||
@@ -408,7 +408,8 @@ export default abstract class ImportsProcessor<
|
||||
const isRootDocument =
|
||||
!parentExternalId || !!importInput[parentExternalId];
|
||||
|
||||
const defaults = {
|
||||
const document = Document.build({
|
||||
id: internalId,
|
||||
title: output.title,
|
||||
icon: output.emoji,
|
||||
content: transformedContent,
|
||||
@@ -429,39 +430,16 @@ export default abstract class ImportsProcessor<
|
||||
createdAt: output.createdAt ?? now,
|
||||
updatedAt: output.updatedAt ?? now,
|
||||
publishedAt: output.updatedAt ?? output.createdAt ?? now,
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
await Document.findOrCreateWithCtx(
|
||||
ctx,
|
||||
{
|
||||
where: {
|
||||
id: internalId,
|
||||
},
|
||||
defaults,
|
||||
silent: true,
|
||||
},
|
||||
{
|
||||
name: "create",
|
||||
data: { title: output.title, source: "import" },
|
||||
}
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof UniqueConstraintError) {
|
||||
Logger.error(
|
||||
`ImportsProcessor document creation failed due to unique constraint error (${internalId}: ${defaults.title})`,
|
||||
err,
|
||||
{
|
||||
fields: err.fields,
|
||||
documentId: internalId,
|
||||
title: defaults.title,
|
||||
collectionId: defaults.collectionId,
|
||||
parentDocumentId: defaults.parentDocumentId,
|
||||
}
|
||||
);
|
||||
await document.saveWithCtx(
|
||||
ctx,
|
||||
{ silent: true },
|
||||
{
|
||||
name: "create",
|
||||
data: { title: output.title, source: "import" },
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
);
|
||||
|
||||
// Update document id for attachments in document content.
|
||||
await Attachment.update(
|
||||
@@ -486,13 +464,12 @@ export default abstract class ImportsProcessor<
|
||||
* @param actorId ID of the user who created the import.
|
||||
* @returns Updated ProseMirrorDoc.
|
||||
*/
|
||||
private async updateMentionsAndAttachments({
|
||||
private updateMentionsAndAttachments({
|
||||
content,
|
||||
attachments,
|
||||
idMap,
|
||||
importInput,
|
||||
actorId,
|
||||
teamId,
|
||||
}: {
|
||||
content: ProsemirrorDoc;
|
||||
attachments: Attachment[];
|
||||
@@ -500,8 +477,7 @@ export default abstract class ImportsProcessor<
|
||||
// oxlint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
importInput: Record<string, ImportInput<any>[number]>;
|
||||
actorId: string;
|
||||
teamId: string;
|
||||
}): Promise<ProsemirrorDoc> {
|
||||
}): ProsemirrorDoc {
|
||||
// special case when the doc content is empty.
|
||||
if (!content.content.length) {
|
||||
return content;
|
||||
@@ -510,7 +486,7 @@ export default abstract class ImportsProcessor<
|
||||
const attachmentsMap = keyBy(attachments, "id");
|
||||
const doc = ProsemirrorHelper.toProsemirror(content);
|
||||
|
||||
const transformMentionNode = async (node: Node): Promise<Node> => {
|
||||
const transformMentionNode = (node: Node): Node => {
|
||||
const json = node.toJSON() as ProsemirrorData;
|
||||
const attrs = json.attrs ?? {};
|
||||
|
||||
@@ -518,7 +494,7 @@ export default abstract class ImportsProcessor<
|
||||
attrs.actorId = actorId;
|
||||
|
||||
const externalId = attrs.modelId as string;
|
||||
attrs.modelId = await this.getInternalId(externalId, idMap, teamId);
|
||||
attrs.modelId = this.getInternalId(externalId, idMap);
|
||||
|
||||
const isCollectionMention = !!importInput[externalId]; // the referenced externalId is a root page.
|
||||
attrs.type = isCollectionMention
|
||||
@@ -533,72 +509,43 @@ 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);
|
||||
};
|
||||
|
||||
const transformFragment = async (fragment: Fragment): Promise<Fragment> => {
|
||||
const nodePromises: Promise<Node>[] = [];
|
||||
const transformFragment = (fragment: Fragment): Fragment => {
|
||||
const nodes: Node[] = [];
|
||||
|
||||
fragment.forEach((node) => {
|
||||
if (node.type.name === "mention") {
|
||||
nodePromises.push(transformMentionNode(node));
|
||||
} else if (node.type.name === "attachment") {
|
||||
nodePromises.push(Promise.resolve(transformAttachmentNode(node)));
|
||||
} else {
|
||||
nodePromises.push(
|
||||
transformFragment(node.content).then((transformedContent) =>
|
||||
node.copy(transformedContent)
|
||||
)
|
||||
);
|
||||
}
|
||||
nodes.push(
|
||||
node.type.name === "mention"
|
||||
? transformMentionNode(node)
|
||||
: node.type.name === "attachment"
|
||||
? transformAttachmentNode(node)
|
||||
: node.copy(transformFragment(node.content))
|
||||
);
|
||||
});
|
||||
|
||||
const nodes = await Promise.all(nodePromises);
|
||||
return Fragment.fromArray(nodes);
|
||||
};
|
||||
|
||||
return doc.copy(await transformFragment(doc.content)).toJSON();
|
||||
return doc.copy(transformFragment(doc.content)).toJSON();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get internalId for the given externalId.
|
||||
* Returned internalId will be used as "id" for collections and documents created in the import.
|
||||
*
|
||||
* @param teamId teamId associated with the import.
|
||||
* @param externalId externalId from a source.
|
||||
* @param idMap Map of internalId to externalId.
|
||||
* @returns Mapped internalId.
|
||||
*/
|
||||
private async getInternalId(
|
||||
externalId: string,
|
||||
idMap: Record<string, string>,
|
||||
teamId?: string
|
||||
) {
|
||||
let internalId = idMap[externalId];
|
||||
|
||||
if (!internalId && teamId) {
|
||||
const existingId = (
|
||||
await Document.findOne({
|
||||
attributes: ["id"],
|
||||
where: {
|
||||
teamId,
|
||||
sourceMetadata: {
|
||||
externalId,
|
||||
},
|
||||
},
|
||||
})
|
||||
)?.id;
|
||||
|
||||
if (existingId) {
|
||||
return existingId;
|
||||
}
|
||||
}
|
||||
|
||||
idMap[externalId] = internalId ?? uuidv4();
|
||||
return idMap[externalId];
|
||||
private getInternalId(externalId: string, idMap: Record<string, string>) {
|
||||
const internalId = idMap[externalId] ?? uuidv4();
|
||||
idMap[externalId] = internalId;
|
||||
return internalId;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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).toEqual(false);
|
||||
expect(body.policies[0].abilities.permanentDelete).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should return deleted documents, including users drafts without collection", async () => {
|
||||
@@ -2303,26 +2303,6 @@ 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();
|
||||
});
|
||||
|
||||
@@ -4452,7 +4432,7 @@ describe("#documents.delete", () => {
|
||||
expect(deletedDoc?.deletedAt).not.toBe(null);
|
||||
});
|
||||
|
||||
it("should allow permanently deleting a document as admin", async () => {
|
||||
it("should allow permanently deleting a document", async () => {
|
||||
const user = await buildAdmin();
|
||||
const document = await buildDocument({
|
||||
userId: user.id,
|
||||
@@ -4476,31 +4456,6 @@ 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,82 +128,6 @@ 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 });
|
||||
@@ -260,7 +184,9 @@ describe("#events.list", () => {
|
||||
documentId: document.id,
|
||||
},
|
||||
});
|
||||
expect(res.status).toEqual(403);
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.length).toEqual(0);
|
||||
});
|
||||
|
||||
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, Document } from "@server/models";
|
||||
import { Event, User, Collection } from "@server/models";
|
||||
import { authorize } from "@server/policies";
|
||||
import { presentEvent } from "@server/presenters";
|
||||
import { APIContext } from "@server/types";
|
||||
@@ -52,25 +52,20 @@ 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,
|
||||
|
||||
@@ -58,8 +58,6 @@ export const TeamsUpdateSchema = BaseSchema.extend({
|
||||
.optional(),
|
||||
/** Side to display the document's table of contents in relation to the main content. */
|
||||
tocPosition: z.nativeEnum(TOCPosition).optional(),
|
||||
/** Whether to prevent shared documents from being embedded in iframes on external websites. */
|
||||
preventDocumentEmbedding: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
|
||||
@@ -194,11 +194,8 @@ export const renderShare = async (ctx: Context, next: Next) => {
|
||||
ctx.status = 404;
|
||||
}
|
||||
|
||||
// Allow shares to be embedded in iframes on other websites unless prevented by team preference
|
||||
const preventEmbedding = team?.getPreference(TeamPreference.PreventDocumentEmbedding) ?? false;
|
||||
if (!preventEmbedding) {
|
||||
ctx.remove("X-Frame-Options");
|
||||
}
|
||||
// Allow shares to be embedded in iframes on other websites
|
||||
ctx.remove("X-Frame-Options");
|
||||
|
||||
const publicBranding =
|
||||
team?.getPreference(TeamPreference.PublicBranding) ?? false;
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
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();
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
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,6 +30,7 @@ export default abstract class OAuthClient {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
data = await response.json();
|
||||
} catch (err) {
|
||||
throw InvalidRequestError(err.message);
|
||||
}
|
||||
@@ -39,12 +40,6 @@ export default abstract class OAuthClient {
|
||||
throw AuthenticationError();
|
||||
}
|
||||
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch (err) {
|
||||
throw InvalidRequestError(err.message);
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
|
||||
@@ -32,7 +32,6 @@ export const TeamPreferenceDefaults: TeamPreferences = {
|
||||
[TeamPreference.Commenting]: true,
|
||||
[TeamPreference.CustomTheme]: undefined,
|
||||
[TeamPreference.TocPosition]: TOCPosition.Left,
|
||||
[TeamPreference.PreventDocumentEmbedding]: false,
|
||||
};
|
||||
|
||||
export const UserPreferenceDefaults: UserPreferences = {
|
||||
|
||||
@@ -23,9 +23,8 @@ export default function splitHeading(type: NodeType): Command {
|
||||
const previousBlockIsCollapsed = !!collapsedNodes.find(
|
||||
(a) => a.pos === previousBlock?.pos
|
||||
);
|
||||
const isEmpty = $from.parent.content.size === 0;
|
||||
|
||||
if (previousBlockIsCollapsed && !isEmpty) {
|
||||
if (previousBlockIsCollapsed) {
|
||||
// Insert a new heading directly before this one
|
||||
const transaction = state.tr.insert(
|
||||
$from.before(),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
mathBackspaceCmd,
|
||||
insertMathCmd,
|
||||
makeInlineMathInputRule,
|
||||
mathSchemaSpec,
|
||||
} from "@benrbray/prosemirror-math";
|
||||
import { PluginSimple } from "markdown-it";
|
||||
@@ -15,8 +16,6 @@ import MathPlugin from "../extensions/Math";
|
||||
import { MarkdownSerializerState } from "../lib/markdown/serializer";
|
||||
import mathRule, { REGEX_INLINE_MATH_DOLLARS } from "../rules/math";
|
||||
import Node from "./Node";
|
||||
import { InputRule } from "prosemirror-inputrules";
|
||||
import { isInCode } from "../queries/isInCode";
|
||||
|
||||
export default class Math extends Node {
|
||||
get name() {
|
||||
@@ -36,34 +35,10 @@ export default class Math extends Node {
|
||||
|
||||
inputRules({ schema }: { schema: Schema }) {
|
||||
return [
|
||||
new InputRule(REGEX_INLINE_MATH_DOLLARS, (state, match, start, end) => {
|
||||
if (isInCode(state)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let $start = state.doc.resolve(start);
|
||||
let index = $start.index();
|
||||
let $end = state.doc.resolve(end);
|
||||
// check if replacement valid
|
||||
if (
|
||||
!$start.parent.canReplaceWith(
|
||||
index,
|
||||
$end.index(),
|
||||
schema.nodes.math_inline
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
// perform replacement
|
||||
return state.tr.replaceRangeWith(
|
||||
start,
|
||||
end,
|
||||
schema.nodes.math_inline.create(
|
||||
undefined,
|
||||
schema.nodes.math_inline.schema.text(match[1])
|
||||
)
|
||||
);
|
||||
}),
|
||||
makeInlineMathInputRule(
|
||||
REGEX_INLINE_MATH_DOLLARS,
|
||||
schema.nodes.math_inline
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -17,19 +17,14 @@ export function getMarksBetween(
|
||||
let marks: { start: number; end: number; mark: Mark }[] = [];
|
||||
|
||||
state.doc.nodesBetween(start, end, (node, pos) => {
|
||||
if (node.isText) {
|
||||
const nodeStart = Math.max(start, pos);
|
||||
const nodeEnd = Math.min(end, pos + node.nodeSize);
|
||||
|
||||
marks = [
|
||||
...marks,
|
||||
...node.marks.map((mark) => ({
|
||||
start: nodeStart,
|
||||
end: nodeEnd,
|
||||
mark,
|
||||
})),
|
||||
];
|
||||
}
|
||||
marks = [
|
||||
...marks,
|
||||
...node.marks.map((mark) => ({
|
||||
start: pos,
|
||||
end: pos + node.nodeSize,
|
||||
mark,
|
||||
})),
|
||||
];
|
||||
});
|
||||
|
||||
return marks;
|
||||
|
||||
@@ -20,21 +20,21 @@ type Options = {
|
||||
*/
|
||||
export function isInCode(state: EditorState, options?: Options): boolean {
|
||||
const { nodes, marks } = state.schema;
|
||||
const opts =
|
||||
options?.inclusive !== undefined
|
||||
? { inclusive: options?.inclusive }
|
||||
: undefined;
|
||||
|
||||
if (!options?.onlyMark) {
|
||||
if (
|
||||
nodes.code_block &&
|
||||
isNodeActive(nodes.code_block, undefined, opts)(state)
|
||||
isNodeActive(nodes.code_block, undefined, {
|
||||
inclusive: options?.inclusive,
|
||||
})(state)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
nodes.code_fence &&
|
||||
isNodeActive(nodes.code_fence, undefined, opts)(state)
|
||||
isNodeActive(nodes.code_fence, undefined, {
|
||||
inclusive: options?.inclusive,
|
||||
})(state)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
@@ -42,7 +42,9 @@ export function isInCode(state: EditorState, options?: Options): boolean {
|
||||
|
||||
if (!options?.onlyBlock) {
|
||||
if (marks.code_inline) {
|
||||
return isMarkActive(marks.code_inline, undefined, opts)(state);
|
||||
return isMarkActive(marks.code_inline, undefined, {
|
||||
inclusive: options?.inclusive,
|
||||
})(state);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -468,21 +468,19 @@
|
||||
"Replace": "Ersetzen",
|
||||
"Replace all": "Alle ersetzen",
|
||||
"Image width": "Bildbreite",
|
||||
"Width": "Breite",
|
||||
"Image height": "Bildhöhe",
|
||||
"Height": "Höhe",
|
||||
"Profile picture": "Profilbild",
|
||||
"Create a new doc": "Neues Dokument erstellen",
|
||||
"{{ userName }} won't be notified, as they do not have access to this document": "{{ userName }} wird nicht benachrichtigt, da sie keinen Zugriff auf dieses Dokument haben",
|
||||
"Keep as link": "Als Link beibehalten",
|
||||
"Mention": "Erwähnung",
|
||||
"Embed": "Einbetten",
|
||||
"Insert after": "Danach einfügen",
|
||||
"Insert before": "Davor einfügen",
|
||||
"Move up": "Nach oben",
|
||||
"Move down": "Nach unten",
|
||||
"Move left": "Nach links bewegen",
|
||||
"Move right": "Nach rechts bewegen",
|
||||
"Insert after": "Insert after",
|
||||
"Insert before": "Insert before",
|
||||
"Move up": "Move up",
|
||||
"Move down": "Move down",
|
||||
"Move left": "Move left",
|
||||
"Move right": "Move right",
|
||||
"Align center": "Zentrieren",
|
||||
"Align left": "Links ausrichten",
|
||||
"Align right": "Rechts ausrichten",
|
||||
@@ -499,6 +497,8 @@
|
||||
"Create a new child doc": "Neues untergeordnetes Dokument erstellen",
|
||||
"Delete table": "Tabelle löschen",
|
||||
"Delete file": "Datei löschen",
|
||||
"Width": "Breite",
|
||||
"Height": "Höhe",
|
||||
"Download file": "Datei herunterladen",
|
||||
"Replace file": "Datei ersetzen",
|
||||
"Delete image": "Bild löschen",
|
||||
@@ -584,7 +584,7 @@
|
||||
"Manual sort": "Manuelle Sortierung",
|
||||
"Collection menu": "Sammlungsmenü",
|
||||
"Comment options": "Kommentar Optionen",
|
||||
"Enable viewer insights": "Leserstatistiken aktivieren",
|
||||
"Enable viewer insights": "Leser Statistiken aktivieren",
|
||||
"Enable embeds": "Einbettungen aktivieren",
|
||||
"File": "Datei",
|
||||
"Group members": "Gruppenmitglieder",
|
||||
|
||||
@@ -206,6 +206,8 @@
|
||||
"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",
|
||||
@@ -475,7 +477,6 @@
|
||||
"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",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"New API key": "Nuova chiave API",
|
||||
"Delete": "Cancella",
|
||||
"Revoke": "Revoca",
|
||||
"Revoke API key": "Revoca chiave API",
|
||||
"Revoke API key": "Revoke API key",
|
||||
"Revoke token": "Revoca token",
|
||||
"Open collection": "Apri la raccolta",
|
||||
"New collection": "Nuova raccolta",
|
||||
@@ -40,8 +40,8 @@
|
||||
"Copy ID": "Copia ID",
|
||||
"Clear IndexedDB cache": "Pulisci cache IndexedDB",
|
||||
"IndexedDB cache cleared": "Cache IndexedDB pulita",
|
||||
"Clear local storage": "Pulisci archivio locale",
|
||||
"Local storage cleared": "Archivio locale pulito",
|
||||
"Clear local storage": "Clear local storage",
|
||||
"Local storage cleared": "Local storage cleared",
|
||||
"Toggle debug logging": "Attiva/Disattiva il log di debug",
|
||||
"Debug logging enabled": "Log di debug attivato",
|
||||
"Debug logging disabled": "Log di debug disattivato",
|
||||
@@ -88,9 +88,9 @@
|
||||
"Create template": "Crea un modello",
|
||||
"Open random document": "Apri un documento casuale",
|
||||
"Search documents for \"{{searchQuery}}\"": "Cerca documenti per \"{{searchQuery}}\"",
|
||||
"Move to workspace": "Sposta nello spazio di lavoro",
|
||||
"Move to workspace": "Sposta allo spazio di lavoro",
|
||||
"Move": "Sposta",
|
||||
"Move to collection": "Sposta nella raccolta",
|
||||
"Move to collection": "Sposta alla raccolta",
|
||||
"Move {{ documentType }}": "Sposta {{ documentType }}",
|
||||
"Are you sure you want to archive this document?": "Sei sicuro di voler archiviare questo documento?",
|
||||
"Document archived": "Documento archiviato",
|
||||
@@ -142,7 +142,7 @@
|
||||
"Change theme": "Cambia tema",
|
||||
"Change theme to": "Cambia tema in",
|
||||
"Share link copied": "Link di condivisione copiato",
|
||||
"Go to collection": "Vai alla raccolta",
|
||||
"Go to collection": "Go to collection",
|
||||
"Go to document": "Vai al documento",
|
||||
"Revoke link": "Revoca il link",
|
||||
"Share link revoked": "Link condivisione revocato",
|
||||
@@ -175,7 +175,7 @@
|
||||
"currently viewing": "attualmente visualizzato",
|
||||
"previously edited": "precedentemente modificato",
|
||||
"You": "Tu",
|
||||
"Avatar of {{ name }}": "Avatar di {{ name }}",
|
||||
"Avatar of {{ name }}": "Avatar of {{ name }}",
|
||||
"Viewers": "Visitatori",
|
||||
"Collections are used to group documents and choose permissions": "Le raccolte sono usate per raggruppare i documenti e assegnare i permessi",
|
||||
"Name": "Nome",
|
||||
@@ -206,14 +206,14 @@
|
||||
"Move document": "Sposta il documento",
|
||||
"Moving": "Spostamento",
|
||||
"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>.": "Spostare il documento <em>{{ title }}</em> alla raccolta {{ newCollectionName }} cambierà i permessi per tutti i membri dello spazio di lavoro da <em>{{ prevPermission }}</em> a <em>{{ newPermission }}</em>.",
|
||||
"More options": "Più opzioni",
|
||||
"More options": "More options",
|
||||
"Submenu": "Sottomenu",
|
||||
"Collections could not be loaded, please reload the app": "Impossibile caricare le raccolte, per favore ricarica l'app",
|
||||
"Start view": "Schermata iniziale",
|
||||
"Install now": "Installa ora",
|
||||
"Disconnect": "Disconnetti",
|
||||
"Disconnecting": "Disconnessione in corso",
|
||||
"Are you sure you want to disconnect the <em>{{ service }}</em> integration?": "Sei sicuro di voler disconnettere l'integrazione con <em>{{ service }}</em>?",
|
||||
"Disconnecting": "Disconnecting",
|
||||
"Are you sure you want to disconnect the <em>{{ service }}</em> integration?": "Are you sure you want to disconnect the <em>{{ service }}</em> integration?",
|
||||
"This will stop sending analytics events to the configured instance.": "This will stop sending analytics events to the configured instance.",
|
||||
"Deleted Collection": "Raccolte Eliminate",
|
||||
"Untitled": "Senza titolo",
|
||||
@@ -260,10 +260,10 @@
|
||||
"Sorry, part of the application failed to load. This may be because it was updated since you opened the tab or because of a failed network request. Please try reloading.": "Spiacenti, parte dell'applicazione non si è caricata correttamente. È possibile che sia stata aggiornata da quando hai aperto la scheda oppure è fallita una richiesta di rete. Si prega di ricaricare la pagina.",
|
||||
"Reload": "Ricaricare",
|
||||
"Something Unexpected Happened": "È successo qualcosa di imprevisto",
|
||||
"An error has occurred multiple times recently. If it continues please try clearing the cache or using a different browser.": "Di recente un errore è stato riscontrato più volte. Se persiste prova a pulire la cache o ad utilizzare un browser differente.",
|
||||
"An error has occurred multiple times recently. If it continues please try clearing the cache or using a different browser.": "An error has occurred multiple times recently. If it continues please try clearing the cache or using a different browser.",
|
||||
"Sorry, an unrecoverable error occurred{{notified}}. Please try reloading the page, it may have been a temporary glitch.": "Spiacenti, si è verificato un errore irreversibile{{notified}}. Prova a ricaricare la pagina, potrebbe essere un problema temporaneo.",
|
||||
"our engineers have been notified": "i nostri ingegneri sono stati informati",
|
||||
"Clear cache + reload": "Pulisci la cache e aggiorna",
|
||||
"Clear cache + reload": "Clear cache + reload",
|
||||
"Show detail": "Mostra dettagli",
|
||||
"{{userName}} archived": "{{userName}} archiviato",
|
||||
"{{userName}} restored": "{{userName}} recuperato",
|
||||
@@ -284,9 +284,9 @@
|
||||
"You will receive an email when it's complete.": "Riceverai un'email quando sarà completata.",
|
||||
"Include attachments": "Includi allegati",
|
||||
"Including uploaded images and files in the exported data": "Includere immagini e file caricati nei dati esportati",
|
||||
"{{count}} more user": "{{count}} utente ancora",
|
||||
"{{count}} more user_plural": "{{count}} utenti ancora",
|
||||
"Filter options": "Opzioni di filtro",
|
||||
"{{count}} more user": "{{count}} more user",
|
||||
"{{count}} more user_plural": "{{count}} more users",
|
||||
"Filter options": "Filter options",
|
||||
"Filter": "Filtro",
|
||||
"No results": "Nessun risultato",
|
||||
"{{authorName}} created <3></3>": "Creato\n\n\n\n\n\n\n\n",
|
||||
@@ -319,11 +319,11 @@
|
||||
"Change Language": "Cambia Lingua",
|
||||
"Dismiss": "Chiudi",
|
||||
"Lightbox": "Lightbox",
|
||||
"View, navigate, or download images in the document": "Visualizza, naviga, o scarica immagini nel documento",
|
||||
"View, navigate, or download images in the document": "View, navigate, or download images in the document",
|
||||
"Close": "Chiudi",
|
||||
"Previous": "Precedente",
|
||||
"Next": "Successivo",
|
||||
"Image failed to load": "Il caricamento dell'immagine è fallito",
|
||||
"Previous": "Previous",
|
||||
"Next": "Next",
|
||||
"Image failed to load": "Image failed to load",
|
||||
"You’re offline.": "Sei offline.",
|
||||
"Sorry, an error occurred.": "Spiacenti, si è verificato un errore.",
|
||||
"Click to retry": "Clicca per riprovare",
|
||||
@@ -332,7 +332,7 @@
|
||||
"Mark all as read": "Contrassegna tutto come letto",
|
||||
"You're all caught up": "Sei completamente aggiornato",
|
||||
"Icon": "Icona",
|
||||
"OAuth client icon": "Icona client OAuth",
|
||||
"OAuth client icon": "OAuth client icon",
|
||||
"My App": "La mia App",
|
||||
"Tagline": "Slogan",
|
||||
"A short description": "Una breve descrizione",
|
||||
@@ -367,7 +367,7 @@
|
||||
"Disable this setting to discourage search engines from indexing the page": "Disabilita questa impostazione per scoraggiare i motori di ricerca dall'indicizzare la pagina",
|
||||
"Show last modified": "Mostra l'ultima modifica",
|
||||
"Display the last modified timestamp on the shared page": "Mostra la data dell'ultima modifica nella pagina condivisa",
|
||||
"All documents in this collection will be shared on the web, including any new documents added later": "Tutti i documenti in questa raccolta saranno condivisi sul web, inclusi eventuali nuovi documenti aggiunti successivamente",
|
||||
"All documents in this collection will be shared on the web, including any new documents added later": "All documents in this collection will be shared on the web, including any new documents added later",
|
||||
"Invite": "Invita",
|
||||
"{{ userName }} was added to the collection": "{{ userName }} stato aggiunto alla raccolta",
|
||||
"{{ count }} people added to the collection": "Persone aggiunte alla collezione",
|
||||
@@ -409,15 +409,15 @@
|
||||
"{{ count }} groups added to the document": "Gruppi aggiunti al documento",
|
||||
"{{ count }} groups added to the document_plural": "Gruppi aggiunti al documento",
|
||||
"Logo": "Logo",
|
||||
"Expand sidebar": "Espandi barra laterale",
|
||||
"Collapse sidebar": "Nascondi barra laterale",
|
||||
"Expand sidebar": "Expand sidebar",
|
||||
"Collapse sidebar": "Collapse sidebar",
|
||||
"Archived collections": "Collezioni archiviate",
|
||||
"New doc": "Nuovo documento",
|
||||
"Empty": "Vuoto",
|
||||
"Collapse": "Raggruppa",
|
||||
"Expand": "Espandi",
|
||||
"Document not supported – try Markdown, Plain text, HTML, or Word": "Documento non supportato – prova Markdown, testo semplice, HTML o Word",
|
||||
"Import files": "Importa file",
|
||||
"Import files": "Import files",
|
||||
"Go back": "Torna indietro",
|
||||
"Go forward": "Vai avanti",
|
||||
"Could not load shared documents": "Impossibile caricare i documenti condivisi",
|
||||
@@ -456,7 +456,7 @@
|
||||
"New email": "Nuova email",
|
||||
"Email can't be empty": "L'email non può essere vuota",
|
||||
"Your import completed": "Importazione completata",
|
||||
"Sorry, invalid embed link": "Link d'incorporamento non valido",
|
||||
"Sorry, invalid embed link": "Sorry, invalid embed link",
|
||||
"Previous match": "Pagina precedente",
|
||||
"Next match": "Prossima Partita",
|
||||
"Find and replace": "Trova e sostituisci",
|
||||
@@ -467,22 +467,20 @@
|
||||
"Replacement": "Sostituire",
|
||||
"Replace": "Sostituisci",
|
||||
"Replace all": "Sostituisci tutti",
|
||||
"Image width": "Larghezza immagine",
|
||||
"Width": "Largezza",
|
||||
"Image height": "Altezza immagine",
|
||||
"Height": "Altezza",
|
||||
"Image width": "Image width",
|
||||
"Image height": "Image height",
|
||||
"Profile picture": "Immagine del profilo",
|
||||
"Create a new doc": "Crea un nuovo documento",
|
||||
"{{ userName }} won't be notified, as they do not have access to this document": "{{ userName }} Non verrà notificato, in quanto Non",
|
||||
"Keep as link": "Mantieni come link",
|
||||
"Mention": "Menzione",
|
||||
"Embed": "Incorpora",
|
||||
"Insert after": "Inserisci dopo",
|
||||
"Insert before": "Inserisci prima",
|
||||
"Move up": "Sposta su",
|
||||
"Move down": "Sposta giù",
|
||||
"Move left": "Sposta a sinistra",
|
||||
"Move right": "Sposta a destra",
|
||||
"Insert after": "Insert after",
|
||||
"Insert before": "Insert before",
|
||||
"Move up": "Move up",
|
||||
"Move down": "Move down",
|
||||
"Move left": "Move left",
|
||||
"Move right": "Move right",
|
||||
"Align center": "Allinea al centro",
|
||||
"Align left": "Allinea a sinistra",
|
||||
"Align right": "Allinea a destra",
|
||||
@@ -499,6 +497,8 @@
|
||||
"Create a new child doc": "Crea un nuovo documento",
|
||||
"Delete table": "Elimina tabella",
|
||||
"Delete file": "Cancella file",
|
||||
"Width": "Width",
|
||||
"Height": "Height",
|
||||
"Download file": "Scarica il file",
|
||||
"Replace file": "Sostituisci file",
|
||||
"Delete image": "Elimina immagine",
|
||||
@@ -559,7 +559,7 @@
|
||||
"Outdent": "Riduci rientro",
|
||||
"Video": "Video",
|
||||
"None": "Nessuno",
|
||||
"Delete embed": "Elimina incorporamento",
|
||||
"Delete embed": "Delete embed",
|
||||
"Rename": "Rinomina",
|
||||
"Could not import file": "Impossibile importare il file",
|
||||
"Unsubscribed from document": "Annullata l'iscrizione al documento",
|
||||
@@ -576,7 +576,7 @@
|
||||
"Import": "Importa",
|
||||
"Install": "Installa",
|
||||
"Integrations": "Integrazioni",
|
||||
"API key": "Chiave API",
|
||||
"API key": "API key",
|
||||
"Show path to document": "Mostra percorso del documento",
|
||||
"Sort in sidebar": "Ordina nella barra laterale",
|
||||
"A-Z sort": "Ordina da A-Z",
|
||||
@@ -622,7 +622,7 @@
|
||||
"mentioned you in": "Ti ha menzionato in",
|
||||
"left a comment on": "Lasciato un commento",
|
||||
"resolved a comment on": "Risolto un commento",
|
||||
"reacted {{ emoji }} to your comment on": "ha reagito con {{ emoji }} al tuo commento su",
|
||||
"reacted {{ emoji }} to your comment on": "reacted {{ emoji }} to your comment on",
|
||||
"shared": "condiviso",
|
||||
"invited you to": "Ti ha invitato a",
|
||||
"Choose a date": "Seleziona una data",
|
||||
@@ -635,7 +635,7 @@
|
||||
"30 days": "30 giorni",
|
||||
"60 days": "60 giorni",
|
||||
"90 days": "90 giorni",
|
||||
"Custom": "Personalizzato",
|
||||
"Custom": "Custom",
|
||||
"No expiration": "Nessuna scadenza",
|
||||
"The document archive is empty at the moment.": "L'archivio documenti è vuoto al momento.",
|
||||
"Drop documents to import": "Trascina qui i documenti oppure",
|
||||
@@ -664,14 +664,14 @@
|
||||
"Reply": "Rispondi",
|
||||
"Post": "Pubblica",
|
||||
"Upload image": "Carica immagine",
|
||||
"No resolved comments": "Nessun commento risolto",
|
||||
"No resolved comments": "No resolved comments",
|
||||
"No comments yet": "Nessun commento",
|
||||
"New comments": "Nuovi commenti",
|
||||
"Most recent": "Più recente",
|
||||
"Order in doc": "Ordine nel doc",
|
||||
"Order in doc": "Order in doc",
|
||||
"Resolved": "Risolto",
|
||||
"Sort comments": "Ordina commenti",
|
||||
"Show {{ count }} reply": "Mostra {{ count }} risposte",
|
||||
"Show {{ count }} reply": "Show {{ count }} reply",
|
||||
"Show {{ count }} reply_plural": "Mostra {{ count }} risposte",
|
||||
"Error updating comment": "Errore durante l'aggiornamento del commento",
|
||||
"Document is too large": "Il documento è troppo grande",
|
||||
@@ -692,7 +692,7 @@
|
||||
"only you": "solo tu",
|
||||
"person": "persona",
|
||||
"people": "persone",
|
||||
"Document title": "Titolo del documento",
|
||||
"Document title": "Document title",
|
||||
"Last updated": "Ultimo aggiornamento",
|
||||
"Type '/' to insert, or start writing…": "Digita '/' per inserire, o inizia a scrivere…",
|
||||
"Hide contents": "Nascondi contenuti",
|
||||
@@ -706,26 +706,26 @@
|
||||
"No history yet": "Nessuna cronologia",
|
||||
"Source": "Origine",
|
||||
"Created": "Creato",
|
||||
"Imported from {{ source }}": "Importato da {{ source }}",
|
||||
"Imported from {{ source }}": "Imported from {{ source }}",
|
||||
"Stats": "Statistiche",
|
||||
"{{ number }} minute read": "{{ number }} minuto di lettura",
|
||||
"{{ number }} words": "{{ number }} parola",
|
||||
"{{ number }} words_plural": "{{ number }} parole",
|
||||
"{{ number }} characters": "{{ number }} carattere",
|
||||
"{{ number }} characters_plural": "{{ number }} caratteri",
|
||||
"{{ number }} minute read": "{{ number }} minute read",
|
||||
"{{ number }} words": "{{ number }} word",
|
||||
"{{ number }} words_plural": "{{ number }} words",
|
||||
"{{ number }} characters": "{{ number }} character",
|
||||
"{{ number }} characters_plural": "{{ number }} characters",
|
||||
"{{ number }} emoji": "{{ number }} emoji",
|
||||
"No text selected": "Nessun testo selezionato",
|
||||
"{{ number }} words selected": "{{ number }} parola selezionata",
|
||||
"{{ number }} words selected_plural": "{{ number }} parole selezionate",
|
||||
"{{ number }} characters selected": "{{ number }} carattere selezionato",
|
||||
"{{ number }} characters selected_plural": "{{ number }} caratteri selezionati",
|
||||
"{{ number }} words selected": "{{ number }} word selected",
|
||||
"{{ number }} words selected_plural": "{{ number }} words selected",
|
||||
"{{ number }} characters selected": "{{ number }} character selected",
|
||||
"{{ number }} characters selected_plural": "{{ number }} characters selected",
|
||||
"Contributors": "Contributori",
|
||||
"Creator": "Autore",
|
||||
"Last edited": "Ultima modifica",
|
||||
"Previously edited": "Modificato precedentemente",
|
||||
"Sorry, the last change could not be persisted – please reload the page": "Spiacenti, non è stato possibile mantenere l'ultima modifica. Si prega di ricaricare la pagina",
|
||||
"{{ count }} days": "{{ count }} giorno",
|
||||
"{{ count }} days_plural": "{{ count }} giorni",
|
||||
"{{ count }} days": "{{ count }} day",
|
||||
"{{ count }} days_plural": "{{ count }} days",
|
||||
"This template will be permanently deleted in <2></2> unless restored.": "Questo modello verrà eliminato definitivamente in <2></2> salvo ripristino.",
|
||||
"This document will be permanently deleted in <2></2> unless restored.": "Questo documento verrà eliminato definitivamente tra <2></2> salvo ripristino.",
|
||||
"Highlight some text and use the <1></1> control to add placeholders that can be filled out when creating new documents": "Evidenzia del testo e usa il comando <1></1> per aggiungere dei segnaposto che possono essere compilati nella creazione di nuovi documenti",
|
||||
@@ -733,7 +733,7 @@
|
||||
"Deleted by {{userName}}": "Eliminato da {{userName}}",
|
||||
"Observing {{ userName }}": "Osservazione di {{ userName }}",
|
||||
"Backlinks": "Backlink",
|
||||
"This document is large which may affect performance": "Questo documento è grande, potrebbe influenzare le prestazioni",
|
||||
"This document is large which may affect performance": "This document is large which may affect performance",
|
||||
"Are you sure you want to delete the <em>{{ documentTitle }}</em> template?": "Sei sicuro di voler eliminare il template <em>{{ documentTitle }}</em>?",
|
||||
"Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history</em>.": "Sei sicuro? L'eliminazione del documento <em>{{ documentTitle }}</em> cancellerà tutta la sua cronologia</em>.",
|
||||
"Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and <em>{{ any }} nested document</em>.": "Sei sicuro? L'eliminazione del documento <em>{{ documentTitle }}</em> cancellerà tutta la sua cronologia e <em>{{ any }} documenti annidati</em>.",
|
||||
@@ -753,19 +753,19 @@
|
||||
"Search documents": "Cerca documenti",
|
||||
"No documents found for your filters.": "Nessun documento trovato per i tuoi filtri.",
|
||||
"You’ve not got any drafts at the moment.": "Al momento non hai bozze.",
|
||||
"Payment Required": "Pagamento Richiesto",
|
||||
"No access to this doc": "Non hai accesso a questo documento",
|
||||
"It doesn’t look like you have permission to access this document.": "Sembra che tu non abbia i permessi per accedere a questo documento",
|
||||
"Please request access from the document owner.": "Richiedi l'accesso al proprietario del documento",
|
||||
"Not found": "Non trovato",
|
||||
"The page you’re looking for cannot be found. It might have been deleted or the link is incorrect.": "La pagina che stai cercando non è stata trovata. Potrebbe essere stata eliminata oppure il link non è corretto.",
|
||||
"Payment Required": "Payment Required",
|
||||
"No access to this doc": "No access to this doc",
|
||||
"It doesn’t look like you have permission to access this document.": "It doesn’t look like you have permission to access this document.",
|
||||
"Please request access from the document owner.": "Please request access from the document owner.",
|
||||
"Not found": "Not found",
|
||||
"The page you’re looking for cannot be found. It might have been deleted or the link is incorrect.": "The page you’re looking for cannot be found. It might have been deleted or the link is incorrect.",
|
||||
"Offline": "Non in linea",
|
||||
"We were unable to load the document while offline.": "Impossibile caricare il documento offline.",
|
||||
"Your account has been suspended": "Il tuo account è stato sospeso",
|
||||
"Warning Sign": "Simbolo di avvertenza",
|
||||
"A workspace admin (<em>{{ suspendedContactEmail }}</em>) has suspended your account. To re-activate your account, please reach out to them directly.": "Un amministratore dello spazio di lavoro (<em>{{ suspendedContactEmail }}</em>) ha sospeso il tuo account. Contattalo per farlo riattivare.",
|
||||
"Something went wrong": "Qualcosa è andato storto",
|
||||
"Sorry, an unknown error occurred loading the page. Please try again or contact support if the issue persists.": "Si è verificato un errore sconosciuto durante il caricamento della pagina. Riprova o contatta il supporto se il problema persiste.",
|
||||
"A workspace admin (<em>{{ suspendedContactEmail }}</em>) has suspended your account. To re-activate your account, please reach out to them directly.": "A workspace admin (<em>{{ suspendedContactEmail }}</em>) has suspended your account. To re-activate your account, please reach out to them directly.",
|
||||
"Something went wrong": "Something went wrong",
|
||||
"Sorry, an unknown error occurred loading the page. Please try again or contact support if the issue persists.": "Sorry, an unknown error occurred loading the page. Please try again or contact support if the issue persists.",
|
||||
"Created by me": "Creato da me",
|
||||
"Weird, this shouldn’t ever be empty": "Strano, questo non dovrebbe mai essere vuoto",
|
||||
"You haven’t created any documents yet": "Non hai ancora creato alcun documento",
|
||||
@@ -774,21 +774,21 @@
|
||||
"Those email addresses are already invited": "Agli indirizzi email è stato già inviato un invito",
|
||||
"Sorry, you can only send {{MAX_INVITES}} invites at a time": "Spiacenti, puoi inviare solo {{MAX_INVITES}} inviti alla volta",
|
||||
"Invited {{roleName}} will receive access to": "Invited {{roleName}} will receive access to",
|
||||
"{{collectionCount}} collections": "{{collectionCount}} raccolte",
|
||||
"{{collectionCount}} collections": "{{collectionCount}} collections",
|
||||
"Admin": "Amministratore",
|
||||
"Can manage all workspace settings": "Può gestire tutte le impostazioni dello spazio di lavoro",
|
||||
"Can create, edit, and delete documents": "Può creare, modificare ed eliminare documenti",
|
||||
"Can view and comment": "Può vedere e commentare",
|
||||
"Invite people to join your workspace. They can sign in with {{signinMethods}} or use their email address.": "Invita persone ad entrare nel tuo spazio di lavoro. Possono registrarsi con {{signinMethods}} oppure usando il proprio indirizzo email.",
|
||||
"Can manage all workspace settings": "Can manage all workspace settings",
|
||||
"Can create, edit, and delete documents": "Can create, edit, and delete documents",
|
||||
"Can view and comment": "Can view and comment",
|
||||
"Invite people to join your workspace. They can sign in with {{signinMethods}} or use their email address.": "Invite people to join your workspace. They can sign in with {{signinMethods}} or use their email address.",
|
||||
"Invite members to join your workspace. They will need to sign in with {{signinMethods}}.": "Invita membri a unirsi alla tua area di lavoro. Dovranno accedere con {{signinMethods}} o utilizzare il loro indirizzo email.",
|
||||
"As an admin you can also <2>enable email sign-in</2>.": "Come amministratore puoi anche <2>abilitare l'autenticazione via email</2>.",
|
||||
"Invite as": "Invita come",
|
||||
"Invite as": "Invite as",
|
||||
"Email": "Email",
|
||||
"Add another": "Aggiungi ancora",
|
||||
"Inviting": "Sto invitando",
|
||||
"Send Invites": "Spedisci gli inviti",
|
||||
"Open command menu": "Apri menu dei comandi",
|
||||
"Forward": "Inoltro",
|
||||
"Forward": "Forward",
|
||||
"Edit current document": "Modifica il documento corrente",
|
||||
"Move current document": "Sposta il documento corrente",
|
||||
"Open document history": "Apri cronologia del documento",
|
||||
@@ -796,11 +796,11 @@
|
||||
"Jump to home": "Vai alla home",
|
||||
"Focus search input": "Focus input di ricerca",
|
||||
"Open this guide": "Apri questa guida",
|
||||
"Enter": "Invio",
|
||||
"Enter": "Enter",
|
||||
"Publish document and exit": "Pubblica documento ed esci",
|
||||
"Save document": "Salva il documento",
|
||||
"Cancel editing": "Annulla modifica",
|
||||
"Collaboration": "Collaborazione",
|
||||
"Collaboration": "Collaboration",
|
||||
"Formatting": "Formattazione",
|
||||
"Paragraph": "Paragrafo",
|
||||
"Large header": "Intestazione grande",
|
||||
@@ -809,20 +809,20 @@
|
||||
"Underline": "Sottolineato",
|
||||
"Undo": "Annulla",
|
||||
"Redo": "Ripristina",
|
||||
"Move block up": "Sposta blocco in alto",
|
||||
"Move block down": "Sposta blocco in basso",
|
||||
"Move block up": "Move block up",
|
||||
"Move block down": "Move block down",
|
||||
"Lists": "Elenchi",
|
||||
"Toggle task list item": "Attiva/Disattiva elemento da un elenco di attività",
|
||||
"Tab": "Scheda",
|
||||
"Toggle task list item": "Toggle task list item",
|
||||
"Tab": "Tab",
|
||||
"Indent list item": "Aumenta rientro",
|
||||
"Outdent list item": "Riduci rientro",
|
||||
"Move list item up": "Sposta elemento su",
|
||||
"Move list item down": "Sposta elemento giù",
|
||||
"Tables": "Tabelle",
|
||||
"Insert row": "Inserisci riga",
|
||||
"Next cell": "Cella successiva",
|
||||
"Previous cell": "Cella precedente",
|
||||
"Space": "Spazio",
|
||||
"Tables": "Tables",
|
||||
"Insert row": "Insert row",
|
||||
"Next cell": "Next cell",
|
||||
"Previous cell": "Previous cell",
|
||||
"Space": "Space",
|
||||
"Numbered list": "Elenco numerato",
|
||||
"Blockquote": "Citazione",
|
||||
"Horizontal divider": "Separatore orizzontale",
|
||||
@@ -830,18 +830,18 @@
|
||||
"Inline code": "Codice inline",
|
||||
"Inline LaTeX": "LaTeX in linea",
|
||||
"Triggers": "Triggers",
|
||||
"Mention users and more": "Menziona utenti e altro ancora",
|
||||
"Mention users and more": "Mention users and more",
|
||||
"Emoji": "Emoji",
|
||||
"Insert block": "Inserisci blocco",
|
||||
"Insert block": "Insert block",
|
||||
"Sign In": "Accedi",
|
||||
"Continue with Email": "Continua con email",
|
||||
"Continue with {{ authProviderName }}": "Continua con {{ authProviderName }}",
|
||||
"Back to home": "Torna alla home",
|
||||
"The workspace could not be found": "Non è stato possibile trovare lo spazio di lavoro",
|
||||
"To continue, enter your workspace’s subdomain.": "Per continuare, inserisci il sottodominio del tuo spazio di lavoro.",
|
||||
"subdomain": "sottodominio",
|
||||
"The workspace could not be found": "The workspace could not be found",
|
||||
"To continue, enter your workspace’s subdomain.": "To continue, enter your workspace’s subdomain.",
|
||||
"subdomain": "subdomain",
|
||||
"Continue": "Continua",
|
||||
"Sorry, the code you entered is invalid or has expired.": "Il codice che hai inserito non è valido o è scaduto.",
|
||||
"Sorry, the code you entered is invalid or has expired.": "Sorry, the code you entered is invalid or has expired.",
|
||||
"The domain associated with your email address has not been allowed for this workspace.": "Il dominio associato al tuo indirizzo email non è permesso in questa area di lavoro.",
|
||||
"Unable to sign-in. Please navigate to your workspace's custom URL, then try to sign-in again.<1></1>If you were invited to a workspace, you will find a link to it in the invite email.": "Impossibile effettuare l'accesso. Per favore vai all'URL personalizzato della tua area di lavoro, quindi prova di nuovo ad accedere.<1></1>Se sei stato invitato in un'area di lavoro, troverai un link di accesso nella mail d'invito.",
|
||||
"Sorry, a new account cannot be created with a personal Gmail address.<1></1>Please use a Google Workspaces account instead.": "Spiacenti, non è possibile creare un nuovo account con un indirizzo Gmail personale.<1></1>Utilizza invece un account Google Workspaces.",
|
||||
@@ -906,7 +906,7 @@
|
||||
"reactions": "reactions",
|
||||
"pins": "pins",
|
||||
"shares": "shares",
|
||||
"users": "utenti",
|
||||
"users": "users",
|
||||
"teams": "teams",
|
||||
"workspace": "workspace",
|
||||
"Read all data": "Read all data",
|
||||
|
||||
@@ -175,7 +175,7 @@
|
||||
"currently viewing": "현재 보는 중",
|
||||
"previously edited": "이전에 수정됨",
|
||||
"You": "본인",
|
||||
"Avatar of {{ name }}": "{{ name }}의 아바타",
|
||||
"Avatar of {{ name }}": "Avatar of {{ name }}",
|
||||
"Viewers": "열람자",
|
||||
"Collections are used to group documents and choose permissions": "컬렉션은 문서를 그룹화하고 권한을 지정하는 데 사용됩니다",
|
||||
"Name": "이름",
|
||||
@@ -206,7 +206,7 @@
|
||||
"Move document": "문서 이동",
|
||||
"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>.": "문서 <em>{{ title }}</em>을(를) {{ newCollectionName }} 컬렉션으로 이동하면 모든 작업 공간 멤버의 권한이 <em>{{ prevPermission }}</em>에서 <em>{{ newPermission }}</em>으로 변경됩니다.",
|
||||
"More options": "더 많은 옵션",
|
||||
"More options": "More options",
|
||||
"Submenu": "하위 메뉴",
|
||||
"Collections could not be loaded, please reload the app": "컬렉션을 불러올 수 없습니다. 앱을 새로고침하세요",
|
||||
"Start view": "시작 화면",
|
||||
@@ -286,7 +286,7 @@
|
||||
"Including uploaded images and files in the exported data": "내보낼 데이터에 업로드된 이미지 및 파일 포함",
|
||||
"{{count}} more user": "{{count}}명의 추가 사용자",
|
||||
"{{count}} more user_plural": "{{count}}명의 추가 사용자",
|
||||
"Filter options": "필터 옵션",
|
||||
"Filter options": "Filter options",
|
||||
"Filter": "필터",
|
||||
"No results": "결과 없음",
|
||||
"{{authorName}} created <3></3>": "{{authorName }} 이(가) <3></3> 생성",
|
||||
@@ -318,12 +318,12 @@
|
||||
"Permission": "권한",
|
||||
"Change Language": "언어 변경",
|
||||
"Dismiss": "닫기",
|
||||
"Lightbox": "라이트박스",
|
||||
"View, navigate, or download images in the document": "문서에서 이미지 보기, 탐색 또는 다운로드",
|
||||
"Lightbox": "Lightbox",
|
||||
"View, navigate, or download images in the document": "View, navigate, or download images in the document",
|
||||
"Close": "닫기",
|
||||
"Previous": "이전",
|
||||
"Next": "다음",
|
||||
"Image failed to load": "이미지를 로드하는 데 실패함",
|
||||
"Previous": "Previous",
|
||||
"Next": "Next",
|
||||
"Image failed to load": "Image failed to load",
|
||||
"You’re offline.": "오프라인 상태입니다.",
|
||||
"Sorry, an error occurred.": "죄송합니다. 오류가 발생했습니다.",
|
||||
"Click to retry": "클릭하여 재시도하기",
|
||||
@@ -332,7 +332,7 @@
|
||||
"Mark all as read": "모두 읽은 상태로 표시",
|
||||
"You're all caught up": "모두 확인함",
|
||||
"Icon": "아이콘",
|
||||
"OAuth client icon": "OAuth 클라이언트 아이콘",
|
||||
"OAuth client icon": "OAuth client icon",
|
||||
"My App": "내 앱",
|
||||
"Tagline": "태그라인",
|
||||
"A short description": "간단한 설명",
|
||||
@@ -409,15 +409,15 @@
|
||||
"{{ count }} groups added to the document": "{{ count }} 개의 그룹이 문서에 추가됨",
|
||||
"{{ count }} groups added to the document_plural": "{{ count }} 개의 그룹이 문서에 추가됨",
|
||||
"Logo": "로고",
|
||||
"Expand sidebar": "사이드바 펼치기",
|
||||
"Collapse sidebar": "사이드바 축소",
|
||||
"Expand sidebar": "Expand sidebar",
|
||||
"Collapse sidebar": "Collapse sidebar",
|
||||
"Archived collections": "보관된 컬렉션",
|
||||
"New doc": "새 문서",
|
||||
"Empty": "비어 있음",
|
||||
"Collapse": "감추기",
|
||||
"Expand": "펼치기",
|
||||
"Document not supported – try Markdown, Plain text, HTML, or Word": "이 문서는 지원되지 않습니다 – Markdown, Plain Text, HTML이나 Word를 이용해주세요",
|
||||
"Import files": "파일 가져오기",
|
||||
"Import files": "Import files",
|
||||
"Go back": "돌아가기",
|
||||
"Go forward": "앞으로 가기",
|
||||
"Could not load shared documents": "공유 문서를 불러올 수 없습니다.",
|
||||
@@ -467,22 +467,20 @@
|
||||
"Replacement": "대체",
|
||||
"Replace": "바꾸기",
|
||||
"Replace all": "모두 바꾸기",
|
||||
"Image width": "이미지 너비",
|
||||
"Width": "너비",
|
||||
"Image height": "이미지 높이",
|
||||
"Height": "높이",
|
||||
"Image width": "Image width",
|
||||
"Image height": "Image height",
|
||||
"Profile picture": "프로필 사진",
|
||||
"Create a new doc": "새 문서 만들기",
|
||||
"{{ userName }} won't be notified, as they do not have access to this document": "{{ userName }} 은(는) 이 문서에 대한 액세스 권한이 없으므로 알림을 받지 않습니다.",
|
||||
"Keep as link": "링크로 유지",
|
||||
"Mention": "언급됨",
|
||||
"Embed": "내장",
|
||||
"Insert after": "다음에 삽입",
|
||||
"Insert before": "이전에 삽입",
|
||||
"Move up": "위로 이동",
|
||||
"Move down": "아래로 이동",
|
||||
"Move left": "왼쪽으로 이동",
|
||||
"Move right": "오른쪽으로 이동",
|
||||
"Insert after": "Insert after",
|
||||
"Insert before": "Insert before",
|
||||
"Move up": "Move up",
|
||||
"Move down": "Move down",
|
||||
"Move left": "Move left",
|
||||
"Move right": "Move right",
|
||||
"Align center": "가운데 정렬",
|
||||
"Align left": "왼쪽 정렬",
|
||||
"Align right": "오른쪽 정렬",
|
||||
@@ -499,6 +497,8 @@
|
||||
"Create a new child doc": "새 하위 문서 생성",
|
||||
"Delete table": "테이블 삭제",
|
||||
"Delete file": "파일 삭제",
|
||||
"Width": "Width",
|
||||
"Height": "Height",
|
||||
"Download file": "파일 다운로드",
|
||||
"Replace file": "파일 바꾸기",
|
||||
"Delete image": "이미지 삭제",
|
||||
@@ -692,7 +692,7 @@
|
||||
"only you": "나만",
|
||||
"person": "개인",
|
||||
"people": "명",
|
||||
"Document title": "문서 제목",
|
||||
"Document title": "Document title",
|
||||
"Last updated": "마지막 업데이트",
|
||||
"Type '/' to insert, or start writing…": "'/'를 입력하여 삽입하거나 쓰기 시작...",
|
||||
"Hide contents": "내용 숨기기",
|
||||
@@ -937,7 +937,7 @@
|
||||
"Rotate secret": "시크릿 교체",
|
||||
"Rotating the client secret will invalidate the current secret. Make sure to update any applications using these credentials.": "클라이언트 시크릿을 교체하면 현재 시크릿이 무효화됩니다. 이 자격 증명을 사용하는 모든 애플리케이션을 업데이트해야 합니다.",
|
||||
"Displayed to users when authorizing": "사용자가 권한을 부여할 때 표시됨",
|
||||
"Application icon": "애플리케이션 아이콘",
|
||||
"Application icon": "Application icon",
|
||||
"Developer information shown to users when authorizing": "사용자가 권한을 부여할 때 표시되는 개발자 정보",
|
||||
"Developer name": "개발자 이름",
|
||||
"Developer URL": "개발자 URL",
|
||||
@@ -1001,7 +1001,7 @@
|
||||
"Search people": "사용자 검색",
|
||||
"No people matching your search": "찾으시는 사용자가 없습니다.",
|
||||
"No people left to add": "추가할 사용자가 없습니다",
|
||||
"Group admin": "그룹 관리",
|
||||
"Group admin": "Group admin",
|
||||
"Member": "멤버",
|
||||
"Admins": "관리자",
|
||||
"Date created": "생성 일자",
|
||||
@@ -1045,7 +1045,7 @@
|
||||
"These settings affect the way that your workspace appears to everyone on the team.": "이러한 설정은 팀의 모든 사람에게 워크스페이스가 표시되는 방식에 영향을 줍니다.",
|
||||
"Display": "표시",
|
||||
"The logo is displayed at the top left of the application.": "로고는 애플리케이션의 왼쪽 상단에 표시됩니다.",
|
||||
"Workspace logo": "워크스페이스 로고",
|
||||
"Workspace logo": "Workspace logo",
|
||||
"The workspace name, usually the same as your company name.": "워크스페이스 이름은 일반적으로 회사 이름과 동일합니다.",
|
||||
"Description": "설명",
|
||||
"A short description of your workspace.": "워크스페이스에 대한 간략한 설명입니다.",
|
||||
@@ -1276,7 +1276,7 @@
|
||||
"{{ user }} updated {{ timeAgo }}": "{{ user }} 업데이트 됨 {{ timeAgo }}",
|
||||
"You created {{ timeAgo }}": "{{ timeAgo }} 전에 내가 생성함",
|
||||
"{{ user }} created {{ timeAgo }}": "{{ user }} 이(가) {{ timeAgo }} 전에 생성",
|
||||
"Caption": "캡션",
|
||||
"Open": "열기",
|
||||
"Caption": "Caption",
|
||||
"Open": "Open",
|
||||
"Error loading data": "데이터 로딩 오류"
|
||||
}
|
||||
|
||||
@@ -468,21 +468,19 @@
|
||||
"Replace": "Vervang",
|
||||
"Replace all": "Vervang alle",
|
||||
"Image width": "Afbeelding breedte",
|
||||
"Width": "Breedte",
|
||||
"Image height": "Afbeelding hoogte",
|
||||
"Height": "Hoogte",
|
||||
"Profile picture": "Profielfoto",
|
||||
"Create a new doc": "Maak een nieuw document",
|
||||
"{{ userName }} won't be notified, as they do not have access to this document": "{{ userName }} wordt niet verwittigd omdat ze geen toegang hebben tot dit document",
|
||||
"Keep as link": "Behoud als link",
|
||||
"Mention": "Vermelding",
|
||||
"Embed": "Insluiten",
|
||||
"Insert after": "Invoegen achter",
|
||||
"Insert before": "Invoegen voor",
|
||||
"Move up": "Omhoog verplaatsen",
|
||||
"Move down": "Omlaag verplaatsen",
|
||||
"Move left": "Links verplaatsen",
|
||||
"Move right": "Rechts verplaatsen",
|
||||
"Insert after": "Insert after",
|
||||
"Insert before": "Insert before",
|
||||
"Move up": "Move up",
|
||||
"Move down": "Move down",
|
||||
"Move left": "Move left",
|
||||
"Move right": "Move right",
|
||||
"Align center": "Centreer",
|
||||
"Align left": "Links uitlijnen",
|
||||
"Align right": "Rechts uitlijnen",
|
||||
@@ -499,6 +497,8 @@
|
||||
"Create a new child doc": "Maak een nieuw subdocument",
|
||||
"Delete table": "Tabel verwijderen",
|
||||
"Delete file": "Verwijder bestand",
|
||||
"Width": "Breedte",
|
||||
"Height": "Hoogte",
|
||||
"Download file": "Download bestand",
|
||||
"Replace file": "Vervang bestand",
|
||||
"Delete image": "Afbeelding verwijderen",
|
||||
|
||||
@@ -468,21 +468,19 @@
|
||||
"Replace": "替换",
|
||||
"Replace all": "全部替换",
|
||||
"Image width": "图像宽度",
|
||||
"Width": "宽",
|
||||
"Image height": "图像高度",
|
||||
"Height": "高",
|
||||
"Profile picture": "个人头像",
|
||||
"Create a new doc": "新建文档",
|
||||
"{{ userName }} won't be notified, as they do not have access to this document": "{{ userName }} 不会被通知,因为他们没有访问此文档的权限",
|
||||
"Keep as link": "保留为链接",
|
||||
"Mention": "提及",
|
||||
"Embed": "嵌入",
|
||||
"Insert after": "在之后插入",
|
||||
"Insert before": "在之前插入",
|
||||
"Move up": "上移",
|
||||
"Move down": "下移",
|
||||
"Move left": "左移",
|
||||
"Move right": "右移",
|
||||
"Insert after": "Insert after",
|
||||
"Insert before": "Insert before",
|
||||
"Move up": "Move up",
|
||||
"Move down": "Move down",
|
||||
"Move left": "Move left",
|
||||
"Move right": "Move right",
|
||||
"Align center": "居中对齐",
|
||||
"Align left": "左对齐",
|
||||
"Align right": "右对齐",
|
||||
@@ -499,6 +497,8 @@
|
||||
"Create a new child doc": "创建一个新的子文档",
|
||||
"Delete table": "删除表格",
|
||||
"Delete file": "删除文件",
|
||||
"Width": "宽",
|
||||
"Height": "高",
|
||||
"Download file": "下载文件",
|
||||
"Replace file": "替换文件",
|
||||
"Delete image": "删除图片",
|
||||
|
||||
@@ -257,8 +257,6 @@ export type SourceMetadata = {
|
||||
externalName?: string;
|
||||
/** Whether the item was created through a trial license. */
|
||||
trial?: boolean;
|
||||
/** The ID of the original document when this document was duplicated. */
|
||||
originalDocumentId?: string;
|
||||
};
|
||||
|
||||
export type CustomTheme = {
|
||||
@@ -299,8 +297,6 @@ export enum TeamPreference {
|
||||
CustomTheme = "customTheme",
|
||||
/** Side to display the document's table of contents in relation to the main content. */
|
||||
TocPosition = "tocPosition",
|
||||
/** Whether to prevent shared documents from being embedded in iframes on external websites. */
|
||||
PreventDocumentEmbedding = "preventDocumentEmbedding",
|
||||
}
|
||||
|
||||
export type TeamPreferences = {
|
||||
@@ -314,7 +310,6 @@ export type TeamPreferences = {
|
||||
[TeamPreference.Commenting]?: boolean;
|
||||
[TeamPreference.CustomTheme]?: Partial<CustomTheme>;
|
||||
[TeamPreference.TocPosition]?: TOCPosition;
|
||||
[TeamPreference.PreventDocumentEmbedding]?: boolean;
|
||||
};
|
||||
|
||||
export enum NavigationNodeType {
|
||||
|
||||
@@ -109,12 +109,6 @@ import {
|
||||
faHandsClapping,
|
||||
faFolderClosed,
|
||||
faFlaskVial,
|
||||
faCircle,
|
||||
faSquare,
|
||||
faPentagon,
|
||||
faHexagon,
|
||||
faDiamond,
|
||||
faSpiral,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import intersection from "lodash/intersection";
|
||||
@@ -577,12 +571,6 @@ export class IconLibrary {
|
||||
faShopify,
|
||||
faSwift,
|
||||
faSlack,
|
||||
faCircle,
|
||||
faSquare,
|
||||
faPentagon,
|
||||
faHexagon,
|
||||
faDiamond,
|
||||
faSpiral,
|
||||
].map((icon) => [
|
||||
icon.iconName,
|
||||
{
|
||||
|
||||
@@ -28,7 +28,7 @@ const isFlagEmojiSupported = (): boolean => {
|
||||
const CANVAS_WIDTH = 20;
|
||||
const textSize = Math.floor(CANVAS_HEIGHT / 2);
|
||||
|
||||
// Initialize canvas context
|
||||
// Initialize convas 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 should be the same.
|
||||
// the result show 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 an emoji's human-readable ID from its string.
|
||||
* Get am 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