Skip to content

Commit

Permalink
Autolinks in documentation editor (#11597)
Browse files Browse the repository at this point in the history
  • Loading branch information
kazcw authored Nov 21, 2024
1 parent 7af5403 commit c431a6b
Show file tree
Hide file tree
Showing 11 changed files with 480 additions and 245 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@
- ["Write" button in component menu allows to evaluate it separately from the
rest of the workflow][11523].
- [The documentation editor can now display tables][11564]
- [The documentation editor supports the Markdown URL syntax, and uses it to
render pasted URLs as links][11597]
- [Table Input Widget is now matched for Table.input method instead of
Table.new. Values must be string literals, and their content is parsed to the
suitable type][11612].
Expand All @@ -51,6 +53,7 @@
[11547]: https://github.com/enso-org/enso/pull/11547
[11523]: https://github.com/enso-org/enso/pull/11523
[11564]: https://github.com/enso-org/enso/pull/11564
[11597]: https://github.com/enso-org/enso/pull/11597
[11612]: https://github.com/enso-org/enso/pull/11612

#### Enso Standard Library
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 @@ -13,6 +13,7 @@ export const documentationEditorBindings = defineKeybinds('documentation-editor'
toggle: ['Mod+D'],
openLink: ['Mod+PointerMain'],
paste: ['Mod+V'],
pasteRaw: ['Mod+Shift+V'],
})

export const interactionBindings = defineKeybinds('current-interaction', {
Expand Down
194 changes: 22 additions & 172 deletions app/gui/src/project-view/components/DocumentationEditor.vue
Original file line number Diff line number Diff line change
@@ -1,18 +1,14 @@
<script setup lang="ts">
import { documentationEditorBindings } from '@/bindings'
import { useDocumentationImages } from '@/components/DocumentationEditor/images'
import { transformPastedText } from '@/components/DocumentationEditor/textPaste'
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 { 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 { ComponentInstance, ref, toRef, watch } from 'vue'
import * as Y from 'yjs'
const { yText } = defineProps<{
Expand All @@ -27,129 +23,11 @@ const markdownEditor = ref<ComponentInstance<typeof MarkdownEditor>>()
const graphStore = useGraphStore()
const projectStore = useProjectStore()
const { transformImageUrl, uploadImage } = useDocumentationImages(
const { transformImageUrl, tryUploadPastedImage, tryUploadDroppedImage } = useDocumentationImages(
() => (markdownEditor.value?.loaded ? markdownEditor.value : undefined),
toRef(graphStore, 'modulePath'),
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>,
projectFiles: ProjectFilesAPI,
) {
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:') {
// 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.
return undefined
}
}
function pathUniqueId(path: Path) {
return path.rootId + ':' + path.segments.join('/')
}
function pathDebugRepr(path: Path) {
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) => {
const id = pathUniqueId(path)
return {
location: path,
uniqueId: id,
uploading: computed(() => currentlyUploading.has(id)),
}
}),
)
},
async (path) => {
return withContext(
() => `Loading documentation image (${pathDebugRepr(path)})`,
async () => {
const uploaded = await currentlyUploading.get(pathUniqueId(path))
return uploaded ? Ok(uploaded) : projectFiles.readFileBinary(path)
},
)
},
)
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)
const fullscreenAnimating = ref(false)
Expand All @@ -159,53 +37,25 @@ watch(
(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 })
}
function handlePaste(raw: boolean) {
window.navigator.clipboard.read().then(async (items) => {
if (!markdownEditor.value) return
for (const item of items) {
const textType = item.types.find((type) => type === 'text/plain')
if (textType) {
const blob = await item.getType(textType)
const rawText = await blob.text()
markdownEditor.value.putText(raw ? rawText : transformPastedText(rawText))
break
}
if (tryUploadPastedImage(item)) break
}
})
}
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
}
}
})
},
paste: () => handlePaste(false),
pasteRaw: () => handlePaste(true),
})
</script>

Expand All @@ -219,7 +69,7 @@ const handler = documentationEditorBindings.handler({
class="scrollArea"
@keydown="handler"
@dragover.prevent
@drop.prevent="handleFileDrop($event)"
@drop.prevent="tryUploadDroppedImage($event)"
>
<MarkdownEditor
ref="markdownEditor"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { transformPastedText } from '@/components/DocumentationEditor/textPaste'
import { expect, test } from 'vitest'

test.each([
{
clipboard: '',
},
{
clipboard: 'Text without links',
},
{
clipboard: 'example.com',
inserted: '<https://example.com>',
},
{
clipboard: 'http://example.com',
inserted: '<http://example.com>',
},
{
clipboard: 'Complete URL: http://example.com',
inserted: 'Complete URL: <http://example.com>',
},
{
clipboard: 'example.com/Address containing spaces and a < character',
inserted: '<https://example.com/Address containing spaces and a %3C character>',
},
{
clipboard: 'example.com/Address resembling *bold syntax*',
inserted: '<https://example.com/Address resembling %2Abold syntax%2A>',
},
{
clipboard: 'Url: www.a.example.com, another: www.b.example.com',
inserted: 'Url: <https://www.a.example.com>, another: <https://www.b.example.com>',
},
{
clipboard: 'gopher:///no/autolinking/unusual/protocols',
},
{
clipboard: '/',
},
{
clipboard: '//',
},
{
clipboard: 'nodomain',
},
{
clipboard: '/relative',
},
{
clipboard: 'Sentence.',
},
{
clipboard: 'example.com with trailing text',
},
])('Auto-linking pasted text: $clipboard', ({ clipboard, inserted }) => {
expect(transformPastedText(clipboard)).toBe(inserted ?? clipboard)
})
Loading

0 comments on commit c431a6b

Please sign in to comment.