From 4cbc61ba52fd37423dbce8f1afbec513e56fc34a Mon Sep 17 00:00:00 2001 From: DariaYeremina Date: Tue, 18 Jun 2024 15:49:30 -0300 Subject: [PATCH] feat(markdown): add image button [KHCP-12213] (#88) * feat(markdown): add image button [KHCP-12213] * feat(markdown): address pr [KHCP-12213] * feat(markdown): autoselect 'url' text for link and image [KHCP-12213] * feat(markdown): fix test [KHCP-12213] * feat(markdown): fix autoselect [KHCP-12213] * feat(markdown): fix autoselect for url [KHCP-12213] --- src/components/MarkdownUi.spec.ts | 4 +- src/components/MarkdownUi.vue | 4 +- .../toolbar/MarkdownToolbar.spec.ts | 1 + src/components/toolbar/MarkdownToolbar.vue | 3 +- src/composables/useMarkdownActions.ts | 126 +++++++++++++++++- src/constants.ts | 3 + src/types/markdown-ui.ts | 1 + 7 files changed, 134 insertions(+), 8 deletions(-) diff --git a/src/components/MarkdownUi.spec.ts b/src/components/MarkdownUi.spec.ts index 4e4704b9..5a8fea03 100644 --- a/src/components/MarkdownUi.spec.ts +++ b/src/components/MarkdownUi.spec.ts @@ -555,7 +555,7 @@ describe('', () => { await waitForEmittedEvent(wrapper, eventName) expect(wrapper.emitted(eventName) || []).toHaveLength(1) - expect(wrapper.emitted(eventName)![0][0]).toContain([MARKDOWN_TEMPLATE_LINK.replace(/text/, '')]) + expect(wrapper.emitted(eventName)![0][0]).toContain([MARKDOWN_TEMPLATE_LINK]) }) it('wraps the selected text with the link template', async () => { @@ -641,7 +641,7 @@ describe('', () => { await waitForEmittedEvent(wrapper, eventName) expect(wrapper.emitted(eventName) || []).toHaveLength(1) - expect(wrapper.emitted(eventName)![0][0]).toContain([MARKDOWN_TEMPLATE_LINK.replace(/text/, '').replace(/url/, linkUrl)]) + expect(wrapper.emitted(eventName)![0][0]).toContain([MARKDOWN_TEMPLATE_LINK.replace(/url/, linkUrl)]) }) }) }) diff --git a/src/components/MarkdownUi.vue b/src/components/MarkdownUi.vue index 73e54a67..45aa47b0 100644 --- a/src/components/MarkdownUi.vue +++ b/src/components/MarkdownUi.vue @@ -323,12 +323,14 @@ const markdownHtml = ref('') // A ref to store the preview HTML (if user enables it in the toolbar) const markdownPreviewHtml = ref('') -const { toggleInlineFormatting, insertMarkdownTemplate, insertLink } = composables.useMarkdownActions(textarea, rawMarkdown) +const { toggleInlineFormatting, insertMarkdownTemplate, insertLink, insertImage } = composables.useMarkdownActions(textarea, rawMarkdown) // When the user toggles inline formatting const formatSelection = (format: InlineFormat): void => { if (format === 'link') { insertLink() + } else if (format === 'image') { + insertImage() } else { toggleInlineFormatting(format) } diff --git a/src/components/toolbar/MarkdownToolbar.spec.ts b/src/components/toolbar/MarkdownToolbar.spec.ts index c1f0ad6e..e09dffea 100644 --- a/src/components/toolbar/MarkdownToolbar.spec.ts +++ b/src/components/toolbar/MarkdownToolbar.spec.ts @@ -25,6 +25,7 @@ const formatOptions: Partial[] = [ { label: 'Strikethrough', action: 'strikethrough', keys: ['Shift', 'X'] }, { label: 'Code', action: 'code', keys: ['Shift', 'C'] }, { label: 'Link', action: 'link' }, + { label: 'Image', action: 'image' }, ] const templateOptions: Partial[] = [ diff --git a/src/components/toolbar/MarkdownToolbar.vue b/src/components/toolbar/MarkdownToolbar.vue index 7f8dd5d6..3ecf26ee 100644 --- a/src/components/toolbar/MarkdownToolbar.vue +++ b/src/components/toolbar/MarkdownToolbar.vue @@ -186,7 +186,7 @@ import type { MarkdownMode, FormatOption, TemplateOption, InlineFormat, Markdown import ToolbarButton from '@/components/toolbar/ToolbarButton.vue' import InfoTooltip from '@/components/toolbar/InfoTooltip.vue' import TooltipShortcut from '@/components/toolbar/TooltipShortcut.vue' -import { BoldIcon, ItalicIcon, UnderlineIcon, StrikethroughIcon, /* SubscriptIcon, SuperscriptIcon, MarkIcon, */ CodeIcon, LinkIcon, CodeblockIcon, TableIcon, TasklistIcon, ListUnorderedIcon, ListOrderedIcon, MarkdownIcon, HtmlIcon, BlockquoteIcon, ExpandIcon, CollapseIcon } from '@kong/icons' +import { BoldIcon, ItalicIcon, UnderlineIcon, StrikethroughIcon, /* SubscriptIcon, SuperscriptIcon, MarkIcon, */ CodeIcon, LinkIcon, CodeblockIcon, TableIcon, TasklistIcon, ListUnorderedIcon, ListOrderedIcon, MarkdownIcon, HtmlIcon, BlockquoteIcon, ExpandIcon, CollapseIcon, ImageIcon } from '@kong/icons' import { v4 as uuidv4 } from 'uuid' const uniqueId: Ref = inject(UNIQUE_ID_INJECTION_KEY, ref(uuidv4())) @@ -251,6 +251,7 @@ const formatOptions: FormatOption[] = [ // { label: 'Mark', action: 'mark', icon: MarkIcon }, // Hidden for now { label: 'Code', action: 'code', keys: ['Shift', 'C'], icon: CodeIcon }, { label: 'Link', action: 'link', icon: LinkIcon }, + { label: 'Image', action: 'image', icon: ImageIcon }, ] const templateOptions: TemplateOption[] = [ diff --git a/src/composables/useMarkdownActions.ts b/src/composables/useMarkdownActions.ts index 34208d4d..c96a6511 100644 --- a/src/composables/useMarkdownActions.ts +++ b/src/composables/useMarkdownActions.ts @@ -1,6 +1,19 @@ import { reactive, nextTick } from 'vue' import type { Ref } from 'vue' -import { InlineFormatWrapper, DEFAULT_CODEBLOCK_LANGUAGE, MARKDOWN_TEMPLATE_CODEBLOCK, MARKDOWN_TEMPLATE_TASK, MARKDOWN_TEMPLATE_TASK_COMPLETED, MARKDOWN_TEMPLATE_UL, MARKDOWN_TEMPLATE_OL, MARKDOWN_TEMPLATE_BLOCKQUOTE, MARKDOWN_TEMPLATE_TABLE, NEW_LINE_CHARACTER, MARKDOWN_TEMPLATE_LINK } from '@/constants' +import { + InlineFormatWrapper, + DEFAULT_CODEBLOCK_LANGUAGE, + MARKDOWN_TEMPLATE_CODEBLOCK, + MARKDOWN_TEMPLATE_TASK, + MARKDOWN_TEMPLATE_TASK_COMPLETED, + MARKDOWN_TEMPLATE_UL, + MARKDOWN_TEMPLATE_OL, + MARKDOWN_TEMPLATE_BLOCKQUOTE, + MARKDOWN_TEMPLATE_TABLE, + NEW_LINE_CHARACTER, + MARKDOWN_TEMPLATE_LINK, + MARKDOWN_TEMPLATE_IMAGE, +} from '@/constants' import type { InlineFormat, MarkdownTemplate } from '@/types' /** @@ -400,6 +413,15 @@ export default function useMarkdownActions( } } + /** + * Highlights text in a textarea on a given position. + * @returns {void} + */ + const selectText = (start: number, end: number): void => { + const textarea = getTextarea() as HTMLTextAreaElement + textarea.setSelectionRange(start, end) + } + /** * Insert a markdown link at the current cursor position. * @returns {Promise} @@ -419,6 +441,8 @@ export default function useMarkdownActions( const startText = rawMarkdown.value.substring(0, selectedText.start) const endText = rawMarkdown.value.substring(selectedText.end) let newContent: string = '' + const urlTextLength = 3 + const textTextLength = 4 // If text is selected, check the type of selected text and insert the link template around it if (selectedText.text.length !== 0) { @@ -431,7 +455,7 @@ export default function useMarkdownActions( // Check if the selected text is a URL const isUrl = /^http(s)?:\/\//.test(selectedText.text) // Prepare the content - newContent = isUrl ? MARKDOWN_TEMPLATE_LINK.replace(/text/, '').replace(/url/, selectedText.text) : MARKDOWN_TEMPLATE_LINK.replace(/text/, selectedText.text) + newContent = isUrl ? MARKDOWN_TEMPLATE_LINK.replace(/url/, selectedText.text) : MARKDOWN_TEMPLATE_LINK.replace(/text/, selectedText.text) // Update the markdown rawMarkdown.value = startText + newContent + endText @@ -446,6 +470,8 @@ export default function useMarkdownActions( textarea.selectionStart = startText.length + selectedText.text.length + 3 textarea.selectionEnd = startText.length + selectedText.text.length + 6 } + + selectText(textarea.selectionStart, textarea.selectionStart + (isUrl ? textTextLength : urlTextLength)) } else { // No text is selected @@ -456,9 +482,9 @@ export default function useMarkdownActions( } // Prepare the content - newContent = MARKDOWN_TEMPLATE_LINK.replace(/text/, '') + newContent = MARKDOWN_TEMPLATE_LINK - let cursorPosition = 1 + let cursorPosition = 7 // Check if we need a space before or after the template if (/\w+$/.test(startText)) { @@ -476,12 +502,103 @@ export default function useMarkdownActions( // Set the cursor position textarea.selectionEnd = selectedText.start + cursorPosition + + selectText(textarea.selectionEnd, textarea.selectionEnd + urlTextLength) } } catch (err) { console.warn('insertLink', err) } } + /** + * Insert a markdown image at the current cursor position. + * @returns {Promise} + */ + const insertImage = async (): Promise => { + try { + const textarea = getTextarea() + + if (!textarea) { + return + } + + // Update the selected text object + getTextSelection() + + // Get the text before and after the cursor + const startText = rawMarkdown.value.substring(0, selectedText.start) + const endText = rawMarkdown.value.substring(selectedText.end) + let newContent: string = '' + const urlTextLength = 3 + const altTextLength = 3 + + // If text is selected, check the type of selected text and insert the link template around it + if (selectedText.text.length !== 0) { + // If the user tries to click the button twice (with `url` selected) exit early + if (selectedText.text === 'url' && startText.endsWith('(') && endText.startsWith(')')) { + await focusTextarea() + return + } + + // Check if the selected text is a URL + const isUrl = /^http(s)?:\/\//.test(selectedText.text) + // Prepare the content + newContent = isUrl ? MARKDOWN_TEMPLATE_IMAGE.replace(/url/, selectedText.text) : MARKDOWN_TEMPLATE_IMAGE.replace(/alt/, selectedText.text) + + + // Update the markdown + rawMarkdown.value = startText + newContent + endText + + // Always focus back on the textarea + await focusTextarea() + + // Set the cursor position + if (isUrl) { + textarea.selectionEnd = selectedText.start + 2 + } else { + textarea.selectionStart = startText.length + selectedText.text.length + 4 + textarea.selectionEnd = startText.length + selectedText.text.length + 7 + } + + selectText(textarea.selectionStart, textarea.selectionStart + altTextLength) + } else { + // No text is selected + + // If the user tries to click the button twice (with the cursor in between the brackets) exit early + if (startText.endsWith(MARKDOWN_TEMPLATE_IMAGE.split('alt')[0]) && /^\]\((.*)+\)/.test(endText)) { + await focusTextarea() + return + } + + // Prepare the content + newContent = MARKDOWN_TEMPLATE_IMAGE + + let cursorPosition = 7 + + // Check if we need a space before or after the template + if (/\w+$/.test(startText)) { + newContent = ' ' + newContent + cursorPosition++ + } else if (/^\w+/.test(endText)) { + newContent += ' ' + } + + // Update the markdown + rawMarkdown.value = startText + newContent + endText + + // Always focus back on the textarea + await focusTextarea() + + // Set the cursor position + textarea.selectionEnd = selectedText.start + cursorPosition + + selectText(textarea.selectionEnd, textarea.selectionEnd + urlTextLength) + } + } catch (err) { + console.warn('insertImage', err) + } + } + /** * Insert a new line in the editor. * Conditionally addor remove inline templates if the previous line also started with one. @@ -597,5 +714,6 @@ export default function useMarkdownActions( insertMarkdownTemplate, insertLink, insertNewLine, + insertImage, } } diff --git a/src/constants.ts b/src/constants.ts index a8cf060e..a315111e 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -47,6 +47,9 @@ export const MARKDOWN_TEMPLATE_BLOCKQUOTE = '> ' /** The markdown link template */ export const MARKDOWN_TEMPLATE_LINK = '[text](url)' +/** The markdown image template */ +export const MARKDOWN_TEMPLATE_IMAGE = '![alt](url)' + /** The inline SVG copy icon */ export const COPY_ICON_SVG = '' diff --git a/src/types/markdown-ui.ts b/src/types/markdown-ui.ts index be91abd8..3a77f998 100644 --- a/src/types/markdown-ui.ts +++ b/src/types/markdown-ui.ts @@ -20,6 +20,7 @@ export type InlineFormat = | 'mark' | 'code' | 'link' + | 'image' export interface FormatOption { label: string