Compare commits

...

1 Commits

Author SHA1 Message Date
hmacr 899ab9bfa7 feat: move TOC to available free space 2024-10-06 19:41:37 +05:30
8 changed files with 292 additions and 36 deletions
+20
View File
@@ -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);
+8
View File
@@ -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.
*
+5 -33
View File
@@ -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};
+1 -1
View File
@@ -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";
+34
View File
@@ -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;
}
}