diff --git a/packages/models/src/Domain/Syncable/UserPrefs/PrefDefaults.ts b/packages/models/src/Domain/Syncable/UserPrefs/PrefDefaults.ts index 678302e2c..d943fc7a7 100644 --- a/packages/models/src/Domain/Syncable/UserPrefs/PrefDefaults.ts +++ b/packages/models/src/Domain/Syncable/UserPrefs/PrefDefaults.ts @@ -43,6 +43,7 @@ export const PrefDefaults = { [PrefKey.SuperNoteExportEmbedBehavior]: 'reference', [PrefKey.SuperNoteExportUseMDFrontmatter]: true, [PrefKey.SuperNoteExportPDFPageSize]: 'A4', + [PrefKey.SuperNoteImageAlignment]: 'left', [PrefKey.SystemViewPreferences]: {}, [PrefKey.AuthenticatorNames]: '', [PrefKey.ComponentPreferences]: {}, diff --git a/packages/models/src/Domain/Syncable/UserPrefs/PrefKey.ts b/packages/models/src/Domain/Syncable/UserPrefs/PrefKey.ts index c911452d8..58970f36a 100644 --- a/packages/models/src/Domain/Syncable/UserPrefs/PrefKey.ts +++ b/packages/models/src/Domain/Syncable/UserPrefs/PrefKey.ts @@ -36,6 +36,7 @@ export enum PrefKey { SuperNoteExportEmbedBehavior = 'superNoteExportEmbedBehavior', SuperNoteExportUseMDFrontmatter = 'superNoteExportUseMDFrontmatter', SuperNoteExportPDFPageSize = 'superNoteExportPDFPageSize', + SuperNoteImageAlignment = 'superNoteImageAlignment', AuthenticatorNames = 'authenticatorNames', PaneGesturesEnabled = 'paneGesturesEnabled', ComponentPreferences = 'componentPreferences', @@ -101,4 +102,5 @@ export type PrefValue = { [PrefKey.AddImportsToTag]: boolean [PrefKey.AlwaysCreateNewTagForImports]: boolean [PrefKey.ExistingTagForImports]: string | undefined + [PrefKey.SuperNoteImageAlignment]: 'left' | 'center' | 'right' } diff --git a/packages/web/src/javascripts/Components/Button/IconButton.tsx b/packages/web/src/javascripts/Components/Button/IconButton.tsx index 21cbd02e3..0d3f1df17 100644 --- a/packages/web/src/javascripts/Components/Button/IconButton.tsx +++ b/packages/web/src/javascripts/Components/Button/IconButton.tsx @@ -1,8 +1,8 @@ -import { FunctionComponent, MouseEventHandler } from 'react' +import { ComponentPropsWithoutRef, ForwardedRef, forwardRef, MouseEventHandler } from 'react' import Icon from '@/Components/Icon/Icon' import { IconType } from '@standardnotes/snjs' -type Props = { +interface Props extends ComponentPropsWithoutRef<'button'> { onClick: MouseEventHandler className?: string icon: IconType @@ -12,32 +12,31 @@ type Props = { disabled?: boolean } -const IconButton: FunctionComponent = ({ - onClick, - className = '', - icon, - title, - focusable, - iconClassName = '', - disabled = false, -}) => { - const click: MouseEventHandler = (e) => { - e.preventDefault() - onClick(e) - } - const focusableClass = focusable ? '' : 'focus:shadow-none' - return ( - - ) -} +const IconButton = forwardRef( + ( + { onClick, className = '', icon, title, focusable, iconClassName = '', disabled = false, ...rest }: Props, + ref: ForwardedRef, + ) => { + const click: MouseEventHandler = (e) => { + e.preventDefault() + onClick(e) + } + const focusableClass = focusable ? '' : 'focus:shadow-none' + return ( + + ) + }, +) export default IconButton diff --git a/packages/web/src/javascripts/Components/FilePreview/FilePreview.tsx b/packages/web/src/javascripts/Components/FilePreview/FilePreview.tsx index 26cab51bd..8f777b058 100644 --- a/packages/web/src/javascripts/Components/FilePreview/FilePreview.tsx +++ b/packages/web/src/javascripts/Components/FilePreview/FilePreview.tsx @@ -13,15 +13,23 @@ import { isFileTypePreviewable } from './isFilePreviewable' import PreviewComponent from './PreviewComponent' import Button from '../Button/Button' import { ProtectedIllustration } from '@standardnotes/icons' -import { ImageZoomLevelProps } from './ImageZoomLevelProps' +import { OptionalSuperEmbeddedImageProps } from './OptionalSuperEmbeddedImageProps' type Props = { application: WebApplication file: FileItem 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 isFilePreviewable = useMemo(() => { @@ -137,6 +145,8 @@ const FilePreview = ({ file, application, isEmbeddedInSuper = false, imageZoomLe isEmbeddedInSuper={isEmbeddedInSuper} imageZoomLevel={imageZoomLevel} setImageZoomLevel={setImageZoomLevel} + alignment={alignment} + changeAlignment={changeAlignment} /> ) : ( void +}) { + return Options.map(({ alignment, label }) => ( + + { + // 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() + }} + /> + + )) +} diff --git a/packages/web/src/javascripts/Components/FilePreview/ImagePreview.tsx b/packages/web/src/javascripts/Components/FilePreview/ImagePreview.tsx index 1aef9a4d6..4a11a2bcd 100644 --- a/packages/web/src/javascripts/Components/FilePreview/ImagePreview.tsx +++ b/packages/web/src/javascripts/Components/FilePreview/ImagePreview.tsx @@ -1,12 +1,16 @@ -import { classNames, IconType } from '@standardnotes/snjs' +import { IconType, PrefKey } from '@standardnotes/snjs' import { FunctionComponent, useCallback, useEffect, useState } from 'react' -import IconButton from '../Button/IconButton' -import { ImageZoomLevelProps } from './ImageZoomLevelProps' +import IconButton from '@/Components/Button/IconButton' +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 = { objectUrl: string isEmbeddedInSuper: boolean -} & ImageZoomLevelProps +} & OptionalSuperEmbeddedImageProps const MinimumZoomPercent = 10 const DefaultZoomPercent = 100 @@ -19,6 +23,8 @@ const ImagePreview: FunctionComponent = ({ isEmbeddedInSuper, imageZoomLevel, setImageZoomLevel, + alignment, + changeAlignment, }) => { const [imageWidth, setImageWidth] = useState(0) const [imageHeight, setImageHeight] = useState(0) @@ -48,8 +54,96 @@ const ImagePreview: FunctionComponent = ({ const widthIfEmbedded = imageWidth * (imageZoomPercent / PercentageDivisor) + const imageResizer = ( + <> + {isEmbeddedInSuper ? 'Size' : 'Zoom'}: + { + e.preventDefault() + e.stopPropagation() + const newPercent = imageZoomPercent - ZoomPercentModifier + if (newPercent >= ZoomPercentModifier) { + setImageZoom(newPercent) + } else { + setImageZoom(imageZoomPercent) + } + }} + onMouseDown={(e) => { + e.preventDefault() + }} + /> + {isZoomInputVisible ? ( +
+ { + 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) + } + }} + /> + % +
+ ) : ( + + )} + { + 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 ( -
+
{ + e.preventDefault() + e.stopPropagation() + }} + >
= ({ }} />
-
- {isEmbeddedInSuper ? 'Size' : 'Zoom'}: - { - const newPercent = imageZoomPercent - ZoomPercentModifier - if (newPercent >= ZoomPercentModifier) { - setImageZoom(newPercent) - } else { - setImageZoom(imageZoomPercent) + {!isEmbeddedInSuper && ( +
+ {imageResizer} +
+ )} + {isEmbeddedInSuper && ( +
{ + const editorRoot = document.getElementById(ElementIds.SuperEditorContent) + 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`) } }} - /> - {isZoomInputVisible ? ( -
- { - 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) - } - }} - /> - % + > +
+ {changeAlignment && ( +
+ +
+ )} +
{imageResizer}
- ) : ( - - )} - { - setImageZoom(imageZoomPercent + ZoomPercentModifier) - }} - /> -
+
+ )}
) } diff --git a/packages/web/src/javascripts/Components/FilePreview/ImageZoomLevelProps.tsx b/packages/web/src/javascripts/Components/FilePreview/ImageZoomLevelProps.tsx deleted file mode 100644 index 461e377f3..000000000 --- a/packages/web/src/javascripts/Components/FilePreview/ImageZoomLevelProps.tsx +++ /dev/null @@ -1,4 +0,0 @@ -export type ImageZoomLevelProps = { - imageZoomLevel?: number - setImageZoomLevel?: (zoomLevel: number) => void -} diff --git a/packages/web/src/javascripts/Components/FilePreview/OptionalSuperEmbeddedImageProps.tsx b/packages/web/src/javascripts/Components/FilePreview/OptionalSuperEmbeddedImageProps.tsx new file mode 100644 index 000000000..d63678648 --- /dev/null +++ b/packages/web/src/javascripts/Components/FilePreview/OptionalSuperEmbeddedImageProps.tsx @@ -0,0 +1,8 @@ +import { ElementFormatType } from 'lexical' + +export type OptionalSuperEmbeddedImageProps = { + imageZoomLevel?: number + setImageZoomLevel?: (zoomLevel: number) => void + alignment?: ElementFormatType | null + changeAlignment?: (alignment: ElementFormatType) => void +} diff --git a/packages/web/src/javascripts/Components/FilePreview/PreviewComponent.tsx b/packages/web/src/javascripts/Components/FilePreview/PreviewComponent.tsx index 85534c37f..6a193e927 100644 --- a/packages/web/src/javascripts/Components/FilePreview/PreviewComponent.tsx +++ b/packages/web/src/javascripts/Components/FilePreview/PreviewComponent.tsx @@ -5,7 +5,7 @@ import { FunctionComponent, useCallback, useEffect, useMemo, useRef } from 'reac import Button from '../Button/Button' import { createObjectURLWithRef } from './CreateObjectURLWithRef' import ImagePreview from './ImagePreview' -import { ImageZoomLevelProps } from './ImageZoomLevelProps' +import { OptionalSuperEmbeddedImageProps } from './OptionalSuperEmbeddedImageProps' import { PreviewableTextFileTypes, RequiresNativeFilePreview } from './isFilePreviewable' import TextPreview from './TextPreview' import { parseFileName, sanitizeFileName } from '@standardnotes/utils' @@ -16,7 +16,7 @@ type Props = { file: FileItem bytes: Uint8Array isEmbeddedInSuper: boolean -} & ImageZoomLevelProps +} & OptionalSuperEmbeddedImageProps const PreviewComponent: FunctionComponent = ({ application, @@ -25,6 +25,8 @@ const PreviewComponent: FunctionComponent = ({ isEmbeddedInSuper, imageZoomLevel, setImageZoomLevel, + alignment, + changeAlignment, }) => { const objectUrlRef = useRef() @@ -84,6 +86,8 @@ const PreviewComponent: FunctionComponent = ({ isEmbeddedInSuper={isEmbeddedInSuper} imageZoomLevel={imageZoomLevel} setImageZoomLevel={setImageZoomLevel} + alignment={alignment} + changeAlignment={changeAlignment} /> ) } diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/General/Defaults.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/General/Defaults.tsx index 15e4991c3..480ec6950 100644 --- a/packages/web/src/javascripts/Components/Preferences/Panes/General/Defaults.tsx +++ b/packages/web/src/javascripts/Components/Preferences/Panes/General/Defaults.tsx @@ -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 { WebApplication } from '@/Application/WebApplication' -import { FunctionComponent, useState } from 'react' +import { FunctionComponent, useMemo, useState } from 'react' import HorizontalSeparator from '@/Components/Shared/HorizontalSeparator' import Switch from '@/Components/Switch/Switch' import PreferencesGroup from '../../PreferencesComponents/PreferencesGroup' import PreferencesSegment from '../../PreferencesComponents/PreferencesSegment' import usePreference from '@/Hooks/usePreference' +import Dropdown from '@/Components/Dropdown/Dropdown' +import { DropdownItem } from '@/Components/Dropdown/DropdownItem' import { MutuallyExclusiveMediaQueryBreakpoints, useMediaQuery } from '@/Hooks/useMediaQuery' type Props = { @@ -27,6 +29,27 @@ const Defaults: FunctionComponent = ({ application }) => { const addNoteToParentFolders = usePreference(PrefKey.NoteAddToParentFolders) 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 = () => { application.toggleGlobalSpellcheck().catch(console.error) @@ -79,23 +102,46 @@ const Defaults: FunctionComponent = ({ application }) => {
{!isMobile && ( -
-
- Use always-visible toolbar in Super notes - - When enabled, the Super toolbar will always be shown at the top of the note. It can be temporarily - toggled using Cmd/Ctrl+Shift+K. When disabled, the Super toolbar will only be shown as a floating - toolbar when text is selected. - + <> +
+
+ Use always-visible toolbar in Super notes + + When enabled, the Super toolbar will always be shown at the top of the note. It can be temporarily + toggled using Cmd/Ctrl+Shift+K. When disabled, the Super toolbar will only be shown as a floating + toolbar when text is selected. + +
+ { + application + .setPreference(PrefKey.AlwaysShowSuperToolbar, !alwaysShowSuperToolbar) + .catch(console.error) + }} + checked={alwaysShowSuperToolbar} + />
- { - application.setPreference(PrefKey.AlwaysShowSuperToolbar, !alwaysShowSuperToolbar).catch(console.error) + + + )} +
+ Default image alignment in Super notes +
+ { + application + .setPreference( + PrefKey.SuperNoteImageAlignment, + alignment as PrefValue[PrefKey.SuperNoteImageAlignment], + ) + .catch(console.error) }} - checked={alwaysShowSuperToolbar} />
- )} +
) diff --git a/packages/web/src/javascripts/Components/SuperEditor/Lexical/Theme/Theme.ts b/packages/web/src/javascripts/Components/SuperEditor/Lexical/Theme/Theme.ts index d3174ad97..f34efe571 100644 --- a/packages/web/src/javascripts/Components/SuperEditor/Lexical/Theme/Theme.ts +++ b/packages/web/src/javascripts/Components/SuperEditor/Lexical/Theme/Theme.ts @@ -38,7 +38,7 @@ const BlocksEditorTheme: EditorThemeClasses = { }, embedBlock: { base: 'Lexical__embedBlock', - focus: 'Lexical__embedBlockFocus', + focus: 'embedBlockFocused', }, hashtag: 'Lexical__hashtag', heading: { diff --git a/packages/web/src/javascripts/Components/SuperEditor/Lexical/Theme/editor.scss b/packages/web/src/javascripts/Components/SuperEditor/Lexical/Theme/editor.scss index e51b8a9c7..b806a0e9c 100644 --- a/packages/web/src/javascripts/Components/SuperEditor/Lexical/Theme/editor.scss +++ b/packages/web/src/javascripts/Components/SuperEditor/Lexical/Theme/editor.scss @@ -253,7 +253,7 @@ .Lexical__embedBlock { user-select: none; } -.Lexical__embedBlockFocus { +.embedBlockFocused { outline: 2px solid var(--sn-stylekit-info-color); } diff --git a/packages/web/src/javascripts/Components/SuperEditor/Plugins/EncryptedFilePlugin/Nodes/FileComponent.tsx b/packages/web/src/javascripts/Components/SuperEditor/Plugins/EncryptedFilePlugin/Nodes/FileComponent.tsx index 98428f892..4836db795 100644 --- a/packages/web/src/javascripts/Components/SuperEditor/Plugins/EncryptedFilePlugin/Nodes/FileComponent.tsx +++ b/packages/web/src/javascripts/Components/SuperEditor/Plugins/EncryptedFilePlugin/Nodes/FileComponent.tsx @@ -1,6 +1,13 @@ import { BlockWithAlignableContents } from '@lexical/react/LexicalBlockWithAlignableContents' 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 FilePreview from '@/Components/FilePreview/FilePreview' import { FileItem } from '@standardnotes/snjs' @@ -16,13 +23,22 @@ export type FileComponentProps = Readonly<{ focus: string }> format: ElementFormatType | null + setFormat: (format: ElementFormatType) => void nodeKey: NodeKey fileUuid: string zoomLevel: number 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 [editor] = useLexicalComposerContext() const [file, setFile] = useState(() => application.items.findItem(fileUuid)) @@ -71,6 +87,19 @@ function FileComponent({ className, format, nodeKey, fileUuid, zoomLevel, setZoo [editor, setZoomLevel], ) + const changeAlignment = useCallback( + (alignment: ElementFormatType) => + editor.update( + () => { + setFormat(alignment) + }, + { + tag: SKIP_DOM_SELECTION_TAG, + }, + ), + [editor, setFormat], + ) + const [isSelected, setSelected] = useLexicalNodeSelection(nodeKey) useEffect(() => { @@ -147,6 +176,8 @@ function FileComponent({ className, format, nodeKey, fileUuid, zoomLevel, setZoo application={application} imageZoomLevel={zoomLevel} setImageZoomLevel={setImageZoomLevel} + alignment={format} + changeAlignment={changeAlignment} /> )}
diff --git a/packages/web/src/javascripts/Components/SuperEditor/Plugins/EncryptedFilePlugin/Nodes/FileNode.tsx b/packages/web/src/javascripts/Components/SuperEditor/Plugins/EncryptedFilePlugin/Nodes/FileNode.tsx index 597eba9d0..9f8b88941 100644 --- a/packages/web/src/javascripts/Components/SuperEditor/Plugins/EncryptedFilePlugin/Nodes/FileNode.tsx +++ b/packages/web/src/javascripts/Components/SuperEditor/Plugins/EncryptedFilePlugin/Nodes/FileNode.tsx @@ -94,6 +94,7 @@ export class FileNode extends DecoratorBlockNode implements ItemNodeInterface { format: ElementFormatType | null + setFormat: (format: ElementFormatType) => void node: InlineFileNode 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 [editor] = useLexicalComposerContext() @@ -57,11 +61,49 @@ const InlineFileComponent = ({ className, src, mimeType, fileName, format, node, 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 ( {mimeType.startsWith('image') ? ( -
+
{ + e.preventDefault() + e.stopPropagation() + }} + > {fileName} +
{ + 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`) + } + }} + > +
+ +
+
) : mimeType.startsWith('video') ? (