feat: resize images in super editor (#2052)
This commit is contained in:
@@ -8,13 +8,15 @@ import { isFileTypePreviewable } from './isFilePreviewable'
|
||||
import PreviewComponent from './PreviewComponent'
|
||||
import Button from '../Button/Button'
|
||||
import { ProtectedIllustration } from '@standardnotes/icons'
|
||||
import { ImageZoomLevelProps } from './ImageZoomLevelProps'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
file: FileItem
|
||||
}
|
||||
isEmbeddedInSuper?: boolean
|
||||
} & ImageZoomLevelProps
|
||||
|
||||
const FilePreview = ({ file, application }: Props) => {
|
||||
const FilePreview = ({ file, application, isEmbeddedInSuper = false, imageZoomLevel, setImageZoomLevel }: Props) => {
|
||||
const [isAuthorized, setIsAuthorized] = useState(application.isAuthorizedToRenderItem(file))
|
||||
|
||||
const isFilePreviewable = useMemo(() => {
|
||||
@@ -112,7 +114,14 @@ const FilePreview = ({ file, application }: Props) => {
|
||||
<span className="mt-3">Loading file...</span>
|
||||
</div>
|
||||
) : downloadedBytes ? (
|
||||
<PreviewComponent application={application} file={file} bytes={downloadedBytes} />
|
||||
<PreviewComponent
|
||||
application={application}
|
||||
file={file}
|
||||
bytes={downloadedBytes}
|
||||
isEmbeddedInSuper={isEmbeddedInSuper}
|
||||
imageZoomLevel={imageZoomLevel}
|
||||
setImageZoomLevel={setImageZoomLevel}
|
||||
/>
|
||||
) : (
|
||||
<FilePreviewError
|
||||
file={file}
|
||||
|
||||
@@ -1,24 +1,66 @@
|
||||
import { IconType } from '@standardnotes/snjs'
|
||||
import { FunctionComponent, useRef, useState } from 'react'
|
||||
import { classNames, IconType } from '@standardnotes/snjs'
|
||||
import { FunctionComponent, useCallback, useEffect, useState } from 'react'
|
||||
import IconButton from '../Button/IconButton'
|
||||
import { ImageZoomLevelProps } from './ImageZoomLevelProps'
|
||||
|
||||
type Props = {
|
||||
objectUrl: string
|
||||
}
|
||||
isEmbeddedInSuper: boolean
|
||||
} & ImageZoomLevelProps
|
||||
|
||||
const ImagePreview: FunctionComponent<Props> = ({ objectUrl }) => {
|
||||
const initialImgHeightRef = useRef<number>()
|
||||
const MinimumZoomPercent = 10
|
||||
const DefaultZoomPercent = 100
|
||||
const MaximumZoomPercent = 1000
|
||||
const ZoomPercentModifier = 10
|
||||
const PercentageDivisor = 100
|
||||
|
||||
const [imageZoomPercent, setImageZoomPercent] = useState(100)
|
||||
const ImagePreview: FunctionComponent<Props> = ({
|
||||
objectUrl,
|
||||
isEmbeddedInSuper,
|
||||
imageZoomLevel,
|
||||
setImageZoomLevel,
|
||||
}) => {
|
||||
const [imageHeight, setImageHeight] = useState<number>(0)
|
||||
const [imageZoomPercent, setImageZoomPercent] = useState(imageZoomLevel ? imageZoomLevel : DefaultZoomPercent)
|
||||
const [isZoomInputVisible, setIsZoomInputVisible] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setImageZoomPercent(imageZoomLevel ? imageZoomLevel : DefaultZoomPercent)
|
||||
}, [imageZoomLevel])
|
||||
|
||||
const setImageZoom = useCallback(
|
||||
(zoomLevel: number) => {
|
||||
setImageZoomPercent(zoomLevel)
|
||||
setImageZoomLevel?.(zoomLevel)
|
||||
},
|
||||
[setImageZoomLevel],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const image = new Image()
|
||||
image.src = objectUrl
|
||||
image.onload = () => {
|
||||
setImageHeight(image.height)
|
||||
}
|
||||
}, [objectUrl])
|
||||
|
||||
const heightIfEmbedded = imageHeight * (imageZoomPercent / PercentageDivisor)
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 w-full items-center justify-center">
|
||||
<div className="relative flex h-full w-full items-center justify-center overflow-auto">
|
||||
<div className="group flex h-full min-h-0 w-full items-center justify-center">
|
||||
<div
|
||||
className="relative flex h-full w-full items-center justify-center overflow-auto"
|
||||
style={{
|
||||
height: isEmbeddedInSuper ? `${heightIfEmbedded}px` : '',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={objectUrl}
|
||||
style={{
|
||||
height: `${imageZoomPercent}%`,
|
||||
...(imageZoomPercent <= 100
|
||||
height: isEmbeddedInSuper ? `${heightIfEmbedded}px` : `${imageZoomPercent}%`,
|
||||
...(isEmbeddedInSuper
|
||||
? {}
|
||||
: imageZoomPercent <= DefaultZoomPercent
|
||||
? {
|
||||
minWidth: '100%',
|
||||
objectFit: 'contain',
|
||||
@@ -31,39 +73,70 @@ const ImagePreview: FunctionComponent<Props> = ({ objectUrl }) => {
|
||||
maxWidth: 'none',
|
||||
}),
|
||||
}}
|
||||
ref={(imgElement) => {
|
||||
if (!initialImgHeightRef.current) {
|
||||
initialImgHeightRef.current = imgElement?.height
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute left-1/2 bottom-6 flex -translate-x-1/2 items-center rounded border border-solid border-border bg-default py-1 px-3">
|
||||
<span className="mr-1.5">Zoom:</span>
|
||||
<div
|
||||
className={classNames(
|
||||
isEmbeddedInSuper ? 'hidden focus-within:flex group-hover:flex' : '',
|
||||
'absolute left-1/2 bottom-6 flex -translate-x-1/2 items-center rounded border border-solid border-border bg-default py-1 px-3',
|
||||
)}
|
||||
>
|
||||
<span className="mr-1.5">{isEmbeddedInSuper ? 'Size' : 'Zoom'}:</span>
|
||||
<IconButton
|
||||
className="rounded p-1 hover:bg-contrast"
|
||||
icon={'subtract' as IconType}
|
||||
title="Zoom Out"
|
||||
title={isEmbeddedInSuper ? 'Decrease size' : 'Zoom Out'}
|
||||
focusable={true}
|
||||
onClick={() => {
|
||||
setImageZoomPercent((percent) => {
|
||||
const newPercent = percent - 10
|
||||
if (newPercent >= 10) {
|
||||
return newPercent
|
||||
} else {
|
||||
return percent
|
||||
}
|
||||
})
|
||||
const newPercent = imageZoomPercent - ZoomPercentModifier
|
||||
if (newPercent >= ZoomPercentModifier) {
|
||||
setImageZoom(newPercent)
|
||||
} else {
|
||||
setImageZoom(imageZoomPercent)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className="mx-2">{imageZoomPercent}%</span>
|
||||
{isZoomInputVisible ? (
|
||||
<div className="mx-2">
|
||||
<input
|
||||
type="number"
|
||||
className="w-10 text-center"
|
||||
defaultValue={imageZoomPercent}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation()
|
||||
if (event.key === 'Enter') {
|
||||
const value = parseInt(event.currentTarget.value)
|
||||
if (value >= MinimumZoomPercent && value <= MaximumZoomPercent) {
|
||||
setImageZoom(value)
|
||||
}
|
||||
setIsZoomInputVisible(false)
|
||||
}
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
setIsZoomInputVisible(false)
|
||||
const value = parseInt(event.currentTarget.value)
|
||||
if (value >= MinimumZoomPercent && value <= MaximumZoomPercent) {
|
||||
setImageZoom(value)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
%
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="mx-1 rounded py-1 px-1.5 hover:bg-contrast"
|
||||
onClick={() => setIsZoomInputVisible((visible) => !visible)}
|
||||
>
|
||||
{imageZoomPercent}%
|
||||
</button>
|
||||
)}
|
||||
<IconButton
|
||||
className="rounded p-1 hover:bg-contrast"
|
||||
icon="add"
|
||||
title="Zoom In"
|
||||
title={isEmbeddedInSuper ? 'Increase size' : 'Zoom In'}
|
||||
focusable={true}
|
||||
onClick={() => {
|
||||
setImageZoomPercent((percent) => percent + 10)
|
||||
setImageZoom(imageZoomPercent + ZoomPercentModifier)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export type ImageZoomLevelProps = {
|
||||
imageZoomLevel?: number
|
||||
setImageZoomLevel?: (zoomLevel: number) => void
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { AppPaneId } from '../ResponsivePane/AppPaneMetadata'
|
||||
import { useResponsiveAppPane } from '../ResponsivePane/ResponsivePaneProvider'
|
||||
import { createObjectURLWithRef } from './CreateObjectURLWithRef'
|
||||
import ImagePreview from './ImagePreview'
|
||||
import { ImageZoomLevelProps } from './ImageZoomLevelProps'
|
||||
import { PreviewableTextFileTypes, RequiresNativeFilePreview } from './isFilePreviewable'
|
||||
import TextPreview from './TextPreview'
|
||||
|
||||
@@ -14,9 +15,17 @@ type Props = {
|
||||
application: WebApplication
|
||||
file: FileItem
|
||||
bytes: Uint8Array
|
||||
}
|
||||
isEmbeddedInSuper: boolean
|
||||
} & ImageZoomLevelProps
|
||||
|
||||
const PreviewComponent: FunctionComponent<Props> = ({ application, file, bytes }) => {
|
||||
const PreviewComponent: FunctionComponent<Props> = ({
|
||||
application,
|
||||
file,
|
||||
bytes,
|
||||
isEmbeddedInSuper,
|
||||
imageZoomLevel,
|
||||
setImageZoomLevel,
|
||||
}) => {
|
||||
const { selectedPane } = useResponsiveAppPane()
|
||||
|
||||
const objectUrlRef = useRef<string>()
|
||||
@@ -73,7 +82,14 @@ const PreviewComponent: FunctionComponent<Props> = ({ application, file, bytes }
|
||||
}
|
||||
|
||||
if (file.mimeType.startsWith('image/')) {
|
||||
return <ImagePreview objectUrl={objectUrl} />
|
||||
return (
|
||||
<ImagePreview
|
||||
objectUrl={objectUrl}
|
||||
isEmbeddedInSuper={isEmbeddedInSuper}
|
||||
imageZoomLevel={imageZoomLevel}
|
||||
setImageZoomLevel={setImageZoomLevel}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (file.mimeType.startsWith('video/')) {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { BlockWithAlignableContents } from '@lexical/react/LexicalBlockWithAlignableContents'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { ElementFormatType, NodeKey } from 'lexical'
|
||||
import { useApplication } from '@/Components/ApplicationView/ApplicationProvider'
|
||||
import FilePreview from '@/Components/FilePreview/FilePreview'
|
||||
import { FileItem } from '@standardnotes/snjs'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
|
||||
export type FileComponentProps = Readonly<{
|
||||
className: Readonly<{
|
||||
@@ -13,10 +14,13 @@ export type FileComponentProps = Readonly<{
|
||||
format: ElementFormatType | null
|
||||
nodeKey: NodeKey
|
||||
fileUuid: string
|
||||
zoomLevel: number
|
||||
setZoomLevel: (zoomLevel: number) => void
|
||||
}>
|
||||
|
||||
export function FileComponent({ className, format, nodeKey, fileUuid }: FileComponentProps) {
|
||||
export function FileComponent({ className, format, nodeKey, fileUuid, zoomLevel, setZoomLevel }: FileComponentProps) {
|
||||
const application = useApplication()
|
||||
const [editor] = useLexicalComposerContext()
|
||||
const file = useMemo(() => application.items.findItem<FileItem>(fileUuid), [application, fileUuid])
|
||||
|
||||
const [canLoad, setCanLoad] = useState(false)
|
||||
@@ -53,13 +57,32 @@ export function FileComponent({ className, format, nodeKey, fileUuid }: FileComp
|
||||
}
|
||||
}, [blockObserver])
|
||||
|
||||
const setImageZoomLevel = useCallback(
|
||||
(zoomLevel: number) => {
|
||||
editor.update(() => {
|
||||
setZoomLevel(zoomLevel)
|
||||
})
|
||||
},
|
||||
[editor, setZoomLevel],
|
||||
)
|
||||
|
||||
if (!file) {
|
||||
return <div>Unable to find file {fileUuid}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<BlockWithAlignableContents className={className} format={format} nodeKey={nodeKey}>
|
||||
<div ref={blockWrapperRef}>{canLoad && <FilePreview file={file} application={application} />}</div>
|
||||
<div ref={blockWrapperRef}>
|
||||
{canLoad && (
|
||||
<FilePreview
|
||||
isEmbeddedInSuper={true}
|
||||
file={file}
|
||||
application={application}
|
||||
imageZoomLevel={zoomLevel}
|
||||
setImageZoomLevel={setImageZoomLevel}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</BlockWithAlignableContents>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,18 +7,20 @@ import { ItemNodeInterface } from '../../ItemNodeInterface'
|
||||
|
||||
export class FileNode extends DecoratorBlockNode implements ItemNodeInterface {
|
||||
__id: string
|
||||
__zoomLevel: number
|
||||
|
||||
static getType(): string {
|
||||
return 'snfile'
|
||||
}
|
||||
|
||||
static clone(node: FileNode): FileNode {
|
||||
return new FileNode(node.__id, node.__format, node.__key)
|
||||
return new FileNode(node.__id, node.__format, node.__key, node.__zoomLevel)
|
||||
}
|
||||
|
||||
static importJSON(serializedNode: SerializedFileNode): FileNode {
|
||||
const node = $createFileNode(serializedNode.fileUuid)
|
||||
node.setFormat(serializedNode.format)
|
||||
node.setZoomLevel(serializedNode.zoomLevel)
|
||||
return node
|
||||
}
|
||||
|
||||
@@ -28,6 +30,7 @@ export class FileNode extends DecoratorBlockNode implements ItemNodeInterface {
|
||||
fileUuid: this.getId(),
|
||||
version: 1,
|
||||
type: 'snfile',
|
||||
zoomLevel: this.__zoomLevel,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,9 +56,10 @@ export class FileNode extends DecoratorBlockNode implements ItemNodeInterface {
|
||||
return { element }
|
||||
}
|
||||
|
||||
constructor(id: string, format?: ElementFormatType, key?: NodeKey) {
|
||||
constructor(id: string, format?: ElementFormatType, key?: NodeKey, zoomLevel?: number) {
|
||||
super(format, key)
|
||||
this.__id = id
|
||||
this.__zoomLevel = zoomLevel || 100
|
||||
}
|
||||
|
||||
getId(): string {
|
||||
@@ -66,6 +70,11 @@ export class FileNode extends DecoratorBlockNode implements ItemNodeInterface {
|
||||
return `[File: ${this.__id}]`
|
||||
}
|
||||
|
||||
setZoomLevel(zoomLevel: number): void {
|
||||
const writable = this.getWritable()
|
||||
writable.__zoomLevel = zoomLevel
|
||||
}
|
||||
|
||||
decorate(_editor: LexicalEditor, config: EditorConfig): JSX.Element {
|
||||
const embedBlockTheme = config.theme.embedBlock || {}
|
||||
const className = {
|
||||
@@ -73,6 +82,15 @@ export class FileNode extends DecoratorBlockNode implements ItemNodeInterface {
|
||||
focus: embedBlockTheme.focus || '',
|
||||
}
|
||||
|
||||
return <FileComponent className={className} format={this.__format} nodeKey={this.getKey()} fileUuid={this.__id} />
|
||||
return (
|
||||
<FileComponent
|
||||
className={className}
|
||||
format={this.__format}
|
||||
nodeKey={this.getKey()}
|
||||
fileUuid={this.__id}
|
||||
zoomLevel={this.__zoomLevel}
|
||||
setZoomLevel={this.setZoomLevel.bind(this)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ export type SerializedFileNode = Spread<
|
||||
fileUuid: string
|
||||
version: 1
|
||||
type: 'snfile'
|
||||
zoomLevel: number
|
||||
},
|
||||
SerializedDecoratorBlockNode
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user