feat: Added image alignment options and preference in Super notes (#2903)

This commit is contained in:
Aman Harwara
2025-06-16 16:33:27 +05:30
committed by GitHub
parent 57cb8445fd
commit 7bce025efb
19 changed files with 441 additions and 130 deletions

View File

@@ -43,6 +43,7 @@ export const PrefDefaults = {
[PrefKey.SuperNoteExportEmbedBehavior]: 'reference', [PrefKey.SuperNoteExportEmbedBehavior]: 'reference',
[PrefKey.SuperNoteExportUseMDFrontmatter]: true, [PrefKey.SuperNoteExportUseMDFrontmatter]: true,
[PrefKey.SuperNoteExportPDFPageSize]: 'A4', [PrefKey.SuperNoteExportPDFPageSize]: 'A4',
[PrefKey.SuperNoteImageAlignment]: 'left',
[PrefKey.SystemViewPreferences]: {}, [PrefKey.SystemViewPreferences]: {},
[PrefKey.AuthenticatorNames]: '', [PrefKey.AuthenticatorNames]: '',
[PrefKey.ComponentPreferences]: {}, [PrefKey.ComponentPreferences]: {},

View File

@@ -36,6 +36,7 @@ export enum PrefKey {
SuperNoteExportEmbedBehavior = 'superNoteExportEmbedBehavior', SuperNoteExportEmbedBehavior = 'superNoteExportEmbedBehavior',
SuperNoteExportUseMDFrontmatter = 'superNoteExportUseMDFrontmatter', SuperNoteExportUseMDFrontmatter = 'superNoteExportUseMDFrontmatter',
SuperNoteExportPDFPageSize = 'superNoteExportPDFPageSize', SuperNoteExportPDFPageSize = 'superNoteExportPDFPageSize',
SuperNoteImageAlignment = 'superNoteImageAlignment',
AuthenticatorNames = 'authenticatorNames', AuthenticatorNames = 'authenticatorNames',
PaneGesturesEnabled = 'paneGesturesEnabled', PaneGesturesEnabled = 'paneGesturesEnabled',
ComponentPreferences = 'componentPreferences', ComponentPreferences = 'componentPreferences',
@@ -101,4 +102,5 @@ export type PrefValue = {
[PrefKey.AddImportsToTag]: boolean [PrefKey.AddImportsToTag]: boolean
[PrefKey.AlwaysCreateNewTagForImports]: boolean [PrefKey.AlwaysCreateNewTagForImports]: boolean
[PrefKey.ExistingTagForImports]: string | undefined [PrefKey.ExistingTagForImports]: string | undefined
[PrefKey.SuperNoteImageAlignment]: 'left' | 'center' | 'right'
} }

View File

@@ -1,8 +1,8 @@
import { FunctionComponent, MouseEventHandler } from 'react' import { ComponentPropsWithoutRef, ForwardedRef, forwardRef, MouseEventHandler } from 'react'
import Icon from '@/Components/Icon/Icon' import Icon from '@/Components/Icon/Icon'
import { IconType } from '@standardnotes/snjs' import { IconType } from '@standardnotes/snjs'
type Props = { interface Props extends ComponentPropsWithoutRef<'button'> {
onClick: MouseEventHandler<HTMLButtonElement> onClick: MouseEventHandler<HTMLButtonElement>
className?: string className?: string
icon: IconType icon: IconType
@@ -12,32 +12,31 @@ type Props = {
disabled?: boolean disabled?: boolean
} }
const IconButton: FunctionComponent<Props> = ({ const IconButton = forwardRef(
onClick, (
className = '', { onClick, className = '', icon, title, focusable, iconClassName = '', disabled = false, ...rest }: Props,
icon, ref: ForwardedRef<HTMLButtonElement>,
title, ) => {
focusable, const click: MouseEventHandler<HTMLButtonElement> = (e) => {
iconClassName = '', e.preventDefault()
disabled = false, onClick(e)
}) => { }
const click: MouseEventHandler<HTMLButtonElement> = (e) => { const focusableClass = focusable ? '' : 'focus:shadow-none'
e.preventDefault() return (
onClick(e) <button
} {...rest}
const focusableClass = focusable ? '' : 'focus:shadow-none' type="button"
return ( title={title}
<button className={`no-border flex cursor-pointer flex-row items-center bg-transparent ${focusableClass} ${className}`}
type="button" onClick={click}
title={title} disabled={disabled}
className={`no-border flex cursor-pointer flex-row items-center bg-transparent ${focusableClass} ${className}`} aria-label={title}
onClick={click} ref={ref}
disabled={disabled} >
aria-label={title} <Icon type={icon} className={iconClassName} />
> </button>
<Icon type={icon} className={iconClassName} /> )
</button> },
) )
}
export default IconButton export default IconButton

View File

@@ -13,15 +13,23 @@ import { isFileTypePreviewable } from './isFilePreviewable'
import PreviewComponent from './PreviewComponent' import PreviewComponent from './PreviewComponent'
import Button from '../Button/Button' import Button from '../Button/Button'
import { ProtectedIllustration } from '@standardnotes/icons' import { ProtectedIllustration } from '@standardnotes/icons'
import { ImageZoomLevelProps } from './ImageZoomLevelProps' import { OptionalSuperEmbeddedImageProps } from './OptionalSuperEmbeddedImageProps'
type Props = { type Props = {
application: WebApplication application: WebApplication
file: FileItem file: FileItem
isEmbeddedInSuper?: boolean isEmbeddedInSuper?: boolean
} & ImageZoomLevelProps } & OptionalSuperEmbeddedImageProps
const FilePreview = ({ file, application, isEmbeddedInSuper = false, imageZoomLevel, setImageZoomLevel }: Props) => { const FilePreview = ({
file,
application,
isEmbeddedInSuper = false,
imageZoomLevel,
setImageZoomLevel,
alignment,
changeAlignment,
}: Props) => {
const [isAuthorized, setIsAuthorized] = useState(application.isAuthorizedToRenderItem(file)) const [isAuthorized, setIsAuthorized] = useState(application.isAuthorizedToRenderItem(file))
const isFilePreviewable = useMemo(() => { const isFilePreviewable = useMemo(() => {
@@ -137,6 +145,8 @@ const FilePreview = ({ file, application, isEmbeddedInSuper = false, imageZoomLe
isEmbeddedInSuper={isEmbeddedInSuper} isEmbeddedInSuper={isEmbeddedInSuper}
imageZoomLevel={imageZoomLevel} imageZoomLevel={imageZoomLevel}
setImageZoomLevel={setImageZoomLevel} setImageZoomLevel={setImageZoomLevel}
alignment={alignment}
changeAlignment={changeAlignment}
/> />
) : ( ) : (
<FilePreviewError <FilePreviewError

View File

@@ -0,0 +1,64 @@
import { classNames, IconType } from '@standardnotes/snjs'
import IconButton from '@/Components/Button/IconButton'
import StyledTooltip from '@/Components/StyledTooltip/StyledTooltip'
import { ElementFormatType } from 'lexical'
export function getCSSValueFromAlignment(format: ElementFormatType) {
switch (format) {
case 'start':
case 'left':
return 'start'
case 'right':
case 'end':
return 'end'
default:
return 'center'
}
}
const Options = [
{
alignment: 'left',
label: 'Left align',
},
{
alignment: 'center',
label: 'Center align',
},
{
alignment: 'right',
label: 'Right align',
},
]
export function ImageAlignmentOptions({
alignment: currentAlignment,
changeAlignment,
}: {
alignment: ElementFormatType
changeAlignment: (format: ElementFormatType) => void
}) {
return Options.map(({ alignment, label }) => (
<StyledTooltip label={label} key={alignment}>
<IconButton
className={classNames(
alignment === currentAlignment && '!bg-info text-info-contrast',
'rounded p-1 hover:bg-contrast',
)}
icon={`format-align-${alignment}` as IconType}
title={label}
focusable={true}
onClick={(e) => {
// the preventDefault and stopPropagation for these events are required
// so that the keyboard doesn't jump when you select another option
e.preventDefault()
e.stopPropagation()
changeAlignment(alignment as ElementFormatType)
}}
onMouseDown={(e) => {
e.preventDefault()
}}
/>
</StyledTooltip>
))
}

View File

@@ -1,12 +1,16 @@
import { classNames, IconType } from '@standardnotes/snjs' import { IconType, PrefKey } from '@standardnotes/snjs'
import { FunctionComponent, useCallback, useEffect, useState } from 'react' import { FunctionComponent, useCallback, useEffect, useState } from 'react'
import IconButton from '../Button/IconButton' import IconButton from '@/Components/Button/IconButton'
import { ImageZoomLevelProps } from './ImageZoomLevelProps' import { OptionalSuperEmbeddedImageProps } from './OptionalSuperEmbeddedImageProps'
import usePreference from '@/Hooks/usePreference'
import { getCSSValueFromAlignment, ImageAlignmentOptions } from './ImageAlignmentOptions'
import { ElementIds } from '../../Constants/ElementIDs'
import { getOverflows } from '@/Components/Popover/Utils/Collisions'
type Props = { type Props = {
objectUrl: string objectUrl: string
isEmbeddedInSuper: boolean isEmbeddedInSuper: boolean
} & ImageZoomLevelProps } & OptionalSuperEmbeddedImageProps
const MinimumZoomPercent = 10 const MinimumZoomPercent = 10
const DefaultZoomPercent = 100 const DefaultZoomPercent = 100
@@ -19,6 +23,8 @@ const ImagePreview: FunctionComponent<Props> = ({
isEmbeddedInSuper, isEmbeddedInSuper,
imageZoomLevel, imageZoomLevel,
setImageZoomLevel, setImageZoomLevel,
alignment,
changeAlignment,
}) => { }) => {
const [imageWidth, setImageWidth] = useState(0) const [imageWidth, setImageWidth] = useState(0)
const [imageHeight, setImageHeight] = useState<number>(0) const [imageHeight, setImageHeight] = useState<number>(0)
@@ -48,8 +54,96 @@ const ImagePreview: FunctionComponent<Props> = ({
const widthIfEmbedded = imageWidth * (imageZoomPercent / PercentageDivisor) const widthIfEmbedded = imageWidth * (imageZoomPercent / PercentageDivisor)
const imageResizer = (
<>
<span className="mr-1.5">{isEmbeddedInSuper ? 'Size' : 'Zoom'}:</span>
<IconButton
className="rounded p-1 hover:bg-contrast"
icon={'subtract' as IconType}
title={isEmbeddedInSuper ? 'Decrease size' : 'Zoom Out'}
focusable={true}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
const newPercent = imageZoomPercent - ZoomPercentModifier
if (newPercent >= ZoomPercentModifier) {
setImageZoom(newPercent)
} else {
setImageZoom(imageZoomPercent)
}
}}
onMouseDown={(e) => {
e.preventDefault()
}}
/>
{isZoomInputVisible ? (
<div className="mx-2">
<input
type="number"
className="w-10 bg-default 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 px-1.5 py-1 hover:bg-contrast"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
setIsZoomInputVisible((visible) => !visible)
}}
>
{imageZoomPercent}%
</button>
)}
<IconButton
className="rounded p-1 hover:bg-contrast"
icon="add"
title={isEmbeddedInSuper ? 'Increase size' : 'Zoom In'}
focusable={true}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
setImageZoom(imageZoomPercent + ZoomPercentModifier)
}}
onMouseDown={(e) => {
e.preventDefault()
}}
/>
</>
)
const defaultSuperImageAlignment = usePreference(PrefKey.SuperNoteImageAlignment)
const finalAlignment = alignment || defaultSuperImageAlignment
const justifyContent = isEmbeddedInSuper ? getCSSValueFromAlignment(finalAlignment) : 'center'
return ( return (
<div className="group relative flex h-full min-h-0 w-full items-center justify-center"> <div
className="group relative flex h-full min-h-0 w-full items-center"
style={{ justifyContent }}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
}}
>
<div <div
className="relative flex h-full w-full items-center justify-center overflow-auto" className="relative flex h-full w-full items-center justify-center overflow-auto"
style={{ style={{
@@ -78,71 +172,37 @@ const ImagePreview: FunctionComponent<Props> = ({
}} }}
/> />
</div> </div>
<div {!isEmbeddedInSuper && (
className={classNames( <div className="absolute bottom-6 left-1/2 flex -translate-x-1/2 items-center rounded border border-solid border-border bg-default px-3 py-1">
isEmbeddedInSuper ? 'hidden focus-within:flex group-hover:flex' : '', {imageResizer}
'absolute bottom-6 left-1/2 flex -translate-x-1/2 items-center rounded border border-solid border-border bg-default px-3 py-1', </div>
)} )}
> {isEmbeddedInSuper && (
<span className="mr-1.5">{isEmbeddedInSuper ? 'Size' : 'Zoom'}:</span> <div
<IconButton className="invisible absolute bottom-full left-1/2 z-10 w-max -translate-x-1/2 px-1 pb-1 focus-within:visible group-hover:visible [.embedBlockFocused_&]:visible"
className="rounded p-1 hover:bg-contrast" ref={(popover) => {
icon={'subtract' as IconType} const editorRoot = document.getElementById(ElementIds.SuperEditorContent)
title={isEmbeddedInSuper ? 'Decrease size' : 'Zoom Out'} if (!popover || !editorRoot) {
focusable={true} return
onClick={() => { }
const newPercent = imageZoomPercent - ZoomPercentModifier const editorRootRect = editorRoot.getBoundingClientRect()
if (newPercent >= ZoomPercentModifier) { const popoverRect = popover.getBoundingClientRect()
setImageZoom(newPercent) const overflows = getOverflows(popoverRect, editorRootRect)
} else { if (overflows.top > 0) {
setImageZoom(imageZoomPercent) popover.style.setProperty('--tw-translate-y', `${overflows.top}px`)
} }
}} }}
/> >
{isZoomInputVisible ? ( <div className="flex divide-x divide-border rounded border border-border bg-default">
<div className="mx-2"> {changeAlignment && (
<input <div className="flex items-center gap-1 px-1 py-0.5">
type="number" <ImageAlignmentOptions alignment={finalAlignment} changeAlignment={changeAlignment} />
className="w-10 bg-default text-center" </div>
defaultValue={imageZoomPercent} )}
onKeyDown={(event) => { <div className="flex items-center px-2 py-0.5 text-sm">{imageResizer}</div>
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> </div>
) : ( </div>
<button )}
className="mx-1 rounded px-1.5 py-1 hover:bg-contrast"
onClick={() => setIsZoomInputVisible((visible) => !visible)}
>
{imageZoomPercent}%
</button>
)}
<IconButton
className="rounded p-1 hover:bg-contrast"
icon="add"
title={isEmbeddedInSuper ? 'Increase size' : 'Zoom In'}
focusable={true}
onClick={() => {
setImageZoom(imageZoomPercent + ZoomPercentModifier)
}}
/>
</div>
</div> </div>
) )
} }

View File

@@ -1,4 +0,0 @@
export type ImageZoomLevelProps = {
imageZoomLevel?: number
setImageZoomLevel?: (zoomLevel: number) => void
}

View File

@@ -0,0 +1,8 @@
import { ElementFormatType } from 'lexical'
export type OptionalSuperEmbeddedImageProps = {
imageZoomLevel?: number
setImageZoomLevel?: (zoomLevel: number) => void
alignment?: ElementFormatType | null
changeAlignment?: (alignment: ElementFormatType) => void
}

View File

@@ -5,7 +5,7 @@ import { FunctionComponent, useCallback, useEffect, useMemo, useRef } from 'reac
import Button from '../Button/Button' import Button from '../Button/Button'
import { createObjectURLWithRef } from './CreateObjectURLWithRef' import { createObjectURLWithRef } from './CreateObjectURLWithRef'
import ImagePreview from './ImagePreview' import ImagePreview from './ImagePreview'
import { ImageZoomLevelProps } from './ImageZoomLevelProps' import { OptionalSuperEmbeddedImageProps } from './OptionalSuperEmbeddedImageProps'
import { PreviewableTextFileTypes, RequiresNativeFilePreview } from './isFilePreviewable' import { PreviewableTextFileTypes, RequiresNativeFilePreview } from './isFilePreviewable'
import TextPreview from './TextPreview' import TextPreview from './TextPreview'
import { parseFileName, sanitizeFileName } from '@standardnotes/utils' import { parseFileName, sanitizeFileName } from '@standardnotes/utils'
@@ -16,7 +16,7 @@ type Props = {
file: FileItem file: FileItem
bytes: Uint8Array bytes: Uint8Array
isEmbeddedInSuper: boolean isEmbeddedInSuper: boolean
} & ImageZoomLevelProps } & OptionalSuperEmbeddedImageProps
const PreviewComponent: FunctionComponent<Props> = ({ const PreviewComponent: FunctionComponent<Props> = ({
application, application,
@@ -25,6 +25,8 @@ const PreviewComponent: FunctionComponent<Props> = ({
isEmbeddedInSuper, isEmbeddedInSuper,
imageZoomLevel, imageZoomLevel,
setImageZoomLevel, setImageZoomLevel,
alignment,
changeAlignment,
}) => { }) => {
const objectUrlRef = useRef<string>() const objectUrlRef = useRef<string>()
@@ -84,6 +86,8 @@ const PreviewComponent: FunctionComponent<Props> = ({
isEmbeddedInSuper={isEmbeddedInSuper} isEmbeddedInSuper={isEmbeddedInSuper}
imageZoomLevel={imageZoomLevel} imageZoomLevel={imageZoomLevel}
setImageZoomLevel={setImageZoomLevel} setImageZoomLevel={setImageZoomLevel}
alignment={alignment}
changeAlignment={changeAlignment}
/> />
) )
} }

View File

@@ -1,12 +1,14 @@
import { PrefKey, Platform } from '@standardnotes/snjs' import { PrefKey, Platform, PrefValue } from '@standardnotes/snjs'
import { Subtitle, Text, Title } from '@/Components/Preferences/PreferencesComponents/Content' import { Subtitle, Text, Title } from '@/Components/Preferences/PreferencesComponents/Content'
import { WebApplication } from '@/Application/WebApplication' import { WebApplication } from '@/Application/WebApplication'
import { FunctionComponent, useState } from 'react' import { FunctionComponent, useMemo, useState } from 'react'
import HorizontalSeparator from '@/Components/Shared/HorizontalSeparator' import HorizontalSeparator from '@/Components/Shared/HorizontalSeparator'
import Switch from '@/Components/Switch/Switch' import Switch from '@/Components/Switch/Switch'
import PreferencesGroup from '../../PreferencesComponents/PreferencesGroup' import PreferencesGroup from '../../PreferencesComponents/PreferencesGroup'
import PreferencesSegment from '../../PreferencesComponents/PreferencesSegment' import PreferencesSegment from '../../PreferencesComponents/PreferencesSegment'
import usePreference from '@/Hooks/usePreference' import usePreference from '@/Hooks/usePreference'
import Dropdown from '@/Components/Dropdown/Dropdown'
import { DropdownItem } from '@/Components/Dropdown/DropdownItem'
import { MutuallyExclusiveMediaQueryBreakpoints, useMediaQuery } from '@/Hooks/useMediaQuery' import { MutuallyExclusiveMediaQueryBreakpoints, useMediaQuery } from '@/Hooks/useMediaQuery'
type Props = { type Props = {
@@ -27,6 +29,27 @@ const Defaults: FunctionComponent<Props> = ({ application }) => {
const addNoteToParentFolders = usePreference(PrefKey.NoteAddToParentFolders) const addNoteToParentFolders = usePreference(PrefKey.NoteAddToParentFolders)
const alwaysShowSuperToolbar = usePreference(PrefKey.AlwaysShowSuperToolbar) const alwaysShowSuperToolbar = usePreference(PrefKey.AlwaysShowSuperToolbar)
const defaultSuperImageAlignment = usePreference(PrefKey.SuperNoteImageAlignment)
const imageAlignmentOptions = useMemo(
(): DropdownItem[] => [
{
icon: 'format-align-left',
label: 'Left align',
value: 'left',
},
{
icon: 'format-align-center',
label: 'Center align',
value: 'center',
},
{
icon: 'format-align-right',
label: 'Right align',
value: 'right',
},
],
[],
)
const toggleSpellcheck = () => { const toggleSpellcheck = () => {
application.toggleGlobalSpellcheck().catch(console.error) application.toggleGlobalSpellcheck().catch(console.error)
@@ -79,23 +102,46 @@ const Defaults: FunctionComponent<Props> = ({ application }) => {
</div> </div>
<HorizontalSeparator classes="my-4" /> <HorizontalSeparator classes="my-4" />
{!isMobile && ( {!isMobile && (
<div className="flex justify-between gap-2 md:items-center"> <>
<div className="flex flex-col"> <div className="flex justify-between gap-2 md:items-center">
<Subtitle>Use always-visible toolbar in Super notes</Subtitle> <div className="flex flex-col">
<Text> <Subtitle>Use always-visible toolbar in Super notes</Subtitle>
When enabled, the Super toolbar will always be shown at the top of the note. It can be temporarily <Text>
toggled using Cmd/Ctrl+Shift+K. When disabled, the Super toolbar will only be shown as a floating When enabled, the Super toolbar will always be shown at the top of the note. It can be temporarily
toolbar when text is selected. toggled using Cmd/Ctrl+Shift+K. When disabled, the Super toolbar will only be shown as a floating
</Text> toolbar when text is selected.
</Text>
</div>
<Switch
onChange={() => {
application
.setPreference(PrefKey.AlwaysShowSuperToolbar, !alwaysShowSuperToolbar)
.catch(console.error)
}}
checked={alwaysShowSuperToolbar}
/>
</div> </div>
<Switch <HorizontalSeparator classes="my-4" />
onChange={() => { </>
application.setPreference(PrefKey.AlwaysShowSuperToolbar, !alwaysShowSuperToolbar).catch(console.error) )}
<div>
<Subtitle>Default image alignment in Super notes</Subtitle>
<div className="mt-2">
<Dropdown
label="Default image alignment in super notes"
items={imageAlignmentOptions}
value={defaultSuperImageAlignment}
onChange={(alignment) => {
application
.setPreference(
PrefKey.SuperNoteImageAlignment,
alignment as PrefValue[PrefKey.SuperNoteImageAlignment],
)
.catch(console.error)
}} }}
checked={alwaysShowSuperToolbar}
/> />
</div> </div>
)} </div>
</PreferencesSegment> </PreferencesSegment>
</PreferencesGroup> </PreferencesGroup>
) )

View File

@@ -38,7 +38,7 @@ const BlocksEditorTheme: EditorThemeClasses = {
}, },
embedBlock: { embedBlock: {
base: 'Lexical__embedBlock', base: 'Lexical__embedBlock',
focus: 'Lexical__embedBlockFocus', focus: 'embedBlockFocused',
}, },
hashtag: 'Lexical__hashtag', hashtag: 'Lexical__hashtag',
heading: { heading: {

View File

@@ -253,7 +253,7 @@
.Lexical__embedBlock { .Lexical__embedBlock {
user-select: none; user-select: none;
} }
.Lexical__embedBlockFocus { .embedBlockFocused {
outline: 2px solid var(--sn-stylekit-info-color); outline: 2px solid var(--sn-stylekit-info-color);
} }

View File

@@ -1,6 +1,13 @@
import { BlockWithAlignableContents } from '@lexical/react/LexicalBlockWithAlignableContents' import { BlockWithAlignableContents } from '@lexical/react/LexicalBlockWithAlignableContents'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { $getNodeByKey, CLICK_COMMAND, COMMAND_PRIORITY_LOW, ElementFormatType, NodeKey } from 'lexical' import {
$getNodeByKey,
CLICK_COMMAND,
COMMAND_PRIORITY_LOW,
ElementFormatType,
NodeKey,
SKIP_DOM_SELECTION_TAG,
} from 'lexical'
import { useApplication } from '@/Components/ApplicationProvider' import { useApplication } from '@/Components/ApplicationProvider'
import FilePreview from '@/Components/FilePreview/FilePreview' import FilePreview from '@/Components/FilePreview/FilePreview'
import { FileItem } from '@standardnotes/snjs' import { FileItem } from '@standardnotes/snjs'
@@ -16,13 +23,22 @@ export type FileComponentProps = Readonly<{
focus: string focus: string
}> }>
format: ElementFormatType | null format: ElementFormatType | null
setFormat: (format: ElementFormatType) => void
nodeKey: NodeKey nodeKey: NodeKey
fileUuid: string fileUuid: string
zoomLevel: number zoomLevel: number
setZoomLevel: (zoomLevel: number) => void setZoomLevel: (zoomLevel: number) => void
}> }>
function FileComponent({ className, format, nodeKey, fileUuid, zoomLevel, setZoomLevel }: FileComponentProps) { function FileComponent({
className,
format,
setFormat,
nodeKey,
fileUuid,
zoomLevel,
setZoomLevel,
}: FileComponentProps) {
const application = useApplication() const application = useApplication()
const [editor] = useLexicalComposerContext() const [editor] = useLexicalComposerContext()
const [file, setFile] = useState(() => application.items.findItem<FileItem>(fileUuid)) const [file, setFile] = useState(() => application.items.findItem<FileItem>(fileUuid))
@@ -71,6 +87,19 @@ function FileComponent({ className, format, nodeKey, fileUuid, zoomLevel, setZoo
[editor, setZoomLevel], [editor, setZoomLevel],
) )
const changeAlignment = useCallback(
(alignment: ElementFormatType) =>
editor.update(
() => {
setFormat(alignment)
},
{
tag: SKIP_DOM_SELECTION_TAG,
},
),
[editor, setFormat],
)
const [isSelected, setSelected] = useLexicalNodeSelection(nodeKey) const [isSelected, setSelected] = useLexicalNodeSelection(nodeKey)
useEffect(() => { useEffect(() => {
@@ -147,6 +176,8 @@ function FileComponent({ className, format, nodeKey, fileUuid, zoomLevel, setZoo
application={application} application={application}
imageZoomLevel={zoomLevel} imageZoomLevel={zoomLevel}
setImageZoomLevel={setImageZoomLevel} setImageZoomLevel={setImageZoomLevel}
alignment={format}
changeAlignment={changeAlignment}
/> />
)} )}
</div> </div>

View File

@@ -94,6 +94,7 @@ export class FileNode extends DecoratorBlockNode implements ItemNodeInterface {
<FileComponent <FileComponent
className={className} className={className}
format={this.__format} format={this.__format}
setFormat={this.setFormat.bind(this)}
nodeKey={this.getKey()} nodeKey={this.getKey()}
fileUuid={this.__id} fileUuid={this.__id}
zoomLevel={this.__zoomLevel} zoomLevel={this.__zoomLevel}

View File

@@ -1,5 +1,5 @@
import { BlockWithAlignableContents } from '@lexical/react/LexicalBlockWithAlignableContents' import { BlockWithAlignableContents } from '@lexical/react/LexicalBlockWithAlignableContents'
import { Platform, classNames } from '@standardnotes/snjs' import { Platform, PrefKey, classNames } from '@standardnotes/snjs'
import { ElementFormatType, NodeKey } from 'lexical' import { ElementFormatType, NodeKey } from 'lexical'
import { InlineFileNode } from './InlineFileNode' import { InlineFileNode } from './InlineFileNode'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
@@ -9,6 +9,9 @@ import { $createFileNode } from '../EncryptedFilePlugin/Nodes/FileUtils'
import { isIOS } from '@standardnotes/ui-services' import { isIOS } from '@standardnotes/ui-services'
import Icon from '@/Components/Icon/Icon' import Icon from '@/Components/Icon/Icon'
import Spinner from '@/Components/Spinner/Spinner' import Spinner from '@/Components/Spinner/Spinner'
import usePreference from '@/Hooks/usePreference'
import { getCSSValueFromAlignment, ImageAlignmentOptions } from '@/Components/FilePreview/ImageAlignmentOptions'
import { getOverflows } from '@/Components/Popover/Utils/Collisions'
type Props = { type Props = {
fileName: string | undefined fileName: string | undefined
@@ -19,11 +22,12 @@ type Props = {
focus: string focus: string
}> }>
format: ElementFormatType | null format: ElementFormatType | null
setFormat: (format: ElementFormatType) => void
node: InlineFileNode node: InlineFileNode
nodeKey: NodeKey nodeKey: NodeKey
} }
const InlineFileComponent = ({ className, src, mimeType, fileName, format, node, nodeKey }: Props) => { const InlineFileComponent = ({ className, src, mimeType, fileName, format, setFormat, node, nodeKey }: Props) => {
const application = useApplication() const application = useApplication()
const [editor] = useLexicalComposerContext() const [editor] = useLexicalComposerContext()
@@ -57,11 +61,49 @@ const InlineFileComponent = ({ className, src, mimeType, fileName, format, node,
const isPDF = mimeType === 'application/pdf' const isPDF = mimeType === 'application/pdf'
const defaultSuperImageAlignment = usePreference(PrefKey.SuperNoteImageAlignment)
const finalAlignment = format || defaultSuperImageAlignment
const alignItems: 'start' | 'center' | 'end' = getCSSValueFromAlignment(finalAlignment)
const changeAlignment = useCallback(
(format: ElementFormatType) => {
editor.update(() => {
setFormat(format)
})
},
[editor, setFormat],
)
return ( return (
<BlockWithAlignableContents className={className} format={format} nodeKey={nodeKey}> <BlockWithAlignableContents className={className} format={format} nodeKey={nodeKey}>
{mimeType.startsWith('image') ? ( {mimeType.startsWith('image') ? (
<div className="relative flex min-h-[2rem] flex-col items-center gap-2.5"> <div
className="group relative flex min-h-[2rem] flex-col gap-2.5"
style={{ alignItems }}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
}}
>
<img alt={fileName} src={src} /> <img alt={fileName} src={src} />
<div
className="invisible absolute bottom-full left-1/2 z-10 w-max -translate-x-1/2 px-1 pb-1 focus-within:visible group-hover:visible [.embedBlockFocused_&]:visible"
ref={(popover) => {
const editorRoot = editor.getRootElement()
if (!popover || !editorRoot) {
return
}
const editorRootRect = editorRoot.getBoundingClientRect()
const popoverRect = popover.getBoundingClientRect()
const overflows = getOverflows(popoverRect, editorRootRect)
if (overflows.top > 0) {
popover.style.setProperty('--tw-translate-y', `${overflows.top}px`)
}
}}
>
<div className="flex gap-1 rounded border border-border bg-default px-1 py-0.5">
<ImageAlignmentOptions alignment={finalAlignment} changeAlignment={changeAlignment} />
</div>
</div>
</div> </div>
) : mimeType.startsWith('video') ? ( ) : mimeType.startsWith('video') ? (
<video className="h-full w-full" controls autoPlay> <video className="h-full w-full" controls autoPlay>

View File

@@ -172,6 +172,7 @@ export class InlineFileNode extends DecoratorBlockNode {
<InlineFileComponent <InlineFileComponent
className={className} className={className}
format={this.__format} format={this.__format}
setFormat={this.setFormat.bind(this)}
node={this} node={this}
nodeKey={this.getKey()} nodeKey={this.getKey()}
src={this.__src} src={this.__src}

View File

@@ -4,13 +4,16 @@ import Spinner from '@/Components/Spinner/Spinner'
import { isDesktopApplication } from '@/Utils' import { isDesktopApplication } from '@/Utils'
import { BlockWithAlignableContents } from '@lexical/react/LexicalBlockWithAlignableContents' import { BlockWithAlignableContents } from '@lexical/react/LexicalBlockWithAlignableContents'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { classNames, Platform } from '@standardnotes/snjs' import { classNames, Platform, PrefKey } from '@standardnotes/snjs'
import { $getNodeByKey, CLICK_COMMAND, COMMAND_PRIORITY_LOW, ElementFormatType, NodeKey } from 'lexical' import { $getNodeByKey, CLICK_COMMAND, COMMAND_PRIORITY_LOW, ElementFormatType, NodeKey } from 'lexical'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { $createFileNode } from '../EncryptedFilePlugin/Nodes/FileUtils' import { $createFileNode } from '../EncryptedFilePlugin/Nodes/FileUtils'
import { RemoteImageNode } from './RemoteImageNode' import { RemoteImageNode } from './RemoteImageNode'
import { isIOS } from '@standardnotes/ui-services' import { isIOS } from '@standardnotes/ui-services'
import { useLexicalNodeSelection } from '@lexical/react/useLexicalNodeSelection' import { useLexicalNodeSelection } from '@lexical/react/useLexicalNodeSelection'
import usePreference from '@/Hooks/usePreference'
import { getCSSValueFromAlignment, ImageAlignmentOptions } from '@/Components/FilePreview/ImageAlignmentOptions'
import { getOverflows } from '@/Components/Popover/Utils/Collisions'
type Props = { type Props = {
src: string src: string
@@ -21,10 +24,11 @@ type Props = {
focus: string focus: string
}> }>
format: ElementFormatType | null format: ElementFormatType | null
setFormat: (format: ElementFormatType) => void
nodeKey: NodeKey nodeKey: NodeKey
} }
const RemoteImageComponent = ({ className, src, alt, node, format, nodeKey }: Props) => { const RemoteImageComponent = ({ className, src, alt, node, format, nodeKey, setFormat }: Props) => {
const application = useApplication() const application = useApplication()
const [editor] = useLexicalComposerContext() const [editor] = useLexicalComposerContext()
@@ -91,9 +95,30 @@ const RemoteImageComponent = ({ className, src, alt, node, format, nodeKey }: Pr
) )
}, [editor, isSelected, nodeKey, setSelected]) }, [editor, isSelected, nodeKey, setSelected])
const changeAlignment = useCallback(
(format: ElementFormatType) => {
editor.update(() => {
setFormat(format)
})
},
[editor, setFormat],
)
const defaultSuperImageAlignment = usePreference(PrefKey.SuperNoteImageAlignment)
const finalAlignment = format || defaultSuperImageAlignment
const alignItems: 'start' | 'center' | 'end' = getCSSValueFromAlignment(finalAlignment)
return ( return (
<BlockWithAlignableContents className={className} format={format} nodeKey={nodeKey}> <BlockWithAlignableContents className={className} format={format} nodeKey={nodeKey}>
<div ref={ref} className="relative flex min-h-[2rem] flex-col items-center gap-2.5"> <div
ref={ref}
className="group relative flex min-h-[2rem] flex-col gap-2.5"
style={{ alignItems }}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
}}
>
<img <img
alt={alt} alt={alt}
src={src} src={src}
@@ -101,6 +126,25 @@ const RemoteImageComponent = ({ className, src, alt, node, format, nodeKey }: Pr
setDidImageLoad(true) setDidImageLoad(true)
}} }}
/> />
<div
className="invisible absolute bottom-full left-1/2 z-10 w-max -translate-x-1/2 px-1 pb-1 focus-within:visible group-hover:visible [.embedBlockFocused_&]:visible"
ref={(popover) => {
const editorRoot = editor.getRootElement()
if (!popover || !editorRoot) {
return
}
const editorRootRect = editorRoot.getBoundingClientRect()
const popoverRect = popover.getBoundingClientRect()
const overflows = getOverflows(popoverRect, editorRootRect)
if (overflows.top > 0) {
popover.style.setProperty('--tw-translate-y', `${overflows.top}px`)
}
}}
>
<div className="flex gap-1 rounded border border-border bg-default px-1 py-0.5">
<ImageAlignmentOptions alignment={finalAlignment} changeAlignment={changeAlignment} />
</div>
</div>
{didImageLoad && canShowSaveButton && ( {didImageLoad && canShowSaveButton && (
<button <button
className={classNames( className={classNames(

View File

@@ -94,6 +94,7 @@ export class RemoteImageNode extends DecoratorBlockNode {
<RemoteImageComponent <RemoteImageComponent
className={className} className={className}
format={this.__format} format={this.__format}
setFormat={this.setFormat.bind(this)}
nodeKey={this.getKey()} nodeKey={this.getKey()}
node={this} node={this}
src={this.__src} src={this.__src}

View File

@@ -15,4 +15,5 @@ export const ElementIds = {
SearchBar: 'search-bar', SearchBar: 'search-bar',
ConflictResolutionButton: 'conflict-resolution-button', ConflictResolutionButton: 'conflict-resolution-button',
SuperEditor: 'super-editor', SuperEditor: 'super-editor',
SuperEditorContent: 'super-editor-content',
} as const } as const