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

Pasting image in Documentation Editor #11547

Merged
merged 16 commits into from
Nov 19, 2024
Merged
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)
Copy link
Contributor

Choose a reason for hiding this comment

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

missing break

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)
kazcw marked this conversation as resolved.
Show resolved Hide resolved
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
Loading