Skip to content

Commit

Permalink
Pasting image in Documentation Editor (#11547)
Browse files Browse the repository at this point in the history
Fixes #10059

https://github.com/user-attachments/assets/a528e26a-b388-4a2a-9bf4-3ccc734373f6

# Important Notes
* I put the logic for project's files management to a single composable "projectFiles"
  • Loading branch information
farmaazon authored Nov 19, 2024
1 parent c52b8f9 commit af39e0e
Show file tree
Hide file tree
Showing 20 changed files with 726 additions and 162 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
component.][11452]
- [New documentation editor provides improved Markdown editing experience, and
paves the way for new documentation features.][11469]
- [You can now add images to documentation panel][11547] by pasting them from
clipboard or by drag'n'dropping image files.
- ["Write" button in component menu allows to evaluate it separately from the
rest of the workflow][11523].

Expand All @@ -42,6 +44,7 @@
[11448]: https://github.com/enso-org/enso/pull/11448
[11452]: https://github.com/enso-org/enso/pull/11452
[11469]: https://github.com/enso-org/enso/pull/11469
[11547]: https://github.com/enso-org/enso/pull/11547
[11523]: https://github.com/enso-org/enso/pull/11523

#### Enso Standard Library
Expand Down
11 changes: 8 additions & 3 deletions app/gui/e2e/project-view/rightPanel.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,18 @@ import * as locate from './locate'
test('Main method documentation', async ({ page }) => {
await actions.goToGraph(page)

const rightDock = locate.rightDock(page)
// Documentation panel hotkey opens right-dock.
await expect(locate.rightDock(page)).toBeHidden()
await expect(rightDock).toBeHidden()
await page.keyboard.press(`${CONTROL_KEY}+D`)
await expect(locate.rightDock(page)).toBeVisible()
await expect(rightDock).toBeVisible()

// Right-dock displays main method documentation.
await expect(locate.editorRoot(locate.rightDock(page))).toHaveText('The main method')
await expect(locate.editorRoot(rightDock)).toContainText('The main method')
// All three images are loaded properly
await expect(rightDock.getByAltText('Image')).toHaveCount(3)
for (const img of await rightDock.getByAltText('Image').all())
await expect(img).toHaveJSProperty('naturalWidth', 3)

// Documentation hotkey closes right-dock.p
await page.keyboard.press(`${CONTROL_KEY}+D`)
Expand Down
1 change: 1 addition & 0 deletions app/gui/src/project-view/bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export const codeEditorBindings = defineKeybinds('code-editor', {
export const documentationEditorBindings = defineKeybinds('documentation-editor', {
toggle: ['Mod+D'],
openLink: ['Mod+PointerMain'],
paste: ['Mod+V'],
})

export const interactionBindings = defineKeybinds('current-interaction', {
Expand Down
154 changes: 143 additions & 11 deletions app/gui/src/project-view/components/DocumentationEditor.vue
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
<script setup lang="ts">
import { documentationEditorBindings } from '@/bindings'
import FullscreenButton from '@/components/FullscreenButton.vue'
import MarkdownEditor from '@/components/MarkdownEditor.vue'
import { fetcherUrlTransformer } from '@/components/MarkdownEditor/imageUrlTransformer'
import WithFullscreenMode from '@/components/WithFullscreenMode.vue'
import { useGraphStore } from '@/stores/graph'
import { useProjectStore } from '@/stores/project'
import { useProjectFiles } from '@/stores/projectFiles'
import { Vec2 } from '@/util/data/vec2'
import type { ToValue } from '@/util/reactivity'
import { ref, toRef, toValue, watch } from 'vue'
import type { Path } from 'ydoc-shared/languageServerTypes'
import { useToast } from '@/util/toast'
import { ComponentInstance, computed, reactive, ref, toRef, toValue, watch } from 'vue'
import type { Path, Uuid } from 'ydoc-shared/languageServerTypes'
import { Err, Ok, mapOk, withContext, type Result } from 'ydoc-shared/util/data/result'
import * as Y from 'yjs'
Expand All @@ -19,26 +23,42 @@ const emit = defineEmits<{
}>()
const toolbarElement = ref<HTMLElement>()
const markdownEditor = ref<ComponentInstance<typeof MarkdownEditor>>()
const graphStore = useGraphStore()
const projectStore = useProjectStore()
const { transformImageUrl } = useDocumentationImages(
const { transformImageUrl, uploadImage } = useDocumentationImages(
toRef(graphStore, 'modulePath'),
projectStore.readFileBinary,
useProjectFiles(projectStore),
)
const uploadErrorToast = useToast.error()
type UploadedImagePosition = { type: 'selection' } | { type: 'coords'; coords: Vec2 }
/**
* A Project File management API for {@link useDocumentationImages} composable.
*/
interface ProjectFilesAPI {
projectRootId: Promise<Uuid | undefined>
readFileBinary(path: Path): Promise<Result<Blob>>
writeFileBinary(path: Path, content: Blob): Promise<Result>
pickUniqueName(path: Path, suggestedName: string): Promise<Result<string>>
ensureDirExists(path: Path): Promise<Result<void>>
}
function useDocumentationImages(
modulePath: ToValue<Path | undefined>,
readFileBinary: (path: Path) => Promise<Result<Blob>>,
projectFiles: ProjectFilesAPI,
) {
async function urlToPath(url: string): Promise<Result<Path> | undefined> {
function urlToPath(url: string): Result<Path> | undefined {
const modulePathValue = toValue(modulePath)
if (!modulePathValue) {
return Err('Current module path is unknown.')
}
const appliedUrl = new URL(url, `file:///${modulePathValue.segments.join('/')}`)
if (appliedUrl.protocol === 'file:') {
const segments = appliedUrl.pathname.split('/')
// The pathname starts with '/', so we remove "" segment.
const segments = decodeURI(appliedUrl.pathname).split('/').slice(1)
return Ok({ rootId: modulePathValue.rootId, segments })
} else {
// Not a relative URL, custom fetching not needed.
Expand All @@ -54,24 +74,81 @@ function useDocumentationImages(
return pathUniqueId(path)
}
const currentlyUploading = reactive(new Map<string, Promise<Blob>>())
const transformImageUrl = fetcherUrlTransformer(
async (url: string) => {
const path = await urlToPath(url)
if (!path) return
return withContext(
() => `Locating documentation image (${url})`,
() => mapOk(path, (path) => ({ location: path, uniqueId: pathUniqueId(path) })),
() =>
mapOk(path, (path) => {
const id = pathUniqueId(path)
return {
location: path,
uniqueId: id,
uploading: computed(() => currentlyUploading.has(id)),
}
}),
)
},
async (path) => {
return withContext(
() => `Loading documentation image (${pathDebugRepr(path)})`,
async () => await readFileBinary(path),
async () => {
const uploaded = await currentlyUploading.get(pathUniqueId(path))
return uploaded ? Ok(uploaded) : projectFiles.readFileBinary(path)
},
)
},
)
return { transformImageUrl }
async function uploadImage(
name: string,
blobPromise: Promise<Blob>,
position: UploadedImagePosition = { type: 'selection' },
) {
const rootId = await projectFiles.projectRootId
if (!rootId) {
uploadErrorToast.show('Cannot upload image: unknown project file tree root.')
return
}
if (!markdownEditor.value || !markdownEditor.value.loaded) {
console.error('Tried to upload image while mardown editor is still not loaded')
return
}
const dirPath = { rootId, segments: ['images'] }
await projectFiles.ensureDirExists(dirPath)
const filename = await projectFiles.pickUniqueName(dirPath, name)
if (!filename.ok) {
uploadErrorToast.reportError(filename.error)
return
}
const path: Path = { rootId, segments: ['images', filename.value] }
const id = pathUniqueId(path)
currentlyUploading.set(id, blobPromise)
const insertedLink = `\n![Image](/images/${encodeURI(filename.value)})\n`
switch (position.type) {
case 'selection':
markdownEditor.value.putText(insertedLink)
break
case 'coords':
markdownEditor.value.putTextAtCoord(insertedLink, position.coords)
break
}
try {
const blob = await blobPromise
const uploadResult = await projectFiles.writeFileBinary(path, blob)
if (!uploadResult.ok)
uploadErrorToast.reportError(uploadResult.error, 'Failed to upload image')
} finally {
currentlyUploading.delete(id)
}
}
return { transformImageUrl, uploadImage }
}
const fullscreen = ref(false)
Expand All @@ -81,6 +158,55 @@ watch(
() => fullscreen.value || fullscreenAnimating.value,
(fullscreenOrAnimating) => emit('update:fullscreen', fullscreenOrAnimating),
)
const supportedImageTypes: Record<string, { extension: 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' },
// Question: do we want to have BMP and ICO here?
}
async function handleFileDrop(event: DragEvent) {
if (!event.dataTransfer?.items) return
for (const item of event.dataTransfer.items) {
if (item.kind !== 'file' || !Object.hasOwn(supportedImageTypes, item.type)) continue
const file = item.getAsFile()
if (!file) continue
const clientPos = new Vec2(event.clientX, event.clientY)
event.stopPropagation()
event.preventDefault()
await uploadImage(file.name, Promise.resolve(file), { type: 'coords', coords: clientPos })
}
}
const handler = documentationEditorBindings.handler({
paste: () => {
window.navigator.clipboard.read().then(async (items) => {
if (markdownEditor.value == null) return
for (const item of items) {
const textType = item.types.find((type) => type === 'text/plain')
if (textType) {
const blob = await item.getType(textType)
markdownEditor.value.putText(await blob.text())
break
}
const imageType = item.types.find((type) => type in supportedImageTypes)
if (imageType) {
const ext = supportedImageTypes[imageType]?.extension ?? ''
uploadImage(`image.${ext}`, item.getType(imageType)).catch((err) =>
uploadErrorToast.show(`Failed to upload image: ${err}`),
)
break
}
}
})
},
})
</script>

<template>
Expand All @@ -89,8 +215,14 @@ watch(
<div ref="toolbarElement" class="toolbar">
<FullscreenButton v-model="fullscreen" />
</div>
<div class="scrollArea">
<div
class="scrollArea"
@keydown="handler"
@dragover.prevent
@drop.prevent="handleFileDrop($event)"
>
<MarkdownEditor
ref="markdownEditor"
:yText="yText"
:transformImageUrl="transformImageUrl"
:toolbarContainer="toolbarElement"
Expand Down
10 changes: 1 addition & 9 deletions app/gui/src/project-view/components/GraphEditor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -664,11 +664,6 @@ async function handleFileDrop(event: DragEvent) {
const MULTIPLE_FILES_GAP = 50
if (!event.dataTransfer?.items) return
const projectRootId = await projectStore.projectRootId
if (projectRootId == null) {
toasts.userActionFailed.show(`Unable to upload file(s): Could not identify project root.`)
return
}
;[...event.dataTransfer.items].forEach(async (item, index) => {
if (item.kind === 'file') {
const file = item.getAsFile()
Expand All @@ -677,10 +672,7 @@ async function handleFileDrop(event: DragEvent) {
const offset = new Vec2(0, index * -MULTIPLE_FILES_GAP)
const pos = graphNavigator.clientToScenePos(clientPos).add(offset)
const uploader = Uploader.Create(
projectStore.lsRpcConnection,
projectStore.dataConnection,
projectRootId,
projectStore.awareness,
projectStore,
file,
pos,
projectStore.isOnLocalBackend,
Expand Down
Loading

0 comments on commit af39e0e

Please sign in to comment.