From 078baf507a8fdc21be7c869f3876d207a3400e8a Mon Sep 17 00:00:00 2001 From: Kaz Date: Thu, 30 Jan 2025 15:01:57 -0800 Subject: [PATCH 1/2] Add-image button --- app/common/src/utilities/data/object.ts | 8 +-- .../input.ts => common/src/utilities/file.ts} | 15 ++++-- app/gui/src/dashboard/layouts/DriveBar.tsx | 6 +-- .../dashboard/layouts/GlobalContextMenu.tsx | 4 +- .../components/DocumentationEditor.vue | 13 +++-- .../components/DocumentationEditor/images.ts | 49 ++++++++++++++----- app/gui/src/project-view/util/ast/prefixes.ts | 2 +- app/gui/src/project-view/util/record.ts | 21 -------- 8 files changed, 66 insertions(+), 52 deletions(-) rename app/{gui/src/dashboard/utilities/input.ts => common/src/utilities/file.ts} (51%) delete mode 100644 app/gui/src/project-view/util/record.ts diff --git a/app/common/src/utilities/data/object.ts b/app/common/src/utilities/data/object.ts index 44279a04d2b3..5599b6b3d3f2 100644 --- a/app/common/src/utilities/data/object.ts +++ b/app/common/src/utilities/data/object.ts @@ -61,13 +61,13 @@ export function unsafeMutable(object: T): { -readonly [K in ke * Return the entries of an object. UNSAFE only when it is possible for an object to have * extra keys. */ -export function unsafeKeys(object: T): readonly (keyof T)[] { +export function unsafeKeys(object: T): (keyof T)[] { // @ts-expect-error This is intentionally a wrapper function with a different type. return Object.keys(object) } /** Return the values of an object. UNSAFE only when it is possible for an object to have extra keys. */ -export function unsafeValues(object: T): readonly T[keyof T][] { +export function unsafeValues(object: T): T[keyof T][] { return Object.values(object) } @@ -77,7 +77,7 @@ export function unsafeValues(object: T): readonly T[keyo */ export function unsafeEntries( object: T, -): readonly { [K in keyof T]: readonly [K, T[K]] }[keyof T][] { +): readonly { [K in keyof T]: [K, T[K]] }[keyof T][] { // @ts-expect-error This is intentionally a wrapper function with a different type. return Object.entries(object) } @@ -87,7 +87,7 @@ export function unsafeEntries( * extra keys. */ export function unsafeFromEntries( - entries: readonly { [K in keyof T]: readonly [K, T[K]] }[keyof T][], + entries: readonly { [K in keyof T]: [K, T[K]] }[keyof T][], ): T { // @ts-expect-error This is intentionally a wrapper function with a different type. return Object.fromEntries(entries) diff --git a/app/gui/src/dashboard/utilities/input.ts b/app/common/src/utilities/file.ts similarity index 51% rename from app/gui/src/dashboard/utilities/input.ts rename to app/common/src/utilities/file.ts index fc07ee6ab218..d44bdcbf92ba 100644 --- a/app/gui/src/dashboard/utilities/input.ts +++ b/app/common/src/utilities/file.ts @@ -1,16 +1,23 @@ -/** @file Functions related to inputs. */ +/** @file Functions related to files. */ + +export type FileExtension = `.${string}` +export type MimeType = `${string}/${string}` + +export interface InputFilesOptions { + accept?: (FileExtension | MimeType)[] +} /** - * Trigger a file input. + * Open a file-selection dialog and read the file selected by the user. */ -export function inputFiles() { +export function readUserSelectedFile(options: InputFilesOptions = {}) { return new Promise((resolve, reject) => { const input = document.createElement('input') input.type = 'file' input.style.display = 'none' + if (options.accept) input.accept = options.accept.join(',') document.body.appendChild(input) input.addEventListener('input', () => { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion resolve(input.files!) }) input.addEventListener('cancel', () => { diff --git a/app/gui/src/dashboard/layouts/DriveBar.tsx b/app/gui/src/dashboard/layouts/DriveBar.tsx index 0d7fe84b56b9..d87204284c09 100644 --- a/app/gui/src/dashboard/layouts/DriveBar.tsx +++ b/app/gui/src/dashboard/layouts/DriveBar.tsx @@ -55,8 +55,8 @@ import { useSetModal } from '#/providers/ModalProvider' import { useText } from '#/providers/TextProvider' import type Backend from '#/services/Backend' import type AssetQuery from '#/utilities/AssetQuery' -import { inputFiles } from '#/utilities/input' import * as sanitizedEventTargets from '#/utilities/sanitizedEventTargets' +import { readUserSelectedFile } from 'enso-common/src/utilities/file' import { useFullUserSession } from '../providers/AuthProvider' import { AssetPanelToggle } from './AssetPanel' @@ -171,7 +171,7 @@ export default function DriveBar(props: DriveBarProps) { void newProject([null, null]) }, uploadFiles: () => { - void inputFiles().then((files) => uploadFiles(Array.from(files))) + void readUserSelectedFile().then((files) => uploadFiles(Array.from(files))) }, }) }, [inputBindings, isCloud, newFolder, newProject, uploadFiles]) @@ -323,7 +323,7 @@ export default function DriveBar(props: DriveBarProps) { isDisabled={shouldBeDisabled} aria-label={getText('uploadFiles')} onPress={async () => { - const files = await inputFiles() + const files = await readUserSelectedFile() await uploadFiles(Array.from(files)) }} /> diff --git a/app/gui/src/dashboard/layouts/GlobalContextMenu.tsx b/app/gui/src/dashboard/layouts/GlobalContextMenu.tsx index fd40a630b254..2561dbaddc24 100644 --- a/app/gui/src/dashboard/layouts/GlobalContextMenu.tsx +++ b/app/gui/src/dashboard/layouts/GlobalContextMenu.tsx @@ -16,7 +16,7 @@ import { useSetModal } from '#/providers/ModalProvider' import { useText } from '#/providers/TextProvider' import type Backend from '#/services/Backend' import { BackendType, type DirectoryId } from '#/services/Backend' -import { inputFiles } from '#/utilities/input' +import { readUserSelectedFile } from 'enso-common/src/utilities/file' /** Props for a {@link GlobalContextMenu}. */ export interface GlobalContextMenuProps { @@ -89,7 +89,7 @@ export const GlobalContextMenu = function GlobalContextMenu(props: GlobalContext hidden={hidden} action="uploadFiles" doAction={async () => { - const files = await inputFiles() + const files = await readUserSelectedFile() await uploadFiles(Array.from(files)) }} /> diff --git a/app/gui/src/project-view/components/DocumentationEditor.vue b/app/gui/src/project-view/components/DocumentationEditor.vue index 0aa1cffebca1..110c93418934 100644 --- a/app/gui/src/project-view/components/DocumentationEditor.vue +++ b/app/gui/src/project-view/components/DocumentationEditor.vue @@ -5,6 +5,7 @@ import { transformPastedText } from '@/components/DocumentationEditor/textPaste' import FullscreenButton from '@/components/FullscreenButton.vue' import MarkdownEditor from '@/components/MarkdownEditor.vue' import { htmlToMarkdown } from '@/components/MarkdownEditor/htmlToMarkdown' +import SvgButton from '@/components/SvgButton.vue' import WithFullscreenMode from '@/components/WithFullscreenMode.vue' import { useGraphStore } from '@/stores/graph' import { useProjectStore } from '@/stores/project' @@ -25,11 +26,12 @@ const markdownEditor = ref>() const graphStore = useGraphStore() const projectStore = useProjectStore() -const { transformImageUrl, tryUploadPastedImage, tryUploadDroppedImage } = useDocumentationImages( - () => (markdownEditor.value?.loaded ? markdownEditor.value : undefined), - toRef(graphStore, 'modulePath'), - useProjectFiles(projectStore), -) +const { transformImageUrl, tryUploadPastedImage, tryUploadDroppedImage, tryUploadImageFile } = + useDocumentationImages( + () => (markdownEditor.value?.loaded ? markdownEditor.value : undefined), + toRef(graphStore, 'modulePath'), + useProjectFiles(projectStore), + ) const fullscreen = ref(false) const fullscreenAnimating = ref(false) @@ -73,6 +75,7 @@ const handler = documentationEditorBindings.handler({
+
> } -const supportedImageTypes: Record = { +const supportedImageTypes: Record = { // List taken from https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Image_types - 'image/apng': { extension: 'apng' }, - 'image/avif': { extension: 'avif' }, - 'image/gif': { extension: 'gif' }, - 'image/jpeg': { extension: 'jpg' }, - 'image/png': { extension: 'png' }, - 'image/svg+xml': { extension: 'svg' }, - 'image/webp': { extension: 'webp' }, + 'image/apng': { extensions: ['apng'] }, + 'image/avif': { extensions: ['avif'] }, + 'image/gif': { extensions: ['gif'] }, + 'image/jpeg': { extensions: ['jpg', 'jpeg'] }, + 'image/png': { extensions: ['png'] }, + 'image/svg+xml': { extensions: ['svg'] }, + 'image/webp': { extensions: ['webp'] }, } function pathUniqueId(path: Path) { @@ -74,7 +80,7 @@ export function useDocumentationImages( /** URL transformer that enables displaying images from the current project. */ const transformImageUrl = fetcherUrlTransformer( async (url: string) => { - const path = await urlToPath(url) + const path = urlToPath(url) if (!path) return return withContext( () => `Locating documentation image (${url})`, @@ -165,9 +171,9 @@ export function useDocumentationImages( /** If the given clipboard content contains a supported image, upload it and insert a reference into the editor. */ function tryUploadPastedImage(item: ClipboardItem): boolean { - const imageType = item.types.find((type) => type in supportedImageTypes) + const imageType = item.types.find((type): type is MimeType => type in supportedImageTypes) if (imageType) { - const ext = supportedImageTypes[imageType]?.extension ?? '' + const ext = supportedImageTypes[imageType]?.extensions[0] ?? '' uploadImage(`image.${ext}`, item.getType(imageType)) return true } else { @@ -175,5 +181,24 @@ export function useDocumentationImages( } } - return { transformImageUrl, tryUploadDroppedImage, tryUploadPastedImage } + async function selectFiles() { + try { + const mimeTypes = unsafeKeys(supportedImageTypes) + const extensions = Object.values(supportedImageTypes).flatMap(({ extensions }) => + extensions.map((e) => `.${e}`), + ) + return await readUserSelectedFile({ accept: [...mimeTypes, ...extensions] }) + } catch (e) { + console.error(e) + return [] + } + } + + async function tryUploadImageFile() { + for (const file of await selectFiles()) { + await uploadImage(file.name, Promise.resolve(file)) + } + } + + return { transformImageUrl, tryUploadDroppedImage, tryUploadPastedImage, tryUploadImageFile } } diff --git a/app/gui/src/project-view/util/ast/prefixes.ts b/app/gui/src/project-view/util/ast/prefixes.ts index 3314c93c8201..68df7a971c9d 100644 --- a/app/gui/src/project-view/util/ast/prefixes.ts +++ b/app/gui/src/project-view/util/ast/prefixes.ts @@ -1,6 +1,6 @@ import { Ast } from '@/util/ast' import { Pattern } from '@/util/ast/match' -import { unsafeKeys } from '@/util/record' +import { unsafeKeys } from 'enso-common/src/utilities/data/object' type Matches = Record diff --git a/app/gui/src/project-view/util/record.ts b/app/gui/src/project-view/util/record.ts deleted file mode 100644 index 8e43992b31d2..000000000000 --- a/app/gui/src/project-view/util/record.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** Unsafe whe the record can have extra keys which are not in `K`. */ -export function unsafeEntries(obj: Record): [K, V][] { - return Object.entries(obj) as any -} - -/** Unsafe whe the record can have extra keys which are not in `K`. */ -export function unsafeKeys(obj: Record): K[] { - return Object.keys(obj) as any -} - -/** Swap keys and value in a record. */ -export function swapKeysAndValues( - record: Record, -): Record { - const swappedRecord: Record = {} as Record - for (const key in record) { - const value = record[key] - swappedRecord[value] = key as K - } - return swappedRecord -} From ebfc5c5bdeda2bcde7ec24acaa47bd4524bb76cd Mon Sep 17 00:00:00 2001 From: Kaz Date: Fri, 31 Jan 2025 05:58:07 -0800 Subject: [PATCH 2/2] Review --- .../src/project-view/components/DocumentationEditor/images.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/gui/src/project-view/components/DocumentationEditor/images.ts b/app/gui/src/project-view/components/DocumentationEditor/images.ts index bf6048e47996..9ef5099d61ac 100644 --- a/app/gui/src/project-view/components/DocumentationEditor/images.ts +++ b/app/gui/src/project-view/components/DocumentationEditor/images.ts @@ -31,7 +31,7 @@ const supportedImageTypes: Record = { 'image/apng': { extensions: ['apng'] }, 'image/avif': { extensions: ['avif'] }, 'image/gif': { extensions: ['gif'] }, - 'image/jpeg': { extensions: ['jpg', 'jpeg'] }, + 'image/jpeg': { extensions: ['jpg', 'jpeg', 'jfif', 'pjpeg', 'pjp'] }, 'image/png': { extensions: ['png'] }, 'image/svg+xml': { extensions: ['svg'] }, 'image/webp': { extensions: ['webp'] },