mirror of
https://github.com/outline/outline.git
synced 2026-06-13 19:35:02 +03:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 899ab9bfa7 |
@@ -14,6 +14,9 @@ class DocumentContext {
|
||||
@observable
|
||||
headings: Heading[] = [];
|
||||
|
||||
@observable
|
||||
fullWidthElements: HTMLElement[] = [];
|
||||
|
||||
@computed
|
||||
get hasHeadings() {
|
||||
return this.headings.length > 0;
|
||||
@@ -35,6 +38,7 @@ class DocumentContext {
|
||||
updateState = () => {
|
||||
this.updateHeadings();
|
||||
this.updateTasks();
|
||||
this.updateFullWidthElements();
|
||||
};
|
||||
|
||||
private updateHeadings() {
|
||||
@@ -54,6 +58,22 @@ class DocumentContext {
|
||||
const completed = tasks.filter((t) => t.completed).length ?? 0;
|
||||
this.document?.updateTasks(total, completed);
|
||||
}
|
||||
|
||||
private updateFullWidthElements() {
|
||||
const currFullWidthElements = this.editor?.getFullWidthElements() ?? [];
|
||||
|
||||
const newElems = currFullWidthElements.filter(
|
||||
(elem) => !this.fullWidthElements.includes(elem)
|
||||
);
|
||||
const obsoleteElems = this.fullWidthElements.filter(
|
||||
(elem) => !currFullWidthElements.includes(elem)
|
||||
);
|
||||
const hasChanged = newElems.length > 0 || obsoleteElems.length > 0;
|
||||
|
||||
if (hasChanged) {
|
||||
this.fullWidthElements = currFullWidthElements;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const Context = React.createContext<DocumentContext | null>(null);
|
||||
|
||||
@@ -648,6 +648,14 @@ export class Editor extends React.PureComponent<
|
||||
*/
|
||||
public getComments = () => ProsemirrorHelper.getComments(this.view.state.doc);
|
||||
|
||||
/**
|
||||
* Return the full-width HTML elements in the current editor.
|
||||
*
|
||||
* @returns A list of full-width HTML elements in the document
|
||||
*/
|
||||
public getFullWidthElements = () =>
|
||||
ProsemirrorHelper.getFullWidthElements(this.view);
|
||||
|
||||
/**
|
||||
* Remove all marks related to a specific comment from the document.
|
||||
*
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import { s } from "@shared/styles";
|
||||
import { useDocumentContext } from "~/components/DocumentContext";
|
||||
import useWindowScrollPosition from "~/hooks/useWindowScrollPosition";
|
||||
import { decodeURIComponentSafe } from "~/utils/urls";
|
||||
import ContentsPositioner from "./ContentsPositioner";
|
||||
|
||||
const HEADING_OFFSET = 20;
|
||||
|
||||
@@ -51,11 +49,11 @@ function Contents() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (headings.length === 0) {
|
||||
return <StickyWrapper />;
|
||||
return <ContentsPositioner />;
|
||||
}
|
||||
|
||||
return (
|
||||
<StickyWrapper>
|
||||
<ContentsPositioner>
|
||||
<Heading>{t("Contents")}</Heading>
|
||||
<List>
|
||||
{headings
|
||||
@@ -70,36 +68,10 @@ function Contents() {
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</StickyWrapper>
|
||||
</ContentsPositioner>
|
||||
);
|
||||
}
|
||||
|
||||
const StickyWrapper = styled.div`
|
||||
display: none;
|
||||
|
||||
position: sticky;
|
||||
top: 90px;
|
||||
max-height: calc(100vh - 90px);
|
||||
width: ${EditorStyleHelper.tocWidth}px;
|
||||
|
||||
padding: 0 16px;
|
||||
overflow-y: auto;
|
||||
border-radius: 8px;
|
||||
|
||||
background: ${s("background")};
|
||||
transition: ${s("backgroundTransition")};
|
||||
|
||||
@supports (backdrop-filter: blur(20px)) {
|
||||
backdrop-filter: blur(20px);
|
||||
background: ${(props) => transparentize(0.2, props.theme.background)};
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
display: block;
|
||||
z-index: ${depths.toc};
|
||||
`};
|
||||
`;
|
||||
|
||||
const Heading = styled.h3`
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
import sortBy from "lodash/sortBy";
|
||||
import { observer } from "mobx-react";
|
||||
import { transparentize } from "polished";
|
||||
import React, { PropsWithChildren } from "react";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import { useDocumentContext } from "~/components/DocumentContext";
|
||||
import useIsMounted from "~/hooks/useIsMounted";
|
||||
|
||||
const HeaderHeight = 64;
|
||||
const StickyTopPosition = 90;
|
||||
|
||||
const InitialMarginTop = "calc(44px + 6vh)";
|
||||
|
||||
type SpaceBound = {
|
||||
idx: number;
|
||||
top: number;
|
||||
bottom: number;
|
||||
};
|
||||
|
||||
const ContentsPositioner = ({ children }: PropsWithChildren<unknown>) => {
|
||||
const theme = useTheme();
|
||||
const isMounted = useIsMounted();
|
||||
const { headings, fullWidthElements } = useDocumentContext();
|
||||
|
||||
const positionerRef = React.useRef<HTMLDivElement>(null);
|
||||
const contentsObserverRef = React.useRef<IntersectionObserver>();
|
||||
const fullWidthElementsRef = React.useRef<HTMLElement[]>([]); // needed for async observer callbacks.
|
||||
|
||||
const handlePositioning = React.useCallback(() => {
|
||||
if (!positionerRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const positionerRect = positionerRef.current.getBoundingClientRect();
|
||||
|
||||
const scroll = window.scrollY;
|
||||
|
||||
const sortedElemsBound = sortBy(
|
||||
fullWidthElementsRef.current.map<SpaceBound>((elem, idx) => {
|
||||
const rect = elem.getBoundingClientRect();
|
||||
return {
|
||||
idx,
|
||||
top: Math.ceil(rect.top + scroll), // adjust scroll position to prevent position jumps on scroll
|
||||
bottom: Math.ceil(rect.bottom + scroll),
|
||||
};
|
||||
}),
|
||||
(rect) => rect.top
|
||||
);
|
||||
|
||||
const spacesBound = sortedElemsBound.map<SpaceBound>((elemBound, idx) => {
|
||||
const bottom =
|
||||
idx !== sortedElemsBound.length - 1
|
||||
? sortedElemsBound[idx + 1].top - 1
|
||||
: window.innerHeight + scroll;
|
||||
return {
|
||||
idx: idx + 1,
|
||||
top: elemBound.bottom + 1,
|
||||
bottom,
|
||||
};
|
||||
});
|
||||
|
||||
// insert the initial position in case no full-width elems are present (or)
|
||||
// the first full-width elem is below StickyTopPosition.
|
||||
if (!spacesBound.length || sortedElemsBound[0]?.top > StickyTopPosition) {
|
||||
const bottom = sortedElemsBound[0]
|
||||
? sortedElemsBound[0].top - 1
|
||||
: window.innerHeight + scroll;
|
||||
spacesBound.unshift({
|
||||
idx: 0,
|
||||
top: StickyTopPosition,
|
||||
bottom,
|
||||
});
|
||||
}
|
||||
|
||||
const visibleSpacesBound = spacesBound.filter((spaceBound) => {
|
||||
const actualBottom = spaceBound.bottom - scroll;
|
||||
return actualBottom >= 0 && actualBottom <= window.innerHeight;
|
||||
});
|
||||
|
||||
if (!visibleSpacesBound.length) {
|
||||
// keep using the previously set marginTop.
|
||||
return;
|
||||
}
|
||||
|
||||
let spaceToUse = visibleSpacesBound.find((space) => {
|
||||
const top =
|
||||
space.top - scroll >= StickyTopPosition
|
||||
? space.top - scroll
|
||||
: StickyTopPosition;
|
||||
const bottom =
|
||||
space.bottom - scroll <= window.innerHeight
|
||||
? space.bottom - scroll
|
||||
: window.innerHeight;
|
||||
return bottom - top + 1 >= positionerRect.height;
|
||||
});
|
||||
|
||||
// use the biggest space available to ensure
|
||||
// minimum overlap with the following content.
|
||||
if (!spaceToUse) {
|
||||
// descending sort based on size
|
||||
const sortedSpacesBound = spacesBound.sort((a, b) =>
|
||||
a.bottom - a.top + 1 >= b.bottom - b.top + 1 ? -1 : 1
|
||||
);
|
||||
|
||||
// If last space, the additional height of contents will be hidden.
|
||||
// so, use the next best space if available.
|
||||
if (
|
||||
sortedSpacesBound[0].bottom - scroll === window.innerHeight &&
|
||||
sortedSpacesBound.length > 1
|
||||
) {
|
||||
spaceToUse = sortedSpacesBound[1];
|
||||
} else {
|
||||
spaceToUse = sortedSpacesBound[0];
|
||||
}
|
||||
}
|
||||
|
||||
let marginTop;
|
||||
|
||||
if (spaceToUse.idx === 0) {
|
||||
// In the initial position, if the contents box overlaps with a full-width elem,
|
||||
// push it up to use the available space.
|
||||
if (sortedElemsBound[0]) {
|
||||
const spaceHeight = spaceToUse.bottom - spaceToUse.top + 1;
|
||||
const diff =
|
||||
spaceHeight - positionerRect.height > 0
|
||||
? spaceHeight - positionerRect.height
|
||||
: 0;
|
||||
marginTop = `min(${InitialMarginTop}, ${diff}px)`;
|
||||
} else {
|
||||
marginTop = InitialMarginTop;
|
||||
}
|
||||
} else {
|
||||
marginTop = `${spaceToUse.top - HeaderHeight}px`;
|
||||
}
|
||||
positionerRef.current.style.marginTop = marginTop;
|
||||
|
||||
if (isMounted()) {
|
||||
positionerRef.current.style.transition = `${theme["backgroundTransition"]}, margin-top 100ms ease-out`;
|
||||
}
|
||||
}, [theme, isMounted]);
|
||||
|
||||
// prevent first render flicker.
|
||||
React.useLayoutEffect(() => {
|
||||
fullWidthElementsRef.current = fullWidthElements;
|
||||
handlePositioning();
|
||||
}, [fullWidthElements, handlePositioning]);
|
||||
|
||||
// setup the observers.
|
||||
React.useEffect(() => {
|
||||
if (!positionerRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
fullWidthElementsRef.current = fullWidthElements;
|
||||
|
||||
const positionerRect = positionerRef.current.getBoundingClientRect();
|
||||
|
||||
// Box for the contents position when it becomes sticky.
|
||||
// Whenever a full-width element enters/leaves the box, re-position the contents.
|
||||
const rootMargin = `-${StickyTopPosition}px 0px -${
|
||||
window.innerHeight - (StickyTopPosition + positionerRect.height)
|
||||
}px 0px`;
|
||||
|
||||
const fullWidthElemsObserver = new IntersectionObserver(
|
||||
() => handlePositioning(),
|
||||
{
|
||||
rootMargin,
|
||||
}
|
||||
);
|
||||
|
||||
// observe contents in case it goes out of viewport in the bottom.
|
||||
if (!contentsObserverRef.current) {
|
||||
contentsObserverRef.current = new IntersectionObserver(
|
||||
() => handlePositioning(),
|
||||
{ rootMargin: "-101% 0px 0px" }
|
||||
);
|
||||
contentsObserverRef.current.observe(positionerRef.current);
|
||||
}
|
||||
|
||||
if (!fullWidthElements.length) {
|
||||
positionerRef.current.style.marginTop = InitialMarginTop;
|
||||
} else {
|
||||
fullWidthElements.forEach((elem) => fullWidthElemsObserver.observe(elem));
|
||||
}
|
||||
|
||||
return () => fullWidthElemsObserver.disconnect();
|
||||
}, [headings, fullWidthElements, handlePositioning]); // when headings change, contents box size changes.
|
||||
|
||||
return <Positioner ref={positionerRef}>{children}</Positioner>;
|
||||
};
|
||||
|
||||
const Positioner = styled.div`
|
||||
display: none;
|
||||
|
||||
position: sticky;
|
||||
top: ${StickyTopPosition}px;
|
||||
max-height: calc(100vh - ${StickyTopPosition}px);
|
||||
width: ${EditorStyleHelper.tocWidth}px;
|
||||
|
||||
padding: 0 16px;
|
||||
overflow-y: auto;
|
||||
border-radius: 8px;
|
||||
|
||||
background: ${s("background")};
|
||||
transition: ${s("backgroundTransition")};
|
||||
|
||||
@supports (backdrop-filter: blur(20px)) {
|
||||
backdrop-filter: blur(20px);
|
||||
background: ${(props) => transparentize(0.2, props.theme.background)};
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
display: block;
|
||||
z-index: ${depths.toc};
|
||||
`};
|
||||
`;
|
||||
|
||||
export default observer(ContentsPositioner);
|
||||
@@ -630,8 +630,6 @@ type ContentsContainerProps = {
|
||||
|
||||
const ContentsContainer = styled.div<ContentsContainerProps>`
|
||||
${breakpoint("tablet")`
|
||||
margin-top: calc(44px + 6vh);
|
||||
|
||||
grid-row: 1;
|
||||
grid-column: ${({ docFullWidth, position }: ContentsContainerProps) =>
|
||||
position === TOCPosition.Left ? 1 : docFullWidth ? 2 : 3};
|
||||
|
||||
@@ -554,7 +554,7 @@ iframe.embed {
|
||||
clear: initial;
|
||||
}
|
||||
|
||||
.image-full-width {
|
||||
.${EditorStyleHelper.imageFullWidth} {
|
||||
width: initial;
|
||||
max-width: 100vw;
|
||||
clear: both;
|
||||
|
||||
@@ -6,6 +6,9 @@ export class EditorStyleHelper {
|
||||
|
||||
static readonly imageHandle = "image-handle";
|
||||
|
||||
/** Full-width image layout */
|
||||
static readonly imageFullWidth = "image-full-width";
|
||||
|
||||
// Comments
|
||||
|
||||
static readonly comment = "comment-marker";
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Node, Schema } from "prosemirror-model";
|
||||
import { EditorView } from "prosemirror-view";
|
||||
import headingToSlug from "../editor/lib/headingToSlug";
|
||||
import textBetween from "../editor/lib/textBetween";
|
||||
import { EditorStyleHelper } from "../editor/styles/EditorStyleHelper";
|
||||
import { ProsemirrorData } from "../types";
|
||||
|
||||
export type Heading = {
|
||||
@@ -307,4 +309,36 @@ export class ProsemirrorHelper {
|
||||
});
|
||||
return headings;
|
||||
}
|
||||
|
||||
static getFullWidthElements(view: EditorView) {
|
||||
const fullWidthElements: HTMLElement[] = [];
|
||||
|
||||
const isFullWidthNode = (node: globalThis.Node) => {
|
||||
const classList = Array.from((node as HTMLElement).classList.values());
|
||||
|
||||
const hasFullWidthCls = classList.some(
|
||||
(cls) =>
|
||||
cls === EditorStyleHelper.imageFullWidth ||
|
||||
cls === EditorStyleHelper.tableFullWidth
|
||||
);
|
||||
if (hasFullWidthCls) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Array.from(node.childNodes.values())
|
||||
.filter((childNode) => childNode instanceof HTMLElement)
|
||||
.some(isFullWidthNode);
|
||||
};
|
||||
|
||||
view.state.doc.descendants((node, pos) => {
|
||||
if (node.type.name === "table" || node.type.name === "image") {
|
||||
const domNode = view.nodeDOM(pos);
|
||||
if (domNode && isFullWidthNode(domNode)) {
|
||||
fullWidthElements.push(domNode as HTMLElement);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return fullWidthElements;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user