Skip to content

Commit

Permalink
feat(markdown): add image button [KHCP-12213] (#88)
Browse files Browse the repository at this point in the history
* 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]
  • Loading branch information
DariaYeremina authored Jun 18, 2024
1 parent def70e4 commit 4cbc61b
Show file tree
Hide file tree
Showing 7 changed files with 134 additions and 8 deletions.
4 changes: 2 additions & 2 deletions src/components/MarkdownUi.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -555,7 +555,7 @@ describe('<MarkdownUi />', () => {
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 () => {
Expand Down Expand Up @@ -641,7 +641,7 @@ describe('<MarkdownUi />', () => {
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)])
})
})
})
Expand Down
4 changes: 3 additions & 1 deletion src/components/MarkdownUi.vue
Original file line number Diff line number Diff line change
Expand Up @@ -323,12 +323,14 @@ const markdownHtml = ref<string>('')
// A ref to store the preview HTML (if user enables it in the toolbar)
const markdownPreviewHtml = ref<string>('')
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)
}
Expand Down
1 change: 1 addition & 0 deletions src/components/toolbar/MarkdownToolbar.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const formatOptions: Partial<FormatOption>[] = [
{ 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<TemplateOption>[] = [
Expand Down
3 changes: 2 additions & 1 deletion src/components/toolbar/MarkdownToolbar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> = inject(UNIQUE_ID_INJECTION_KEY, ref(uuidv4()))
Expand Down Expand Up @@ -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[] = [
Expand Down
126 changes: 122 additions & 4 deletions src/composables/useMarkdownActions.ts
Original file line number Diff line number Diff line change
@@ -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'

/**
Expand Down Expand Up @@ -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<void>}
Expand All @@ -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) {
Expand All @@ -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
Expand All @@ -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

Expand All @@ -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)) {
Expand All @@ -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<void>}
*/
const insertImage = async (): Promise<void> => {
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.
Expand Down Expand Up @@ -597,5 +714,6 @@ export default function useMarkdownActions(
insertMarkdownTemplate,
insertLink,
insertNewLine,
insertImage,
}
}
3 changes: 3 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '<span class="kui-icon copy-icon button-icon" data-testid="kui-icon-wrapper-copy-icon" style="pointer-events:none; box-sizing: border-box; color: currentcolor; display: block; height: 20px; line-height: 0; width: 20px;"><svg data-testid="kui-icon-svg-copy-icon" fill="none" height="100%" role="img" viewBox="0 0 24 24" width="100%" xmlns="http://www.w3.org/2000/svg"><path d="M5 22C4.45 22 3.97917 21.8042 3.5875 21.4125C3.19583 21.0208 3 20.55 3 20V6H5V20H16V22H5ZM9 18C8.45 18 7.97917 17.8042 7.5875 17.4125C7.19583 17.0208 7 16.55 7 16V4C7 3.45 7.19583 2.97917 7.5875 2.5875C7.97917 2.19583 8.45 2 9 2H18C18.55 2 19.0208 2.19583 19.4125 2.5875C19.8042 2.97917 20 3.45 20 4V16C20 16.55 19.8042 17.0208 19.4125 17.4125C19.0208 17.8042 18.55 18 18 18H9ZM9 16H18V4H9V16Z" fill="currentColor"></path></svg></span>'

Expand Down
1 change: 1 addition & 0 deletions src/types/markdown-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export type InlineFormat =
| 'mark'
| 'code'
| 'link'
| 'image'

export interface FormatOption {
label: string
Expand Down

0 comments on commit 4cbc61b

Please sign in to comment.