feat: Full width images (#4389)
* feat: Full width images * lint * fix: Enable TOC overlaid on full size images * Vendorize react-medium-image-zoom * tsc * fix * Remove body scroll lock
This commit is contained in:
@@ -68,6 +68,7 @@ export default class ComponentView {
|
|||||||
const children = this.component({
|
const children = this.component({
|
||||||
theme,
|
theme,
|
||||||
node: this.node,
|
node: this.node,
|
||||||
|
view: this.view,
|
||||||
isSelected: this.isSelected,
|
isSelected: this.isSelected,
|
||||||
isEditable: this.view.editable,
|
isEditable: this.view.editable,
|
||||||
getPos: this.getPos,
|
getPos: this.getPos,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
AlignImageLeftIcon,
|
AlignImageLeftIcon,
|
||||||
AlignImageRightIcon,
|
AlignImageRightIcon,
|
||||||
AlignImageCenterIcon,
|
AlignImageCenterIcon,
|
||||||
|
AlignFullWidthIcon,
|
||||||
} from "outline-icons";
|
} from "outline-icons";
|
||||||
import { EditorState } from "prosemirror-state";
|
import { EditorState } from "prosemirror-state";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
@@ -23,6 +24,9 @@ export default function imageMenuItems(
|
|||||||
const isRightAligned = isNodeActive(schema.nodes.image, {
|
const isRightAligned = isNodeActive(schema.nodes.image, {
|
||||||
layoutClass: "right-50",
|
layoutClass: "right-50",
|
||||||
});
|
});
|
||||||
|
const isFullWidthAligned = isNodeActive(schema.nodes.image, {
|
||||||
|
layoutClass: "full-width",
|
||||||
|
});
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -40,7 +44,8 @@ export default function imageMenuItems(
|
|||||||
active: (state) =>
|
active: (state) =>
|
||||||
isNodeActive(schema.nodes.image)(state) &&
|
isNodeActive(schema.nodes.image)(state) &&
|
||||||
!isLeftAligned(state) &&
|
!isLeftAligned(state) &&
|
||||||
!isRightAligned(state),
|
!isRightAligned(state) &&
|
||||||
|
!isFullWidthAligned(state),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "alignRight",
|
name: "alignRight",
|
||||||
@@ -49,6 +54,13 @@ export default function imageMenuItems(
|
|||||||
visible: true,
|
visible: true,
|
||||||
active: isRightAligned,
|
active: isRightAligned,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "alignFullWidth",
|
||||||
|
tooltip: dictionary.alignFullWidth,
|
||||||
|
icon: <AlignFullWidthIcon />,
|
||||||
|
visible: true,
|
||||||
|
active: isFullWidthAligned,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "separator",
|
name: "separator",
|
||||||
visible: true,
|
visible: true,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export default function useDictionary() {
|
|||||||
alignCenter: t("Align center"),
|
alignCenter: t("Align center"),
|
||||||
alignLeft: t("Align left"),
|
alignLeft: t("Align left"),
|
||||||
alignRight: t("Align right"),
|
alignRight: t("Align right"),
|
||||||
|
alignFullWidth: t("Full width"),
|
||||||
bulletList: t("Bulleted list"),
|
bulletList: t("Bulleted list"),
|
||||||
checkboxList: t("Todo list"),
|
checkboxList: t("Todo list"),
|
||||||
codeBlock: t("Code block"),
|
codeBlock: t("Code block"),
|
||||||
@@ -28,9 +29,6 @@ export default function useDictionary() {
|
|||||||
deleteImage: t("Delete image"),
|
deleteImage: t("Delete image"),
|
||||||
downloadImage: t("Download image"),
|
downloadImage: t("Download image"),
|
||||||
replaceImage: t("Replace image"),
|
replaceImage: t("Replace image"),
|
||||||
alignImageLeft: t("Float left"),
|
|
||||||
alignImageRight: t("Float right"),
|
|
||||||
alignImageDefault: t("Center large"),
|
|
||||||
em: t("Italic"),
|
em: t("Italic"),
|
||||||
embedInvalidLink: t("Sorry, that link won’t work for this embed type"),
|
embedInvalidLink: t("Sorry, that link won’t work for this embed type"),
|
||||||
file: t("File attachment"),
|
file: t("File attachment"),
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { transparentize } from "polished";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
@@ -100,21 +101,27 @@ const Sticky = styled.div`
|
|||||||
top: 80px;
|
top: 80px;
|
||||||
max-height: calc(100vh - 80px);
|
max-height: calc(100vh - 80px);
|
||||||
|
|
||||||
box-shadow: 1px 0 0 ${(props) => props.theme.divider};
|
background: ${(props) => props.theme.background};
|
||||||
margin-top: 40px;
|
margin-top: 72px;
|
||||||
margin-right: 52px;
|
margin-right: 52px;
|
||||||
min-width: 204px;
|
min-width: 204px;
|
||||||
width: 204px;
|
width: 228px;
|
||||||
min-height: 40px;
|
min-height: 40px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
padding: 4px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
|
||||||
|
@supports (backdrop-filter: blur(20px)) {
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
background: ${(props) => transparentize(0.2, props.theme.background)};
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Heading = styled.h3`
|
const Heading = styled.h3`
|
||||||
font-size: 11px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
color: ${(props) => props.theme.textTertiary};
|
||||||
color: ${(props) => props.theme.sidebarText};
|
letter-spacing: 0.03em;
|
||||||
letter-spacing: 0.04em;
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Empty = styled(Text)`
|
const Empty = styled(Text)`
|
||||||
@@ -128,10 +135,9 @@ const ListItem = styled.li<{ level: number; active?: boolean }>`
|
|||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
padding-right: 2em;
|
padding-right: 2em;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
border-right: 3px solid
|
|
||||||
${(props) => (props.active ? props.theme.divider : "transparent")};
|
|
||||||
|
|
||||||
a {
|
a {
|
||||||
|
font-weight: ${(props) => (props.active ? "600" : "inherit")};
|
||||||
color: ${(props) =>
|
color: ${(props) =>
|
||||||
props.active ? props.theme.primary : props.theme.text};
|
props.active ? props.theme.primary : props.theme.text};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -481,7 +481,12 @@ class DocumentScene extends React.Component<Props> {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Background key={revision ? revision.id : document.id} column auto>
|
<Background
|
||||||
|
id="full-width-container"
|
||||||
|
key={revision ? revision.id : document.id}
|
||||||
|
column
|
||||||
|
auto
|
||||||
|
>
|
||||||
<Route
|
<Route
|
||||||
path={`${document.url}/move`}
|
path={`${document.url}/move`}
|
||||||
component={() => (
|
component={() => (
|
||||||
@@ -562,7 +567,7 @@ class DocumentScene extends React.Component<Props> {
|
|||||||
>
|
>
|
||||||
<Notices document={document} readOnly={readOnly} />
|
<Notices document={document} readOnly={readOnly} />
|
||||||
<React.Suspense fallback={<PlaceholderDocument />}>
|
<React.Suspense fallback={<PlaceholderDocument />}>
|
||||||
<Flex auto={!readOnly}>
|
<Flex auto={!readOnly} reverse>
|
||||||
{revision ? (
|
{revision ? (
|
||||||
<RevisionViewer
|
<RevisionViewer
|
||||||
isDraft={document.isDraft}
|
isDraft={document.isDraft}
|
||||||
@@ -572,12 +577,6 @@ class DocumentScene extends React.Component<Props> {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{showContents && (
|
|
||||||
<Contents
|
|
||||||
headings={this.headings}
|
|
||||||
isFullWidth={document.fullWidth}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Editor
|
<Editor
|
||||||
id={document.id}
|
id={document.id}
|
||||||
key={embedsDisabled ? "disabled" : "enabled"}
|
key={embedsDisabled ? "disabled" : "enabled"}
|
||||||
@@ -624,6 +623,13 @@ class DocumentScene extends React.Component<Props> {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Editor>
|
</Editor>
|
||||||
|
|
||||||
|
{showContents && (
|
||||||
|
<Contents
|
||||||
|
headings={this.headings}
|
||||||
|
isFullWidth={document.fullWidth}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|||||||
@@ -287,7 +287,8 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
|
|||||||
style={
|
style={
|
||||||
showCache
|
showCache
|
||||||
? {
|
? {
|
||||||
display: "none",
|
opacity: 0,
|
||||||
|
pointerEvents: "none",
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -141,7 +141,7 @@
|
|||||||
"node-fetch": "2.6.7",
|
"node-fetch": "2.6.7",
|
||||||
"node-htmldiff": "^0.9.4",
|
"node-htmldiff": "^0.9.4",
|
||||||
"nodemailer": "^6.6.1",
|
"nodemailer": "^6.6.1",
|
||||||
"outline-icons": "^1.45.2",
|
"outline-icons": "^1.46.0",
|
||||||
"oy-vey": "^0.12.0",
|
"oy-vey": "^0.12.0",
|
||||||
"passport": "^0.6.0",
|
"passport": "^0.6.0",
|
||||||
"passport-google-oauth2": "^0.2.0",
|
"passport-google-oauth2": "^0.2.0",
|
||||||
@@ -180,7 +180,6 @@
|
|||||||
"react-helmet": "^6.1.0",
|
"react-helmet": "^6.1.0",
|
||||||
"react-hook-form": "^7.37.0",
|
"react-hook-form": "^7.37.0",
|
||||||
"react-i18next": "^11.16.6",
|
"react-i18next": "^11.16.6",
|
||||||
"react-medium-image-zoom": "^3.1.3",
|
|
||||||
"react-merge-refs": "^2.0.1",
|
"react-merge-refs": "^2.0.1",
|
||||||
"react-portal": "^4.2.0",
|
"react-portal": "^4.2.0",
|
||||||
"react-router-dom": "^5.2.0",
|
"react-router-dom": "^5.2.0",
|
||||||
@@ -284,7 +283,6 @@
|
|||||||
"@types/react-color": "^3.0.6",
|
"@types/react-color": "^3.0.6",
|
||||||
"@types/react-dom": "^17.0.11",
|
"@types/react-dom": "^17.0.11",
|
||||||
"@types/react-helmet": "^6.1.5",
|
"@types/react-helmet": "^6.1.5",
|
||||||
"@types/react-medium-image-zoom": "^3.0.1",
|
|
||||||
"@types/react-portal": "^4.0.4",
|
"@types/react-portal": "^4.0.4",
|
||||||
"@types/react-router-dom": "^5.3.2",
|
"@types/react-router-dom": "^5.3.2",
|
||||||
"@types/react-table": "^7.7.9",
|
"@types/react-table": "^7.7.9",
|
||||||
|
|||||||
585
shared/editor/components/ImageZoom/Controlled.tsx
Normal file
585
shared/editor/components/ImageZoom/Controlled.tsx
Normal file
@@ -0,0 +1,585 @@
|
|||||||
|
import React, {
|
||||||
|
CSSProperties,
|
||||||
|
Component,
|
||||||
|
ImgHTMLAttributes,
|
||||||
|
KeyboardEvent,
|
||||||
|
MouseEvent,
|
||||||
|
ReactElement,
|
||||||
|
ReactNode,
|
||||||
|
SyntheticEvent,
|
||||||
|
createRef,
|
||||||
|
} from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { VisuallyHidden } from "reakit";
|
||||||
|
import type { SupportedImage } from "./types";
|
||||||
|
import {
|
||||||
|
getImgAlt,
|
||||||
|
getImgSrc,
|
||||||
|
getStyleModalImg,
|
||||||
|
testDiv,
|
||||||
|
testImg,
|
||||||
|
testSvg,
|
||||||
|
} from "./utils";
|
||||||
|
|
||||||
|
let elDialogContainer: HTMLDivElement;
|
||||||
|
|
||||||
|
if (typeof document !== "undefined") {
|
||||||
|
elDialogContainer = document.createElement("div");
|
||||||
|
elDialogContainer.setAttribute("data-rmiz-portal", "");
|
||||||
|
document.body.appendChild(elDialogContainer);
|
||||||
|
}
|
||||||
|
|
||||||
|
const enum ModalState {
|
||||||
|
LOADED = "LOADED",
|
||||||
|
LOADING = "LOADING",
|
||||||
|
UNLOADED = "UNLOADED",
|
||||||
|
UNLOADING = "UNLOADING",
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BodyAttrs {
|
||||||
|
overflow: string;
|
||||||
|
width: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultBodyAttrs: BodyAttrs = {
|
||||||
|
overflow: "",
|
||||||
|
width: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ControlledProps {
|
||||||
|
children: ReactNode;
|
||||||
|
classDialog?: string;
|
||||||
|
isZoomed: boolean;
|
||||||
|
onZoomChange?: (value: boolean) => void;
|
||||||
|
wrapElement?: "div" | "span";
|
||||||
|
ZoomContent?: (data: {
|
||||||
|
img: ReactElement | null;
|
||||||
|
modalState: ModalState;
|
||||||
|
onUnzoom: () => void;
|
||||||
|
}) => ReactElement;
|
||||||
|
zoomImg?: ImgHTMLAttributes<HTMLImageElement>;
|
||||||
|
zoomMargin?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Controlled(props: ControlledProps) {
|
||||||
|
return <ControlledBase {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ControlledDefaultProps {
|
||||||
|
wrapElement: "div" | "span";
|
||||||
|
zoomMargin: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ControlledPropsWithDefaults = ControlledDefaultProps & ControlledProps;
|
||||||
|
|
||||||
|
interface ControlledState {
|
||||||
|
id: string;
|
||||||
|
isZoomImgLoaded: boolean;
|
||||||
|
loadedImgEl: HTMLImageElement | undefined;
|
||||||
|
modalState: ModalState;
|
||||||
|
shouldRefresh: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ControlledBase extends Component<
|
||||||
|
ControlledPropsWithDefaults,
|
||||||
|
ControlledState
|
||||||
|
> {
|
||||||
|
static defaultProps: ControlledDefaultProps = {
|
||||||
|
wrapElement: "div",
|
||||||
|
zoomMargin: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
state: ControlledState = {
|
||||||
|
id: "",
|
||||||
|
isZoomImgLoaded: false,
|
||||||
|
loadedImgEl: undefined,
|
||||||
|
modalState: ModalState.UNLOADED,
|
||||||
|
shouldRefresh: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
private refContent = createRef<HTMLDivElement>();
|
||||||
|
private refDialog = createRef<HTMLDialogElement>();
|
||||||
|
private refModalContent = createRef<HTMLDivElement>();
|
||||||
|
private refModalImg = createRef<HTMLImageElement>();
|
||||||
|
private refWrap = createRef<HTMLDivElement>();
|
||||||
|
|
||||||
|
private changeObserver: MutationObserver | undefined;
|
||||||
|
private imgEl: SupportedImage | null = null;
|
||||||
|
private imgElObserver: ResizeObserver | undefined;
|
||||||
|
private prevBodyAttrs: BodyAttrs = defaultBodyAttrs;
|
||||||
|
private styleModalImg: CSSProperties = {};
|
||||||
|
private touchYStart?: number;
|
||||||
|
private touchYEnd?: number;
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {
|
||||||
|
handleDialogCancel,
|
||||||
|
handleDialogClick,
|
||||||
|
handleDialogKeyDown,
|
||||||
|
handleUnzoom,
|
||||||
|
imgEl,
|
||||||
|
props: {
|
||||||
|
children,
|
||||||
|
classDialog,
|
||||||
|
isZoomed,
|
||||||
|
wrapElement: WrapElement,
|
||||||
|
ZoomContent,
|
||||||
|
zoomImg,
|
||||||
|
zoomMargin,
|
||||||
|
},
|
||||||
|
refContent,
|
||||||
|
refDialog,
|
||||||
|
refModalContent,
|
||||||
|
refModalImg,
|
||||||
|
refWrap,
|
||||||
|
state: { id, isZoomImgLoaded, loadedImgEl, modalState, shouldRefresh },
|
||||||
|
} = this;
|
||||||
|
|
||||||
|
const idModal = `rmiz-modal-${id}`;
|
||||||
|
const idModalImg = `rmiz-modal-img-${id}`;
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
const isDiv = testDiv(imgEl);
|
||||||
|
const isImg = testImg(imgEl);
|
||||||
|
const isSvg = testSvg(imgEl);
|
||||||
|
|
||||||
|
const imgAlt = getImgAlt(imgEl);
|
||||||
|
const imgSrc = getImgSrc(imgEl);
|
||||||
|
const imgSizes = isImg ? imgEl.sizes : undefined;
|
||||||
|
const imgSrcSet = isImg ? imgEl.srcset : undefined;
|
||||||
|
|
||||||
|
const hasZoomImg = !!zoomImg?.src;
|
||||||
|
|
||||||
|
const hasImage =
|
||||||
|
imgEl &&
|
||||||
|
(loadedImgEl || isSvg) &&
|
||||||
|
window.getComputedStyle(imgEl).display !== "none";
|
||||||
|
|
||||||
|
const isModalActive =
|
||||||
|
modalState === ModalState.LOADING || modalState === ModalState.LOADED;
|
||||||
|
|
||||||
|
const dataContentState = hasImage ? "found" : "not-found";
|
||||||
|
|
||||||
|
const dataOverlayState =
|
||||||
|
modalState === ModalState.UNLOADED || modalState === ModalState.UNLOADING
|
||||||
|
? "hidden"
|
||||||
|
: "visible";
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
const styleContent: CSSProperties = {
|
||||||
|
visibility: modalState === ModalState.UNLOADED ? "visible" : "hidden",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Share this with UNSAFE_handleSvg
|
||||||
|
this.styleModalImg = hasImage
|
||||||
|
? getStyleModalImg({
|
||||||
|
hasZoomImg,
|
||||||
|
imgSrc,
|
||||||
|
isSvg,
|
||||||
|
isZoomed: isZoomed && isModalActive,
|
||||||
|
loadedImgEl,
|
||||||
|
offset: zoomMargin,
|
||||||
|
shouldRefresh,
|
||||||
|
targetEl: imgEl,
|
||||||
|
})
|
||||||
|
: {};
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
let modalContent = null;
|
||||||
|
|
||||||
|
if (hasImage) {
|
||||||
|
const modalImg =
|
||||||
|
isImg || isDiv ? (
|
||||||
|
<img
|
||||||
|
alt={imgAlt}
|
||||||
|
sizes={imgSizes}
|
||||||
|
src={imgSrc}
|
||||||
|
srcSet={imgSrcSet}
|
||||||
|
{...(isZoomImgLoaded && modalState === ModalState.LOADED
|
||||||
|
? zoomImg
|
||||||
|
: {})}
|
||||||
|
data-rmiz-modal-img=""
|
||||||
|
height={this.styleModalImg.height || undefined}
|
||||||
|
id={idModalImg}
|
||||||
|
ref={refModalImg}
|
||||||
|
style={this.styleModalImg}
|
||||||
|
width={this.styleModalImg.width || undefined}
|
||||||
|
/>
|
||||||
|
) : isSvg ? (
|
||||||
|
<div
|
||||||
|
data-rmiz-modal-img
|
||||||
|
ref={refModalImg}
|
||||||
|
style={this.styleModalImg}
|
||||||
|
/>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
modalContent = ZoomContent ? (
|
||||||
|
<ZoomContent
|
||||||
|
modalState={modalState}
|
||||||
|
img={modalImg}
|
||||||
|
onUnzoom={handleUnzoom}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
modalImg
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WrapElement aria-owns={idModal} data-rmiz="" ref={refWrap}>
|
||||||
|
<WrapElement
|
||||||
|
data-rmiz-content={dataContentState}
|
||||||
|
ref={refContent}
|
||||||
|
style={styleContent}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</WrapElement>
|
||||||
|
{hasImage &&
|
||||||
|
elDialogContainer !== null &&
|
||||||
|
createPortal(
|
||||||
|
<dialog
|
||||||
|
aria-labelledby={idModalImg}
|
||||||
|
aria-modal="true"
|
||||||
|
className={classDialog}
|
||||||
|
data-rmiz-modal=""
|
||||||
|
id={idModal}
|
||||||
|
onClick={handleDialogClick}
|
||||||
|
// @ts-expect-error does not exist
|
||||||
|
onClose={handleUnzoom}
|
||||||
|
onCancel={handleDialogCancel}
|
||||||
|
onKeyDown={handleDialogKeyDown}
|
||||||
|
ref={refDialog}
|
||||||
|
role="dialog"
|
||||||
|
>
|
||||||
|
<div data-rmiz-modal-overlay={dataOverlayState} />
|
||||||
|
<div data-rmiz-modal-content="" ref={refModalContent}>
|
||||||
|
{modalContent}
|
||||||
|
<VisuallyHidden>
|
||||||
|
<button onClick={handleUnzoom}>Close</button>
|
||||||
|
</VisuallyHidden>
|
||||||
|
</div>
|
||||||
|
</dialog>,
|
||||||
|
elDialogContainer
|
||||||
|
)}
|
||||||
|
</WrapElement>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.setId();
|
||||||
|
this.setAndTrackImg();
|
||||||
|
this.handleImgLoad();
|
||||||
|
this.UNSAFE_handleSvg();
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
this.changeObserver?.disconnect?.();
|
||||||
|
this.imgElObserver?.disconnect?.();
|
||||||
|
this.imgEl?.removeEventListener?.("load", this.handleImgLoad);
|
||||||
|
this.imgEl?.removeEventListener?.("click", this.handleZoom);
|
||||||
|
this.refModalImg.current?.removeEventListener?.(
|
||||||
|
"transitionend",
|
||||||
|
this.handleZoomEnd
|
||||||
|
);
|
||||||
|
this.refModalImg.current?.removeEventListener?.(
|
||||||
|
"transitionend",
|
||||||
|
this.handleUnzoomEnd
|
||||||
|
);
|
||||||
|
window.removeEventListener("wheel", this.handleWheel);
|
||||||
|
window.removeEventListener("touchstart", this.handleTouchStart);
|
||||||
|
window.removeEventListener("touchend", this.handleTouchMove);
|
||||||
|
window.removeEventListener("touchcancel", this.handleTouchCancel);
|
||||||
|
window.removeEventListener("resize", this.handleResize);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps: ControlledPropsWithDefaults) {
|
||||||
|
this.UNSAFE_handleSvg();
|
||||||
|
this.handleIfZoomChanged(prevProps.isZoomed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Because of SSR, set a unique ID after render
|
||||||
|
|
||||||
|
setId = () => {
|
||||||
|
const gen4 = () => Math.random().toString(16).slice(-4);
|
||||||
|
this.setState({ id: gen4() + gen4() + gen4() });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Find and set the image we're working with
|
||||||
|
|
||||||
|
setAndTrackImg = () => {
|
||||||
|
const contentEl = this.refContent.current;
|
||||||
|
|
||||||
|
if (!contentEl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.imgEl = contentEl.querySelector(
|
||||||
|
':is(img, svg, [role="img"], [data-zoom]):not([aria-hidden="true"])'
|
||||||
|
) as SupportedImage | null;
|
||||||
|
|
||||||
|
if (this.imgEl) {
|
||||||
|
this.changeObserver?.disconnect?.();
|
||||||
|
this.imgEl?.addEventListener?.("load", this.handleImgLoad);
|
||||||
|
this.imgEl?.addEventListener?.("click", this.handleZoom);
|
||||||
|
|
||||||
|
if (!this.state.loadedImgEl) {
|
||||||
|
this.handleImgLoad();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.imgElObserver = new ResizeObserver((entries) => {
|
||||||
|
const entry = entries[0];
|
||||||
|
|
||||||
|
if (entry?.target) {
|
||||||
|
this.imgEl = entry.target as SupportedImage;
|
||||||
|
this.setState({}); // Force a re-render
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.imgElObserver.observe(this.imgEl);
|
||||||
|
} else if (!this.changeObserver) {
|
||||||
|
this.changeObserver = new MutationObserver(this.setAndTrackImg);
|
||||||
|
this.changeObserver.observe(contentEl, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Show modal when zoomed; hide modal when unzoomed
|
||||||
|
|
||||||
|
handleIfZoomChanged = (prevIsZoomed: boolean) => {
|
||||||
|
const { isZoomed } = this.props;
|
||||||
|
|
||||||
|
if (!prevIsZoomed && isZoomed) {
|
||||||
|
this.zoom();
|
||||||
|
} else if (prevIsZoomed && !isZoomed) {
|
||||||
|
this.unzoom();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ensure we always have the latest img src value loaded
|
||||||
|
|
||||||
|
handleImgLoad = () => {
|
||||||
|
const { imgEl } = this;
|
||||||
|
|
||||||
|
const imgSrc = getImgSrc(imgEl);
|
||||||
|
|
||||||
|
if (!imgSrc) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const img = new Image();
|
||||||
|
|
||||||
|
if (testImg(imgEl)) {
|
||||||
|
img.sizes = imgEl.sizes;
|
||||||
|
img.srcset = imgEl.srcset;
|
||||||
|
}
|
||||||
|
|
||||||
|
// img.src must be set after sizes and srcset
|
||||||
|
// because of Firefox flickering on zoom
|
||||||
|
img.src = imgSrc;
|
||||||
|
|
||||||
|
const setLoaded = () => {
|
||||||
|
this.setState({ loadedImgEl: img });
|
||||||
|
};
|
||||||
|
|
||||||
|
img
|
||||||
|
.decode()
|
||||||
|
.then(setLoaded)
|
||||||
|
.catch(() => {
|
||||||
|
img.onload = setLoaded;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Report zoom state changes
|
||||||
|
|
||||||
|
handleZoom = () => {
|
||||||
|
this.props.onZoomChange?.(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
handleUnzoom = () => {
|
||||||
|
this.props.onZoomChange?.(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Prevent the browser from removing the dialog on Escape
|
||||||
|
|
||||||
|
handleDialogCancel = (e: SyntheticEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Have dialog.click() only close in certain situations
|
||||||
|
|
||||||
|
handleDialogClick = (e: MouseEvent<HTMLDialogElement>) => {
|
||||||
|
if (
|
||||||
|
e.target === this.refModalContent.current ||
|
||||||
|
e.target === this.refModalImg.current
|
||||||
|
) {
|
||||||
|
this.handleUnzoom();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Intercept default dialog.close() and use ours so we can animate
|
||||||
|
|
||||||
|
handleDialogKeyDown = (e: KeyboardEvent<HTMLDialogElement>) => {
|
||||||
|
if (e.key === "Escape" || e.keyCode === 27) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
this.handleUnzoom();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle wheel and swipe events
|
||||||
|
|
||||||
|
handleWheel = (e: WheelEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
queueMicrotask(() => {
|
||||||
|
this.handleUnzoom();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
handleTouchStart = (e: TouchEvent) => {
|
||||||
|
if (e.changedTouches.length === 1 && e.changedTouches[0]) {
|
||||||
|
this.touchYStart = e.changedTouches[0].screenY;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleTouchMove = (e: TouchEvent) => {
|
||||||
|
if (this.touchYStart !== null && e.changedTouches[0]) {
|
||||||
|
this.touchYEnd = e.changedTouches[0].screenY;
|
||||||
|
|
||||||
|
const max = Math.max(this.touchYStart || 0, this.touchYEnd);
|
||||||
|
const min = Math.min(this.touchYStart || 0, this.touchYEnd);
|
||||||
|
const delta = Math.abs(max - min);
|
||||||
|
const threshold = 10;
|
||||||
|
|
||||||
|
if (delta > threshold) {
|
||||||
|
this.touchYStart = undefined;
|
||||||
|
this.touchYEnd = undefined;
|
||||||
|
this.handleUnzoom();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleTouchCancel = () => {
|
||||||
|
this.touchYStart = undefined;
|
||||||
|
this.touchYEnd = undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Force re-render on resize
|
||||||
|
|
||||||
|
handleResize = () => {
|
||||||
|
this.setState({ shouldRefresh: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Perform zoom actions
|
||||||
|
|
||||||
|
zoom = () => {
|
||||||
|
this.refDialog.current?.showModal?.();
|
||||||
|
this.setState({ modalState: ModalState.LOADING });
|
||||||
|
this.loadZoomImg();
|
||||||
|
|
||||||
|
window.addEventListener("wheel", this.handleWheel, { passive: true });
|
||||||
|
window.addEventListener("touchstart", this.handleTouchStart, {
|
||||||
|
passive: true,
|
||||||
|
});
|
||||||
|
window.addEventListener("touchend", this.handleTouchMove, {
|
||||||
|
passive: true,
|
||||||
|
});
|
||||||
|
window.addEventListener("touchcancel", this.handleTouchCancel, {
|
||||||
|
passive: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.refModalImg.current?.addEventListener?.(
|
||||||
|
"transitionend",
|
||||||
|
this.handleZoomEnd,
|
||||||
|
{ once: true }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
handleZoomEnd = () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
this.setState({ modalState: ModalState.LOADED });
|
||||||
|
window.addEventListener("resize", this.handleResize, { passive: true });
|
||||||
|
}, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Perform unzoom actions
|
||||||
|
|
||||||
|
unzoom = () => {
|
||||||
|
this.setState({ modalState: ModalState.UNLOADING });
|
||||||
|
|
||||||
|
window.removeEventListener("wheel", this.handleWheel);
|
||||||
|
window.removeEventListener("touchstart", this.handleTouchStart);
|
||||||
|
window.removeEventListener("touchend", this.handleTouchMove);
|
||||||
|
window.removeEventListener("touchcancel", this.handleTouchCancel);
|
||||||
|
|
||||||
|
this.refModalImg.current?.addEventListener?.(
|
||||||
|
"transitionend",
|
||||||
|
this.handleUnzoomEnd,
|
||||||
|
{ once: true }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
handleUnzoomEnd = () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
window.removeEventListener("resize", this.handleResize);
|
||||||
|
|
||||||
|
this.setState({
|
||||||
|
shouldRefresh: false,
|
||||||
|
modalState: ModalState.UNLOADED,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.refDialog.current?.close?.();
|
||||||
|
}, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load the zoomImg manually
|
||||||
|
|
||||||
|
loadZoomImg = () => {
|
||||||
|
const {
|
||||||
|
props: { zoomImg },
|
||||||
|
} = this;
|
||||||
|
const zoomImgSrc = zoomImg?.src;
|
||||||
|
|
||||||
|
if (zoomImgSrc) {
|
||||||
|
const img = new Image();
|
||||||
|
img.sizes = zoomImg?.sizes ?? "";
|
||||||
|
img.srcset = zoomImg?.srcSet ?? "";
|
||||||
|
img.src = zoomImgSrc;
|
||||||
|
|
||||||
|
const setLoaded = () => {
|
||||||
|
this.setState({ isZoomImgLoaded: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
img
|
||||||
|
.decode()
|
||||||
|
.then(setLoaded)
|
||||||
|
.catch(() => {
|
||||||
|
img.onload = setLoaded;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hackily deal with SVGs because of all of their unknowns.
|
||||||
|
|
||||||
|
UNSAFE_handleSvg = () => {
|
||||||
|
const { imgEl, refModalImg, styleModalImg } = this;
|
||||||
|
|
||||||
|
if (testSvg(imgEl)) {
|
||||||
|
const tmp = document.createElement("div");
|
||||||
|
tmp.innerHTML = imgEl.outerHTML;
|
||||||
|
|
||||||
|
const svg = tmp.firstChild as SVGSVGElement;
|
||||||
|
svg.style.width = `${styleModalImg.width ?? 0}px`;
|
||||||
|
svg.style.height = `${styleModalImg.height ?? 0}px`;
|
||||||
|
svg.addEventListener("click", this.handleUnzoom);
|
||||||
|
|
||||||
|
refModalImg.current?.firstChild?.remove?.();
|
||||||
|
refModalImg.current?.appendChild?.(svg);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
30
shared/editor/components/ImageZoom/LICENSE
Normal file
30
shared/editor/components/ImageZoom/LICENSE
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
Copyright (c) 2020, Robert Pearce
|
||||||
|
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
Redistribution and use in source and binary forms, with or without
|
||||||
|
modification, are permitted provided that the following conditions are met:
|
||||||
|
|
||||||
|
* Redistributions of source code must retain the above copyright
|
||||||
|
notice, this list of conditions and the following disclaimer.
|
||||||
|
|
||||||
|
* Redistributions in binary form must reproduce the above
|
||||||
|
copyright notice, this list of conditions and the following
|
||||||
|
disclaimer in the documentation and/or other materials provided
|
||||||
|
with the distribution.
|
||||||
|
|
||||||
|
* Neither the name of Robert Pearce nor the names of other
|
||||||
|
contributors may be used to endorse or promote products derived
|
||||||
|
from this software without specific prior written permission.
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||||
|
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||||
|
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||||
|
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
||||||
|
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||||
|
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
||||||
|
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
||||||
|
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
||||||
|
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||||
|
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||||
|
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
60
shared/editor/components/ImageZoom/Styles.tsx
Normal file
60
shared/editor/components/ImageZoom/Styles.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { transparentize } from "polished";
|
||||||
|
import { createGlobalStyle } from "styled-components";
|
||||||
|
|
||||||
|
export default createGlobalStyle`
|
||||||
|
[data-rmiz] {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
[data-rmiz-content="found"] img,
|
||||||
|
[data-rmiz-content="found"] svg,
|
||||||
|
[data-rmiz-content="found"] [role="img"],
|
||||||
|
[data-rmiz-content="found"] [data-zoom] {
|
||||||
|
cursor: zoom-in;
|
||||||
|
}
|
||||||
|
[data-rmiz-modal]::backdrop {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
[data-rmiz-modal][open] {
|
||||||
|
position: fixed;
|
||||||
|
width: 100vw;
|
||||||
|
width: 100svw;
|
||||||
|
height: 100vh;
|
||||||
|
height: 100svh;
|
||||||
|
max-width: none;
|
||||||
|
max-height: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
[data-rmiz-modal-overlay] {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
transition: background-color 0.3s;
|
||||||
|
}
|
||||||
|
[data-rmiz-modal-overlay="hidden"] {
|
||||||
|
background-color: ${(props) => transparentize(1, props.theme.background)};
|
||||||
|
}
|
||||||
|
[data-rmiz-modal-overlay="visible"] {
|
||||||
|
background-color: ${(props) => props.theme.background};
|
||||||
|
}
|
||||||
|
[data-rmiz-modal-content] {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
[data-rmiz-modal-img] {
|
||||||
|
position: absolute;
|
||||||
|
cursor: zoom-out;
|
||||||
|
image-rendering: high-quality;
|
||||||
|
transform-origin: top left;
|
||||||
|
transition: transform 0.3s;
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
[data-rmiz-modal-overlay],
|
||||||
|
[data-rmiz-modal-img] {
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
19
shared/editor/components/ImageZoom/index.tsx
Normal file
19
shared/editor/components/ImageZoom/index.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { Controlled, ControlledProps } from "./Controlled";
|
||||||
|
import Styles from "./Styles";
|
||||||
|
|
||||||
|
export type UncontrolledProps = Omit<
|
||||||
|
ControlledProps,
|
||||||
|
"isZoomed" | "onZoomChange"
|
||||||
|
>;
|
||||||
|
|
||||||
|
export default function Zoom(props: UncontrolledProps) {
|
||||||
|
const [isZoomed, setIsZoomed] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Styles />
|
||||||
|
<Controlled {...props} isZoomed={isZoomed} onZoomChange={setIsZoomed} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
shared/editor/components/ImageZoom/types.ts
Normal file
5
shared/editor/components/ImageZoom/types.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export type SupportedImage =
|
||||||
|
| HTMLImageElement
|
||||||
|
| HTMLDivElement
|
||||||
|
| HTMLSpanElement
|
||||||
|
| SVGElement;
|
||||||
555
shared/editor/components/ImageZoom/utils.ts
Normal file
555
shared/editor/components/ImageZoom/utils.ts
Normal file
@@ -0,0 +1,555 @@
|
|||||||
|
import { CSSProperties } from "react";
|
||||||
|
import type { SupportedImage } from "./types";
|
||||||
|
|
||||||
|
interface TestElType {
|
||||||
|
(type: string, el: unknown): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const testElType: TestElType = (type, el) =>
|
||||||
|
type === (el as Element)?.tagName?.toUpperCase?.();
|
||||||
|
|
||||||
|
export const testDiv = (el: unknown): el is HTMLDivElement | HTMLSpanElement =>
|
||||||
|
testElType("DIV", el) || testElType("SPAN", el);
|
||||||
|
|
||||||
|
export const testImg = (el: unknown): el is HTMLImageElement =>
|
||||||
|
testElType("IMG", el);
|
||||||
|
|
||||||
|
export const testSvg = (el: unknown): el is SVGElement => testElType("SVG", el);
|
||||||
|
|
||||||
|
export interface GetScaleToWindow {
|
||||||
|
(data: { width: number; height: number; offset: number }): number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getScaleToWindow: GetScaleToWindow = ({
|
||||||
|
height,
|
||||||
|
offset,
|
||||||
|
width,
|
||||||
|
}) => {
|
||||||
|
return Math.min(
|
||||||
|
(window.innerWidth - offset * 2) / width, // scale X-axis
|
||||||
|
(window.innerHeight - offset * 2) / height // scale Y-axis
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface GetScaleToWindowMax {
|
||||||
|
(data: {
|
||||||
|
containerHeight: number;
|
||||||
|
containerWidth: number;
|
||||||
|
offset: number;
|
||||||
|
targetHeight: number;
|
||||||
|
targetWidth: number;
|
||||||
|
}): number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getScaleToWindowMax: GetScaleToWindowMax = ({
|
||||||
|
containerHeight,
|
||||||
|
containerWidth,
|
||||||
|
offset,
|
||||||
|
targetHeight,
|
||||||
|
targetWidth,
|
||||||
|
}) => {
|
||||||
|
const scale = getScaleToWindow({
|
||||||
|
height: targetHeight,
|
||||||
|
offset,
|
||||||
|
width: targetWidth,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ratio =
|
||||||
|
targetWidth > targetHeight
|
||||||
|
? targetWidth / containerWidth
|
||||||
|
: targetHeight / containerHeight;
|
||||||
|
|
||||||
|
return scale > 1 ? ratio : scale * ratio;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface GetScale {
|
||||||
|
(data: {
|
||||||
|
containerHeight: number;
|
||||||
|
containerWidth: number;
|
||||||
|
hasScalableSrc: boolean;
|
||||||
|
offset: number;
|
||||||
|
targetHeight: number;
|
||||||
|
targetWidth: number;
|
||||||
|
}): number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getScale: GetScale = ({
|
||||||
|
containerHeight,
|
||||||
|
containerWidth,
|
||||||
|
hasScalableSrc,
|
||||||
|
offset,
|
||||||
|
targetHeight,
|
||||||
|
targetWidth,
|
||||||
|
}) => {
|
||||||
|
return !hasScalableSrc && targetHeight && targetWidth
|
||||||
|
? getScaleToWindowMax({
|
||||||
|
containerHeight,
|
||||||
|
containerWidth,
|
||||||
|
offset,
|
||||||
|
targetHeight,
|
||||||
|
targetWidth,
|
||||||
|
})
|
||||||
|
: getScaleToWindow({
|
||||||
|
height: containerHeight,
|
||||||
|
offset,
|
||||||
|
width: containerWidth,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const URL_REGEX = /url(?:\(['"]?)(.*?)(?:['"]?\))/;
|
||||||
|
|
||||||
|
export interface GetImgSrc {
|
||||||
|
(imgEl: SupportedImage | null): string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getImgSrc: GetImgSrc = (imgEl) => {
|
||||||
|
if (imgEl) {
|
||||||
|
if (testImg(imgEl)) {
|
||||||
|
return imgEl.currentSrc;
|
||||||
|
} else if (testDiv(imgEl)) {
|
||||||
|
const bgImg = window.getComputedStyle(imgEl).backgroundImage;
|
||||||
|
|
||||||
|
if (bgImg) {
|
||||||
|
return URL_REGEX.exec(bgImg)?.[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface GetImgAlt {
|
||||||
|
(imgEl: SupportedImage | null): string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getImgAlt: GetImgAlt = (imgEl) => {
|
||||||
|
if (imgEl) {
|
||||||
|
if (testImg(imgEl)) {
|
||||||
|
return imgEl.alt ?? undefined;
|
||||||
|
} else {
|
||||||
|
return imgEl.getAttribute("aria-label") ?? undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface GetImgRegularStyle {
|
||||||
|
(data: {
|
||||||
|
containerHeight: number;
|
||||||
|
containerLeft: number;
|
||||||
|
containerTop: number;
|
||||||
|
containerWidth: number;
|
||||||
|
hasScalableSrc: boolean;
|
||||||
|
offset: number;
|
||||||
|
targetHeight: number;
|
||||||
|
targetWidth: number;
|
||||||
|
}): CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getImgRegularStyle: GetImgRegularStyle = ({
|
||||||
|
containerHeight,
|
||||||
|
containerLeft,
|
||||||
|
containerTop,
|
||||||
|
containerWidth,
|
||||||
|
hasScalableSrc,
|
||||||
|
offset,
|
||||||
|
targetHeight,
|
||||||
|
targetWidth,
|
||||||
|
}) => {
|
||||||
|
const scale = getScale({
|
||||||
|
containerHeight,
|
||||||
|
containerWidth,
|
||||||
|
hasScalableSrc,
|
||||||
|
offset,
|
||||||
|
targetHeight,
|
||||||
|
targetWidth,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
top: containerTop,
|
||||||
|
left: containerLeft,
|
||||||
|
width: containerWidth * scale,
|
||||||
|
height: containerHeight * scale,
|
||||||
|
transform: `translate(0,0) scale(${1 / scale})`,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ParsePosition {
|
||||||
|
(data: { position: string; relativeNum: number }): number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const parsePosition: ParsePosition = ({ position, relativeNum }) => {
|
||||||
|
const positionNum = parseFloat(position);
|
||||||
|
|
||||||
|
return position.endsWith("%")
|
||||||
|
? (relativeNum * positionNum) / 100
|
||||||
|
: positionNum;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface GetImgObjectFitStyle {
|
||||||
|
(data: {
|
||||||
|
containerHeight: number;
|
||||||
|
containerLeft: number;
|
||||||
|
containerTop: number;
|
||||||
|
containerWidth: number;
|
||||||
|
hasScalableSrc: boolean;
|
||||||
|
objectFit: string;
|
||||||
|
objectPosition: string;
|
||||||
|
offset: number;
|
||||||
|
targetHeight: number;
|
||||||
|
targetWidth: number;
|
||||||
|
}): CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getImgObjectFitStyle: GetImgObjectFitStyle = ({
|
||||||
|
containerHeight,
|
||||||
|
containerLeft,
|
||||||
|
containerTop,
|
||||||
|
containerWidth,
|
||||||
|
hasScalableSrc,
|
||||||
|
objectFit,
|
||||||
|
objectPosition,
|
||||||
|
offset,
|
||||||
|
targetHeight,
|
||||||
|
targetWidth,
|
||||||
|
}) => {
|
||||||
|
if (objectFit === "scale-down") {
|
||||||
|
if (targetWidth <= containerWidth && targetHeight <= containerHeight) {
|
||||||
|
objectFit = "none";
|
||||||
|
} else {
|
||||||
|
objectFit = "contain";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (objectFit === "cover" || objectFit === "contain") {
|
||||||
|
const widthRatio = containerWidth / targetWidth;
|
||||||
|
const heightRatio = containerHeight / targetHeight;
|
||||||
|
|
||||||
|
const ratio =
|
||||||
|
objectFit === "cover"
|
||||||
|
? Math.max(widthRatio, heightRatio)
|
||||||
|
: Math.min(widthRatio, heightRatio);
|
||||||
|
|
||||||
|
const [posLeft = "50%", posTop = "50%"] = objectPosition.split(" ");
|
||||||
|
const posX = parsePosition({
|
||||||
|
position: posLeft,
|
||||||
|
relativeNum: containerWidth - targetWidth * ratio,
|
||||||
|
});
|
||||||
|
const posY = parsePosition({
|
||||||
|
position: posTop,
|
||||||
|
relativeNum: containerHeight - targetHeight * ratio,
|
||||||
|
});
|
||||||
|
|
||||||
|
const scale = getScale({
|
||||||
|
containerHeight: targetHeight * ratio,
|
||||||
|
containerWidth: targetWidth * ratio,
|
||||||
|
hasScalableSrc,
|
||||||
|
offset,
|
||||||
|
targetHeight,
|
||||||
|
targetWidth,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
top: containerTop + posY,
|
||||||
|
left: containerLeft + posX,
|
||||||
|
width: targetWidth * ratio * scale,
|
||||||
|
height: targetHeight * ratio * scale,
|
||||||
|
transform: `translate(0,0) scale(${1 / scale})`,
|
||||||
|
};
|
||||||
|
} else if (objectFit === "none") {
|
||||||
|
const [posLeft = "50%", posTop = "50%"] = objectPosition.split(" ");
|
||||||
|
const posX = parsePosition({
|
||||||
|
position: posLeft,
|
||||||
|
relativeNum: containerWidth - targetWidth,
|
||||||
|
});
|
||||||
|
const posY = parsePosition({
|
||||||
|
position: posTop,
|
||||||
|
relativeNum: containerHeight - targetHeight,
|
||||||
|
});
|
||||||
|
|
||||||
|
const scale = getScale({
|
||||||
|
containerHeight: targetHeight,
|
||||||
|
containerWidth: targetWidth,
|
||||||
|
hasScalableSrc,
|
||||||
|
offset,
|
||||||
|
targetHeight,
|
||||||
|
targetWidth,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
top: containerTop + posY,
|
||||||
|
left: containerLeft + posX,
|
||||||
|
width: targetWidth * scale,
|
||||||
|
height: targetHeight * scale,
|
||||||
|
transform: `translate(0,0) scale(${1 / scale})`,
|
||||||
|
};
|
||||||
|
} else if (objectFit === "fill") {
|
||||||
|
const widthRatio = containerWidth / targetWidth;
|
||||||
|
const heightRatio = containerHeight / targetHeight;
|
||||||
|
const ratio = Math.max(widthRatio, heightRatio);
|
||||||
|
|
||||||
|
const scale = getScale({
|
||||||
|
containerHeight: targetHeight * ratio,
|
||||||
|
containerWidth: targetWidth * ratio,
|
||||||
|
hasScalableSrc,
|
||||||
|
offset,
|
||||||
|
targetHeight,
|
||||||
|
targetWidth,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
width: containerWidth * scale,
|
||||||
|
height: containerHeight * scale,
|
||||||
|
transform: `translate(0,0) scale(${1 / scale})`,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface GetDivImgStyle {
|
||||||
|
(data: {
|
||||||
|
backgroundPosition: string;
|
||||||
|
backgroundSize: string;
|
||||||
|
containerHeight: number;
|
||||||
|
containerLeft: number;
|
||||||
|
containerTop: number;
|
||||||
|
containerWidth: number;
|
||||||
|
hasScalableSrc: boolean;
|
||||||
|
offset: number;
|
||||||
|
targetHeight: number;
|
||||||
|
targetWidth: number;
|
||||||
|
}): CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getDivImgStyle: GetDivImgStyle = ({
|
||||||
|
backgroundPosition,
|
||||||
|
backgroundSize,
|
||||||
|
containerHeight,
|
||||||
|
containerLeft,
|
||||||
|
containerTop,
|
||||||
|
containerWidth,
|
||||||
|
hasScalableSrc,
|
||||||
|
offset,
|
||||||
|
targetHeight,
|
||||||
|
targetWidth,
|
||||||
|
}) => {
|
||||||
|
if (backgroundSize === "cover" || backgroundSize === "contain") {
|
||||||
|
const widthRatio = containerWidth / targetWidth;
|
||||||
|
const heightRatio = containerHeight / targetHeight;
|
||||||
|
|
||||||
|
const ratio =
|
||||||
|
backgroundSize === "cover"
|
||||||
|
? Math.max(widthRatio, heightRatio)
|
||||||
|
: Math.min(widthRatio, heightRatio);
|
||||||
|
|
||||||
|
const [posLeft = "50%", posTop = "50%"] = backgroundPosition.split(" ");
|
||||||
|
const posX = parsePosition({
|
||||||
|
position: posLeft,
|
||||||
|
relativeNum: containerWidth - targetWidth * ratio,
|
||||||
|
});
|
||||||
|
const posY = parsePosition({
|
||||||
|
position: posTop,
|
||||||
|
relativeNum: containerHeight - targetHeight * ratio,
|
||||||
|
});
|
||||||
|
|
||||||
|
const scale = getScale({
|
||||||
|
containerHeight: targetHeight * ratio,
|
||||||
|
containerWidth: targetWidth * ratio,
|
||||||
|
hasScalableSrc,
|
||||||
|
offset,
|
||||||
|
targetHeight,
|
||||||
|
targetWidth,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
top: containerTop + posY,
|
||||||
|
left: containerLeft + posX,
|
||||||
|
width: targetWidth * ratio * scale,
|
||||||
|
height: targetHeight * ratio * scale,
|
||||||
|
transform: `translate(0,0) scale(${1 / scale})`,
|
||||||
|
};
|
||||||
|
} else if (backgroundSize === "auto") {
|
||||||
|
const [posLeft = "50%", posTop = "50%"] = backgroundPosition.split(" ");
|
||||||
|
const posX = parsePosition({
|
||||||
|
position: posLeft,
|
||||||
|
relativeNum: containerWidth - targetWidth,
|
||||||
|
});
|
||||||
|
const posY = parsePosition({
|
||||||
|
position: posTop,
|
||||||
|
relativeNum: containerHeight - targetHeight,
|
||||||
|
});
|
||||||
|
|
||||||
|
const scale = getScale({
|
||||||
|
containerHeight: targetHeight,
|
||||||
|
containerWidth: targetWidth,
|
||||||
|
hasScalableSrc,
|
||||||
|
offset,
|
||||||
|
targetHeight,
|
||||||
|
targetWidth,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
top: containerTop + posY,
|
||||||
|
left: containerLeft + posX,
|
||||||
|
width: targetWidth * scale,
|
||||||
|
height: targetHeight * scale,
|
||||||
|
transform: `translate(0,0) scale(${1 / scale})`,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const [sizeW = "50%", sizeH = "50%"] = backgroundSize.split(" ");
|
||||||
|
const sizeWidth = parsePosition({
|
||||||
|
position: sizeW,
|
||||||
|
relativeNum: containerWidth,
|
||||||
|
});
|
||||||
|
const sizeHeight = parsePosition({
|
||||||
|
position: sizeH,
|
||||||
|
relativeNum: containerHeight,
|
||||||
|
});
|
||||||
|
|
||||||
|
const widthRatio = sizeWidth / targetWidth;
|
||||||
|
const heightRatio = sizeHeight / targetHeight;
|
||||||
|
|
||||||
|
// @TODO: something funny is happening with this ratio
|
||||||
|
const ratio = Math.min(widthRatio, heightRatio);
|
||||||
|
|
||||||
|
const [posLeft = "50%", posTop = "50%"] = backgroundPosition.split(" ");
|
||||||
|
const posX = parsePosition({
|
||||||
|
position: posLeft,
|
||||||
|
relativeNum: containerWidth - targetWidth * ratio,
|
||||||
|
});
|
||||||
|
const posY = parsePosition({
|
||||||
|
position: posTop,
|
||||||
|
relativeNum: containerHeight - targetHeight * ratio,
|
||||||
|
});
|
||||||
|
|
||||||
|
const scale = getScale({
|
||||||
|
containerHeight: targetHeight * ratio,
|
||||||
|
containerWidth: targetWidth * ratio,
|
||||||
|
hasScalableSrc,
|
||||||
|
offset,
|
||||||
|
targetHeight,
|
||||||
|
targetWidth,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
top: containerTop + posY,
|
||||||
|
left: containerLeft + posX,
|
||||||
|
width: targetWidth * ratio * scale,
|
||||||
|
height: targetHeight * ratio * scale,
|
||||||
|
transform: `translate(0,0) scale(${1 / scale})`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const SRC_SVG_REGEX = /\.svg$/i;
|
||||||
|
|
||||||
|
export interface GetStyleModalImg {
|
||||||
|
(data: {
|
||||||
|
hasZoomImg: boolean;
|
||||||
|
imgSrc: string | undefined;
|
||||||
|
isSvg: boolean;
|
||||||
|
isZoomed: boolean;
|
||||||
|
loadedImgEl: HTMLImageElement | undefined;
|
||||||
|
offset: number;
|
||||||
|
shouldRefresh: boolean;
|
||||||
|
targetEl: SupportedImage;
|
||||||
|
}): CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getStyleModalImg: GetStyleModalImg = ({
|
||||||
|
hasZoomImg,
|
||||||
|
imgSrc,
|
||||||
|
isSvg,
|
||||||
|
isZoomed,
|
||||||
|
loadedImgEl,
|
||||||
|
offset,
|
||||||
|
shouldRefresh,
|
||||||
|
targetEl,
|
||||||
|
}) => {
|
||||||
|
const hasScalableSrc =
|
||||||
|
isSvg ||
|
||||||
|
imgSrc?.slice?.(0, 18) === "data:image/svg+xml" ||
|
||||||
|
hasZoomImg ||
|
||||||
|
!!(imgSrc && SRC_SVG_REGEX.test(imgSrc));
|
||||||
|
|
||||||
|
const imgRect = targetEl.getBoundingClientRect();
|
||||||
|
const targetElComputedStyle = window.getComputedStyle(targetEl);
|
||||||
|
|
||||||
|
const styleImgRegular = getImgRegularStyle({
|
||||||
|
containerHeight: imgRect.height,
|
||||||
|
containerLeft: imgRect.left,
|
||||||
|
containerTop: imgRect.top,
|
||||||
|
containerWidth: imgRect.width,
|
||||||
|
hasScalableSrc,
|
||||||
|
offset,
|
||||||
|
targetHeight: loadedImgEl?.naturalHeight ?? imgRect.height,
|
||||||
|
targetWidth: loadedImgEl?.naturalWidth ?? imgRect.width,
|
||||||
|
});
|
||||||
|
|
||||||
|
const styleImgObjectFit =
|
||||||
|
loadedImgEl && targetElComputedStyle.objectFit
|
||||||
|
? getImgObjectFitStyle({
|
||||||
|
containerHeight: imgRect.height,
|
||||||
|
containerLeft: imgRect.left,
|
||||||
|
containerTop: imgRect.top,
|
||||||
|
containerWidth: imgRect.width,
|
||||||
|
hasScalableSrc,
|
||||||
|
objectFit: targetElComputedStyle.objectFit,
|
||||||
|
objectPosition: targetElComputedStyle.objectPosition,
|
||||||
|
offset,
|
||||||
|
targetHeight: loadedImgEl.naturalHeight,
|
||||||
|
targetWidth: loadedImgEl.naturalWidth,
|
||||||
|
})
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const styleDivImg =
|
||||||
|
loadedImgEl && testDiv(targetEl)
|
||||||
|
? getDivImgStyle({
|
||||||
|
backgroundPosition: targetElComputedStyle.backgroundPosition,
|
||||||
|
backgroundSize: targetElComputedStyle.backgroundSize,
|
||||||
|
containerHeight: imgRect.height,
|
||||||
|
containerLeft: imgRect.left,
|
||||||
|
containerTop: imgRect.top,
|
||||||
|
containerWidth: imgRect.width,
|
||||||
|
hasScalableSrc,
|
||||||
|
offset,
|
||||||
|
targetHeight: loadedImgEl.naturalHeight,
|
||||||
|
targetWidth: loadedImgEl.naturalWidth,
|
||||||
|
})
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const style = Object.assign(
|
||||||
|
{},
|
||||||
|
styleImgRegular,
|
||||||
|
styleImgObjectFit,
|
||||||
|
styleDivImg
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isZoomed) {
|
||||||
|
const viewportX = window.innerWidth / 2;
|
||||||
|
const viewportY = window.innerHeight / 2;
|
||||||
|
|
||||||
|
const childCenterX =
|
||||||
|
parseFloat(String(style.left || 0)) +
|
||||||
|
parseFloat(String(style.width || 0)) / 2;
|
||||||
|
const childCenterY =
|
||||||
|
parseFloat(String(style.top || 0)) +
|
||||||
|
parseFloat(String(style.height || 0)) / 2;
|
||||||
|
|
||||||
|
const translateX = viewportX - childCenterX;
|
||||||
|
const translateY = viewportY - childCenterY;
|
||||||
|
|
||||||
|
// For scenarios like resizing the browser window
|
||||||
|
if (shouldRefresh) {
|
||||||
|
style.transitionDuration = "0.01ms";
|
||||||
|
}
|
||||||
|
|
||||||
|
style.transform = `translate(${translateX}px,${translateY}px) scale(1)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return style;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface GetStyleGhost {
|
||||||
|
(imgEl: SupportedImage | null): CSSProperties;
|
||||||
|
}
|
||||||
@@ -247,9 +247,6 @@ li {
|
|||||||
pointer-events: ${props.readOnly ? "initial" : "none"};
|
pointer-events: ${props.readOnly ? "initial" : "none"};
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
transition-property: width, height;
|
|
||||||
transition-duration: 150ms;
|
|
||||||
transition-timing-function: ease-in-out;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ProseMirror-selectednode img {
|
.ProseMirror-selectednode img {
|
||||||
@@ -289,6 +286,20 @@ li {
|
|||||||
clear: initial;
|
clear: initial;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.image-full-width {
|
||||||
|
width: initial;
|
||||||
|
max-width: 100vw;
|
||||||
|
clear: both;
|
||||||
|
position: initial;
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-width: 100vw;
|
||||||
|
max-height: 50vh;
|
||||||
|
object-fit: cover;
|
||||||
|
object-position: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.ProseMirror-hideselection *::selection {
|
.ProseMirror-hideselection *::selection {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import ImageZoom from "react-medium-image-zoom";
|
|
||||||
import Frame from "../components/Frame";
|
import Frame from "../components/Frame";
|
||||||
|
import ImageZoom from "../components/ImageZoom";
|
||||||
import { EmbedProps as Props } from ".";
|
import { EmbedProps as Props } from ".";
|
||||||
|
|
||||||
const IFRAME_REGEX = /^https:\/\/(invis\.io\/.*)|(projects\.invisionapp\.com\/share\/.*)$/;
|
const IFRAME_REGEX = /^https:\/\/(invis\.io\/.*)|(projects\.invisionapp\.com\/share\/.*)$/;
|
||||||
@@ -9,19 +9,18 @@ const IMAGE_REGEX = /^https:\/\/(opal\.invisionapp\.com\/static-signed\/live-emb
|
|||||||
function InVision(props: Props) {
|
function InVision(props: Props) {
|
||||||
if (IMAGE_REGEX.test(props.attrs.href)) {
|
if (IMAGE_REGEX.test(props.attrs.href)) {
|
||||||
return (
|
return (
|
||||||
<ImageZoom
|
<div className={props.isSelected ? "ProseMirror-selectednode" : ""}>
|
||||||
// @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call.
|
<ImageZoom zoomMargin={24}>
|
||||||
className={props.isSelected ? "ProseMirror-selectednode" : ""}
|
<img
|
||||||
image={{
|
src={props.attrs.href}
|
||||||
src: props.attrs.href,
|
alt="InVision Embed"
|
||||||
alt: "InVision Embed",
|
style={{
|
||||||
style: {
|
maxWidth: "100%",
|
||||||
maxWidth: "100%",
|
maxHeight: "75vh",
|
||||||
maxHeight: "75vh",
|
}}
|
||||||
},
|
/>
|
||||||
}}
|
</ImageZoom>
|
||||||
shouldRespectMaxDimension
|
</div>
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,13 +10,13 @@ import {
|
|||||||
} from "prosemirror-state";
|
} from "prosemirror-state";
|
||||||
import { EditorView } from "prosemirror-view";
|
import { EditorView } from "prosemirror-view";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import ImageZoom, { ImageZoom_Image } from "react-medium-image-zoom";
|
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import breakpoint from "styled-components-breakpoint";
|
import breakpoint from "styled-components-breakpoint";
|
||||||
import { getDataTransferFiles, getEventFiles } from "../../utils/files";
|
import { getDataTransferFiles, getEventFiles } from "../../utils/files";
|
||||||
import { sanitizeUrl } from "../../utils/urls";
|
import { sanitizeUrl } from "../../utils/urls";
|
||||||
import { AttachmentValidation } from "../../validations";
|
import { AttachmentValidation } from "../../validations";
|
||||||
import insertFiles, { Options } from "../commands/insertFiles";
|
import insertFiles, { Options } from "../commands/insertFiles";
|
||||||
|
import ImageZoom from "../components/ImageZoom";
|
||||||
import { MarkdownSerializerState } from "../lib/markdown/serializer";
|
import { MarkdownSerializerState } from "../lib/markdown/serializer";
|
||||||
import uploadPlaceholderPlugin from "../lib/uploadPlaceholder";
|
import uploadPlaceholderPlugin from "../lib/uploadPlaceholder";
|
||||||
import { ComponentProps, Dispatch } from "../types";
|
import { ComponentProps, Dispatch } from "../types";
|
||||||
@@ -99,7 +99,7 @@ const uploadPlugin = (options: Options) =>
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const IMAGE_CLASSES = ["right-50", "left-50"];
|
const IMAGE_CLASSES = ["right-50", "left-50", "full-width"];
|
||||||
const imageSizeRegex = /\s=(\d+)?x(\d+)?$/;
|
const imageSizeRegex = /\s=(\d+)?x(\d+)?$/;
|
||||||
|
|
||||||
type TitleAttributes = {
|
type TitleAttributes = {
|
||||||
@@ -362,7 +362,6 @@ export default class Image extends Node {
|
|||||||
return (
|
return (
|
||||||
<ImageComponent
|
<ImageComponent
|
||||||
{...props}
|
{...props}
|
||||||
view={this.editor.view}
|
|
||||||
onClick={this.handleSelect(props)}
|
onClick={this.handleSelect(props)}
|
||||||
onDownload={this.handleDownload(props)}
|
onDownload={this.handleDownload(props)}
|
||||||
onChangeSize={this.handleChangeSize(props)}
|
onChangeSize={this.handleChangeSize(props)}
|
||||||
@@ -475,6 +474,19 @@ export default class Image extends Node {
|
|||||||
dispatch(state.tr.setNodeMarkup(selection.from, undefined, attrs));
|
dispatch(state.tr.setNodeMarkup(selection.from, undefined, attrs));
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
alignFullWidth: () => (state: EditorState, dispatch: Dispatch) => {
|
||||||
|
if (!(state.selection instanceof NodeSelection)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const attrs = {
|
||||||
|
...state.selection.node.attrs,
|
||||||
|
title: null,
|
||||||
|
layoutClass: "full-width",
|
||||||
|
};
|
||||||
|
const { selection } = state;
|
||||||
|
dispatch(state.tr.setNodeMarkup(selection.from, undefined, attrs));
|
||||||
|
return true;
|
||||||
|
},
|
||||||
replaceImage: () => (state: EditorState) => {
|
replaceImage: () => (state: EditorState) => {
|
||||||
if (!(state.selection instanceof NodeSelection)) {
|
if (!(state.selection instanceof NodeSelection)) {
|
||||||
return false;
|
return false;
|
||||||
@@ -584,9 +596,12 @@ const ImageComponent = (
|
|||||||
view: EditorView;
|
view: EditorView;
|
||||||
}
|
}
|
||||||
) => {
|
) => {
|
||||||
const { theme, isSelected, node, isEditable } = props;
|
const { isSelected, node, isEditable } = props;
|
||||||
const { alt, src, layoutClass } = node.attrs;
|
const { src, layoutClass } = node.attrs;
|
||||||
const className = layoutClass ? `image image-${layoutClass}` : "image";
|
const className = layoutClass ? `image image-${layoutClass}` : "image";
|
||||||
|
const [contentWidth, setContentWidth] = React.useState(
|
||||||
|
() => document.body.querySelector("#full-width-container")?.clientWidth || 0
|
||||||
|
);
|
||||||
const [naturalWidth, setNaturalWidth] = React.useState(node.attrs.width);
|
const [naturalWidth, setNaturalWidth] = React.useState(node.attrs.width);
|
||||||
const [naturalHeight, setNaturalHeight] = React.useState(node.attrs.height);
|
const [naturalHeight, setNaturalHeight] = React.useState(node.attrs.height);
|
||||||
const [size, setSize] = React.useState({
|
const [size, setSize] = React.useState({
|
||||||
@@ -596,8 +611,25 @@ const ImageComponent = (
|
|||||||
const [sizeAtDragStart, setSizeAtDragStart] = React.useState(size);
|
const [sizeAtDragStart, setSizeAtDragStart] = React.useState(size);
|
||||||
const [offset, setOffset] = React.useState(0);
|
const [offset, setOffset] = React.useState(0);
|
||||||
const [dragging, setDragging] = React.useState<DragDirection>();
|
const [dragging, setDragging] = React.useState<DragDirection>();
|
||||||
const documentWidth = props.view?.dom.clientWidth;
|
const [documentWidth, setDocumentWidth] = React.useState(
|
||||||
|
props.view?.dom.clientWidth || 0
|
||||||
|
);
|
||||||
const maxWidth = layoutClass ? documentWidth / 3 : documentWidth;
|
const maxWidth = layoutClass ? documentWidth / 3 : documentWidth;
|
||||||
|
const isFullWidth = layoutClass === "full-width";
|
||||||
|
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
const handleResize = () => {
|
||||||
|
const contentWidth =
|
||||||
|
document.body.querySelector("#full-width-container")?.clientWidth || 0;
|
||||||
|
setContentWidth(contentWidth);
|
||||||
|
setDocumentWidth(props.view?.dom.clientWidth || 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("resize", handleResize);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("resize", handleResize);
|
||||||
|
};
|
||||||
|
}, [props.view]);
|
||||||
|
|
||||||
const constrainWidth = (width: number) => {
|
const constrainWidth = (width: number) => {
|
||||||
const minWidth = documentWidth * 0.1;
|
const minWidth = documentWidth * 0.1;
|
||||||
@@ -687,52 +719,51 @@ const ImageComponent = (
|
|||||||
};
|
};
|
||||||
}, [dragging, handlePointerMove, handlePointerUp]);
|
}, [dragging, handlePointerMove, handlePointerUp]);
|
||||||
|
|
||||||
const style = { width: size.width || "auto" };
|
const style = isFullWidth
|
||||||
|
? { width: contentWidth }
|
||||||
|
: { width: size.width || "auto" };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div contentEditable={false} className={className}>
|
<div contentEditable={false} className={className}>
|
||||||
<ImageWrapper
|
<ImageWrapper
|
||||||
|
isFullWidth={isFullWidth}
|
||||||
className={isSelected || dragging ? "ProseMirror-selectednode" : ""}
|
className={isSelected || dragging ? "ProseMirror-selectednode" : ""}
|
||||||
onClick={dragging ? undefined : props.onClick}
|
onClick={dragging ? undefined : props.onClick}
|
||||||
style={style}
|
style={{
|
||||||
|
...style,
|
||||||
|
...(isFullWidth
|
||||||
|
? { marginLeft: -(contentWidth - documentWidth) / 2 }
|
||||||
|
: {}),
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{!dragging && size.width > 60 && size.height > 60 && (
|
{!dragging && size.width > 60 && size.height > 60 && (
|
||||||
<Button onClick={props.onDownload}>
|
<Button onClick={props.onDownload}>
|
||||||
<DownloadIcon color="currentColor" />
|
<DownloadIcon color="currentColor" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<ImageZoom
|
<ImageZoom zoomMargin={24}>
|
||||||
image={
|
<img
|
||||||
{
|
style={style}
|
||||||
style,
|
src={sanitizeUrl(src) ?? ""}
|
||||||
src: sanitizeUrl(src) ?? "",
|
onLoad={(ev: React.SyntheticEvent<HTMLImageElement>) => {
|
||||||
alt,
|
// For some SVG's Firefox does not provide the naturalWidth, in this
|
||||||
onLoad: (ev: React.SyntheticEvent<HTMLImageElement>) => {
|
// rare case we need to provide a default so that the image can be
|
||||||
// For some SVG's Firefox does not provide the naturalWidth, in this
|
// seen and is not sized to 0px
|
||||||
// rare case we need to provide a default so that the image can be
|
const nw = (ev.target as HTMLImageElement).naturalWidth || 300;
|
||||||
// seen and is not sized to 0px
|
const nh = (ev.target as HTMLImageElement).naturalHeight;
|
||||||
const nw = (ev.target as HTMLImageElement).naturalWidth || 300;
|
setNaturalWidth(nw);
|
||||||
const nh = (ev.target as HTMLImageElement).naturalHeight;
|
setNaturalHeight(nh);
|
||||||
setNaturalWidth(nw);
|
|
||||||
setNaturalHeight(nh);
|
|
||||||
|
|
||||||
if (!node.attrs.width) {
|
if (!node.attrs.width) {
|
||||||
setSize((state) => ({
|
setSize((state) => ({
|
||||||
...state,
|
...state,
|
||||||
width: nw,
|
width: nw,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
},
|
}}
|
||||||
} as ImageZoom_Image
|
/>
|
||||||
}
|
</ImageZoom>
|
||||||
defaultStyles={{
|
{isEditable && !isFullWidth && (
|
||||||
overlay: {
|
|
||||||
backgroundColor: theme.background,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
shouldRespectMaxDimension
|
|
||||||
/>
|
|
||||||
{isEditable && (
|
|
||||||
<>
|
<>
|
||||||
<ResizeLeft
|
<ResizeLeft
|
||||||
onPointerDown={handlePointerDown("left")}
|
onPointerDown={handlePointerDown("left")}
|
||||||
@@ -846,16 +877,23 @@ const Caption = styled.p`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const ImageWrapper = styled.div`
|
const ImageWrapper = styled.div<{ isFullWidth: boolean }>`
|
||||||
line-height: 0;
|
line-height: 0;
|
||||||
position: relative;
|
position: relative;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
max-width: 100%;
|
max-width: ${(props) => (props.isFullWidth ? "initial" : "100%")};
|
||||||
transition-property: width, height;
|
transition-property: width, height;
|
||||||
transition-duration: 150ms;
|
transition-duration: ${(props) => (props.isFullWidth ? "0ms" : "150ms")};
|
||||||
transition-timing-function: ease-in-out;
|
transition-timing-function: ease-in-out;
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
img {
|
||||||
|
transition-property: width, height;
|
||||||
|
transition-duration: ${(props) => (props.isFullWidth ? "0ms" : "150ms")};
|
||||||
|
transition-timing-function: ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
${Button} {
|
${Button} {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Node as ProsemirrorNode } from "prosemirror-model";
|
import { Node as ProsemirrorNode } from "prosemirror-model";
|
||||||
import { EditorState, Transaction } from "prosemirror-state";
|
import { EditorState, Transaction } from "prosemirror-state";
|
||||||
|
import { EditorView } from "prosemirror-view";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { DefaultTheme } from "styled-components";
|
import { DefaultTheme } from "styled-components";
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@ export type MenuItem = {
|
|||||||
|
|
||||||
export type ComponentProps = {
|
export type ComponentProps = {
|
||||||
theme: DefaultTheme;
|
theme: DefaultTheme;
|
||||||
|
view: EditorView;
|
||||||
node: ProsemirrorNode;
|
node: ProsemirrorNode;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
isEditable: boolean;
|
isEditable: boolean;
|
||||||
|
|||||||
@@ -234,6 +234,7 @@
|
|||||||
"Align center": "Align center",
|
"Align center": "Align center",
|
||||||
"Align left": "Align left",
|
"Align left": "Align left",
|
||||||
"Align right": "Align right",
|
"Align right": "Align right",
|
||||||
|
"Full width": "Full width",
|
||||||
"Bulleted list": "Bulleted list",
|
"Bulleted list": "Bulleted list",
|
||||||
"Todo list": "Task list",
|
"Todo list": "Task list",
|
||||||
"Code block": "Code block",
|
"Code block": "Code block",
|
||||||
@@ -249,9 +250,6 @@
|
|||||||
"Delete image": "Delete image",
|
"Delete image": "Delete image",
|
||||||
"Download image": "Download image",
|
"Download image": "Download image",
|
||||||
"Replace image": "Replace image",
|
"Replace image": "Replace image",
|
||||||
"Float left": "Float left",
|
|
||||||
"Float right": "Float right",
|
|
||||||
"Center large": "Center large",
|
|
||||||
"Italic": "Italic",
|
"Italic": "Italic",
|
||||||
"Sorry, that link won’t work for this embed type": "Sorry, that link won’t work for this embed type",
|
"Sorry, that link won’t work for this embed type": "Sorry, that link won’t work for this embed type",
|
||||||
"File attachment": "File attachment",
|
"File attachment": "File attachment",
|
||||||
@@ -313,7 +311,6 @@
|
|||||||
"Choose a collection": "Choose a collection",
|
"Choose a collection": "Choose a collection",
|
||||||
"Unpublish": "Unpublish",
|
"Unpublish": "Unpublish",
|
||||||
"Enable embeds": "Enable embeds",
|
"Enable embeds": "Enable embeds",
|
||||||
"Full width": "Full width",
|
|
||||||
"Export options": "Export options",
|
"Export options": "Export options",
|
||||||
"Edit group": "Edit group",
|
"Edit group": "Edit group",
|
||||||
"Delete group": "Delete group",
|
"Delete group": "Delete group",
|
||||||
|
|||||||
20
yarn.lock
20
yarn.lock
@@ -3318,13 +3318,6 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
"@types/react" "*"
|
"@types/react" "*"
|
||||||
|
|
||||||
"@types/react-medium-image-zoom@^3.0.1":
|
|
||||||
version "3.0.1"
|
|
||||||
resolved "https://registry.yarnpkg.com/@types/react-medium-image-zoom/-/react-medium-image-zoom-3.0.1.tgz#3c7440edb29515b9d5d3eab808b46d604f8a030b"
|
|
||||||
integrity sha512-jZejvKxPGOZDiNME3z6Grjex2xTDEXGo+FIrqMTfCClYU4qWR3yOHKI0Hv0C3DzzK99o38CD3bMw6TQ0f5yy4w==
|
|
||||||
dependencies:
|
|
||||||
"@types/react" "*"
|
|
||||||
|
|
||||||
"@types/react-portal@^4.0.4":
|
"@types/react-portal@^4.0.4":
|
||||||
version "4.0.4"
|
version "4.0.4"
|
||||||
resolved "https://registry.yarnpkg.com/@types/react-portal/-/react-portal-4.0.4.tgz#1c0e5a248f6e18a66f981139c13b6e796f4a92b6"
|
resolved "https://registry.yarnpkg.com/@types/react-portal/-/react-portal-4.0.4.tgz#1c0e5a248f6e18a66f981139c13b6e796f4a92b6"
|
||||||
@@ -11639,10 +11632,10 @@ os-browserify@^0.3.0:
|
|||||||
resolved "https://registry.yarnpkg.com/os-browserify/-/os-browserify-0.3.0.tgz#854373c7f5c2315914fc9bfc6bd8238fdda1ec27"
|
resolved "https://registry.yarnpkg.com/os-browserify/-/os-browserify-0.3.0.tgz#854373c7f5c2315914fc9bfc6bd8238fdda1ec27"
|
||||||
integrity sha1-hUNzx/XCMVkU/Jv8a9gjj92h7Cc=
|
integrity sha1-hUNzx/XCMVkU/Jv8a9gjj92h7Cc=
|
||||||
|
|
||||||
outline-icons@^1.45.2:
|
outline-icons@^1.46.0:
|
||||||
version "1.45.2"
|
version "1.46.0"
|
||||||
resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-1.45.2.tgz#7249b26af6b58db6a0ba601a5ad5043d18e2841d"
|
resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-1.46.0.tgz#04b8f3b1f3b33b0396ebecf30a70d05c90f1f136"
|
||||||
integrity sha512-BClcM9JhfloM5FlrqWFr4i9Kc+n1rdvL9in5O83oH+1nGY/ZMDOcxaP0G+m7ucaltDMBRjEyaXBrNk5vX1Iorw==
|
integrity sha512-zC69rYqIHW/vr4IC+2ceAGFYV7artcOTewgduGlNl5Sn+xw8sMdmB1RvIc2ctxd6lxtJRRF5jJ6CRqpo/tM2cg==
|
||||||
|
|
||||||
oy-vey@^0.12.0:
|
oy-vey@^0.12.0:
|
||||||
version "0.12.0"
|
version "0.12.0"
|
||||||
@@ -12841,11 +12834,6 @@ react-is@^18.0.0:
|
|||||||
resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b"
|
resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b"
|
||||||
integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==
|
integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==
|
||||||
|
|
||||||
react-medium-image-zoom@^3.1.3:
|
|
||||||
version "3.1.3"
|
|
||||||
resolved "https://registry.yarnpkg.com/react-medium-image-zoom/-/react-medium-image-zoom-3.1.3.tgz#b1470abc5a342d65c23021c01bafa8c731821478"
|
|
||||||
integrity sha512-5CoU8whSCz5Xz2xNeGD34dDfZ6jaf/pybdfZh8HNUmA9mbXbLfj0n6bQWfEUwkq9lsNg1sEkyeIJq2tcvZY8bw==
|
|
||||||
|
|
||||||
react-merge-refs@^2.0.1:
|
react-merge-refs@^2.0.1:
|
||||||
version "2.0.1"
|
version "2.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/react-merge-refs/-/react-merge-refs-2.0.1.tgz#a1f8c2dadefa635333e9b91ec59f30b65228b006"
|
resolved "https://registry.yarnpkg.com/react-merge-refs/-/react-merge-refs-2.0.1.tgz#a1f8c2dadefa635333e9b91ec59f30b65228b006"
|
||||||
|
|||||||
Reference in New Issue
Block a user