mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
155 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e9beac59f | |||
| a0f7c76405 | |||
| 454a4e9a8d | |||
| ef9c410d97 | |||
| 7c2f779f68 | |||
| c45de6904b | |||
| 4758778fc7 | |||
| 401ae73a04 | |||
| 0ddbd9c608 | |||
| 6c4e2a9d11 | |||
| d8f1f55a80 | |||
| 9b811c999d | |||
| 5a60329021 | |||
| 042ea7b61f | |||
| 80acc16791 | |||
| 3c25b2b047 | |||
| 16f1328a83 | |||
| d1a7a30c00 | |||
| 5b67273d8f | |||
| fdd8ecc79d | |||
| 7c15d03b50 | |||
| b691311f88 | |||
| eda023c908 | |||
| 2331bbbd36 | |||
| 228d1faa9f | |||
| ff6d30581a | |||
| 027545a768 | |||
| 91585ee09d | |||
| a13f2c7311 | |||
| d4a51b420f | |||
| faa02623b3 | |||
| 2baf4d7d8b | |||
| 2b21ac1b97 | |||
| d2fcd1dee6 | |||
| 3b43460a0a | |||
| 1864ed605f | |||
| 20932a08d0 | |||
| 7e1ea69939 | |||
| a3983c36c9 | |||
| ccdcda372f | |||
| 07ad5032b4 | |||
| 286aea2701 | |||
| 30e63e022c | |||
| b88670b58d | |||
| ddc883bfcd | |||
| 67691477a9 | |||
| e3807a1c75 | |||
| f95ce018e1 | |||
| 2201fd7bd6 | |||
| fbb793ab8e | |||
| 31f8a3fb44 | |||
| 03ebca2f0c | |||
| 2a17e0cbf6 | |||
| 9ac1e13227 | |||
| bd0240b7a5 | |||
| 81bd68380e | |||
| b3d8bd1cc8 | |||
| a30487c2d7 | |||
| 8b3c58a162 | |||
| 43a91626b2 | |||
| 15c8a4867f | |||
| d94caf2783 | |||
| aaeb6f7dc6 | |||
| e0289aed40 | |||
| 8865d394c6 | |||
| 1d893a06f9 | |||
| 0c291ee806 | |||
| 8732155dbb | |||
| 56e01b784d | |||
| e47d493d13 | |||
| 2677c964a5 | |||
| f8927ff819 | |||
| 72adcd10ef | |||
| 7bc37cb700 | |||
| 217e53d8b6 | |||
| 404f5ff871 | |||
| 0db6f39f43 | |||
| 479b805613 | |||
| 48f1047016 | |||
| caf7333682 | |||
| cd59af4a9b | |||
| 8d549abaa9 | |||
| 5e705f3dc7 | |||
| 5d71398ea6 | |||
| dbd85d62cb | |||
| d180ecbe96 | |||
| 9046abb682 | |||
| 5810ddb589 | |||
| 0d30220017 | |||
| b60e46a961 | |||
| 3c6e2aaac6 | |||
| eae6204d55 | |||
| 1e78079ade | |||
| d3fc6fc0fd | |||
| 0f10fe4052 | |||
| d928d456de | |||
| 5206beaf19 | |||
| 70113be9af | |||
| 04ea3431e7 | |||
| d3ce70016e | |||
| 46d6664307 | |||
| 2427f4747a | |||
| 60b456f35a | |||
| 64b2718673 | |||
| 4b14fa5dd7 | |||
| abb38ea447 | |||
| e81f97b2de | |||
| e653b185a4 | |||
| 39e12cef65 | |||
| b1230d0c81 | |||
| 6e9e1c15a5 | |||
| 66331d3d4f | |||
| ea07b72c7a | |||
| 5dcd7a74ca | |||
| 5c83070941 | |||
| a9ab196a18 | |||
| b9fc301589 | |||
| c56add74c6 | |||
| 5ae4834333 | |||
| 437865e7aa | |||
| 042f2ff737 | |||
| 5656384cc4 | |||
| 098d91808b | |||
| 21d446881e | |||
| cf32d227e6 | |||
| e59e121179 | |||
| 98a182c892 | |||
| 6bc1b789ee | |||
| a8674c7dda | |||
| c952dfa065 | |||
| 9a95fa47a0 | |||
| 5bfb2c89c8 | |||
| 9b6a645928 | |||
| d550fb79d3 | |||
| ed9cf4cee3 | |||
| 8cc2853102 | |||
| 814bacbead | |||
| 9431df45c2 | |||
| a75d6b298e | |||
| ff1bc5db2a | |||
| e6e9512979 | |||
| 29db1ef1bf | |||
| b54a370e01 | |||
| cce22bcdee | |||
| da62c2c044 | |||
| 201690e342 | |||
| 4fddc0fd87 | |||
| 72c7b0373b | |||
| 7c5d834f39 | |||
| f1f3159b12 | |||
| a62739be8f | |||
| 3124423eeb | |||
| 40dbbe10c5 | |||
| a4ba1f18bc | |||
| 0aabcb8d22 |
+5
-1
@@ -30,7 +30,7 @@ REDIS_URL=redis://localhost:6379
|
||||
|
||||
# URL should point to the fully qualified, publicly accessible URL. If using a
|
||||
# proxy the port in URL and PORT may be different.
|
||||
URL=http://localhost:3000
|
||||
URL=https://app.outline.dev:3000
|
||||
PORT=3000
|
||||
|
||||
# See [documentation](docs/SERVICES.md) on running a separate collaboration
|
||||
@@ -181,3 +181,7 @@ RATE_LIMITER_ENABLED=true
|
||||
# Configure default throttling parameters for rate limiter
|
||||
RATE_LIMITER_REQUESTS=1000
|
||||
RATE_LIMITER_DURATION_WINDOW=60
|
||||
|
||||
# Iframely API config
|
||||
IFRAMELY_URL=
|
||||
IFRAMELY_API_KEY=
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
up:
|
||||
docker-compose up -d redis postgres s3
|
||||
yarn install-local-ssl
|
||||
yarn install --pure-lockfile
|
||||
yarn dev:watch
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"extends": [
|
||||
"../.eslintrc",
|
||||
"plugin:react/recommended",
|
||||
"plugin:react-hooks/recommended",
|
||||
],
|
||||
"plugins": [
|
||||
|
||||
@@ -61,8 +61,11 @@ export const openDocument = createAction({
|
||||
// cache if the document is renamed
|
||||
id: path.url,
|
||||
name: path.title,
|
||||
icon: () =>
|
||||
stores.documents.get(path.id)?.isStarred ? <StarredIcon /> : null,
|
||||
icon: function _Icon() {
|
||||
return stores.documents.get(path.id)?.isStarred ? (
|
||||
<StarredIcon />
|
||||
) : null;
|
||||
},
|
||||
section: DocumentSection,
|
||||
perform: () => history.push(path.url),
|
||||
}));
|
||||
@@ -159,7 +162,7 @@ export const publishDocument = createAction({
|
||||
}
|
||||
|
||||
if (document?.collectionId) {
|
||||
await document.save({
|
||||
await document.save(undefined, {
|
||||
publish: true,
|
||||
});
|
||||
stores.toasts.showToast(t("Document published"), {
|
||||
@@ -404,13 +407,19 @@ export const pinDocumentToCollection = createAction({
|
||||
return;
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
await document?.pin(document.collectionId);
|
||||
try {
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
await document?.pin(document.collectionId);
|
||||
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
|
||||
if (!collection || !location.pathname.startsWith(collection?.url)) {
|
||||
stores.toasts.showToast(t("Pinned to collection"));
|
||||
if (!collection || !location.pathname.startsWith(collection?.url)) {
|
||||
stores.toasts.showToast(t("Pinned to collection"));
|
||||
}
|
||||
} catch (err) {
|
||||
stores.toasts.showToast(err.message, {
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -443,10 +452,16 @@ export const pinDocumentToHome = createAction({
|
||||
}
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
|
||||
await document?.pin();
|
||||
try {
|
||||
await document?.pin();
|
||||
|
||||
if (location.pathname !== homePath()) {
|
||||
stores.toasts.showToast(t("Pinned to team home"));
|
||||
if (location.pathname !== homePath()) {
|
||||
stores.toasts.showToast(t("Pinned to team home"));
|
||||
}
|
||||
} catch (err) {
|
||||
stores.toasts.showToast(err.message, {
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -43,8 +43,9 @@ export const changeTheme = createAction({
|
||||
isContextMenu ? t("Appearance") : t("Change theme"),
|
||||
analyticsName: "Change theme",
|
||||
placeholder: ({ t }) => t("Change theme to"),
|
||||
icon: () =>
|
||||
stores.ui.resolvedTheme === "light" ? <SunIcon /> : <MoonIcon />,
|
||||
icon: function _Icon() {
|
||||
return stores.ui.resolvedTheme === "light" ? <SunIcon /> : <MoonIcon />;
|
||||
},
|
||||
keywords: "appearance display",
|
||||
section: SettingsSection,
|
||||
children: [changeToLightTheme, changeToDarkTheme, changeToSystemTheme],
|
||||
|
||||
@@ -16,18 +16,20 @@ export const createTeamsList = ({ stores }: { stores: RootStore }) =>
|
||||
analyticsName: "Switch workspace",
|
||||
section: TeamSection,
|
||||
keywords: "change switch workspace organization team",
|
||||
icon: () => (
|
||||
<StyledTeamLogo
|
||||
alt={session.name}
|
||||
model={{
|
||||
initial: session.name[0],
|
||||
avatarUrl: session.avatarUrl,
|
||||
id: session.id,
|
||||
color: stringToColor(session.id),
|
||||
}}
|
||||
size={24}
|
||||
/>
|
||||
),
|
||||
icon: function _Icon() {
|
||||
return (
|
||||
<StyledTeamLogo
|
||||
alt={session.name}
|
||||
model={{
|
||||
initial: session.name[0],
|
||||
avatarUrl: session.avatarUrl,
|
||||
id: session.id,
|
||||
color: stringToColor(session.id),
|
||||
}}
|
||||
size={24}
|
||||
/>
|
||||
);
|
||||
},
|
||||
visible: ({ currentTeamId }: ActionContext) => currentTeamId !== session.id,
|
||||
perform: () => (window.location.href = session.url),
|
||||
})) ?? [];
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/* eslint-disable react/prop-types */
|
||||
import * as React from "react";
|
||||
import Tooltip, { Props as TooltipProps } from "~/components/Tooltip";
|
||||
import { Action, ActionContext } from "~/types";
|
||||
|
||||
export type Props = React.ComponentPropsWithoutRef<"button"> & {
|
||||
export type Props = React.HTMLAttributes<HTMLButtonElement> & {
|
||||
/** Show the button in a disabled state */
|
||||
disabled?: boolean;
|
||||
/** Hide the button entirely if action is not applicable */
|
||||
@@ -18,11 +19,11 @@ export type Props = React.ComponentPropsWithoutRef<"button"> & {
|
||||
/**
|
||||
* Button that can be used to trigger an action definition.
|
||||
*/
|
||||
const ActionButton = React.forwardRef(
|
||||
(
|
||||
const ActionButton = React.forwardRef<HTMLButtonElement, Props>(
|
||||
function _ActionButton(
|
||||
{ action, context, tooltip, hideOnActionDisabled, ...rest }: Props,
|
||||
ref: React.Ref<HTMLButtonElement>
|
||||
) => {
|
||||
) {
|
||||
const [executing, setExecuting] = React.useState(false);
|
||||
const disabled = rest.disabled;
|
||||
|
||||
|
||||
@@ -5,7 +5,11 @@ import * as React from "react";
|
||||
import { IntegrationService } from "@shared/types";
|
||||
import env from "~/env";
|
||||
|
||||
const Analytics: React.FC = ({ children }) => {
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const Analytics: React.FC = ({ children }: Props) => {
|
||||
// Google Analytics 3
|
||||
React.useEffect(() => {
|
||||
if (!env.GOOGLE_ANALYTICS_ID?.startsWith("UA-")) {
|
||||
|
||||
@@ -20,6 +20,10 @@ function ArrowKeyNavigation(
|
||||
const handleKeyDown = React.useCallback(
|
||||
(ev) => {
|
||||
if (onEscape) {
|
||||
if (ev.nativeEvent.isComposing) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev.key === "Escape") {
|
||||
onEscape(ev);
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Redirect } from "react-router-dom";
|
||||
import LoadingIndicator from "~/components/LoadingIndicator";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { changeLanguage } from "~/utils/language";
|
||||
import LoadingIndicator from "./LoadingIndicator";
|
||||
|
||||
type Props = {
|
||||
children: JSX.Element;
|
||||
@@ -22,15 +22,13 @@ const Authenticated = ({ children }: Props) => {
|
||||
}, [i18n, language]);
|
||||
|
||||
if (auth.authenticated) {
|
||||
const { user, team } = auth;
|
||||
|
||||
if (!team || !user) {
|
||||
return <LoadingIndicator />;
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
if (auth.isFetching) {
|
||||
return <LoadingIndicator />;
|
||||
}
|
||||
|
||||
void auth.logout(true);
|
||||
return <Redirect to="/" />;
|
||||
};
|
||||
|
||||
@@ -37,7 +37,11 @@ const DocumentInsights = lazyWithRetry(
|
||||
);
|
||||
const CommandBar = lazyWithRetry(() => import("~/components/CommandBar"));
|
||||
|
||||
const AuthenticatedLayout: React.FC = ({ children }) => {
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const AuthenticatedLayout: React.FC = ({ children }: Props) => {
|
||||
const { ui, auth } = useStores();
|
||||
const location = useLocation();
|
||||
const can = usePolicy(ui.activeCollectionId);
|
||||
|
||||
@@ -7,7 +7,11 @@ const Badge = styled.span<{ yellow?: boolean; primary?: boolean }>`
|
||||
background-color: ${({ yellow, primary, theme }) =>
|
||||
yellow ? theme.yellow : primary ? theme.accent : "transparent"};
|
||||
color: ${({ primary, yellow, theme }) =>
|
||||
primary ? theme.white : yellow ? theme.almostBlack : theme.textTertiary};
|
||||
primary
|
||||
? theme.accentText
|
||||
: yellow
|
||||
? theme.almostBlack
|
||||
: theme.textTertiary};
|
||||
border: 1px solid
|
||||
${({ primary, yellow, theme }) =>
|
||||
primary || yellow
|
||||
|
||||
@@ -3,6 +3,7 @@ import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
withStickyHeader?: boolean;
|
||||
};
|
||||
|
||||
@@ -26,7 +27,7 @@ const Content = styled.div`
|
||||
`};
|
||||
`;
|
||||
|
||||
const CenteredContent: React.FC<Props> = ({ children, ...rest }) => (
|
||||
const CenteredContent: React.FC<Props> = ({ children, ...rest }: Props) => (
|
||||
<Container {...rest}>
|
||||
<Content>{children}</Content>
|
||||
</Container>
|
||||
|
||||
@@ -52,7 +52,11 @@ function CommandBar() {
|
||||
);
|
||||
}
|
||||
|
||||
const KBarPortal: React.FC = ({ children }) => {
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const KBarPortal: React.FC = ({ children }: Props) => {
|
||||
const { showing } = useKBar((state) => ({
|
||||
showing: state.visualState !== "hidden",
|
||||
}));
|
||||
|
||||
@@ -17,6 +17,7 @@ type Props = {
|
||||
danger?: boolean;
|
||||
/** Keep the submit button disabled */
|
||||
disabled?: boolean;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const ConfirmationDialog: React.FC<Props> = ({
|
||||
@@ -26,7 +27,7 @@ const ConfirmationDialog: React.FC<Props> = ({
|
||||
savingText,
|
||||
danger,
|
||||
disabled = false,
|
||||
}) => {
|
||||
}: Props) => {
|
||||
const [isSaving, setIsSaving] = React.useState(false);
|
||||
const { dialogs } = useStores();
|
||||
const { showToast } = useToasts();
|
||||
|
||||
+125
-131
@@ -30,144 +30,138 @@ export type RefHandle = {
|
||||
* Defines a content editable component with the same interface as a native
|
||||
* HTMLInputElement (or, as close as we can get).
|
||||
*/
|
||||
const ContentEditable = React.forwardRef(
|
||||
(
|
||||
{
|
||||
disabled,
|
||||
onChange,
|
||||
onInput,
|
||||
onBlur,
|
||||
onKeyDown,
|
||||
value,
|
||||
children,
|
||||
className,
|
||||
maxLength,
|
||||
autoFocus,
|
||||
placeholder,
|
||||
readOnly,
|
||||
dir,
|
||||
onClick,
|
||||
...rest
|
||||
}: Props,
|
||||
ref: React.RefObject<RefHandle>
|
||||
) => {
|
||||
const contentRef = React.useRef<HTMLSpanElement>(null);
|
||||
const [innerValue, setInnerValue] = React.useState<string>(value);
|
||||
const lastValue = React.useRef(value);
|
||||
const ContentEditable = React.forwardRef(function _ContentEditable(
|
||||
{
|
||||
disabled,
|
||||
onChange,
|
||||
onInput,
|
||||
onBlur,
|
||||
onKeyDown,
|
||||
value,
|
||||
children,
|
||||
className,
|
||||
maxLength,
|
||||
autoFocus,
|
||||
placeholder,
|
||||
readOnly,
|
||||
dir,
|
||||
onClick,
|
||||
...rest
|
||||
}: Props,
|
||||
ref: React.RefObject<RefHandle>
|
||||
) {
|
||||
const contentRef = React.useRef<HTMLSpanElement>(null);
|
||||
const [innerValue, setInnerValue] = React.useState<string>(value);
|
||||
const lastValue = React.useRef(value);
|
||||
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
focus: () => {
|
||||
if (contentRef.current) {
|
||||
contentRef.current.focus();
|
||||
// looks unnecessary but required because of https://github.com/outline/outline/issues/5198
|
||||
if (!contentRef.current.innerText) {
|
||||
placeCaret(contentRef.current, true);
|
||||
}
|
||||
}
|
||||
},
|
||||
focusAtStart: () => {
|
||||
if (contentRef.current) {
|
||||
contentRef.current.focus();
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
focus: () => {
|
||||
if (contentRef.current) {
|
||||
contentRef.current.focus();
|
||||
// looks unnecessary but required because of https://github.com/outline/outline/issues/5198
|
||||
if (!contentRef.current.innerText) {
|
||||
placeCaret(contentRef.current, true);
|
||||
}
|
||||
},
|
||||
focusAtEnd: () => {
|
||||
if (contentRef.current) {
|
||||
contentRef.current.focus();
|
||||
placeCaret(contentRef.current, false);
|
||||
}
|
||||
},
|
||||
getComputedDirection: () => {
|
||||
if (contentRef.current) {
|
||||
return window.getComputedStyle(contentRef.current).direction;
|
||||
}
|
||||
return "ltr";
|
||||
},
|
||||
}));
|
||||
|
||||
const wrappedEvent =
|
||||
(
|
||||
callback:
|
||||
| React.FocusEventHandler<HTMLSpanElement>
|
||||
| React.FormEventHandler<HTMLSpanElement>
|
||||
| React.KeyboardEventHandler<HTMLSpanElement>
|
||||
| undefined
|
||||
) =>
|
||||
(event: any) => {
|
||||
if (readOnly) {
|
||||
return;
|
||||
}
|
||||
|
||||
const text = event.currentTarget.textContent || "";
|
||||
|
||||
if (
|
||||
maxLength &&
|
||||
isPrintableKeyEvent(event) &&
|
||||
text.length >= maxLength
|
||||
) {
|
||||
event?.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (text !== lastValue.current) {
|
||||
lastValue.current = text;
|
||||
onChange?.(text);
|
||||
}
|
||||
|
||||
callback?.(event);
|
||||
};
|
||||
|
||||
// This is to account for being within a React.Suspense boundary, in this
|
||||
// case the component may be rendered with display: none. React 18 may solve
|
||||
// this in the future by delaying useEffect hooks:
|
||||
// https://github.com/facebook/react/issues/14536#issuecomment-861980492
|
||||
const isVisible = useOnScreen(contentRef);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (autoFocus && isVisible && !disabled && !readOnly) {
|
||||
contentRef.current?.focus();
|
||||
}
|
||||
}, [autoFocus, disabled, isVisible, readOnly, contentRef]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (contentRef.current && value !== contentRef.current.textContent) {
|
||||
setInnerValue(value);
|
||||
},
|
||||
focusAtStart: () => {
|
||||
if (contentRef.current) {
|
||||
contentRef.current.focus();
|
||||
placeCaret(contentRef.current, true);
|
||||
}
|
||||
}, [value, contentRef]);
|
||||
},
|
||||
focusAtEnd: () => {
|
||||
if (contentRef.current) {
|
||||
contentRef.current.focus();
|
||||
placeCaret(contentRef.current, false);
|
||||
}
|
||||
},
|
||||
getComputedDirection: () => {
|
||||
if (contentRef.current) {
|
||||
return window.getComputedStyle(contentRef.current).direction;
|
||||
}
|
||||
return "ltr";
|
||||
},
|
||||
}));
|
||||
|
||||
// Ensure only plain text can be pasted into input when pasting from another
|
||||
// rich text source. Note: If `onPaste` prop is passed then it takes
|
||||
// priority over this behavior.
|
||||
const handlePaste = React.useCallback(
|
||||
(event: React.ClipboardEvent<HTMLSpanElement>) => {
|
||||
event.preventDefault();
|
||||
const text = event.clipboardData.getData("text/plain");
|
||||
window.document.execCommand("insertText", false, text);
|
||||
},
|
||||
[]
|
||||
);
|
||||
const wrappedEvent =
|
||||
(
|
||||
callback:
|
||||
| React.FocusEventHandler<HTMLSpanElement>
|
||||
| React.FormEventHandler<HTMLSpanElement>
|
||||
| React.KeyboardEventHandler<HTMLSpanElement>
|
||||
| undefined
|
||||
) =>
|
||||
(event: any) => {
|
||||
if (readOnly) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className} dir={dir} onClick={onClick}>
|
||||
<Content
|
||||
ref={contentRef}
|
||||
contentEditable={!disabled && !readOnly}
|
||||
onInput={wrappedEvent(onInput)}
|
||||
onBlur={wrappedEvent(onBlur)}
|
||||
onKeyDown={wrappedEvent(onKeyDown)}
|
||||
onPaste={handlePaste}
|
||||
data-placeholder={placeholder}
|
||||
suppressContentEditableWarning
|
||||
role="textbox"
|
||||
{...rest}
|
||||
>
|
||||
{innerValue}
|
||||
</Content>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
const text = event.currentTarget.textContent || "";
|
||||
|
||||
if (maxLength && isPrintableKeyEvent(event) && text.length >= maxLength) {
|
||||
event?.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (text !== lastValue.current) {
|
||||
lastValue.current = text;
|
||||
onChange?.(text);
|
||||
}
|
||||
|
||||
callback?.(event);
|
||||
};
|
||||
|
||||
// This is to account for being within a React.Suspense boundary, in this
|
||||
// case the component may be rendered with display: none. React 18 may solve
|
||||
// this in the future by delaying useEffect hooks:
|
||||
// https://github.com/facebook/react/issues/14536#issuecomment-861980492
|
||||
const isVisible = useOnScreen(contentRef);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (autoFocus && isVisible && !disabled && !readOnly) {
|
||||
contentRef.current?.focus();
|
||||
}
|
||||
}, [autoFocus, disabled, isVisible, readOnly, contentRef]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (contentRef.current && value !== contentRef.current.textContent) {
|
||||
setInnerValue(value);
|
||||
}
|
||||
}, [value, contentRef]);
|
||||
|
||||
// Ensure only plain text can be pasted into input when pasting from another
|
||||
// rich text source. Note: If `onPaste` prop is passed then it takes
|
||||
// priority over this behavior.
|
||||
const handlePaste = React.useCallback(
|
||||
(event: React.ClipboardEvent<HTMLSpanElement>) => {
|
||||
event.preventDefault();
|
||||
const text = event.clipboardData.getData("text/plain");
|
||||
window.document.execCommand("insertText", false, text);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className} dir={dir} onClick={onClick}>
|
||||
<Content
|
||||
ref={contentRef}
|
||||
contentEditable={!disabled && !readOnly}
|
||||
onInput={wrappedEvent(onInput)}
|
||||
onBlur={wrappedEvent(onBlur)}
|
||||
onKeyDown={wrappedEvent(onKeyDown)}
|
||||
onPaste={handlePaste}
|
||||
data-placeholder={placeholder}
|
||||
suppressContentEditableWarning
|
||||
role="textbox"
|
||||
{...rest}
|
||||
>
|
||||
{innerValue}
|
||||
</Content>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
function placeCaret(element: HTMLElement, atStart: boolean) {
|
||||
if (
|
||||
|
||||
@@ -8,6 +8,7 @@ import breakpoint from "styled-components-breakpoint";
|
||||
import MenuIconWrapper from "../MenuIconWrapper";
|
||||
|
||||
type Props = {
|
||||
id?: string;
|
||||
onClick?: (event: React.SyntheticEvent) => void | Promise<void>;
|
||||
active?: boolean;
|
||||
selected?: boolean;
|
||||
@@ -21,6 +22,7 @@ type Props = {
|
||||
level?: number;
|
||||
icon?: React.ReactElement;
|
||||
children?: React.ReactNode;
|
||||
ref?: React.LegacyRef<HTMLButtonElement> | undefined;
|
||||
};
|
||||
|
||||
const MenuItem = (
|
||||
@@ -37,34 +39,26 @@ const MenuItem = (
|
||||
}: Props,
|
||||
ref: React.Ref<HTMLAnchorElement>
|
||||
) => {
|
||||
const handleClick = React.useCallback(
|
||||
async (ev) => {
|
||||
hide?.();
|
||||
const content = React.useCallback(
|
||||
(props) => {
|
||||
const handleClick = async (ev: React.MouseEvent) => {
|
||||
hide?.();
|
||||
|
||||
if (onClick) {
|
||||
if (onClick) {
|
||||
ev.preventDefault();
|
||||
await onClick(ev);
|
||||
}
|
||||
};
|
||||
|
||||
// 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 handleMouseDown = (ev: React.MouseEvent) => {
|
||||
ev.preventDefault();
|
||||
await onClick(ev);
|
||||
}
|
||||
},
|
||||
[onClick, hide]
|
||||
);
|
||||
ev.stopPropagation();
|
||||
};
|
||||
|
||||
// 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 handleMouseDown = React.useCallback((ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<BaseMenuItem
|
||||
onClick={disabled ? undefined : onClick}
|
||||
disabled={disabled}
|
||||
hide={hide}
|
||||
{...rest}
|
||||
>
|
||||
{(props) => (
|
||||
return (
|
||||
<MenuAnchor
|
||||
{...props}
|
||||
$active={active}
|
||||
@@ -85,7 +79,19 @@ const MenuItem = (
|
||||
{icon && <MenuIconWrapper>{icon}</MenuIconWrapper>}
|
||||
{children}
|
||||
</MenuAnchor>
|
||||
)}
|
||||
);
|
||||
},
|
||||
[active, as, hide, icon, onClick, ref, children, selected]
|
||||
);
|
||||
|
||||
return (
|
||||
<BaseMenuItem
|
||||
onClick={disabled ? undefined : onClick}
|
||||
disabled={disabled}
|
||||
hide={hide}
|
||||
{...rest}
|
||||
>
|
||||
{content}
|
||||
</BaseMenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -44,37 +44,35 @@ type SubMenuProps = MenuStateReturn & {
|
||||
title: React.ReactNode;
|
||||
};
|
||||
|
||||
const SubMenu = React.forwardRef(
|
||||
(
|
||||
{ templateItems, title, parentMenuState, ...rest }: SubMenuProps,
|
||||
ref: React.LegacyRef<HTMLButtonElement>
|
||||
) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const menu = useMenuState();
|
||||
const SubMenu = React.forwardRef(function _Template(
|
||||
{ templateItems, title, parentMenuState, ...rest }: SubMenuProps,
|
||||
ref: React.LegacyRef<HTMLButtonElement>
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const menu = useMenuState();
|
||||
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
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
|
||||
@@ -135,6 +133,7 @@ function Template({ items, actions, context, ...menu }: Props) {
|
||||
return (
|
||||
<MenuItem
|
||||
as={Link}
|
||||
id={`${item.title}-${index}`}
|
||||
to={item.to}
|
||||
key={index}
|
||||
disabled={item.disabled}
|
||||
@@ -150,6 +149,7 @@ function Template({ items, actions, context, ...menu }: Props) {
|
||||
if (item.type === "link") {
|
||||
return (
|
||||
<MenuItem
|
||||
id={`${item.title}-${index}`}
|
||||
href={item.href}
|
||||
key={index}
|
||||
disabled={item.disabled}
|
||||
@@ -168,6 +168,7 @@ function Template({ items, actions, context, ...menu }: Props) {
|
||||
return (
|
||||
<MenuItem
|
||||
as="button"
|
||||
id={`${item.title}-${index}`}
|
||||
onClick={item.onClick}
|
||||
disabled={item.disabled}
|
||||
selected={item.selected}
|
||||
@@ -186,6 +187,7 @@ function Template({ items, actions, context, ...menu }: Props) {
|
||||
<BaseMenuItem
|
||||
key={index}
|
||||
as={SubMenu}
|
||||
id={`${item.title}-${index}`}
|
||||
templateItems={item.items}
|
||||
parentMenuState={menu}
|
||||
title={<Title title={item.title} icon={item.icon} />}
|
||||
|
||||
@@ -46,6 +46,7 @@ type Props = MenuStateReturn & {
|
||||
onClose?: () => void;
|
||||
/** Called when the context menu is clicked. */
|
||||
onClick?: (ev: React.MouseEvent) => void;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const ContextMenu: React.FC<Props> = ({
|
||||
@@ -54,9 +55,12 @@ const ContextMenu: React.FC<Props> = ({
|
||||
onClose,
|
||||
parentMenuState,
|
||||
...rest
|
||||
}) => {
|
||||
}: Props) => {
|
||||
const previousVisible = usePrevious(rest.visible);
|
||||
const maxHeight = useMenuHeight(rest.visible, rest.unstable_disclosureRef);
|
||||
const maxHeight = useMenuHeight({
|
||||
visible: rest.visible,
|
||||
elementRef: rest.unstable_disclosureRef,
|
||||
});
|
||||
const backgroundRef = React.useRef<HTMLDivElement>(null);
|
||||
const { ui } = useStores();
|
||||
const { t } = useTranslation();
|
||||
@@ -147,7 +151,7 @@ const ContextMenu: React.FC<Props> = ({
|
||||
ref={backgroundRef}
|
||||
hiddenScrollbars
|
||||
style={
|
||||
maxHeight && topAnchor
|
||||
topAnchor
|
||||
? {
|
||||
maxHeight,
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from "~/utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
document: Document;
|
||||
onlyText?: boolean;
|
||||
};
|
||||
@@ -58,7 +59,7 @@ const DocumentBreadcrumb: React.FC<Props> = ({
|
||||
document,
|
||||
children,
|
||||
onlyText,
|
||||
}) => {
|
||||
}: Props) => {
|
||||
const { collections } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const category = useCategory(document);
|
||||
@@ -129,7 +130,11 @@ const DocumentBreadcrumb: React.FC<Props> = ({
|
||||
);
|
||||
}
|
||||
|
||||
return <Breadcrumb items={items} children={children} highlightFirstItem />;
|
||||
return (
|
||||
<Breadcrumb items={items} highlightFirstItem>
|
||||
{children}
|
||||
</Breadcrumb>
|
||||
);
|
||||
};
|
||||
|
||||
const SmallSlash = styled(GoToIcon)`
|
||||
|
||||
@@ -335,16 +335,21 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
|
||||
const innerElementType = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ style, ...rest }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
style={{
|
||||
...style,
|
||||
height: `${parseFloat(style?.height + "") + VERTICAL_PADDING * 2}px`,
|
||||
}}
|
||||
{...rest}
|
||||
/>
|
||||
));
|
||||
>(function innerElementType(
|
||||
{ style, ...rest }: React.HTMLAttributes<HTMLDivElement>,
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
style={{
|
||||
...style,
|
||||
height: `${parseFloat(style?.height + "") + VERTICAL_PADDING * 2}px`,
|
||||
}}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<Container tabIndex={-1} onKeyDown={handleKeyDown}>
|
||||
|
||||
@@ -258,7 +258,6 @@ const Heading = styled.h3<{ rtl?: boolean }>`
|
||||
display: flex;
|
||||
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.25em;
|
||||
white-space: nowrap;
|
||||
|
||||
@@ -15,6 +15,7 @@ import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
showCollection?: boolean;
|
||||
showPublished?: boolean;
|
||||
showLastViewed?: boolean;
|
||||
@@ -36,7 +37,7 @@ const DocumentMeta: React.FC<Props> = ({
|
||||
replace,
|
||||
to,
|
||||
...rest
|
||||
}) => {
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { collections } = useStores();
|
||||
const user = useCurrentUser();
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { sortBy } from "lodash";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { dateToRelative } from "@shared/utils/date";
|
||||
import Document from "~/models/Document";
|
||||
import User from "~/models/User";
|
||||
import Avatar from "~/components/Avatar";
|
||||
@@ -53,7 +53,7 @@ function DocumentViews({ document, isOpen }: Props) {
|
||||
? t("Currently editing")
|
||||
: t("Currently viewing")
|
||||
: t("Viewed {{ timeAgo }} ago", {
|
||||
timeAgo: formatDistanceToNow(
|
||||
timeAgo: dateToRelative(
|
||||
view ? Date.parse(view.lastViewedAt) : new Date()
|
||||
),
|
||||
});
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { deburr, difference, sortBy } from "lodash";
|
||||
import { observer } from "mobx-react";
|
||||
import { DOMParser as ProsemirrorDOMParser } from "prosemirror-model";
|
||||
@@ -10,6 +9,7 @@ import { Optional } from "utility-types";
|
||||
import insertFiles from "@shared/editor/commands/insertFiles";
|
||||
import { AttachmentPreset } from "@shared/types";
|
||||
import { Heading } from "@shared/utils/ProsemirrorHelper";
|
||||
import { dateLocale, dateToRelative } from "@shared/utils/date";
|
||||
import { getDataTransferFiles } from "@shared/utils/files";
|
||||
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
||||
import { isInternalUrl } from "@shared/utils/urls";
|
||||
@@ -23,6 +23,7 @@ import useDictionary from "~/hooks/useDictionary";
|
||||
import useEmbeds from "~/hooks/useEmbeds";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
import useUserLocale from "~/hooks/useUserLocale";
|
||||
import { NotFoundError } from "~/utils/errors";
|
||||
import { uploadFile } from "~/utils/files";
|
||||
import { isModKey } from "~/utils/keyboard";
|
||||
@@ -60,6 +61,8 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
onCreateCommentMark,
|
||||
onDeleteCommentMark,
|
||||
} = props;
|
||||
const userLocale = useUserLocale();
|
||||
const locale = dateLocale(userLocale);
|
||||
const { auth, comments, documents } = useStores();
|
||||
const { showToast } = useToasts();
|
||||
const dictionary = useDictionary();
|
||||
@@ -92,8 +95,10 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
|
||||
try {
|
||||
const document = await documents.fetch(slug);
|
||||
const time = formatDistanceToNow(Date.parse(document.updatedAt), {
|
||||
const time = dateToRelative(Date.parse(document.updatedAt), {
|
||||
addSuffix: true,
|
||||
shorten: true,
|
||||
locale,
|
||||
});
|
||||
|
||||
return [
|
||||
@@ -349,7 +354,6 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
)}
|
||||
{activeLinkElement && !shareId && (
|
||||
<HoverPreview
|
||||
id={props.id}
|
||||
element={activeLinkElement}
|
||||
onClose={handleLinkInactive}
|
||||
/>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import * as React from "react";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const EventBoundary: React.FC<Props> = ({ children, className }) => {
|
||||
const EventBoundary: React.FC<Props> = ({ children, className }: Props) => {
|
||||
const handleClick = React.useCallback((event: React.SyntheticEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
@@ -160,15 +160,16 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
|
||||
);
|
||||
};
|
||||
|
||||
const BaseItem = React.forwardRef(
|
||||
({ to, ...rest }: ItemProps, ref?: React.Ref<HTMLAnchorElement>) => {
|
||||
if (to) {
|
||||
return <CompositeListItem to={to} ref={ref} {...rest} />;
|
||||
}
|
||||
|
||||
return <ListItem ref={ref} {...rest} />;
|
||||
const BaseItem = React.forwardRef(function _BaseItem(
|
||||
{ to, ...rest }: ItemProps,
|
||||
ref?: React.Ref<HTMLAnchorElement>
|
||||
) {
|
||||
if (to) {
|
||||
return <CompositeListItem to={to} ref={ref} {...rest} />;
|
||||
}
|
||||
);
|
||||
|
||||
return <ListItem ref={ref} {...rest} />;
|
||||
});
|
||||
|
||||
const Subtitle = styled.span`
|
||||
svg {
|
||||
|
||||
@@ -99,7 +99,7 @@ function ExportDialog({ collection, onSubmit }: Props) {
|
||||
)}
|
||||
<Flex gap={12} column>
|
||||
{items.map((item) => (
|
||||
<Option>
|
||||
<Option key={item.value}>
|
||||
<input
|
||||
type="radio"
|
||||
name="format"
|
||||
|
||||
@@ -6,6 +6,7 @@ import Scrollable from "~/components/Scrollable";
|
||||
import usePrevious from "~/hooks/usePrevious";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
isOpen: boolean;
|
||||
title?: string;
|
||||
onRequestClose: () => void;
|
||||
@@ -17,7 +18,7 @@ const Guide: React.FC<Props> = ({
|
||||
title = "Untitled",
|
||||
onRequestClose,
|
||||
...rest
|
||||
}) => {
|
||||
}: Props) => {
|
||||
const dialog = useDialogState({
|
||||
animated: 250,
|
||||
});
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
import { transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import { Portal } from "react-portal";
|
||||
import styled from "styled-components";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
||||
import { isExternalUrl } from "@shared/utils/urls";
|
||||
import HoverPreviewDocument from "~/components/HoverPreviewDocument";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import { fadeAndSlideDown } from "~/styles/animations";
|
||||
|
||||
const DELAY_OPEN = 300;
|
||||
const DELAY_CLOSE = 300;
|
||||
|
||||
type Props = {
|
||||
/* The document associated with the editor, if any */
|
||||
id?: string;
|
||||
/* The HTML element that is being hovered over */
|
||||
element: HTMLAnchorElement;
|
||||
/* A callback on close of the hover preview */
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
function HoverPreviewInternal({ element, id, onClose }: Props) {
|
||||
const slug = parseDocumentSlug(element.href);
|
||||
const [isVisible, setVisible] = React.useState(false);
|
||||
const timerClose = React.useRef<ReturnType<typeof setTimeout>>();
|
||||
const timerOpen = React.useRef<ReturnType<typeof setTimeout>>();
|
||||
const cardRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const startCloseTimer = () => {
|
||||
stopOpenTimer();
|
||||
timerClose.current = setTimeout(() => {
|
||||
if (isVisible) {
|
||||
setVisible(false);
|
||||
}
|
||||
onClose();
|
||||
}, DELAY_CLOSE);
|
||||
};
|
||||
|
||||
const stopCloseTimer = () => {
|
||||
if (timerClose.current) {
|
||||
clearTimeout(timerClose.current);
|
||||
}
|
||||
};
|
||||
|
||||
const startOpenTimer = () => {
|
||||
timerOpen.current = setTimeout(() => setVisible(true), DELAY_OPEN);
|
||||
};
|
||||
|
||||
const stopOpenTimer = () => {
|
||||
if (timerOpen.current) {
|
||||
clearTimeout(timerOpen.current);
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
startOpenTimer();
|
||||
|
||||
if (cardRef.current) {
|
||||
cardRef.current.addEventListener("mouseenter", stopCloseTimer);
|
||||
}
|
||||
|
||||
if (cardRef.current) {
|
||||
cardRef.current.addEventListener("mouseleave", startCloseTimer);
|
||||
}
|
||||
|
||||
element.addEventListener("mouseout", startCloseTimer);
|
||||
element.addEventListener("mouseover", stopCloseTimer);
|
||||
element.addEventListener("mouseover", startOpenTimer);
|
||||
return () => {
|
||||
element.removeEventListener("mouseout", startCloseTimer);
|
||||
element.removeEventListener("mouseover", stopCloseTimer);
|
||||
element.removeEventListener("mouseover", startOpenTimer);
|
||||
|
||||
if (cardRef.current) {
|
||||
cardRef.current.removeEventListener("mouseenter", stopCloseTimer);
|
||||
}
|
||||
|
||||
if (cardRef.current) {
|
||||
cardRef.current.removeEventListener("mouseleave", startCloseTimer);
|
||||
}
|
||||
|
||||
if (timerClose.current) {
|
||||
clearTimeout(timerClose.current);
|
||||
}
|
||||
};
|
||||
}, [element, slug]);
|
||||
|
||||
const anchorBounds = element.getBoundingClientRect();
|
||||
const cardBounds = cardRef.current?.getBoundingClientRect();
|
||||
const left = cardBounds
|
||||
? Math.min(anchorBounds.left, window.innerWidth - 16 - 350)
|
||||
: anchorBounds.left;
|
||||
const leftOffset = anchorBounds.left - left;
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Position
|
||||
top={anchorBounds.bottom + window.scrollY}
|
||||
left={left}
|
||||
aria-hidden
|
||||
>
|
||||
<div ref={cardRef}>
|
||||
<HoverPreviewDocument url={element.href} id={id}>
|
||||
{(content: React.ReactNode) =>
|
||||
isVisible ? (
|
||||
<Animate>
|
||||
<Card>
|
||||
<Margin />
|
||||
<CardContent>{content}</CardContent>
|
||||
</Card>
|
||||
<Pointer offset={leftOffset + anchorBounds.width / 2} />
|
||||
</Animate>
|
||||
) : null
|
||||
}
|
||||
</HoverPreviewDocument>
|
||||
</div>
|
||||
</Position>
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function HoverPreview({ element, ...rest }: Props) {
|
||||
const isMobile = useMobile();
|
||||
if (isMobile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// previews only work for internal doc links for now
|
||||
if (isExternalUrl(element.href)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <HoverPreviewInternal {...rest} element={element} />;
|
||||
}
|
||||
|
||||
const Animate = styled.div`
|
||||
animation: ${fadeAndSlideDown} 150ms ease;
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
// fills the gap between the card and pointer to avoid a dead zone
|
||||
const Margin = styled.div`
|
||||
position: absolute;
|
||||
top: -11px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 11px;
|
||||
`;
|
||||
|
||||
const CardContent = styled.div`
|
||||
overflow: hidden;
|
||||
max-height: 20em;
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
// &:after — gradient mask for overflow text
|
||||
const Card = styled.div`
|
||||
backdrop-filter: blur(10px);
|
||||
background: ${s("background")};
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 30px 90px -20px rgba(0, 0, 0, 0.3),
|
||||
0 0 1px 1px rgba(0, 0, 0, 0.05);
|
||||
padding: 16px;
|
||||
width: 350px;
|
||||
font-size: 0.9em;
|
||||
position: relative;
|
||||
|
||||
.placeholder,
|
||||
.heading-anchor {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&:after {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
${(props) => transparentize(1, props.theme.background)} 0%,
|
||||
${(props) => transparentize(1, props.theme.background)} 75%,
|
||||
${s("background")} 90%
|
||||
);
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1.7em;
|
||||
border-bottom: 16px solid ${s("background")};
|
||||
border-bottom-left-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
`;
|
||||
|
||||
const Position = styled.div<{ fixed?: boolean; top?: number; left?: number }>`
|
||||
margin-top: 10px;
|
||||
position: ${({ fixed }) => (fixed ? "fixed" : "absolute")};
|
||||
z-index: ${depths.hoverPreview};
|
||||
display: flex;
|
||||
max-height: 75%;
|
||||
|
||||
${({ top }) => (top !== undefined ? `top: ${top}px` : "")};
|
||||
${({ left }) => (left !== undefined ? `left: ${left}px` : "")};
|
||||
`;
|
||||
|
||||
const Pointer = styled.div<{ offset: number }>`
|
||||
top: -22px;
|
||||
left: ${(props) => props.offset}px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
position: absolute;
|
||||
transform: translateX(-50%);
|
||||
pointer-events: none;
|
||||
|
||||
&:before,
|
||||
&:after {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
&:before {
|
||||
border: 8px solid transparent;
|
||||
border-bottom-color: ${(props) =>
|
||||
props.theme.menuBorder || "rgba(0, 0, 0, 0.1)"};
|
||||
right: -1px;
|
||||
}
|
||||
|
||||
&:after {
|
||||
border: 7px solid transparent;
|
||||
border-bottom-color: ${s("background")};
|
||||
}
|
||||
`;
|
||||
|
||||
export default HoverPreview;
|
||||
@@ -0,0 +1,108 @@
|
||||
import { transparentize } from "polished";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled, { css } from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import Text from "~/components/Text";
|
||||
|
||||
export const CARD_MARGIN = 16;
|
||||
|
||||
const NUMBER_OF_LINES = 10;
|
||||
|
||||
const sharedVars = css`
|
||||
--line-height: 1.6em;
|
||||
`;
|
||||
|
||||
const StyledText = styled(Text)`
|
||||
margin-bottom: 0;
|
||||
`;
|
||||
|
||||
export const Preview = styled(Link)`
|
||||
cursor: ${(props: any) =>
|
||||
props.as === "div" ? "default" : "var(--pointer)"};
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 30px 90px -20px rgba(0, 0, 0, 0.3),
|
||||
0 0 1px 1px rgba(0, 0, 0, 0.05);
|
||||
overflow: hidden;
|
||||
position: absolute;
|
||||
min-width: 350px;
|
||||
max-width: 375px;
|
||||
`;
|
||||
|
||||
export const Title = styled.h2`
|
||||
font-size: 1.25em;
|
||||
margin: 0;
|
||||
color: ${s("text")};
|
||||
`;
|
||||
|
||||
export const Info = styled(StyledText).attrs(() => ({
|
||||
type: "tertiary",
|
||||
size: "xsmall",
|
||||
}))`
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
export const Description = styled(StyledText)`
|
||||
${sharedVars}
|
||||
margin-top: 0.5em;
|
||||
line-height: var(--line-height);
|
||||
max-height: calc(var(--line-height) * ${NUMBER_OF_LINES});
|
||||
`;
|
||||
|
||||
export const Thumbnail = styled.img`
|
||||
object-fit: cover;
|
||||
height: 200px;
|
||||
background: ${s("menuBackground")};
|
||||
`;
|
||||
|
||||
export const CardContent = styled.div`
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
// &:after — gradient mask for overflow text
|
||||
export const Card = styled.div<{ fadeOut?: boolean; $borderRadius?: string }>`
|
||||
backdrop-filter: blur(10px);
|
||||
background: ${s("menuBackground")};
|
||||
padding: 16px;
|
||||
font-size: 0.9em;
|
||||
position: relative;
|
||||
|
||||
.placeholder,
|
||||
.heading-anchor {
|
||||
display: none;
|
||||
}
|
||||
|
||||
// fills the gap between the card and pointer to avoid a dead zone
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
props.fadeOut !== false
|
||||
? `&:after {
|
||||
${sharedVars}
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
${transparentize(1, props.theme.menuBackground)} 0%,
|
||||
${transparentize(1, props.theme.menuBackground)} 75%,
|
||||
${props.theme.menuBackground} 90%
|
||||
);
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: var(--line-height);
|
||||
border-bottom: 16px solid ${props.theme.menuBackground};
|
||||
border-bottom-left-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
}`
|
||||
: ""}
|
||||
`;
|
||||
@@ -0,0 +1,261 @@
|
||||
import { m } from "framer-motion";
|
||||
import * as React from "react";
|
||||
import { Portal } from "react-portal";
|
||||
import styled from "styled-components";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import { UnfurlType } from "@shared/types";
|
||||
import LoadingIndicator from "~/components/LoadingIndicator";
|
||||
import useEventListener from "~/hooks/useEventListener";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import useOnClickOutside from "~/hooks/useOnClickOutside";
|
||||
import useRequest from "~/hooks/useRequest";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import { CARD_MARGIN } from "./Components";
|
||||
import HoverPreviewDocument from "./HoverPreviewDocument";
|
||||
import HoverPreviewLink from "./HoverPreviewLink";
|
||||
import HoverPreviewMention from "./HoverPreviewMention";
|
||||
|
||||
const DELAY_OPEN = 300;
|
||||
const DELAY_CLOSE = 600;
|
||||
|
||||
type Props = {
|
||||
/* The HTML element that is being hovered over */
|
||||
element: HTMLAnchorElement;
|
||||
/* A callback on close of the hover preview */
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
function HoverPreviewInternal({ element, onClose }: Props) {
|
||||
const url = element.href || element.dataset.url;
|
||||
const [isVisible, setVisible] = React.useState(false);
|
||||
const timerClose = React.useRef<ReturnType<typeof setTimeout>>();
|
||||
const timerOpen = React.useRef<ReturnType<typeof setTimeout>>();
|
||||
const cardRef = React.useRef<HTMLDivElement>(null);
|
||||
const stores = useStores();
|
||||
const [cardLeft, setCardLeft] = React.useState(0);
|
||||
const [cardTop, setCardTop] = React.useState(0);
|
||||
const [pointerOffset, setPointerOffset] = React.useState(0);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (isVisible && cardRef.current) {
|
||||
const elem = element.getBoundingClientRect();
|
||||
const card = cardRef.current.getBoundingClientRect();
|
||||
|
||||
const top = elem.bottom + window.scrollY;
|
||||
setCardTop(top);
|
||||
|
||||
let left = elem.left;
|
||||
let pointerOffset = elem.width / 2;
|
||||
if (left + card.width > window.innerWidth) {
|
||||
// shift card leftwards by the amount it went out of screen
|
||||
let shiftBy = left + card.width - window.innerWidth;
|
||||
// shift a littler further to leave some margin between card and window boundary
|
||||
shiftBy += CARD_MARGIN;
|
||||
left -= shiftBy;
|
||||
|
||||
// shift pointer rightwards by same amount so as to position it back correctly
|
||||
pointerOffset += shiftBy;
|
||||
}
|
||||
setCardLeft(left);
|
||||
|
||||
setPointerOffset(pointerOffset);
|
||||
}
|
||||
}, [isVisible, element]);
|
||||
|
||||
const { data, request, loading } = useRequest(
|
||||
React.useCallback(
|
||||
() =>
|
||||
client.post("/urls.unfurl", {
|
||||
url,
|
||||
documentId: stores.ui.activeDocumentId,
|
||||
}),
|
||||
[url, stores.ui.activeDocumentId]
|
||||
)
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (url) {
|
||||
stopOpenTimer();
|
||||
setVisible(false);
|
||||
|
||||
void request();
|
||||
}
|
||||
}, [url, request]);
|
||||
|
||||
const stopOpenTimer = () => {
|
||||
if (timerOpen.current) {
|
||||
clearTimeout(timerOpen.current);
|
||||
timerOpen.current = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const closePreview = React.useCallback(() => {
|
||||
if (isVisible) {
|
||||
stopOpenTimer();
|
||||
setVisible(false);
|
||||
onClose();
|
||||
}
|
||||
}, [isVisible, onClose]);
|
||||
|
||||
useOnClickOutside(cardRef, closePreview);
|
||||
useKeyDown("Escape", closePreview);
|
||||
useEventListener("scroll", closePreview, window, { capture: true });
|
||||
|
||||
const stopCloseTimer = () => {
|
||||
if (timerClose.current) {
|
||||
clearTimeout(timerClose.current);
|
||||
timerClose.current = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const startOpenTimer = () => {
|
||||
if (!timerOpen.current) {
|
||||
timerOpen.current = setTimeout(() => setVisible(true), DELAY_OPEN);
|
||||
}
|
||||
};
|
||||
|
||||
const startCloseTimer = React.useCallback(() => {
|
||||
stopOpenTimer();
|
||||
timerClose.current = setTimeout(closePreview, DELAY_CLOSE);
|
||||
}, [closePreview]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const card = cardRef.current;
|
||||
|
||||
if (data) {
|
||||
startOpenTimer();
|
||||
|
||||
if (card) {
|
||||
card.addEventListener("mouseenter", stopCloseTimer);
|
||||
card.addEventListener("mouseleave", startCloseTimer);
|
||||
}
|
||||
|
||||
element.addEventListener("mouseout", startCloseTimer);
|
||||
element.addEventListener("mouseover", stopCloseTimer);
|
||||
element.addEventListener("mouseover", startOpenTimer);
|
||||
}
|
||||
|
||||
return () => {
|
||||
element.removeEventListener("mouseout", startCloseTimer);
|
||||
element.removeEventListener("mouseover", stopCloseTimer);
|
||||
element.removeEventListener("mouseover", startOpenTimer);
|
||||
|
||||
if (card) {
|
||||
card.removeEventListener("mouseenter", stopCloseTimer);
|
||||
card.removeEventListener("mouseleave", startCloseTimer);
|
||||
}
|
||||
|
||||
stopCloseTimer();
|
||||
};
|
||||
}, [element, startCloseTimer, data]);
|
||||
|
||||
if (loading) {
|
||||
return <LoadingIndicator />;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Position top={cardTop} left={cardLeft} aria-hidden>
|
||||
{isVisible ? (
|
||||
<Animate
|
||||
initial={{ opacity: 0, y: -20, pointerEvents: "none" }}
|
||||
animate={{ opacity: 1, y: 0, pointerEvents: "auto" }}
|
||||
>
|
||||
{data.type === UnfurlType.Mention ? (
|
||||
<HoverPreviewMention
|
||||
ref={cardRef}
|
||||
url={data.thumbnailUrl}
|
||||
title={data.title}
|
||||
info={data.meta.info}
|
||||
color={data.meta.color}
|
||||
/>
|
||||
) : data.type === UnfurlType.Document ? (
|
||||
<HoverPreviewDocument
|
||||
ref={cardRef}
|
||||
id={data.meta.id}
|
||||
url={data.url}
|
||||
title={data.title}
|
||||
description={data.description}
|
||||
info={data.meta.info}
|
||||
/>
|
||||
) : (
|
||||
<HoverPreviewLink
|
||||
ref={cardRef}
|
||||
url={data.url}
|
||||
thumbnailUrl={data.thumbnailUrl}
|
||||
title={data.title}
|
||||
description={data.description}
|
||||
/>
|
||||
)}
|
||||
<Pointer offset={pointerOffset} />
|
||||
</Animate>
|
||||
) : null}
|
||||
</Position>
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function HoverPreview({ element, ...rest }: Props) {
|
||||
const isMobile = useMobile();
|
||||
if (isMobile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <HoverPreviewInternal {...rest} element={element} />;
|
||||
}
|
||||
|
||||
const Animate = styled(m.div)`
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const Position = styled.div<{ fixed?: boolean; top?: number; left?: number }>`
|
||||
margin-top: 10px;
|
||||
position: ${({ fixed }) => (fixed ? "fixed" : "absolute")};
|
||||
z-index: ${depths.hoverPreview};
|
||||
display: flex;
|
||||
max-height: 75%;
|
||||
|
||||
${({ top }) => (top !== undefined ? `top: ${top}px` : "")};
|
||||
${({ left }) => (left !== undefined ? `left: ${left}px` : "")};
|
||||
`;
|
||||
|
||||
const Pointer = styled.div<{ offset: number }>`
|
||||
top: -22px;
|
||||
left: ${(props) => props.offset}px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
position: absolute;
|
||||
transform: translateX(-50%);
|
||||
pointer-events: none;
|
||||
|
||||
&:before,
|
||||
&:after {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
&:before {
|
||||
border: 8px solid transparent;
|
||||
border-bottom-color: ${(props) =>
|
||||
props.theme.menuBorder || "rgba(0, 0, 0, 0.1)"};
|
||||
right: -1px;
|
||||
}
|
||||
|
||||
&:after {
|
||||
border: 7px solid transparent;
|
||||
border-bottom-color: ${s("menuBackground")};
|
||||
}
|
||||
`;
|
||||
|
||||
export default HoverPreview;
|
||||
@@ -0,0 +1,54 @@
|
||||
import * as React from "react";
|
||||
import Editor from "~/components/Editor";
|
||||
import Flex from "~/components/Flex";
|
||||
import {
|
||||
Preview,
|
||||
Title,
|
||||
Info,
|
||||
Card,
|
||||
CardContent,
|
||||
Description,
|
||||
} from "./Components";
|
||||
|
||||
type Props = {
|
||||
/** Document id associated with the editor, if any */
|
||||
id?: string;
|
||||
/** Document url */
|
||||
url: string;
|
||||
/** Title for the preview card */
|
||||
title: string;
|
||||
/** Info about last activity on the document */
|
||||
info: string;
|
||||
/** Text preview of document content */
|
||||
description: string;
|
||||
};
|
||||
|
||||
const HoverPreviewDocument = React.forwardRef(function _HoverPreviewDocument(
|
||||
{ id, url, title, info, description }: Props,
|
||||
ref: React.Ref<HTMLDivElement>
|
||||
) {
|
||||
return (
|
||||
<Preview to={url}>
|
||||
<Card ref={ref}>
|
||||
<CardContent>
|
||||
<Flex column gap={2}>
|
||||
<Title>{title}</Title>
|
||||
<Info>{info}</Info>
|
||||
<Description as="div">
|
||||
<React.Suspense fallback={<div />}>
|
||||
<Editor
|
||||
key={id}
|
||||
defaultValue={description}
|
||||
embedsDisabled
|
||||
readOnly
|
||||
/>
|
||||
</React.Suspense>
|
||||
</Description>
|
||||
</Flex>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Preview>
|
||||
);
|
||||
});
|
||||
|
||||
export default HoverPreviewDocument;
|
||||
@@ -0,0 +1,44 @@
|
||||
import * as React from "react";
|
||||
import Flex from "~/components/Flex";
|
||||
import {
|
||||
Preview,
|
||||
Title,
|
||||
Description,
|
||||
Card,
|
||||
CardContent,
|
||||
Thumbnail,
|
||||
} from "./Components";
|
||||
|
||||
type Props = {
|
||||
/** Link url */
|
||||
url: string;
|
||||
/** Title for the preview card */
|
||||
title: string;
|
||||
/** Url for thumbnail served by the link provider */
|
||||
thumbnailUrl: string;
|
||||
/** Some description about the link provider */
|
||||
description: string;
|
||||
};
|
||||
|
||||
const HoverPreviewLink = React.forwardRef(function _HoverPreviewLink(
|
||||
{ url, thumbnailUrl, title, description }: Props,
|
||||
ref: React.Ref<HTMLDivElement>
|
||||
) {
|
||||
return (
|
||||
<Preview as="a" href={url} target="_blank" rel="noopener noreferrer">
|
||||
<Flex column>
|
||||
{thumbnailUrl ? <Thumbnail src={thumbnailUrl} alt={""} /> : null}
|
||||
<Card ref={ref}>
|
||||
<CardContent>
|
||||
<Flex column>
|
||||
<Title>{title}</Title>
|
||||
<Description>{description}</Description>
|
||||
</Flex>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Flex>
|
||||
</Preview>
|
||||
);
|
||||
});
|
||||
|
||||
export default HoverPreviewLink;
|
||||
@@ -0,0 +1,46 @@
|
||||
import * as React from "react";
|
||||
import Avatar from "~/components/Avatar";
|
||||
import { AvatarSize } from "~/components/Avatar/Avatar";
|
||||
import Flex from "~/components/Flex";
|
||||
import { Preview, Title, Info, Card, CardContent } from "./Components";
|
||||
|
||||
type Props = {
|
||||
/** Resource url, avatar url in case of user mention */
|
||||
url: string;
|
||||
/** Title for the preview card*/
|
||||
title: string;
|
||||
/** Info about mentioned user's recent activity */
|
||||
info: string;
|
||||
/** Used for avatar's background color in absence of avatar url */
|
||||
color: string;
|
||||
};
|
||||
|
||||
const HoverPreviewMention = React.forwardRef(function _HoverPreviewMention(
|
||||
{ url, title, info, color }: Props,
|
||||
ref: React.Ref<HTMLDivElement>
|
||||
) {
|
||||
return (
|
||||
<Preview as="div">
|
||||
<Card fadeOut={false} ref={ref}>
|
||||
<CardContent>
|
||||
<Flex gap={12}>
|
||||
<Avatar
|
||||
model={{
|
||||
avatarUrl: url,
|
||||
initial: title ? title[0] : "?",
|
||||
color,
|
||||
}}
|
||||
size={AvatarSize.XLarge}
|
||||
/>
|
||||
<Flex column gap={2} justify="center">
|
||||
<Title>{title}</Title>
|
||||
<Info>{info}</Info>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Preview>
|
||||
);
|
||||
});
|
||||
|
||||
export default HoverPreviewMention;
|
||||
@@ -0,0 +1,3 @@
|
||||
import HoverPreview from "./HoverPreview";
|
||||
|
||||
export default HoverPreview;
|
||||
@@ -1,64 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
||||
import DocumentMeta from "~/components/DocumentMeta";
|
||||
import Editor from "~/components/Editor";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
/* The document associated with the editor, if any */
|
||||
id?: string;
|
||||
/* The URL we want a preview for */
|
||||
url: string;
|
||||
children: (content: React.ReactNode) => React.ReactNode;
|
||||
};
|
||||
|
||||
function HoverPreviewDocument({ url, id, children }: Props) {
|
||||
const { documents } = useStores();
|
||||
const slug = parseDocumentSlug(url);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (slug) {
|
||||
void documents.prefetchDocument(slug);
|
||||
}
|
||||
}, [documents, slug]);
|
||||
|
||||
const document = slug ? documents.getByUrl(slug) : undefined;
|
||||
if (!document || document.id === id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{children(
|
||||
<Content to={document.url}>
|
||||
<Heading>{document.titleWithDefault}</Heading>
|
||||
<DocumentMeta document={document} />
|
||||
|
||||
<React.Suspense fallback={<div />}>
|
||||
<Editor
|
||||
key={document.id}
|
||||
defaultValue={document.getSummary()}
|
||||
embedsDisabled
|
||||
readOnly
|
||||
/>
|
||||
</React.Suspense>
|
||||
</Content>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const Content = styled(Link)`
|
||||
cursor: var(--pointer);
|
||||
`;
|
||||
|
||||
const Heading = styled.h2`
|
||||
margin: 0 0 0.75em;
|
||||
color: ${s("text")};
|
||||
`;
|
||||
|
||||
export default observer(HoverPreviewDocument);
|
||||
@@ -24,7 +24,7 @@ export default function MarkdownIcon({
|
||||
<path
|
||||
d="M19.2692 7H3.86538C3.38745 7 3 7.38476 3 7.85938V16.2812C3 16.7559 3.38745 17.1406 3.86538 17.1406H19.2692C19.7472 17.1406 20.1346 16.7559 20.1346 16.2812V7.85938C20.1346 7.38476 19.7472 7 19.2692 7Z"
|
||||
stroke={color}
|
||||
stroke-width="2"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<path
|
||||
d="M5.16345 14.9922V9.14844H6.89422L8.62499 11.2969L10.3558 9.14844H12.0865V14.9922H10.3558V11.6406L8.62499 13.7891L6.89422 11.6406V14.9922H5.16345ZM15.9808 14.9922L13.3846 12.1562H15.1154V9.14844H16.8461V12.1562H18.5769L15.9808 14.9922Z"
|
||||
|
||||
@@ -30,6 +30,8 @@ const RealInput = styled.input<{ hasIcon?: boolean }>`
|
||||
color: ${s("text")};
|
||||
height: 30px;
|
||||
min-width: 0;
|
||||
font-size: 15px;
|
||||
|
||||
${ellipsis()}
|
||||
${undraggableOnDesktop()}
|
||||
|
||||
@@ -175,6 +177,7 @@ function Input(
|
||||
labelHidden,
|
||||
onFocus,
|
||||
onBlur,
|
||||
onRequestSubmit,
|
||||
children,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
@@ -16,7 +16,7 @@ type Props = Omit<InputProps, "onChange"> & {
|
||||
onChange: (value: string) => void;
|
||||
};
|
||||
|
||||
const InputColor: React.FC<Props> = ({ value, onChange, ...rest }) => {
|
||||
const InputColor: React.FC<Props> = ({ value, onChange, ...rest }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const menu = useMenuState({
|
||||
modal: true,
|
||||
|
||||
@@ -45,6 +45,10 @@ function InputSearchPage({
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(ev: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (ev.nativeEvent.isComposing) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev.key === "Enter") {
|
||||
ev.preventDefault();
|
||||
history.push(
|
||||
|
||||
@@ -85,11 +85,11 @@ const InputSelect = (props: Props) => {
|
||||
const contentRef = React.useRef<HTMLDivElement>(null);
|
||||
const minWidth = buttonRef.current?.offsetWidth || 0;
|
||||
const margin = 8;
|
||||
const menuMaxHeight = useMenuHeight(
|
||||
select.visible,
|
||||
select.unstable_disclosureRef,
|
||||
margin
|
||||
);
|
||||
const menuMaxHeight = useMenuHeight({
|
||||
visible: select.visible,
|
||||
elementRef: select.unstable_disclosureRef,
|
||||
margin,
|
||||
});
|
||||
const maxHeight = Math.min(
|
||||
menuMaxHeight ?? 0,
|
||||
window.innerHeight -
|
||||
|
||||
@@ -5,10 +5,11 @@ import { s } from "@shared/styles";
|
||||
import Flex from "~/components/Flex";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
label: React.ReactNode | string;
|
||||
};
|
||||
|
||||
const Labeled: React.FC<Props> = ({ label, children, ...props }) => (
|
||||
const Labeled: React.FC<Props> = ({ label, children, ...props }: Props) => (
|
||||
<Flex column {...props}>
|
||||
<Label>{label}</Label>
|
||||
{children}
|
||||
|
||||
@@ -21,14 +21,14 @@ function Icon({ className }: { className?: string }) {
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M21 18H16L14 16V6C14 4.89543 14.8954 4 16 4H28C29.1046 4 30 4.89543 30 6V16C30 17.1046 29.1046 18 28 18H27L25.4142 19.5858C24.6332 20.3668 23.3668 20.3668 22.5858 19.5858L21 18ZM16 15.1716V6H28V16H27H26.1716L25.5858 16.5858L24 18.1716L22.4142 16.5858L21.8284 16H21H16.8284L16 15.1716Z"
|
||||
fill="#2B2F35"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M16 13H4C2.89543 13 2 13.8954 2 15V25C2 26.1046 2.89543 27 4 27H5L6.58579 28.5858C7.36684 29.3668 8.63316 29.3668 9.41421 28.5858L11 27H16C17.1046 27 18 26.1046 18 25V15C18 13.8954 17.1046 13 16 13ZM9 17L6 16.9681C6 16.9681 5 17.016 5 18C5 18.984 6 19 6 19H8.5H10C10 19 9.57627 20.1885 8.38983 21.0831C7.20339 21.9777 5.7197 23 5.7197 23C5.7197 23 4.99153 23.6054 5.5 24.5C6.00847 25.3946 7 24.8403 7 24.8403L9.74576 22.8722L11.9492 24.6614C11.9492 24.6614 12.6271 25.3771 13.3051 24.4825C13.9831 23.5879 13.3051 23.0512 13.3051 23.0512L11.1017 21.262C11.1017 21.262 11.5 21 12 20L12.5 19H14C14 19 15 19.0319 15 18C15 16.9681 14 16.9681 14 16.9681L11 17V16C11 16 11.0169 15 10 15C8.98305 15 9 16 9 16V17Z"
|
||||
fill="#2B2F35"
|
||||
/>
|
||||
|
||||
@@ -16,6 +16,7 @@ import useStores from "~/hooks/useStores";
|
||||
import { isModKey } from "~/utils/keyboard";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
title?: string;
|
||||
sidebar?: React.ReactNode;
|
||||
sidebarRight?: React.ReactNode;
|
||||
@@ -26,7 +27,7 @@ const Layout: React.FC<Props> = ({
|
||||
children,
|
||||
sidebar,
|
||||
sidebarRight,
|
||||
}) => {
|
||||
}: Props) => {
|
||||
const { ui } = useStores();
|
||||
const sidebarCollapsed = !sidebar || ui.sidebarIsClosed;
|
||||
|
||||
|
||||
@@ -2,10 +2,14 @@ import * as React from "react";
|
||||
import Logger from "~/utils/Logger";
|
||||
import { loadPolyfills } from "~/utils/polyfills";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Asyncronously load required polyfills. Should wrap the React tree.
|
||||
*/
|
||||
export const LazyPolyfill: React.FC = ({ children }) => {
|
||||
export const LazyPolyfill: React.FC = ({ children }: Props) => {
|
||||
const [isLoaded, setIsLoaded] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { format as formatDate, formatDistanceToNow } from "date-fns";
|
||||
import { format as formatDate } from "date-fns";
|
||||
import * as React from "react";
|
||||
import { dateLocale, dateToRelative, locales } from "@shared/utils/date";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import useUserLocale from "~/hooks/useUserLocale";
|
||||
import { dateLocale, locales } from "~/utils/i18n";
|
||||
|
||||
let callbacks: (() => void)[] = [];
|
||||
|
||||
@@ -21,6 +21,7 @@ function eachMinute(fn: () => void) {
|
||||
}
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
dateTime: string;
|
||||
tooltipDelay?: number;
|
||||
addSuffix?: boolean;
|
||||
@@ -37,7 +38,7 @@ const LocaleTime: React.FC<Props> = ({
|
||||
format,
|
||||
relative,
|
||||
tooltipDelay,
|
||||
}) => {
|
||||
}: Props) => {
|
||||
const userLocale: string = useUserLocale() || "";
|
||||
const dateFormatLong = {
|
||||
en_US: "MMMM do, yyyy h:mm a",
|
||||
@@ -59,26 +60,21 @@ const LocaleTime: React.FC<Props> = ({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const date = new Date(Date.parse(dateTime));
|
||||
const locale = dateLocale(userLocale);
|
||||
let relativeContent = formatDistanceToNow(Date.parse(dateTime), {
|
||||
const relativeContent = dateToRelative(date, {
|
||||
addSuffix,
|
||||
locale,
|
||||
shorten,
|
||||
});
|
||||
|
||||
if (shorten) {
|
||||
relativeContent = relativeContent
|
||||
.replace("about", "")
|
||||
.replace("less than a minute ago", "just now")
|
||||
.replace("minute", "min");
|
||||
}
|
||||
|
||||
const tooltipContent = formatDate(Date.parse(dateTime), formatLocaleLong, {
|
||||
const tooltipContent = formatDate(date, formatLocaleLong, {
|
||||
locale,
|
||||
});
|
||||
const content =
|
||||
relative !== false
|
||||
? relativeContent
|
||||
: formatDate(Date.parse(dateTime), formatLocale, {
|
||||
: formatDate(date, formatLocale, {
|
||||
locale,
|
||||
});
|
||||
|
||||
|
||||
@@ -19,7 +19,9 @@ import Desktop from "~/utils/Desktop";
|
||||
import ErrorBoundary from "./ErrorBoundary";
|
||||
|
||||
let openModals = 0;
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
isOpen: boolean;
|
||||
isCentered?: boolean;
|
||||
title?: React.ReactNode;
|
||||
@@ -32,7 +34,7 @@ const Modal: React.FC<Props> = ({
|
||||
isCentered,
|
||||
title = "Untitled",
|
||||
onRequestClose,
|
||||
}) => {
|
||||
}: Props) => {
|
||||
const dialog = useDialogState({
|
||||
animated: 250,
|
||||
});
|
||||
|
||||
@@ -5,11 +5,12 @@ import Flex from "./Flex";
|
||||
import Text from "./Text";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
icon?: JSX.Element;
|
||||
description?: JSX.Element;
|
||||
};
|
||||
|
||||
const Notice: React.FC<Props> = ({ children, icon, description }) => (
|
||||
const Notice: React.FC<Props> = ({ children, icon, description }: Props) => (
|
||||
<Container>
|
||||
<Flex as="span" gap={8}>
|
||||
{icon}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { s } from "@shared/styles";
|
||||
import Notification from "~/models/Notification";
|
||||
import CommentEditor from "~/scenes/Document/components/CommentEditor";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { hover } from "~/styles";
|
||||
import { hover, truncateMultiline } from "~/styles";
|
||||
import Avatar from "../Avatar";
|
||||
import { AvatarSize } from "../Avatar/Avatar";
|
||||
import Flex from "../Flex";
|
||||
@@ -76,6 +76,8 @@ function NotificationListItem({ notification, onNavigate }: Props) {
|
||||
const StyledCommentEditor = styled(CommentEditor)`
|
||||
font-size: 0.9em;
|
||||
margin-top: 4px;
|
||||
|
||||
${truncateMultiline(3)}
|
||||
`;
|
||||
|
||||
const StyledAvatar = styled(Avatar)`
|
||||
|
||||
@@ -7,7 +7,11 @@ import { depths } from "@shared/styles";
|
||||
import Popover from "~/components/Popover";
|
||||
import Notifications from "./Notifications";
|
||||
|
||||
const NotificationsPopover: React.FC = ({ children }) => {
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const NotificationsPopover: React.FC = ({ children }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const scrollableRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ const Popover: React.FC<Props> = ({
|
||||
flex,
|
||||
mobilePosition,
|
||||
...rest
|
||||
}) => {
|
||||
}: Props) => {
|
||||
const isMobile = useMobile();
|
||||
|
||||
if (isMobile) {
|
||||
|
||||
@@ -11,6 +11,7 @@ type Props = {
|
||||
left?: React.ReactNode;
|
||||
actions?: React.ReactNode;
|
||||
centered?: boolean;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const Scene: React.FC<Props> = ({
|
||||
@@ -21,7 +22,7 @@ const Scene: React.FC<Props> = ({
|
||||
left,
|
||||
children,
|
||||
centered,
|
||||
}) => (
|
||||
}: Props) => (
|
||||
<FillWidth>
|
||||
<PageTitle title={textTitle || title} />
|
||||
<Header
|
||||
|
||||
@@ -17,7 +17,7 @@ import useStores from "~/hooks/useStores";
|
||||
import { SearchResult } from "~/types";
|
||||
import SearchListItem from "./SearchListItem";
|
||||
|
||||
type Props = { shareId: string };
|
||||
type Props = React.HTMLAttributes<HTMLInputElement> & { shareId: string };
|
||||
|
||||
function SearchPopover({ shareId }: Props) {
|
||||
const { t } = useTranslation();
|
||||
@@ -32,6 +32,7 @@ function SearchPopover({ shareId }: Props) {
|
||||
|
||||
const [query, setQuery] = React.useState("");
|
||||
const searchResults = documents.searchResults(query);
|
||||
const { show, hide } = popover;
|
||||
|
||||
const [cachedQuery, setCachedQuery] = React.useState(query);
|
||||
const [cachedSearchResults, setCachedSearchResults] = React.useState<
|
||||
@@ -42,9 +43,9 @@ function SearchPopover({ shareId }: Props) {
|
||||
if (searchResults) {
|
||||
setCachedQuery(query);
|
||||
setCachedSearchResults(searchResults);
|
||||
popover.show();
|
||||
show();
|
||||
}
|
||||
}, [searchResults, query, popover.show]);
|
||||
}, [searchResults, query, show]);
|
||||
|
||||
const performSearch = React.useCallback(
|
||||
async ({ query, ...options }) => {
|
||||
@@ -88,10 +89,14 @@ function SearchPopover({ shareId }: Props) {
|
||||
|
||||
const handleSearchInputFocus = React.useCallback(() => {
|
||||
focusRef.current = searchInputRef.current;
|
||||
}, []);
|
||||
}, [searchInputRef]);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(ev: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (ev.nativeEvent.isComposing) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev.key === "Enter") {
|
||||
if (searchResults) {
|
||||
popover.show();
|
||||
@@ -137,12 +142,12 @@ function SearchPopover({ shareId }: Props) {
|
||||
);
|
||||
|
||||
const handleSearchItemClick = React.useCallback(() => {
|
||||
popover.hide();
|
||||
hide();
|
||||
if (searchInputRef.current) {
|
||||
searchInputRef.current.value = "";
|
||||
focusRef.current = document.getElementById(bodyContentId);
|
||||
}
|
||||
}, [popover.hide]);
|
||||
}, [searchInputRef, hide]);
|
||||
|
||||
useKeyDown("/", (ev) => {
|
||||
if (
|
||||
|
||||
+172
-171
@@ -27,197 +27,198 @@ type Props = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
const Sidebar = React.forwardRef<HTMLDivElement, Props>(
|
||||
({ children }: Props, ref: React.RefObject<HTMLDivElement>) => {
|
||||
const [isCollapsing, setCollapsing] = React.useState(false);
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const { ui, auth } = useStores();
|
||||
const location = useLocation();
|
||||
const previousLocation = usePrevious(location);
|
||||
const { isMenuOpen } = useMenuContext();
|
||||
const { user } = auth;
|
||||
const width = ui.sidebarWidth;
|
||||
const collapsed = ui.sidebarIsClosed && !isMenuOpen;
|
||||
const maxWidth = theme.sidebarMaxWidth;
|
||||
const minWidth = theme.sidebarMinWidth + 16; // padding
|
||||
const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
{ children }: Props,
|
||||
ref: React.RefObject<HTMLDivElement>
|
||||
) {
|
||||
const [isCollapsing, setCollapsing] = React.useState(false);
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const { ui, auth } = useStores();
|
||||
const location = useLocation();
|
||||
const previousLocation = usePrevious(location);
|
||||
const { isMenuOpen } = useMenuContext();
|
||||
const { user } = auth;
|
||||
const width = ui.sidebarWidth;
|
||||
const collapsed = ui.sidebarIsClosed && !isMenuOpen;
|
||||
const maxWidth = theme.sidebarMaxWidth;
|
||||
const minWidth = theme.sidebarMinWidth + 16; // padding
|
||||
|
||||
const setWidth = ui.setSidebarWidth;
|
||||
const [offset, setOffset] = React.useState(0);
|
||||
const [isAnimating, setAnimating] = React.useState(false);
|
||||
const [isResizing, setResizing] = React.useState(false);
|
||||
const isSmallerThanMinimum = width < minWidth;
|
||||
const setWidth = ui.setSidebarWidth;
|
||||
const [offset, setOffset] = React.useState(0);
|
||||
const [isAnimating, setAnimating] = React.useState(false);
|
||||
const [isResizing, setResizing] = React.useState(false);
|
||||
const isSmallerThanMinimum = width < minWidth;
|
||||
|
||||
const handleDrag = React.useCallback(
|
||||
(event: MouseEvent) => {
|
||||
// suppresses text selection
|
||||
event.preventDefault();
|
||||
// this is simple because the sidebar is always against the left edge
|
||||
const width = Math.min(event.pageX - offset, maxWidth);
|
||||
const isSmallerThanCollapsePoint = width < minWidth / 2;
|
||||
const handleDrag = React.useCallback(
|
||||
(event: MouseEvent) => {
|
||||
// suppresses text selection
|
||||
event.preventDefault();
|
||||
// this is simple because the sidebar is always against the left edge
|
||||
const width = Math.min(event.pageX - offset, maxWidth);
|
||||
const isSmallerThanCollapsePoint = width < minWidth / 2;
|
||||
|
||||
if (isSmallerThanCollapsePoint) {
|
||||
setWidth(theme.sidebarCollapsedWidth);
|
||||
} else {
|
||||
setWidth(width);
|
||||
}
|
||||
},
|
||||
[theme, offset, minWidth, maxWidth, setWidth]
|
||||
);
|
||||
|
||||
const handleStopDrag = React.useCallback(() => {
|
||||
setResizing(false);
|
||||
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
|
||||
if (isSmallerThanMinimum) {
|
||||
const isSmallerThanCollapsePoint = width < minWidth / 2;
|
||||
|
||||
if (isSmallerThanCollapsePoint) {
|
||||
setAnimating(false);
|
||||
setCollapsing(true);
|
||||
ui.collapseSidebar();
|
||||
} else {
|
||||
setWidth(minWidth);
|
||||
setAnimating(true);
|
||||
}
|
||||
if (isSmallerThanCollapsePoint) {
|
||||
setWidth(theme.sidebarCollapsedWidth);
|
||||
} else {
|
||||
setWidth(width);
|
||||
}
|
||||
}, [ui, isSmallerThanMinimum, minWidth, width, setWidth]);
|
||||
},
|
||||
[theme, offset, minWidth, maxWidth, setWidth]
|
||||
);
|
||||
|
||||
const handleMouseDown = React.useCallback(
|
||||
(event) => {
|
||||
setOffset(event.pageX - width);
|
||||
setResizing(true);
|
||||
const handleStopDrag = React.useCallback(() => {
|
||||
setResizing(false);
|
||||
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
|
||||
if (isSmallerThanMinimum) {
|
||||
const isSmallerThanCollapsePoint = width < minWidth / 2;
|
||||
|
||||
if (isSmallerThanCollapsePoint) {
|
||||
setAnimating(false);
|
||||
},
|
||||
[width]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isAnimating) {
|
||||
setTimeout(() => setAnimating(false), ANIMATION_MS);
|
||||
setCollapsing(true);
|
||||
ui.collapseSidebar();
|
||||
} else {
|
||||
setWidth(minWidth);
|
||||
setAnimating(true);
|
||||
}
|
||||
}, [isAnimating]);
|
||||
} else {
|
||||
setWidth(width);
|
||||
}
|
||||
}, [ui, isSmallerThanMinimum, minWidth, width, setWidth]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isCollapsing) {
|
||||
setTimeout(() => {
|
||||
setWidth(minWidth);
|
||||
setCollapsing(false);
|
||||
}, ANIMATION_MS);
|
||||
}
|
||||
}, [setWidth, minWidth, isCollapsing]);
|
||||
const handleMouseDown = React.useCallback(
|
||||
(event) => {
|
||||
setOffset(event.pageX - width);
|
||||
setResizing(true);
|
||||
setAnimating(false);
|
||||
},
|
||||
[width]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isResizing) {
|
||||
document.addEventListener("mousemove", handleDrag);
|
||||
document.addEventListener("mouseup", handleStopDrag);
|
||||
}
|
||||
React.useEffect(() => {
|
||||
if (isAnimating) {
|
||||
setTimeout(() => setAnimating(false), ANIMATION_MS);
|
||||
}
|
||||
}, [isAnimating]);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleDrag);
|
||||
document.removeEventListener("mouseup", handleStopDrag);
|
||||
};
|
||||
}, [isResizing, handleDrag, handleStopDrag]);
|
||||
React.useEffect(() => {
|
||||
if (isCollapsing) {
|
||||
setTimeout(() => {
|
||||
setWidth(minWidth);
|
||||
setCollapsing(false);
|
||||
}, ANIMATION_MS);
|
||||
}
|
||||
}, [setWidth, minWidth, isCollapsing]);
|
||||
|
||||
const handleReset = React.useCallback(() => {
|
||||
ui.setSidebarWidth(theme.sidebarWidth);
|
||||
}, [ui, theme.sidebarWidth]);
|
||||
React.useEffect(() => {
|
||||
if (isResizing) {
|
||||
document.addEventListener("mousemove", handleDrag);
|
||||
document.addEventListener("mouseup", handleStopDrag);
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
ui.setSidebarResizing(isResizing);
|
||||
}, [ui, isResizing]);
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleDrag);
|
||||
document.removeEventListener("mouseup", handleStopDrag);
|
||||
};
|
||||
}, [isResizing, handleDrag, handleStopDrag]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (location !== previousLocation) {
|
||||
ui.hideMobileSidebar();
|
||||
}
|
||||
}, [ui, location, previousLocation]);
|
||||
const handleReset = React.useCallback(() => {
|
||||
ui.setSidebarWidth(theme.sidebarWidth);
|
||||
}, [ui, theme.sidebarWidth]);
|
||||
|
||||
const style = React.useMemo(
|
||||
() => ({
|
||||
width: `${width}px`,
|
||||
}),
|
||||
[width]
|
||||
);
|
||||
React.useEffect(() => {
|
||||
ui.setSidebarResizing(isResizing);
|
||||
}, [ui, isResizing]);
|
||||
|
||||
const toggleStyle = React.useMemo(
|
||||
() => ({
|
||||
right: "auto",
|
||||
marginLeft: `${collapsed ? theme.sidebarCollapsedWidth : width}px`,
|
||||
}),
|
||||
[width, theme.sidebarCollapsedWidth, collapsed]
|
||||
);
|
||||
React.useEffect(() => {
|
||||
if (location !== previousLocation) {
|
||||
ui.hideMobileSidebar();
|
||||
}
|
||||
}, [ui, location, previousLocation]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container
|
||||
ref={ref}
|
||||
style={style}
|
||||
$isAnimating={isAnimating}
|
||||
$isSmallerThanMinimum={isSmallerThanMinimum}
|
||||
$mobileSidebarVisible={ui.mobileSidebarVisible}
|
||||
$collapsed={collapsed}
|
||||
column
|
||||
>
|
||||
{ui.mobileSidebarVisible && (
|
||||
<Portal>
|
||||
<Backdrop onClick={ui.toggleMobileSidebar} />
|
||||
</Portal>
|
||||
)}
|
||||
{children}
|
||||
const style = React.useMemo(
|
||||
() => ({
|
||||
width: `${width}px`,
|
||||
}),
|
||||
[width]
|
||||
);
|
||||
|
||||
{user && (
|
||||
<AccountMenu>
|
||||
{(props: HeaderButtonProps) => (
|
||||
<HeaderButton
|
||||
{...props}
|
||||
showMoreMenu
|
||||
title={user.name}
|
||||
image={
|
||||
<StyledAvatar
|
||||
alt={user.name}
|
||||
model={user}
|
||||
size={24}
|
||||
showBorder={false}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<NotificationsPopover>
|
||||
{(rest: HeaderButtonProps) => (
|
||||
<HeaderButton {...rest} image={<NotificationIcon />} />
|
||||
)}
|
||||
</NotificationsPopover>
|
||||
</HeaderButton>
|
||||
)}
|
||||
</AccountMenu>
|
||||
)}
|
||||
<ResizeBorder
|
||||
onMouseDown={handleMouseDown}
|
||||
onDoubleClick={ui.sidebarIsClosed ? undefined : handleReset}
|
||||
/>
|
||||
{ui.sidebarIsClosed && (
|
||||
<Toggle
|
||||
onClick={ui.toggleCollapsedSidebar}
|
||||
direction={"right"}
|
||||
aria-label={t("Expand")}
|
||||
/>
|
||||
)}
|
||||
</Container>
|
||||
<Toggle
|
||||
style={toggleStyle}
|
||||
onClick={ui.toggleCollapsedSidebar}
|
||||
direction={ui.sidebarIsClosed ? "right" : "left"}
|
||||
aria-label={ui.sidebarIsClosed ? t("Expand") : t("Collapse")}
|
||||
const toggleStyle = React.useMemo(
|
||||
() => ({
|
||||
right: "auto",
|
||||
marginLeft: `${collapsed ? theme.sidebarCollapsedWidth : width}px`,
|
||||
}),
|
||||
[width, theme.sidebarCollapsedWidth, collapsed]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container
|
||||
ref={ref}
|
||||
style={style}
|
||||
$isAnimating={isAnimating}
|
||||
$isSmallerThanMinimum={isSmallerThanMinimum}
|
||||
$mobileSidebarVisible={ui.mobileSidebarVisible}
|
||||
$collapsed={collapsed}
|
||||
column
|
||||
>
|
||||
{ui.mobileSidebarVisible && (
|
||||
<Portal>
|
||||
<Backdrop onClick={ui.toggleMobileSidebar} />
|
||||
</Portal>
|
||||
)}
|
||||
{children}
|
||||
|
||||
{user && (
|
||||
<AccountMenu>
|
||||
{(props: HeaderButtonProps) => (
|
||||
<HeaderButton
|
||||
{...props}
|
||||
showMoreMenu
|
||||
title={user.name}
|
||||
image={
|
||||
<StyledAvatar
|
||||
alt={user.name}
|
||||
model={user}
|
||||
size={24}
|
||||
showBorder={false}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<NotificationsPopover>
|
||||
{(rest: HeaderButtonProps) => (
|
||||
<HeaderButton {...rest} image={<NotificationIcon />} />
|
||||
)}
|
||||
</NotificationsPopover>
|
||||
</HeaderButton>
|
||||
)}
|
||||
</AccountMenu>
|
||||
)}
|
||||
<ResizeBorder
|
||||
onMouseDown={handleMouseDown}
|
||||
onDoubleClick={ui.sidebarIsClosed ? undefined : handleReset}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
{ui.sidebarIsClosed && (
|
||||
<Toggle
|
||||
onClick={ui.toggleCollapsedSidebar}
|
||||
direction={"right"}
|
||||
aria-label={t("Expand")}
|
||||
/>
|
||||
)}
|
||||
</Container>
|
||||
<Toggle
|
||||
style={toggleStyle}
|
||||
onClick={ui.toggleCollapsedSidebar}
|
||||
direction={ui.sidebarIsClosed ? "right" : "left"}
|
||||
aria-label={ui.sidebarIsClosed ? t("Expand") : t("Collapse")}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
const StyledAvatar = styled(Avatar)`
|
||||
margin-left: 4px;
|
||||
|
||||
@@ -37,7 +37,7 @@ const CollectionLink: React.FC<Props> = ({
|
||||
expanded,
|
||||
onDisclosureClick,
|
||||
isDraggingAnyCollection,
|
||||
}) => {
|
||||
}: Props) => {
|
||||
const itemRef = React.useRef<
|
||||
NavigationNode & { depth: number; active: boolean; collectionId: string }
|
||||
>();
|
||||
|
||||
@@ -69,7 +69,7 @@ function InnerDocumentLink(
|
||||
if (isActiveDocument && hasChildDocuments) {
|
||||
void fetchChildDocuments(node.id);
|
||||
}
|
||||
}, [fetchChildDocuments, node, hasChildDocuments, isActiveDocument]);
|
||||
}, [fetchChildDocuments, node.id, hasChildDocuments, isActiveDocument]);
|
||||
|
||||
const pathToNode = React.useMemo(
|
||||
() => collection?.pathToDocument(node.id).map((entry) => entry.id),
|
||||
@@ -125,7 +125,6 @@ function InnerDocumentLink(
|
||||
}
|
||||
await documents.update({
|
||||
id: document.id,
|
||||
text: document.text,
|
||||
title,
|
||||
});
|
||||
},
|
||||
@@ -335,7 +334,9 @@ function InnerDocumentLink(
|
||||
/>
|
||||
}
|
||||
isActive={(match, location: Location<{ starred?: boolean }>) =>
|
||||
!!match && location.state?.starred === inStarredSection
|
||||
((document && location.pathname.endsWith(document.urlId)) ||
|
||||
!!match) &&
|
||||
location.state?.starred === inStarredSection
|
||||
}
|
||||
isActiveDrop={isOverReparent && canDropToReparent}
|
||||
depth={depth}
|
||||
|
||||
@@ -121,7 +121,7 @@ const Input = styled.input`
|
||||
border: 0;
|
||||
padding: 5px 6px;
|
||||
margin: -4px;
|
||||
height: 32px;
|
||||
height: 30px;
|
||||
|
||||
&:focus {
|
||||
outline-color: ${s("accent")};
|
||||
|
||||
@@ -3,9 +3,10 @@ import styled from "styled-components";
|
||||
|
||||
type Props = {
|
||||
expanded: boolean;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const Folder: React.FC<Props> = ({ expanded, children }) => {
|
||||
const Folder: React.FC<Props> = ({ expanded, children }: Props) => {
|
||||
const [openedOnce, setOpenedOnce] = React.useState(expanded);
|
||||
|
||||
// allows us to avoid rendering all children when the folder hasn't been opened
|
||||
|
||||
@@ -9,12 +9,13 @@ type Props = {
|
||||
/** Unique header id – if passed the header will become toggleable */
|
||||
id?: string;
|
||||
title: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggleable sidebar header
|
||||
*/
|
||||
export const Header: React.FC<Props> = ({ id, title, children }) => {
|
||||
export const Header: React.FC<Props> = ({ id, title, children }: Props) => {
|
||||
const [firstRender, setFirstRender] = React.useState(true);
|
||||
const [expanded, setExpanded] = usePersistedState<boolean>(
|
||||
`sidebar-header-${id}`,
|
||||
|
||||
@@ -17,7 +17,7 @@ export type HeaderButtonProps = React.ComponentProps<typeof Button> & {
|
||||
};
|
||||
|
||||
const HeaderButton = React.forwardRef<HTMLButtonElement, HeaderButtonProps>(
|
||||
(
|
||||
function _HeaderButton(
|
||||
{
|
||||
showDisclosure,
|
||||
showMoreMenu,
|
||||
@@ -28,25 +28,27 @@ const HeaderButton = React.forwardRef<HTMLButtonElement, HeaderButtonProps>(
|
||||
...rest
|
||||
}: HeaderButtonProps,
|
||||
ref
|
||||
) => (
|
||||
<Flex justify="space-between" align="center" shrink={false}>
|
||||
<Button
|
||||
{...rest}
|
||||
minHeight={minHeight}
|
||||
as="button"
|
||||
ref={ref}
|
||||
role="button"
|
||||
>
|
||||
<Title gap={8} align="center">
|
||||
{image}
|
||||
{title}
|
||||
</Title>
|
||||
{showDisclosure && <ExpandedIcon />}
|
||||
{showMoreMenu && <MoreIcon />}
|
||||
</Button>
|
||||
{children}
|
||||
</Flex>
|
||||
)
|
||||
) {
|
||||
return (
|
||||
<Flex justify="space-between" align="center" shrink={false}>
|
||||
<Button
|
||||
{...rest}
|
||||
minHeight={minHeight}
|
||||
as="button"
|
||||
ref={ref}
|
||||
role="button"
|
||||
>
|
||||
<Title gap={8} align="center">
|
||||
{image}
|
||||
{title}
|
||||
</Title>
|
||||
{showDisclosure && <ExpandedIcon />}
|
||||
{showMoreMenu && <MoreIcon />}
|
||||
</Button>
|
||||
{children}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const Title = styled(Flex)`
|
||||
|
||||
@@ -200,6 +200,7 @@ const Link = styled(NavLink)<{
|
||||
text-overflow: ellipsis;
|
||||
padding: 6px 16px;
|
||||
border-radius: 4px;
|
||||
min-height: 32px;
|
||||
transition: background 50ms, color 50ms;
|
||||
user-select: none;
|
||||
background: ${(props) =>
|
||||
|
||||
@@ -12,41 +12,42 @@ type Props = {
|
||||
onClick?: React.MouseEventHandler<HTMLButtonElement>;
|
||||
};
|
||||
|
||||
const Toggle = React.forwardRef<HTMLButtonElement, Props>(
|
||||
({ direction = "left", onClick, style }: Props, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const [hovering, setHovering] = React.useState(false);
|
||||
const positionRef = React.useRef<HTMLDivElement>(null);
|
||||
const Toggle = React.forwardRef<HTMLButtonElement, Props>(function Toggle_(
|
||||
{ direction = "left", onClick, style }: Props,
|
||||
ref
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const [hovering, setHovering] = React.useState(false);
|
||||
const positionRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
// Not using CSS hover here so that we can disable pointer events on this
|
||||
// div and allow click through to the editor elements behind.
|
||||
useEventListener("mousemove", (event: MouseEvent) => {
|
||||
if (!positionRef.current) {
|
||||
return;
|
||||
}
|
||||
// Not using CSS hover here so that we can disable pointer events on this
|
||||
// div and allow click through to the editor elements behind.
|
||||
useEventListener("mousemove", (event: MouseEvent) => {
|
||||
if (!positionRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bound = positionRef.current.getBoundingClientRect();
|
||||
const withinBounds =
|
||||
event.clientX >= bound.left && event.clientX <= bound.right;
|
||||
if (withinBounds !== hovering) {
|
||||
setHovering(withinBounds);
|
||||
}
|
||||
});
|
||||
const bound = positionRef.current.getBoundingClientRect();
|
||||
const withinBounds =
|
||||
event.clientX >= bound.left && event.clientX <= bound.right;
|
||||
if (withinBounds !== hovering) {
|
||||
setHovering(withinBounds);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Positioner style={style} ref={positionRef} $hovering={hovering}>
|
||||
<ToggleButton
|
||||
ref={ref}
|
||||
$direction={direction}
|
||||
onClick={onClick}
|
||||
aria-label={t("Toggle sidebar")}
|
||||
>
|
||||
<Arrow />
|
||||
</ToggleButton>
|
||||
</Positioner>
|
||||
);
|
||||
}
|
||||
);
|
||||
return (
|
||||
<Positioner style={style} ref={positionRef} $hovering={hovering}>
|
||||
<ToggleButton
|
||||
ref={ref}
|
||||
$direction={direction}
|
||||
onClick={onClick}
|
||||
aria-label={t("Toggle sidebar")}
|
||||
>
|
||||
<Arrow />
|
||||
</ToggleButton>
|
||||
</Positioner>
|
||||
);
|
||||
});
|
||||
|
||||
export const ToggleButton = styled.button<{ $direction?: "left" | "right" }>`
|
||||
opacity: 0;
|
||||
|
||||
@@ -5,9 +5,10 @@ import Flex from "./Flex";
|
||||
type Props = {
|
||||
size?: number;
|
||||
color?: string;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const Squircle: React.FC<Props> = ({ color, size = 28, children }) => (
|
||||
const Squircle: React.FC<Props> = ({ color, size = 28, children }: Props) => (
|
||||
<Wrapper
|
||||
style={{ width: size, height: size }}
|
||||
align="center"
|
||||
|
||||
@@ -3,6 +3,7 @@ import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
sticky?: boolean;
|
||||
};
|
||||
|
||||
@@ -34,7 +35,7 @@ const Background = styled.div<{ sticky?: boolean }>`
|
||||
z-index: 1;
|
||||
`;
|
||||
|
||||
const Subheading: React.FC<Props> = ({ children, sticky, ...rest }) => (
|
||||
const Subheading: React.FC<Props> = ({ children, sticky, ...rest }: Props) => (
|
||||
<Background sticky={sticky}>
|
||||
<H3 {...rest}>
|
||||
<Underline>{children}</Underline>
|
||||
|
||||
@@ -83,8 +83,11 @@ const Input = styled.label<{ width: number; height: number }>`
|
||||
display: inline-block;
|
||||
width: ${(props) => props.width}px;
|
||||
height: ${(props) => props.height}px;
|
||||
margin-right: 8px;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-right: 8px;
|
||||
}
|
||||
`;
|
||||
|
||||
const Slider = styled.span<{ width: number; height: number }>`
|
||||
|
||||
@@ -8,6 +8,7 @@ import { hover } from "~/styles";
|
||||
type Props = Omit<React.ComponentProps<typeof NavLink>, "children"> & {
|
||||
to: string;
|
||||
exact?: boolean;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const TabLink = styled(NavLink)`
|
||||
@@ -44,7 +45,7 @@ const transition = {
|
||||
damping: 30,
|
||||
};
|
||||
|
||||
const Tab: React.FC<Props> = ({ children, ...rest }) => {
|
||||
const Tab: React.FC<Props> = ({ children, ...rest }: Props) => {
|
||||
const theme = useTheme();
|
||||
const activeStyle = {
|
||||
color: theme.textSecondary,
|
||||
|
||||
@@ -121,9 +121,12 @@ function Table({
|
||||
<InnerTable {...getTableProps()}>
|
||||
<thead>
|
||||
{headerGroups.map((headerGroup) => (
|
||||
<tr {...headerGroup.getHeaderGroupProps()}>
|
||||
<tr {...headerGroup.getHeaderGroupProps()} key={headerGroup.id}>
|
||||
{headerGroup.headers.map((column) => (
|
||||
<Head {...column.getHeaderProps(column.getSortByToggleProps())}>
|
||||
<Head
|
||||
{...column.getHeaderProps(column.getSortByToggleProps())}
|
||||
key={column.id}
|
||||
>
|
||||
<SortWrapper
|
||||
align="center"
|
||||
$sortable={!column.disableSortBy}
|
||||
@@ -146,7 +149,7 @@ function Table({
|
||||
{rows.map((row) => {
|
||||
prepareRow(row);
|
||||
return (
|
||||
<Row {...row.getRowProps()}>
|
||||
<Row {...row.getRowProps()} key={row.id}>
|
||||
{row.cells.map((cell) => (
|
||||
<Cell
|
||||
{...cell.getCellProps([
|
||||
@@ -155,6 +158,7 @@ function Table({
|
||||
className: cell.column.className,
|
||||
},
|
||||
])}
|
||||
key={cell.column.id}
|
||||
>
|
||||
{cell.render("Cell")}
|
||||
</Cell>
|
||||
|
||||
@@ -57,7 +57,11 @@ export const Separator = styled.span`
|
||||
margin-top: 6px;
|
||||
`;
|
||||
|
||||
const Tabs: React.FC = ({ children }) => {
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const Tabs: React.FC = ({ children }: Props) => {
|
||||
const ref = React.useRef<any>();
|
||||
const [shadowVisible, setShadow] = React.useState(false);
|
||||
const { width } = useWindowSize();
|
||||
|
||||
@@ -14,7 +14,7 @@ type Props = {
|
||||
*/
|
||||
const Text = styled.p<Props>`
|
||||
margin-top: 0;
|
||||
text-align: ${(props) => (props.dir ? props.dir : "auto")};
|
||||
text-align: ${(props) => (props.dir ? props.dir : "initial")};
|
||||
color: ${(props) =>
|
||||
props.type === "secondary"
|
||||
? props.theme.textSecondary
|
||||
|
||||
@@ -7,7 +7,11 @@ import useBuildTheme from "~/hooks/useBuildTheme";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { TooltipStyles } from "./Tooltip";
|
||||
|
||||
const Theme: React.FC = ({ children }) => {
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const Theme: React.FC = ({ children }: Props) => {
|
||||
const { auth, ui } = useStores();
|
||||
const theme = useBuildTheme(
|
||||
auth.team?.getPreference(TeamPreference.CustomTheme) ||
|
||||
@@ -29,8 +33,7 @@ const Theme: React.FC = ({ children }) => {
|
||||
<TooltipStyles />
|
||||
<GlobalStyles
|
||||
useCursorPointer={auth.user?.getPreference(
|
||||
UserPreference.UseCursorPointer,
|
||||
true
|
||||
UserPreference.UseCursorPointer
|
||||
)}
|
||||
/>
|
||||
{children}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import * as React from "react";
|
||||
import { dateToRelative } from "@shared/utils/date";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
|
||||
const LocaleTime = lazyWithRetry(() => import("~/components/LocaleTime"));
|
||||
@@ -9,17 +9,11 @@ type Props = React.ComponentProps<typeof LocaleTime> & {
|
||||
};
|
||||
|
||||
function Time({ onClick, ...props }: Props) {
|
||||
let content = formatDistanceToNow(Date.parse(props.dateTime), {
|
||||
const content = dateToRelative(Date.parse(props.dateTime), {
|
||||
addSuffix: props.addSuffix,
|
||||
shorten: props.shorten,
|
||||
});
|
||||
|
||||
if (props.shorten) {
|
||||
content = content
|
||||
.replace("about", "")
|
||||
.replace("less than a minute ago", "just now")
|
||||
.replace("minute", "min");
|
||||
}
|
||||
|
||||
return (
|
||||
<span onClick={onClick}>
|
||||
<React.Suspense
|
||||
|
||||
@@ -70,6 +70,7 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
transports: ["websocket"],
|
||||
reconnectionDelay: 1000,
|
||||
reconnectionDelayMax: 30000,
|
||||
withCredentials: true,
|
||||
});
|
||||
invariant(this.socket, "Socket should be defined");
|
||||
|
||||
@@ -89,18 +90,6 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
fileOperations,
|
||||
notifications,
|
||||
} = this.props;
|
||||
if (!auth.token) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.socket.on("connect", () => {
|
||||
// immediately send current users token to the websocket backend where it
|
||||
// is verified, if all goes well an 'authenticated' message will be
|
||||
// received in response
|
||||
this.socket?.emit("authentication", {
|
||||
token: auth.token,
|
||||
});
|
||||
});
|
||||
|
||||
// on reconnection, reset the transports option, as the Websocket
|
||||
// connection may have failed (caused by proxy, firewall, browser, ...)
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
import {
|
||||
CaretDownIcon,
|
||||
CaretUpIcon,
|
||||
CaseSensitiveIcon,
|
||||
RegexIcon,
|
||||
ReplaceIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { usePopoverState } from "reakit/Popover";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import Button from "~/components/Button";
|
||||
import Flex from "~/components/Flex";
|
||||
import Input from "~/components/Input";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Popover from "~/components/Popover";
|
||||
import { Portal } from "~/components/Portal";
|
||||
import { ResizingHeightContainer } from "~/components/ResizingHeightContainer";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
import useOnClickOutside from "~/hooks/useOnClickOutside";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import { altDisplay, isModKey, metaDisplay } from "~/utils/keyboard";
|
||||
import { useEditor } from "./EditorContext";
|
||||
|
||||
type Props = {
|
||||
readOnly?: boolean;
|
||||
};
|
||||
|
||||
export default function FindAndReplace({ readOnly }: Props) {
|
||||
const editor = useEditor();
|
||||
const finalFocusRef = React.useRef<HTMLElement>(
|
||||
editor.view.dom.parentElement
|
||||
);
|
||||
const selectionRef = React.useRef<string | undefined>();
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
const inputReplaceRef = React.useRef<HTMLInputElement>(null);
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const [showReplace, setShowReplace] = React.useState(false);
|
||||
const [caseSensitive, setCaseSensitive] = React.useState(false);
|
||||
const [regexEnabled, setRegex] = React.useState(false);
|
||||
const [searchTerm, setSearchTerm] = React.useState("");
|
||||
const [replaceTerm, setReplaceTerm] = React.useState("");
|
||||
const popover = usePopoverState();
|
||||
const { show } = popover;
|
||||
|
||||
// Hooks for desktop app menu items
|
||||
React.useEffect(() => {
|
||||
if (!Desktop.bridge) {
|
||||
return;
|
||||
}
|
||||
if ("onFindInPage" in Desktop.bridge) {
|
||||
Desktop.bridge.onFindInPage(() => {
|
||||
selectionRef.current = window.getSelection()?.toString();
|
||||
show();
|
||||
});
|
||||
}
|
||||
if ("onReplaceInPage" in Desktop.bridge) {
|
||||
Desktop.bridge.onReplaceInPage(() => {
|
||||
setShowReplace(true);
|
||||
show();
|
||||
});
|
||||
}
|
||||
}, [show]);
|
||||
|
||||
// Close handlers
|
||||
useKeyDown("Escape", popover.hide);
|
||||
useOnClickOutside(popover.unstable_referenceRef, popover.hide);
|
||||
|
||||
// Keyboard shortcuts
|
||||
useKeyDown(
|
||||
(ev) =>
|
||||
isModKey(ev) &&
|
||||
!popover.visible &&
|
||||
ev.code === "KeyF" &&
|
||||
// Keyboard handler is through the AppMenu on Desktop v1.2.0+
|
||||
!(Desktop.bridge && "onFindInPage" in Desktop.bridge),
|
||||
(ev) => {
|
||||
ev.preventDefault();
|
||||
selectionRef.current = window.getSelection()?.toString();
|
||||
popover.show();
|
||||
}
|
||||
);
|
||||
|
||||
useKeyDown(
|
||||
(ev) => isModKey(ev) && ev.altKey && ev.code === "KeyR" && popover.visible,
|
||||
(ev) => {
|
||||
ev.preventDefault();
|
||||
setRegex((state) => !state);
|
||||
},
|
||||
{ allowInInput: true }
|
||||
);
|
||||
|
||||
useKeyDown(
|
||||
(ev) => isModKey(ev) && ev.altKey && ev.code === "KeyC" && popover.visible,
|
||||
(ev) => {
|
||||
ev.preventDefault();
|
||||
setCaseSensitive((state) => !state);
|
||||
},
|
||||
{ allowInInput: true }
|
||||
);
|
||||
|
||||
// Callbacks
|
||||
const handleMore = React.useCallback(() => {
|
||||
setShowReplace((state) => !state);
|
||||
setTimeout(() => inputReplaceRef.current?.focus(), 100);
|
||||
}, []);
|
||||
|
||||
const handleCaseSensitive = React.useCallback(() => {
|
||||
setCaseSensitive((state) => {
|
||||
const caseSensitive = !state;
|
||||
|
||||
editor.commands.find({
|
||||
text: searchTerm,
|
||||
caseSensitive,
|
||||
regexEnabled,
|
||||
});
|
||||
|
||||
return caseSensitive;
|
||||
});
|
||||
}, [regexEnabled, editor.commands, searchTerm]);
|
||||
|
||||
const handleRegex = React.useCallback(() => {
|
||||
setRegex((state) => {
|
||||
const regexEnabled = !state;
|
||||
|
||||
editor.commands.find({
|
||||
text: searchTerm,
|
||||
caseSensitive,
|
||||
regexEnabled,
|
||||
});
|
||||
|
||||
return regexEnabled;
|
||||
});
|
||||
}, [caseSensitive, editor.commands, searchTerm]);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(ev: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (ev.key === "Enter") {
|
||||
ev.preventDefault();
|
||||
|
||||
if (ev.shiftKey) {
|
||||
editor.commands.prevSearchMatch();
|
||||
} else {
|
||||
editor.commands.nextSearchMatch();
|
||||
}
|
||||
}
|
||||
},
|
||||
[editor.commands]
|
||||
);
|
||||
|
||||
const handleReplace = React.useCallback(
|
||||
(ev) => {
|
||||
if (readOnly) {
|
||||
return;
|
||||
}
|
||||
ev.preventDefault();
|
||||
editor.commands.replace({ text: replaceTerm });
|
||||
},
|
||||
[editor.commands, readOnly, replaceTerm]
|
||||
);
|
||||
|
||||
const handleReplaceAll = React.useCallback(
|
||||
(ev) => {
|
||||
if (readOnly) {
|
||||
return;
|
||||
}
|
||||
ev.preventDefault();
|
||||
editor.commands.replaceAll({ text: replaceTerm });
|
||||
},
|
||||
[editor.commands, readOnly, replaceTerm]
|
||||
);
|
||||
|
||||
const handleChangeFind = React.useCallback(
|
||||
(ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
setSearchTerm(ev.currentTarget.value);
|
||||
|
||||
editor.commands.find({
|
||||
text: ev.currentTarget.value,
|
||||
caseSensitive,
|
||||
regexEnabled,
|
||||
});
|
||||
},
|
||||
[caseSensitive, editor.commands, regexEnabled]
|
||||
);
|
||||
|
||||
const handleReplaceKeyDown = React.useCallback(
|
||||
(ev: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (ev.key === "Enter") {
|
||||
ev.preventDefault();
|
||||
handleReplace(ev);
|
||||
}
|
||||
},
|
||||
[handleReplace]
|
||||
);
|
||||
|
||||
const style: React.CSSProperties = React.useMemo(
|
||||
() => ({
|
||||
position: "absolute",
|
||||
left: "initial",
|
||||
top: 60,
|
||||
right: 16,
|
||||
zIndex: depths.popover,
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (popover.visible) {
|
||||
const startSearchText = selectionRef.current || searchTerm;
|
||||
|
||||
editor.commands.find({
|
||||
text: startSearchText,
|
||||
caseSensitive,
|
||||
regexEnabled,
|
||||
});
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
inputRef.current?.setSelectionRange(0, startSearchText.length);
|
||||
});
|
||||
|
||||
if (selectionRef.current) {
|
||||
setSearchTerm(selectionRef.current);
|
||||
}
|
||||
} else {
|
||||
setShowReplace(false);
|
||||
editor.commands.clearSearch();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [popover.visible]);
|
||||
|
||||
const navigation = (
|
||||
<>
|
||||
<Tooltip
|
||||
tooltip={t("Previous match")}
|
||||
shortcut="shift+enter"
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
>
|
||||
<ButtonLarge onClick={() => editor.commands.prevSearchMatch()}>
|
||||
<CaretUpIcon />
|
||||
</ButtonLarge>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
tooltip={t("Next match")}
|
||||
shortcut="enter"
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
>
|
||||
<ButtonLarge onClick={() => editor.commands.nextSearchMatch()}>
|
||||
<CaretDownIcon />
|
||||
</ButtonLarge>
|
||||
</Tooltip>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Popover
|
||||
{...popover}
|
||||
unstable_finalFocusRef={finalFocusRef}
|
||||
style={style}
|
||||
aria-label={t("Find and replace")}
|
||||
width={420}
|
||||
>
|
||||
<Content column>
|
||||
<Flex gap={8}>
|
||||
<StyledInput
|
||||
ref={inputRef}
|
||||
maxLength={255}
|
||||
value={searchTerm}
|
||||
placeholder={`${t("Find")}…`}
|
||||
onChange={handleChangeFind}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<SearchModifiers gap={8}>
|
||||
<Tooltip
|
||||
tooltip={t("Match case")}
|
||||
shortcut={`${altDisplay}+${metaDisplay}+c`}
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
>
|
||||
<ButtonSmall onClick={handleCaseSensitive}>
|
||||
<CaseSensitiveIcon
|
||||
color={caseSensitive ? theme.accent : theme.textSecondary}
|
||||
/>
|
||||
</ButtonSmall>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
tooltip={t("Enable regex")}
|
||||
shortcut={`${altDisplay}+${metaDisplay}+r`}
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
>
|
||||
<ButtonSmall onClick={handleRegex}>
|
||||
<RegexIcon
|
||||
color={regexEnabled ? theme.accent : theme.textSecondary}
|
||||
/>
|
||||
</ButtonSmall>
|
||||
</Tooltip>
|
||||
</SearchModifiers>
|
||||
</StyledInput>
|
||||
{navigation}
|
||||
{!readOnly && (
|
||||
<Tooltip
|
||||
tooltip={t("Replace options")}
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
>
|
||||
<ButtonLarge onClick={handleMore}>
|
||||
<ReplaceIcon color={theme.textSecondary} />
|
||||
</ButtonLarge>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Flex>
|
||||
<ResizingHeightContainer>
|
||||
{showReplace && !readOnly && (
|
||||
<Flex gap={8}>
|
||||
<StyledInput
|
||||
maxLength={255}
|
||||
value={replaceTerm}
|
||||
ref={inputReplaceRef}
|
||||
placeholder={t("Replacement")}
|
||||
onKeyDown={handleReplaceKeyDown}
|
||||
onRequestSubmit={handleReplaceAll}
|
||||
onChange={(ev) => setReplaceTerm(ev.currentTarget.value)}
|
||||
/>
|
||||
<Button onClick={handleReplace} neutral>
|
||||
{t("Replace")}
|
||||
</Button>
|
||||
<Button onClick={handleReplaceAll} neutral>
|
||||
{t("Replace all")}
|
||||
</Button>
|
||||
</Flex>
|
||||
)}
|
||||
</ResizingHeightContainer>
|
||||
</Content>
|
||||
</Popover>
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
|
||||
const SearchModifiers = styled(Flex)`
|
||||
margin-right: 4px;
|
||||
`;
|
||||
|
||||
const StyledInput = styled(Input)`
|
||||
flex: 1;
|
||||
`;
|
||||
|
||||
const ButtonSmall = styled(NudeButton)`
|
||||
&:hover,
|
||||
&[aria-expanded="true"] {
|
||||
background: ${s("sidebarControlHoverBackground")};
|
||||
}
|
||||
`;
|
||||
|
||||
const ButtonLarge = styled(ButtonSmall)`
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
`;
|
||||
|
||||
const Content = styled(Flex)`
|
||||
padding: 8px 0;
|
||||
margin-bottom: -16px;
|
||||
`;
|
||||
@@ -1,7 +1,9 @@
|
||||
import { NodeSelection } from "prosemirror-state";
|
||||
import { CellSelection, selectedRect } from "prosemirror-tables";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import styled, { css } from "styled-components";
|
||||
import { isCode } from "@shared/editor/lib/isCode";
|
||||
import { findParentNode } from "@shared/editor/queries/findParentNode";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import { Portal } from "~/components/Portal";
|
||||
import useComponentSize from "~/hooks/useComponentSize";
|
||||
@@ -23,6 +25,7 @@ const defaultPosition = {
|
||||
top: 0,
|
||||
offset: 0,
|
||||
maxWidth: 1000,
|
||||
blockSelection: false,
|
||||
visible: false,
|
||||
};
|
||||
|
||||
@@ -52,6 +55,7 @@ function usePosition({
|
||||
top: viewportHeight - menuHeight,
|
||||
offset: 0,
|
||||
maxWidth: 1000,
|
||||
blockSelection: false,
|
||||
visible: true,
|
||||
};
|
||||
}
|
||||
@@ -85,6 +89,17 @@ function usePosition({
|
||||
left: 0,
|
||||
} as DOMRect);
|
||||
|
||||
// position at the top right of code blocks
|
||||
const codeBlock = findParentNode(isCode)(view.state.selection);
|
||||
|
||||
if (codeBlock) {
|
||||
const element = view.nodeDOM(codeBlock.pos);
|
||||
const bounds = (element as HTMLElement).getBoundingClientRect();
|
||||
selectionBounds.top = bounds.top;
|
||||
selectionBounds.left = bounds.right - menuWidth;
|
||||
selectionBounds.right = bounds.right;
|
||||
}
|
||||
|
||||
// tables are an oddity, and need their own positioning logic
|
||||
const isColSelection =
|
||||
selection instanceof CellSelection && selection.isColSelection();
|
||||
@@ -145,7 +160,7 @@ function usePosition({
|
||||
visible: true,
|
||||
};
|
||||
} else {
|
||||
// calcluate the horizontal center of the selection
|
||||
// calculate the horizontal center of the selection
|
||||
const halfSelection =
|
||||
Math.abs(selectionBounds.right - selectionBounds.left) / 2;
|
||||
const centerOfSelection = selectionBounds.left + halfSelection;
|
||||
@@ -178,6 +193,7 @@ function usePosition({
|
||||
top: Math.round(top - offsetParent.top),
|
||||
offset: Math.round(offset),
|
||||
maxWidth: offsetParent.width,
|
||||
blockSelection: codeBlock || isColSelection || isRowSelection,
|
||||
visible: true,
|
||||
};
|
||||
}
|
||||
@@ -211,6 +227,7 @@ const FloatingToolbar = React.forwardRef(
|
||||
<Portal>
|
||||
<Wrapper
|
||||
active={props.active && position.visible}
|
||||
arrow={!position.blockSelection}
|
||||
ref={menuRef}
|
||||
$offset={position.offset}
|
||||
style={{
|
||||
@@ -227,41 +244,52 @@ const FloatingToolbar = React.forwardRef(
|
||||
}
|
||||
);
|
||||
|
||||
const Wrapper = styled.div<{
|
||||
type WrapperProps = {
|
||||
active?: boolean;
|
||||
arrow?: boolean;
|
||||
$offset: number;
|
||||
}>`
|
||||
};
|
||||
|
||||
const arrow = (props: WrapperProps) =>
|
||||
props.arrow
|
||||
? css`
|
||||
&::before {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
transform: translateX(-50%) rotate(45deg);
|
||||
background: ${s("menuBackground")};
|
||||
border-radius: 3px;
|
||||
z-index: -1;
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
left: calc(50% - ${props.$offset || 0}px);
|
||||
pointer-events: none;
|
||||
}
|
||||
`
|
||||
: "";
|
||||
|
||||
const Wrapper = styled.div<WrapperProps>`
|
||||
will-change: opacity, transform;
|
||||
padding: 8px 16px;
|
||||
padding: 6px;
|
||||
position: absolute;
|
||||
z-index: ${depths.editorToolbar};
|
||||
opacity: 0;
|
||||
background-color: ${s("toolbarBackground")};
|
||||
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: 40px;
|
||||
height: 36px;
|
||||
box-sizing: border-box;
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
transform: translateX(-50%) rotate(45deg);
|
||||
background: ${s("toolbarBackground")};
|
||||
border-radius: 3px;
|
||||
z-index: -1;
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
left: calc(50% - ${(props) => props.$offset || 0}px);
|
||||
pointer-events: none;
|
||||
}
|
||||
${arrow}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
|
||||
@@ -3,8 +3,8 @@ import { s } from "@shared/styles";
|
||||
|
||||
const Input = styled.input`
|
||||
font-size: 15px;
|
||||
background: ${s("toolbarInput")};
|
||||
color: ${s("toolbarItem")};
|
||||
background: ${s("inputBorder")};
|
||||
color: ${s("text")};
|
||||
border-radius: 2px;
|
||||
padding: 3px 8px;
|
||||
border: 0;
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Selection } from "prosemirror-state";
|
||||
import { EditorView } from "prosemirror-view";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { s, hideScrollbars } from "@shared/styles";
|
||||
import { isInternalUrl, sanitizeUrl } from "@shared/utils/urls";
|
||||
import Flex from "~/components/Flex";
|
||||
import { ResizingHeightContainer } from "~/components/ResizingHeightContainer";
|
||||
@@ -396,23 +396,24 @@ class LinkEditor extends React.Component<Props, State> {
|
||||
}
|
||||
|
||||
const Wrapper = styled(Flex)`
|
||||
margin-left: -8px;
|
||||
margin-right: -8px;
|
||||
pointer-events: all;
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
const SearchResults = styled(Scrollable)<{ $hasResults: boolean }>`
|
||||
background: ${s("toolbarBackground")};
|
||||
background: ${s("menuBackground")};
|
||||
box-shadow: ${(props) => (props.$hasResults ? s("menuShadow") : "none")};
|
||||
clip-path: inset(0px -100px -100px -100px);
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
left: 0;
|
||||
margin: -8px 0 0;
|
||||
margin-top: -6px;
|
||||
border-radius: 0 0 4px 4px;
|
||||
padding: ${(props) => (props.$hasResults ? "8px 0" : "0")};
|
||||
max-height: 260px;
|
||||
max-height: 240px;
|
||||
${hideScrollbars()}
|
||||
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
position: fixed;
|
||||
|
||||
@@ -60,8 +60,7 @@ const IconWrapper = styled.span<{ selected: boolean }>`
|
||||
margin-right: 4px;
|
||||
height: 24px;
|
||||
opacity: 0.8;
|
||||
color: ${(props) =>
|
||||
props.selected ? props.theme.accentText : props.theme.toolbarItem};
|
||||
color: ${(props) => (props.selected ? s("accentText") : s("textSecondary"))};
|
||||
`;
|
||||
|
||||
const ListItem = styled.div<{
|
||||
@@ -72,11 +71,9 @@ const ListItem = styled.div<{
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
margin: 0 8px;
|
||||
color: ${(props) =>
|
||||
props.selected ? props.theme.accentText : props.theme.toolbarItem};
|
||||
background: ${(props) =>
|
||||
props.selected ? props.theme.accent : "transparent"};
|
||||
margin: 0 6px;
|
||||
color: ${(props) => (props.selected ? s("accentText") : s("textSecondary"))};
|
||||
background: ${(props) => (props.selected ? s("accent") : "transparent")};
|
||||
font-family: ${s("fontFamily")};
|
||||
text-decoration: none;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -15,6 +15,7 @@ import useEventListener from "~/hooks/useEventListener";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import usePrevious from "~/hooks/usePrevious";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
import getCodeMenuItems from "../menus/code";
|
||||
import getDividerMenuItems from "../menus/divider";
|
||||
import getFormattingMenuItems from "../menus/formatting";
|
||||
import getImageMenuItems from "../menus/image";
|
||||
@@ -48,6 +49,14 @@ function useIsActive(state: EditorState) {
|
||||
if (isMarkActive(state.schema.marks.link)(state)) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
(isNodeActive(state.schema.nodes.code_block)(state) ||
|
||||
isNodeActive(state.schema.nodes.code_fence)(state)) &&
|
||||
selection.from > 0
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!selection || selection.empty) {
|
||||
return false;
|
||||
}
|
||||
@@ -122,6 +131,10 @@ export default function SelectionToolbar(props: Props) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!window.getSelection()?.isCollapsed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { dispatch } = view;
|
||||
dispatch(
|
||||
view.state.tr.setSelection(new TextSelection(view.state.doc.resolve(0)))
|
||||
@@ -188,17 +201,11 @@ export default function SelectionToolbar(props: Props) {
|
||||
|
||||
const { onCreateLink, isTemplate, rtl, canComment, ...rest } = props;
|
||||
const { state } = view;
|
||||
const { selection }: { selection: any } = state;
|
||||
const isCodeSelection = isNodeActive(state.schema.nodes.code_block)(state);
|
||||
const { selection } = state;
|
||||
const isDividerSelection = isNodeActive(state.schema.nodes.hr)(state);
|
||||
|
||||
// toolbar is disabled in code blocks, no bold / italic etc
|
||||
if (isCodeSelection || isDragging) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// no toolbar in this circumstance
|
||||
if (readOnly && !canComment) {
|
||||
// no toolbar in read-only without commenting or when dragging
|
||||
if ((readOnly && !canComment) || isDragging) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -207,10 +214,17 @@ export default function SelectionToolbar(props: Props) {
|
||||
const isTableSelection = colIndex !== undefined && rowIndex !== undefined;
|
||||
const link = isMarkActive(state.schema.marks.link)(state);
|
||||
const range = getMarkRange(selection.$from, state.schema.marks.link);
|
||||
const isImageSelection = selection.node?.type?.name === "image";
|
||||
const isImageSelection =
|
||||
selection instanceof NodeSelection && selection.node.type.name === "image";
|
||||
const isCodeSelection =
|
||||
isNodeActive(state.schema.nodes.code_block)(state) ||
|
||||
isNodeActive(state.schema.nodes.code_fence)(state);
|
||||
|
||||
let items: MenuItem[] = [];
|
||||
if (isTableSelection) {
|
||||
|
||||
if (isCodeSelection) {
|
||||
items = getCodeMenuItems(state, readOnly, dictionary);
|
||||
} else if (isTableSelection) {
|
||||
items = getTableMenuItems(dictionary);
|
||||
} else if (colIndex !== undefined) {
|
||||
items = getTableColMenuItems(state, colIndex, rtl, dictionary);
|
||||
|
||||
@@ -212,11 +212,13 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
handleClearSearch();
|
||||
|
||||
const command = item.name ? commands[item.name] : undefined;
|
||||
const attrs =
|
||||
typeof item.attrs === "function" ? item.attrs(view.state) : item.attrs;
|
||||
|
||||
if (command) {
|
||||
command(item.attrs);
|
||||
command(attrs);
|
||||
} else {
|
||||
commands[`create${capitalize(item.name)}`](item.attrs);
|
||||
commands[`create${capitalize(item.name)}`](attrs);
|
||||
}
|
||||
if ("appendSpace" in item) {
|
||||
const { dispatch } = view;
|
||||
@@ -260,6 +262,9 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
const handleLinkInputKeydown = (
|
||||
event: React.KeyboardEvent<HTMLInputElement>
|
||||
) => {
|
||||
if (event.nativeEvent.isComposing) {
|
||||
return;
|
||||
}
|
||||
if (!props.isActive) {
|
||||
return;
|
||||
}
|
||||
@@ -441,6 +446,9 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.isComposing) {
|
||||
return;
|
||||
}
|
||||
if (!props.isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import styled from "styled-components";
|
||||
import { transparentize } from "polished";
|
||||
import styled, { css } from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
|
||||
type Props = { active?: boolean; disabled?: boolean };
|
||||
type Props = {
|
||||
active?: boolean;
|
||||
disabled?: boolean;
|
||||
hovering?: boolean;
|
||||
};
|
||||
|
||||
export default styled.button.attrs((props) => ({
|
||||
type: props.type || "button",
|
||||
@@ -14,6 +19,7 @@ export default styled.button.attrs((props) => ({
|
||||
height: 24px;
|
||||
cursor: var(--pointer);
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
background: none;
|
||||
transition: opacity 100ms ease-in-out;
|
||||
padding: 0;
|
||||
@@ -21,12 +27,19 @@ export default styled.button.attrs((props) => ({
|
||||
outline: none;
|
||||
pointer-events: all;
|
||||
position: relative;
|
||||
color: ${s("toolbarItem")};
|
||||
transition: background 100ms ease-in-out;
|
||||
color: ${s("text")};
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
props.hovering &&
|
||||
css`
|
||||
opacity: 1;
|
||||
`};
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: default;
|
||||
@@ -35,11 +48,16 @@ export default styled.button.attrs((props) => ({
|
||||
&:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
top: -4px;
|
||||
top: -6px;
|
||||
right: -4px;
|
||||
left: -4px;
|
||||
bottom: -4px;
|
||||
bottom: -6px;
|
||||
}
|
||||
|
||||
${(props) => props.active && "opacity: 1;"};
|
||||
${(props) =>
|
||||
props.active &&
|
||||
css`
|
||||
opacity: 1;
|
||||
background: ${(props) => transparentize(0.9, s("accent")(props))};
|
||||
`};
|
||||
`;
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { ExpandedIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useMenuState } from "reakit";
|
||||
import { MenuButton } from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
import { MenuItem } from "@shared/editor/types";
|
||||
import { s } from "@shared/styles";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import Template from "~/components/ContextMenu/Template";
|
||||
import { MenuItem as TMenuItem } from "~/types";
|
||||
import { useEditor } from "./EditorContext";
|
||||
import ToolbarButton from "./ToolbarButton";
|
||||
import ToolbarSeparator from "./ToolbarSeparator";
|
||||
@@ -12,11 +18,59 @@ type Props = {
|
||||
};
|
||||
|
||||
const FlexibleWrapper = styled.div`
|
||||
color: ${s("toolbarItem")};
|
||||
color: ${s("textSecondary")};
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
/*
|
||||
* Renders a dropdown menu in the floating toolbar.
|
||||
*/
|
||||
function ToolbarDropdown(props: { item: MenuItem }) {
|
||||
const menu = useMenuState();
|
||||
const { commands, view } = useEditor();
|
||||
const { item } = props;
|
||||
const { state } = view;
|
||||
|
||||
const items: TMenuItem[] = React.useMemo(() => {
|
||||
const handleClick = (item: MenuItem) => () => {
|
||||
if (!item.name) {
|
||||
return;
|
||||
}
|
||||
|
||||
commands[item.name](
|
||||
typeof item.attrs === "function" ? item.attrs(state) : item.attrs
|
||||
);
|
||||
};
|
||||
|
||||
return item.children
|
||||
? item.children.map((child) => ({
|
||||
type: "button",
|
||||
title: child.label,
|
||||
icon: child.icon,
|
||||
selected: child.active ? child.active(state) : false,
|
||||
onClick: handleClick(child),
|
||||
}))
|
||||
: [];
|
||||
}, [item.children, commands, state]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuButton {...menu}>
|
||||
{(props) => (
|
||||
<ToolbarButton {...props} hovering={menu.visible}>
|
||||
{item.label && <Label>{item.label}</Label>}
|
||||
<Arrow />
|
||||
</ToolbarButton>
|
||||
)}
|
||||
</MenuButton>
|
||||
<ContextMenu aria-label={item.label} {...menu}>
|
||||
<Template {...menu} items={items} />
|
||||
</ContextMenu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolbarMenu(props: Props) {
|
||||
const { commands, view } = useEditor();
|
||||
const { items } = props;
|
||||
@@ -27,10 +81,9 @@ function ToolbarMenu(props: Props) {
|
||||
return;
|
||||
}
|
||||
|
||||
const attrs =
|
||||
typeof item.attrs === "function" ? item.attrs(state) : item.attrs;
|
||||
|
||||
commands[item.name](attrs);
|
||||
commands[item.name](
|
||||
typeof item.attrs === "function" ? item.attrs(state) : item.attrs
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -49,10 +102,17 @@ function ToolbarMenu(props: Props) {
|
||||
tooltip={item.label === item.tooltip ? undefined : item.tooltip}
|
||||
key={index}
|
||||
>
|
||||
<ToolbarButton onClick={handleClick(item)} active={isActive}>
|
||||
{item.label && <Label>{item.label}</Label>}
|
||||
{item.icon}
|
||||
</ToolbarButton>
|
||||
{item.children ? (
|
||||
<ToolbarDropdown item={item} />
|
||||
) : (
|
||||
<ToolbarButton
|
||||
onClick={handleClick(item)}
|
||||
active={isActive && !item.label}
|
||||
>
|
||||
{item.label && <Label>{item.label}</Label>}
|
||||
{item.icon}
|
||||
</ToolbarButton>
|
||||
)}
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
@@ -60,6 +120,11 @@ function ToolbarMenu(props: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
const Arrow = styled(ExpandedIcon)`
|
||||
margin-right: -4px;
|
||||
color: ${s("textSecondary")};
|
||||
`;
|
||||
|
||||
const Label = styled.span`
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
|
||||
@@ -2,12 +2,12 @@ import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
|
||||
const Separator = styled.div`
|
||||
height: 24px;
|
||||
width: 2px;
|
||||
background: ${s("toolbarItem")};
|
||||
opacity: 0.3;
|
||||
height: 36px;
|
||||
width: 1px;
|
||||
background: ${s("textTertiary")};
|
||||
opacity: 0.25;
|
||||
display: inline-block;
|
||||
margin-left: 8px;
|
||||
margin: -6px 2px;
|
||||
`;
|
||||
|
||||
export default Separator;
|
||||
|
||||
@@ -3,10 +3,11 @@ import styled from "styled-components";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
tooltip?: string;
|
||||
};
|
||||
|
||||
const WrappedTooltip: React.FC<Props> = ({ children, tooltip }) => (
|
||||
const WrappedTooltip: React.FC<Props> = ({ children, tooltip }: Props) => (
|
||||
<Tooltip offset={[0, 16]} delay={150} tooltip={tooltip} placement="top">
|
||||
<TooltipContent>{children}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
+22
-16
@@ -46,6 +46,7 @@ import BlockMenu from "./components/BlockMenu";
|
||||
import ComponentView from "./components/ComponentView";
|
||||
import EditorContext from "./components/EditorContext";
|
||||
import EmojiMenu from "./components/EmojiMenu";
|
||||
import FindAndReplace from "./components/FindAndReplace";
|
||||
import { SearchResult } from "./components/LinkEditor";
|
||||
import LinkToolbar from "./components/LinkToolbar";
|
||||
import MentionMenu from "./components/MentionMenu";
|
||||
@@ -770,17 +771,20 @@ export class Editor extends React.PureComponent<
|
||||
ref={this.elementRef}
|
||||
/>
|
||||
{this.view && (
|
||||
<SelectionToolbar
|
||||
rtl={isRTL}
|
||||
readOnly={readOnly}
|
||||
canComment={this.props.canComment}
|
||||
isTemplate={this.props.template === true}
|
||||
onOpen={this.handleOpenSelectionToolbar}
|
||||
onClose={this.handleCloseSelectionToolbar}
|
||||
onSearchLink={this.props.onSearchLink}
|
||||
onClickLink={this.props.onClickLink}
|
||||
onCreateLink={this.props.onCreateLink}
|
||||
/>
|
||||
<>
|
||||
<SelectionToolbar
|
||||
rtl={isRTL}
|
||||
readOnly={readOnly}
|
||||
canComment={this.props.canComment}
|
||||
isTemplate={this.props.template === true}
|
||||
onOpen={this.handleOpenSelectionToolbar}
|
||||
onClose={this.handleCloseSelectionToolbar}
|
||||
onSearchLink={this.props.onSearchLink}
|
||||
onClickLink={this.props.onClickLink}
|
||||
onCreateLink={this.props.onCreateLink}
|
||||
/>
|
||||
{this.commands.find && <FindAndReplace readOnly={readOnly} />}
|
||||
</>
|
||||
)}
|
||||
{!readOnly && this.view && (
|
||||
<>
|
||||
@@ -863,11 +867,13 @@ const EditorContainer = styled(Styles)<{ focusedCommentId?: string }>`
|
||||
`;
|
||||
|
||||
const LazyLoadedEditor = React.forwardRef<Editor, Props>(
|
||||
(props: Props, ref) => (
|
||||
<WithTheme>
|
||||
{(theme) => <Editor theme={theme} {...props} ref={ref} />}
|
||||
</WithTheme>
|
||||
)
|
||||
function _LazyLoadedEditor(props: Props, ref) {
|
||||
return (
|
||||
<WithTheme>
|
||||
{(theme) => <Editor theme={theme} {...props} ref={ref} />}
|
||||
</WithTheme>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const observe = (
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { CopyIcon, ExpandedIcon } from "outline-icons";
|
||||
import { EditorState } from "prosemirror-state";
|
||||
import * as React from "react";
|
||||
import { LANGUAGES } from "@shared/editor/extensions/Prism";
|
||||
import { MenuItem } from "@shared/editor/types";
|
||||
import { Dictionary } from "~/hooks/useDictionary";
|
||||
|
||||
export default function codeMenuItems(
|
||||
state: EditorState,
|
||||
readOnly: boolean | undefined,
|
||||
dictionary: Dictionary
|
||||
): MenuItem[] {
|
||||
const node = state.selection.$from.node();
|
||||
|
||||
return [
|
||||
{
|
||||
name: "copyToClipboard",
|
||||
icon: <CopyIcon />,
|
||||
label: readOnly ? dictionary.copy : undefined,
|
||||
tooltip: dictionary.copy,
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
visible: !readOnly,
|
||||
},
|
||||
{
|
||||
visible: !readOnly,
|
||||
name: "code_block",
|
||||
icon: <ExpandedIcon />,
|
||||
label: LANGUAGES[node.attrs.language ?? "none"],
|
||||
children: Object.entries(LANGUAGES).map(([value, label]) => ({
|
||||
name: "code_block",
|
||||
label,
|
||||
active: () => node.attrs.language === value,
|
||||
attrs: {
|
||||
language: value,
|
||||
},
|
||||
})),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -12,13 +12,6 @@ export default function dividerMenuItems(
|
||||
const { schema } = state;
|
||||
|
||||
return [
|
||||
{
|
||||
name: "hr",
|
||||
tooltip: dictionary.pageBreak,
|
||||
attrs: { markup: "***" },
|
||||
active: isNodeActive(schema.nodes.hr, { markup: "***" }),
|
||||
icon: <PageBreakIcon />,
|
||||
},
|
||||
{
|
||||
name: "hr",
|
||||
tooltip: dictionary.hr,
|
||||
@@ -26,5 +19,12 @@ export default function dividerMenuItems(
|
||||
active: isNodeActive(schema.nodes.hr, { markup: "---" }),
|
||||
icon: <HorizontalRuleIcon />,
|
||||
},
|
||||
{
|
||||
name: "hr",
|
||||
tooltip: dictionary.pageBreak,
|
||||
attrs: { markup: "***" },
|
||||
active: isNodeActive(schema.nodes.hr, { markup: "***" }),
|
||||
icon: <PageBreakIcon />,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -70,21 +70,18 @@ export default function imageMenuItems(
|
||||
tooltip: dictionary.downloadImage,
|
||||
icon: <DownloadIcon />,
|
||||
visible: !!fetch,
|
||||
active: () => false,
|
||||
},
|
||||
{
|
||||
name: "replaceImage",
|
||||
tooltip: dictionary.replaceImage,
|
||||
icon: <ReplaceIcon />,
|
||||
visible: true,
|
||||
active: () => false,
|
||||
},
|
||||
{
|
||||
name: "deleteImage",
|
||||
tooltip: dictionary.deleteImage,
|
||||
icon: <TrashIcon />,
|
||||
visible: true,
|
||||
active: () => false,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ export default function tableMenuItems(dictionary: Dictionary): MenuItem[] {
|
||||
name: "deleteTable",
|
||||
tooltip: dictionary.deleteTable,
|
||||
icon: <TrashIcon />,
|
||||
active: () => false,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -61,13 +61,11 @@ export default function tableColMenuItems(
|
||||
name: rtl ? "addColumnAfter" : "addColumnBefore",
|
||||
tooltip: rtl ? dictionary.addColumnAfter : dictionary.addColumnBefore,
|
||||
icon: <InsertLeftIcon />,
|
||||
active: () => false,
|
||||
},
|
||||
{
|
||||
name: rtl ? "addColumnBefore" : "addColumnAfter",
|
||||
tooltip: rtl ? dictionary.addColumnBefore : dictionary.addColumnAfter,
|
||||
icon: <InsertRightIcon />,
|
||||
active: () => false,
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
@@ -76,7 +74,6 @@ export default function tableColMenuItems(
|
||||
name: "deleteColumn",
|
||||
tooltip: dictionary.deleteColumn,
|
||||
icon: <TrashIcon />,
|
||||
active: () => false,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ export default function tableRowMenuItems(
|
||||
tooltip: dictionary.addRowBefore,
|
||||
icon: <InsertAboveIcon />,
|
||||
attrs: { index: index - 1 },
|
||||
active: () => false,
|
||||
visible: index !== 0,
|
||||
},
|
||||
{
|
||||
@@ -23,7 +22,6 @@ export default function tableRowMenuItems(
|
||||
tooltip: dictionary.addRowAfter,
|
||||
icon: <InsertBelowIcon />,
|
||||
attrs: { index },
|
||||
active: () => false,
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
@@ -32,7 +30,6 @@ export default function tableRowMenuItems(
|
||||
name: "deleteRow",
|
||||
tooltip: dictionary.deleteRow,
|
||||
icon: <TrashIcon />,
|
||||
active: () => false,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -5,6 +5,11 @@ import useIdle from "./useIdle";
|
||||
import useInterval from "./useInterval";
|
||||
import usePageVisibility from "./usePageVisibility";
|
||||
|
||||
// The case of isReloaded=true should never be hit as the app will reload
|
||||
// before the hook is called again, however seems like the only possible
|
||||
// cause of #5384, adding to debug.
|
||||
let isReloaded = false;
|
||||
|
||||
/**
|
||||
* Hook to reload the app around once a day to stop old code from running.
|
||||
*/
|
||||
@@ -25,9 +30,14 @@ export default function useAutoRefresh() {
|
||||
Logger.debug("lifecycle", "Skipping reload due to user activity");
|
||||
return;
|
||||
}
|
||||
if (isReloaded) {
|
||||
Logger.error("lifecycle", new Error("Attempted to reload twice"));
|
||||
return;
|
||||
}
|
||||
|
||||
Logger.debug("lifecycle", "Auto-reloading app…");
|
||||
window.location.reload();
|
||||
isReloaded = true;
|
||||
}
|
||||
}, Minute);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
buildPitchBlackTheme,
|
||||
} from "@shared/styles/theme";
|
||||
import { CustomTheme } from "@shared/types";
|
||||
import type { Theme } from "~/stores/UiStore";
|
||||
import useMediaQuery from "~/hooks/useMediaQuery";
|
||||
import useStores from "./useStores";
|
||||
|
||||
@@ -14,25 +15,30 @@ import useStores from "./useStores";
|
||||
* and the custom theme provided.
|
||||
*
|
||||
* @param customTheme Custom theme to merge with the default theme
|
||||
* @param overrideTheme Optional override the theme to use
|
||||
* @returns The theme to use
|
||||
*/
|
||||
export default function useBuildTheme(customTheme: Partial<CustomTheme> = {}) {
|
||||
export default function useBuildTheme(
|
||||
customTheme: Partial<CustomTheme> = {},
|
||||
overrideTheme?: Theme
|
||||
) {
|
||||
const { ui } = useStores();
|
||||
const isMobile = useMediaQuery(`(max-width: ${breakpoints.tablet}px)`);
|
||||
const isPrinting = useMediaQuery("print");
|
||||
const resolvedTheme = overrideTheme ?? ui.resolvedTheme;
|
||||
|
||||
const theme = React.useMemo(
|
||||
() =>
|
||||
isPrinting
|
||||
? buildLightTheme(customTheme)
|
||||
: isMobile
|
||||
? ui.resolvedTheme === "dark"
|
||||
? resolvedTheme === "dark"
|
||||
? buildPitchBlackTheme(customTheme)
|
||||
: buildLightTheme(customTheme)
|
||||
: ui.resolvedTheme === "dark"
|
||||
: resolvedTheme === "dark"
|
||||
? buildDarkTheme(customTheme)
|
||||
: buildLightTheme(customTheme),
|
||||
[customTheme, isMobile, isPrinting, ui.resolvedTheme]
|
||||
[customTheme, isMobile, isPrinting, resolvedTheme]
|
||||
);
|
||||
|
||||
return theme;
|
||||
|
||||
@@ -26,7 +26,7 @@ export default function useComponentSize(ref: React.RefObject<HTMLElement>): {
|
||||
}
|
||||
|
||||
return () => sizeObserver.disconnect();
|
||||
}, [ref]);
|
||||
}, [ref, size.height, size.width]);
|
||||
|
||||
return size;
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import invariant from "invariant";
|
||||
import useStores from "./useStores";
|
||||
|
||||
export default function useCurrentToken() {
|
||||
const { auth } = useStores();
|
||||
invariant(auth.token, "token is required");
|
||||
return auth.token;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { throttle } from "lodash";
|
||||
import * as React from "react";
|
||||
import { Minute } from "@shared/utils/time";
|
||||
|
||||
@@ -34,10 +35,10 @@ export default function useIdle(timeToIdle: number = 3 * Minute) {
|
||||
}, [timeToIdle]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleUserActivityEvent = () => {
|
||||
const handleUserActivityEvent = throttle(() => {
|
||||
setIsIdle(false);
|
||||
onActivity();
|
||||
};
|
||||
}, 1000);
|
||||
|
||||
activityEvents.forEach((eventName) =>
|
||||
window.addEventListener(eventName, handleUserActivityEvent)
|
||||
|
||||
@@ -8,7 +8,11 @@ type MenuContextType = {
|
||||
|
||||
const MenuContext = React.createContext<MenuContextType | null>(null);
|
||||
|
||||
export const MenuProvider: React.FC = ({ children }) => {
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const MenuProvider: React.FC = ({ children }: Props) => {
|
||||
const [isMenuOpen, setIsMenuOpen] = React.useState(false);
|
||||
const memoized = React.useMemo(
|
||||
() => ({
|
||||
|
||||
+31
-13
@@ -2,26 +2,44 @@ import * as React from "react";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import useWindowSize from "~/hooks/useWindowSize";
|
||||
|
||||
const useMenuHeight = (
|
||||
visible: void | boolean,
|
||||
unstable_disclosureRef?: React.RefObject<HTMLElement | null>,
|
||||
margin = 8
|
||||
) => {
|
||||
const [maxHeight, setMaxHeight] = React.useState<number | undefined>();
|
||||
const useMenuHeight = ({
|
||||
visible,
|
||||
elementRef,
|
||||
maxViewportHeight = 70,
|
||||
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.useEffect(() => {
|
||||
React.useLayoutEffect(() => {
|
||||
if (visible && !isMobile) {
|
||||
const maxHeight = (windowHeight / 100) * maxViewportHeight;
|
||||
|
||||
setMaxHeight(
|
||||
unstable_disclosureRef?.current
|
||||
? windowHeight -
|
||||
unstable_disclosureRef.current.getBoundingClientRect().bottom -
|
||||
margin
|
||||
: undefined
|
||||
Math.min(
|
||||
maxHeight,
|
||||
elementRef?.current
|
||||
? windowHeight -
|
||||
elementRef.current.getBoundingClientRect().bottom -
|
||||
margin
|
||||
: 0
|
||||
)
|
||||
);
|
||||
} else {
|
||||
setMaxHeight(0);
|
||||
}
|
||||
}, [visible, unstable_disclosureRef, windowHeight, margin, isMobile]);
|
||||
}, [visible, elementRef, windowHeight, margin, isMobile, maxViewportHeight]);
|
||||
|
||||
return maxHeight;
|
||||
};
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import useEventListener from "./useEventListener";
|
||||
* @param callback The handler to call when a click outside the element is detected.
|
||||
*/
|
||||
export default function useOnClickOutside(
|
||||
ref: React.RefObject<HTMLElement>,
|
||||
ref: React.RefObject<HTMLElement | null>,
|
||||
callback?: (event: MouseEvent | TouchEvent) => void
|
||||
) {
|
||||
const listener = React.useCallback(
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import * as React from "react";
|
||||
|
||||
const isSupported = "IntersectionObserver" in window;
|
||||
|
||||
/**
|
||||
* Hook to return if a given ref is visible on screen.
|
||||
*
|
||||
* @returns boolean if the node is visible
|
||||
*/
|
||||
export default function useOnScreen(ref: React.RefObject<HTMLElement>) {
|
||||
const isSupported = "IntersectionObserver" in window;
|
||||
const [isIntersecting, setIntersecting] = React.useState(!isSupported);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -28,7 +29,7 @@ export default function useOnScreen(ref: React.RefObject<HTMLElement>) {
|
||||
observer?.unobserve(element);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}, [ref]);
|
||||
|
||||
return isIntersecting;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user