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

Autolinks in documentation editor #11597

Merged
merged 34 commits into from
Nov 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
62d8bdd
Render tables in documentation.
kazcw Nov 6, 2024
40875ef
CHANGELOG, prettier
kazcw Nov 15, 2024
6bd2b95
Apply @farmaazon review.
kazcw Nov 15, 2024
c6432d0
Fix
kazcw Nov 15, 2024
be9bdd3
Lint
kazcw Nov 15, 2024
3d75871
Cleanup
kazcw Nov 15, 2024
e654dfd
Integration tests for GraphNodeComment
kazcw Nov 15, 2024
f317637
Merge remote-tracking branch 'origin/develop' into wip/kw/doc-tables
kazcw Nov 18, 2024
7431384
Workaround stuck CI
kazcw Nov 18, 2024
58adff3
Merge branch 'wip/kw/node-comment-tests' into staging
kazcw Nov 18, 2024
621be55
Merge branch 'wip/kw/doc-tables' into staging
kazcw Nov 18, 2024
89cf8df
CodeMirror implementation of GraphNodeComment
kazcw Nov 15, 2024
caaa01a
Revert "Workaround stuck CI"
kazcw Nov 18, 2024
4606f2b
Merge branch 'develop' into wip/kw/node-comment-tests
mergify[bot] Nov 18, 2024
4f8c80f
Merge branch 'develop' into wip/kw/doc-tables
mergify[bot] Nov 18, 2024
fcac2fd
Revert "Workaround stuck CI"
kazcw Nov 18, 2024
ae2b93f
Merge remote-tracking branch 'origin/wip/kw/node-comment-tests' into …
kazcw Nov 19, 2024
c58c8b7
Merge remote-tracking branch 'origin/develop' into wip/kw/doc-tables
kazcw Nov 19, 2024
fb396d8
Merge branch 'wip/kw/doc-tables' into wip/kw/cm-comments
kazcw Nov 19, 2024
b7549bc
Merge remote-tracking branch 'origin/develop' into wip/kw/doc-tables
kazcw Nov 19, 2024
f11dcce
Fix merge
kazcw Nov 19, 2024
2583a10
Merge branch 'wip/kw/doc-tables' into wip/kw/cm-comments
kazcw Nov 19, 2024
2bdb5e8
Merge remote-tracking branch 'origin/develop' into wip/kw/doc-tables
kazcw Nov 19, 2024
e80de7b
LINKABLE_URL_REGEX: Remove a Lexical special case
kazcw Nov 19, 2024
d69ae37
Autolinks in documentation
kazcw Nov 19, 2024
bddf6e9
Merge branch 'wip/kw/cm-comments' into wip/kw/doc-autolinks
kazcw Nov 19, 2024
510fe3e
CHANGELOG
kazcw Nov 19, 2024
5061569
Remove unused/unsupported Markdown parsers
kazcw Nov 20, 2024
373c56c
Merge remote-tracking branch 'origin/develop' into wip/kw/cm-comments
kazcw Nov 20, 2024
1708507
Merge branch 'wip/kw/cm-comments' into wip/kw/doc-autolinks
kazcw Nov 20, 2024
d9f2c02
Space cases
kazcw Nov 21, 2024
22bb731
Merge branch 'wip/kw/cm-comments' into wip/kw/doc-autolinks
kazcw Nov 21, 2024
95ead92
Remove comment
kazcw Nov 21, 2024
473c170
Merge branch 'develop' into wip/kw/doc-autolinks
kazcw Nov 21, 2024
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
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
Loading