Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add-image button in docs panel #12202

Open
wants to merge 2 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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'] },
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Look at the link in the comment: there are more alternatives for jpeg :)

'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.

Loading