Skip to content

Commit

Permalink
feat: links
Browse files Browse the repository at this point in the history
  • Loading branch information
adamdehaven committed Jan 9, 2024
1 parent 55dea8e commit e3a9db5
Show file tree
Hide file tree
Showing 9 changed files with 259 additions and 11 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@
"vue": "^3.3.13"
},
"dependencies": {
"@kong/icons": "^1.8.9",
"@kong/icons": "^1.8.10",
"@sindresorhus/slugify": "^2.2.1",
"@vueuse/core": "^10.7.0",
"html-format": "^1.1.5",
Expand Down
8 changes: 4 additions & 4 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

158 changes: 157 additions & 1 deletion src/components/MarkdownUi.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { vi, describe, it, expect, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { ref } from 'vue'
import MarkdownUi from './MarkdownUi.vue'
import { InlineFormatWrapper, MARKDOWN_TEMPLATE_CODEBLOCK, MARKDOWN_TEMPLATE_TASK, MARKDOWN_TEMPLATE_UL, MARKDOWN_TEMPLATE_OL, MARKDOWN_TEMPLATE_BLOCKQUOTE, MARKDOWN_TEMPLATE_TABLE } from '@/constants'
import { InlineFormatWrapper, MARKDOWN_TEMPLATE_CODEBLOCK, MARKDOWN_TEMPLATE_TASK, MARKDOWN_TEMPLATE_UL, MARKDOWN_TEMPLATE_OL, MARKDOWN_TEMPLATE_BLOCKQUOTE, MARKDOWN_TEMPLATE_TABLE, MARKDOWN_TEMPLATE_LINK } from '@/constants'
import { KUI_BREAKPOINT_PHABLET } from '@kong/design-tokens'
import type { Theme, MarkdownTemplate } from '@/types'

Expand Down Expand Up @@ -486,8 +486,164 @@ describe('<MarkdownUi />', () => {
expect(wrapper.emitted(eventName) || []).toHaveLength(1)
// @ts-ignore - referencing enum properties
expect(wrapper.emitted(eventName)![0]).toEqual([`${textStart}${InlineFormatWrapper[format]}${textMiddle}${InlineFormatWrapper[format]}${textEnd}`])

let htmlTag: string = ''

switch (format) {
case 'bold':
htmlTag = 'strong'
break
case 'italic':
htmlTag = 'em'
break
case 'underline':
htmlTag = 'ins'
break
case 'strikethrough':
htmlTag = 's'
break
case 'subscript':
htmlTag = 'sub'
break
case 'superscript':
htmlTag = 'sup'
break
case 'code':
htmlTag = 'code'
break
}

expect(wrapper.findTestId('markdown-content').find(htmlTag).isVisible()).toBe(true)
expect(wrapper.findTestId('markdown-content').find(htmlTag).text()).toContain(textMiddle)
})
}

describe('formats text as links', () => {
it('inserts the link template at the cursor position when clicked', async () => {
const content = 'This is a sentence of text.'
const wrapper = mount(MarkdownUi, {
props: {
mode: 'split',
editable: true,
modelValue: content,
},
})

await waitForMarkdownRender(wrapper)

expect(wrapper.findTestId('toolbar').isVisible()).toBe(true)
expect(wrapper.findTestId('markdown-editor-textarea').isVisible()).toBe(true)
expect(wrapper.findTestId('markdown-content').isVisible()).toBe(true)
// Both panes should be visible
expect(wrapper.findTestId('markdown-editor-textarea').isVisible()).toBe(true)
expect(wrapper.findTestId<'textarea'>('markdown-editor-textarea').element.value).toEqual(content)

expect(wrapper.findTestId('markdown-content').isVisible()).toBe(true)
expect(wrapper.findTestId('markdown-content').find('p').isVisible()).toBe(true)
expect(wrapper.findTestId('markdown-content').find('p').text()).toEqual(content)

await wrapper.findTestId<'textarea'>('markdown-editor-textarea').element.focus()

// Move the cursor
wrapper.findTestId<'textarea'>('markdown-editor-textarea').element.selectionEnd = 5 // place the cursor after the first word

// Click the formatting button
await wrapper.findTestId('format-option-link').element.click()

// Verify event is emitted
const eventName = 'update:modelValue'
await waitForEmittedEvent(wrapper, eventName)

expect(wrapper.emitted(eventName) || []).toHaveLength(1)
expect(wrapper.emitted(eventName)![0][0]).toContain([MARKDOWN_TEMPLATE_LINK.replace(/text/, '')])
})

it('wraps the selected text with the link template', async () => {
const linkText = 'link'
const content = `This ${linkText} is in the middle.`
const wrapper = mount(MarkdownUi, {
props: {
mode: 'split',
editable: true,
modelValue: content,
},
})

await waitForMarkdownRender(wrapper)

expect(wrapper.findTestId('toolbar').isVisible()).toBe(true)
expect(wrapper.findTestId('markdown-editor-textarea').isVisible()).toBe(true)
expect(wrapper.findTestId('markdown-content').isVisible()).toBe(true)
// Both panes should be visible
expect(wrapper.findTestId('markdown-editor-textarea').isVisible()).toBe(true)
expect(wrapper.findTestId<'textarea'>('markdown-editor-textarea').element.value).toEqual(content)

expect(wrapper.findTestId('markdown-content').isVisible()).toBe(true)
expect(wrapper.findTestId('markdown-content').find('p').isVisible()).toBe(true)
expect(wrapper.findTestId('markdown-content').find('p').text()).toEqual(content)

await wrapper.findTestId('markdown-editor-textarea').setValue(content)
await wrapper.findTestId<'textarea'>('markdown-editor-textarea').element.focus()

// Select the text `link`
const selectStart = 5
wrapper.findTestId<'textarea'>('markdown-editor-textarea').element.selectionStart = selectStart
wrapper.findTestId<'textarea'>('markdown-editor-textarea').element.selectionEnd = selectStart + linkText.length

// Click the formatting button
await wrapper.findTestId('format-option-link').element.click()

// Verify event is emitted
const eventName = 'update:modelValue'
await waitForEmittedEvent(wrapper, eventName)

expect(wrapper.emitted(eventName) || []).toHaveLength(1)
expect(wrapper.emitted(eventName)![0][0]).toContain([MARKDOWN_TEMPLATE_LINK.replace(/text/, linkText)])
})

it('wraps the selected URL with the link template', async () => {
const linkUrl = 'https://github.com/Kong/markdown'
const content = `This ${linkUrl} is in the middle.`
const wrapper = mount(MarkdownUi, {
props: {
mode: 'split',
editable: true,
modelValue: content,
},
})

await waitForMarkdownRender(wrapper)

expect(wrapper.findTestId('toolbar').isVisible()).toBe(true)
expect(wrapper.findTestId('markdown-editor-textarea').isVisible()).toBe(true)
expect(wrapper.findTestId('markdown-content').isVisible()).toBe(true)
// Both panes should be visible
expect(wrapper.findTestId('markdown-editor-textarea').isVisible()).toBe(true)
expect(wrapper.findTestId<'textarea'>('markdown-editor-textarea').element.value).toEqual(content)

expect(wrapper.findTestId('markdown-content').isVisible()).toBe(true)
expect(wrapper.findTestId('markdown-content').find('p').isVisible()).toBe(true)
expect(wrapper.findTestId('markdown-content').find('p').text()).toEqual(content)

await wrapper.findTestId('markdown-editor-textarea').setValue(content)
await wrapper.findTestId<'textarea'>('markdown-editor-textarea').element.focus()

// Select the text `link`
const selectStart = 5
wrapper.findTestId<'textarea'>('markdown-editor-textarea').element.selectionStart = selectStart
wrapper.findTestId<'textarea'>('markdown-editor-textarea').element.selectionEnd = selectStart + linkUrl.length

// Click the formatting button
await wrapper.findTestId('format-option-link').element.click()

// Verify event is emitted
const eventName = 'update:modelValue'
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)])
})
})
})

describe('template buttons', () => {
Expand Down
8 changes: 6 additions & 2 deletions src/components/MarkdownUi.vue
Original file line number Diff line number Diff line change
Expand Up @@ -298,11 +298,15 @@ 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 } = composables.useMarkdownActions(textarea, rawMarkdown)
const { toggleInlineFormatting, insertMarkdownTemplate, insertLink } = composables.useMarkdownActions(textarea, rawMarkdown)
// When the user toggles inline formatting
const formatSelection = (format: InlineFormat): void => {
toggleInlineFormatting(format)
if (format === 'link') {
insertLink()
} else {
toggleInlineFormatting(format)
}
// Emulate an `input` event to trigger an update
emulateInputEvent()
}
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 @@ -24,6 +24,7 @@ const formatOptions: Partial<FormatOption>[] = [
{ label: 'Underline', action: 'underline', keys: ['U'] },
{ label: 'Strikethrough', action: 'strikethrough', keys: ['Shift', 'X'] },
{ label: 'Code', action: 'code', keys: ['Shift', 'C'] },
{ label: 'Link', action: 'link' },
]

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 @@ -183,7 +183,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, 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 } from '@kong/icons'
import { v4 as uuidv4 } from 'uuid'
const uniqueId: Ref<String> = inject(UNIQUE_ID_INJECTION_KEY, ref(uuidv4()))
Expand Down Expand Up @@ -246,6 +246,7 @@ const formatOptions: FormatOption[] = [
// { label: 'Superscript', action: 'superscript', icon: SuperscriptIcon }, // Hidden for now
// { label: 'Mark', action: 'mark', icon: MarkIcon }, // Hidden for now
{ label: 'Code', action: 'code', keys: ['Shift', 'C'], icon: CodeIcon },
{ label: 'Link', action: 'link', icon: LinkIcon },
]
const templateOptions: TemplateOption[] = [
Expand Down
86 changes: 84 additions & 2 deletions src/composables/useMarkdownActions.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
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 } 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 } from '@/constants'
import type { InlineFormat, MarkdownTemplate } from '@/types'

/**
Expand Down Expand Up @@ -251,7 +251,6 @@ export default function useMarkdownActions(
const numberSuffix = MARKDOWN_TEMPLATE_OL.replace('1', '')

const listNumber = Number((startText.split(NEW_LINE_CHARACTER).at(-2) || startText.split(NEW_LINE_CHARACTER).pop() || '').trimStart().split(numberSuffix)[0]) + 1
console.log('listNumber', listNumber)

rawMarkdown.value = action === 'add' ? rawMarkdown.value.substring(0, selectedText.start - MARKDOWN_TEMPLATE_OL.length) + spaces + MARKDOWN_TEMPLATE_OL + rawMarkdown.value.substring(selectedText.end) : rawMarkdown.value.substring(0, selectedText.start - spaces.length - (listNumber + numberSuffix).length) + (listNumber + numberSuffix) + rawMarkdown.value.substring(selectedText.end)
} else {
Expand Down Expand Up @@ -401,6 +400,88 @@ export default function useMarkdownActions(
}
}

/**
* Insert a markdown link at the current cursor position.
* @returns {Promise<void>}
*/
const insertLink = 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 = ''

// 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_LINK.replace(/text/, '').replace(/url/, selectedText.text) : MARKDOWN_TEMPLATE_LINK.replace(/text/, 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 + 1
} else {
textarea.selectionStart = startText.length + selectedText.text.length + 3
textarea.selectionEnd = startText.length + selectedText.text.length + 6
}
} 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_LINK.split('text')[0]) && /^\]\((.*)+\)/.test(endText)) {
await focusTextarea()
return
}

// Prepare the content
newContent = MARKDOWN_TEMPLATE_LINK.replace(/text/, '')

let cursorPosition = 1

// 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
}
} catch (err) {
console.warn('insertLink', 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 @@ -514,6 +595,7 @@ export default function useMarkdownActions(
toggleInlineFormatting,
toggleTab,
insertMarkdownTemplate,
insertLink,
insertNewLine,
}
}
3 changes: 3 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ export const MARKDOWN_TEMPLATE_OL = '1. '
/** The markdown template for a blockquote. Ensure trailing space remains */
export const MARKDOWN_TEMPLATE_BLOCKQUOTE = '> '

/** The markdown link template */
export const MARKDOWN_TEMPLATE_LINK = '[text](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 @@ -18,6 +18,7 @@ export type InlineFormat =
| 'superscript'
| 'mark'
| 'code'
| 'link'

export interface FormatOption {
label: string
Expand Down

0 comments on commit e3a9db5

Please sign in to comment.