Skip to content

Commit

Permalink
Add-image button in docs panel (#12202)
Browse files Browse the repository at this point in the history
Add button to documentation panel for inserting images; opens a file chooser dialog that is restricted to the same image types we allow when pasting.

https://github.com/user-attachments/assets/56559dff-0199-4fa5-b1f8-8ee530015480

Fixes #10102.
  • Loading branch information
kazcw authored Feb 3, 2025
1 parent 1cb86a5 commit a310865
Show file tree
Hide file tree
Showing 8 changed files with 66 additions and 52 deletions.
8 changes: 4 additions & 4 deletions app/common/src/utilities/data/object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,13 +61,13 @@ export function unsafeMutable<T extends object>(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<T extends object>(object: T): readonly (keyof T)[] {
export function unsafeKeys<T extends object>(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<const T extends object>(object: T): readonly T[keyof T][] {
export function unsafeValues<const T extends object>(object: T): T[keyof T][] {
return Object.values(object)
}

Expand All @@ -77,7 +77,7 @@ export function unsafeValues<const T extends object>(object: T): readonly T[keyo
*/
export function unsafeEntries<T extends object>(
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)
}
Expand All @@ -87,7 +87,7 @@ export function unsafeEntries<T extends object>(
* extra keys.
*/
export function unsafeFromEntries<T extends object>(
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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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<FileList>((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', () => {
Expand Down
6 changes: 3 additions & 3 deletions app/gui/src/dashboard/layouts/DriveBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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])
Expand Down Expand Up @@ -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))
}}
/>
Expand Down
4 changes: 2 additions & 2 deletions app/gui/src/dashboard/layouts/GlobalContextMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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))
}}
/>
Expand Down
13 changes: 8 additions & 5 deletions app/gui/src/project-view/components/DocumentationEditor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -25,11 +26,12 @@ const markdownEditor = ref<ComponentInstance<typeof MarkdownEditor>>()
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)
Expand Down Expand Up @@ -73,6 +75,7 @@ const handler = documentationEditorBindings.handler({
<div class="DocumentationEditor">
<div ref="toolbarElement" class="toolbar">
<FullscreenButton v-model="fullscreen" />
<SvgButton name="image" title="Insert image" @click.stop="tryUploadImageFile()" />
</div>
<slot name="belowToolbar" />
<div
Expand Down
49 changes: 37 additions & 12 deletions app/gui/src/project-view/components/DocumentationEditor/images.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@ import { fetcherUrlTransformer } from '@/components/MarkdownEditor/imageUrlTrans
import { Vec2 } from '@/util/data/vec2'
import type { ToValue } from '@/util/reactivity'
import { useToast } from '@/util/toast'
import { unsafeKeys } from 'enso-common/src/utilities/data/object'
import {
readUserSelectedFile,
type FileExtension,
type MimeType,
} from 'enso-common/src/utilities/file'
import { computed, reactive, toValue } from 'vue'
import type { Path } from 'ydoc-shared/languageServerTypes'
import { Err, mapOk, Ok, Result, withContext } from 'ydoc-shared/util/data/result'
Expand All @@ -20,15 +26,15 @@ interface ProjectFilesAPI {
ensureDirExists(path: Path): Promise<Result<void>>
}

const supportedImageTypes: Record<string, { extension: string }> = {
const supportedImageTypes: Record<MimeType, { extensions: string[] }> = {
// 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', 'jfif', 'pjpeg', 'pjp'] },
'image/png': { extensions: ['png'] },
'image/svg+xml': { extensions: ['svg'] },
'image/webp': { extensions: ['webp'] },
}

function pathUniqueId(path: Path) {
Expand Down Expand Up @@ -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})`,
Expand Down Expand Up @@ -165,15 +171,34 @@ 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 {
return false
}
}

return { transformImageUrl, tryUploadDroppedImage, tryUploadPastedImage }
async function selectFiles() {
try {
const mimeTypes = unsafeKeys(supportedImageTypes)
const extensions = Object.values(supportedImageTypes).flatMap(({ extensions }) =>
extensions.map<FileExtension>((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 }
}
2 changes: 1 addition & 1 deletion app/gui/src/project-view/util/ast/prefixes.ts
Original file line number Diff line number Diff line change
@@ -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<T> = Record<keyof T, Ast.AstId[] | undefined>

Expand Down
21 changes: 0 additions & 21 deletions app/gui/src/project-view/util/record.ts

This file was deleted.

0 comments on commit a310865

Please sign in to comment.