Skip to content

Commit 60bc621

Browse files
author
codewec
committed
feat: editor links
1 parent 31cd1b3 commit 60bc621

5 files changed

Lines changed: 182 additions & 3 deletions

File tree

app/components/editoro/MainContent.vue

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,13 @@ const {
5353
5454
const richEditorHandlers = computed(() => ({
5555
...editorHandlers,
56+
// Disable prompt-based link handler; link editing is handled by custom popover UI.
57+
link: {
58+
canExecute: () => true,
59+
execute: (editor: { chain: () => { focus: () => { run: () => boolean } } }) => editor.chain().focus(),
60+
isActive: (editor: { isActive: (name: string) => boolean }) => editor.isActive('link'),
61+
isDisabled: () => false
62+
},
5663
toggleRawMode: {
5764
canExecute: () => true,
5865
execute: () => {
@@ -62,6 +69,7 @@ const richEditorHandlers = computed(() => ({
6269
isActive: () => false
6370
}
6471
}))
72+
6573
</script>
6674

6775
<template>
@@ -193,7 +201,13 @@ const richEditorHandlers = computed(() => ({
193201
:editor="editor"
194202
:items="props.editorToolbarItems"
195203
class="editoro-toolbar"
196-
/>
204+
>
205+
<template #link>
206+
<EditoroEditorLinkPopover
207+
:editor="editor"
208+
/>
209+
</template>
210+
</UEditorToolbar>
197211
<UEditorSuggestionMenu
198212
:editor="editor"
199213
:items="props.editorSuggestionItems"
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
<script setup lang="ts">
2+
import type { Editor } from '@tiptap/core'
3+
4+
const props = defineProps<{
5+
editor: Editor
6+
autoOpen?: boolean
7+
}>()
8+
9+
const open = ref(false)
10+
const url = ref('')
11+
12+
const active = computed(() => props.editor.isActive('link'))
13+
const disabled = computed(() => {
14+
if (!props.editor.isEditable) return true
15+
const { selection } = props.editor.state
16+
return selection.empty && !props.editor.isActive('link')
17+
})
18+
19+
function openPopover() {
20+
const { href } = props.editor.getAttributes('link')
21+
url.value = href || ''
22+
open.value = true
23+
}
24+
25+
watch(() => props.editor, (editor, _, onCleanup) => {
26+
if (!editor) return
27+
28+
const updateUrl = () => {
29+
const { href } = editor.getAttributes('link')
30+
url.value = href || ''
31+
}
32+
33+
updateUrl()
34+
editor.on('selectionUpdate', updateUrl)
35+
36+
onCleanup(() => {
37+
editor.off('selectionUpdate', updateUrl)
38+
})
39+
}, { immediate: true })
40+
41+
watch(active, (isActive) => {
42+
if (isActive && props.autoOpen) {
43+
open.value = true
44+
}
45+
})
46+
47+
function setLink() {
48+
if (!url.value) return
49+
50+
const { selection } = props.editor.state
51+
const isEmpty = selection.empty
52+
const hasCode = props.editor.isActive('code')
53+
54+
let chain = props.editor.chain().focus()
55+
56+
// When linking code, extend the code mark range first to select the full code.
57+
if (hasCode && !isEmpty) {
58+
chain = chain.extendMarkRange('code').setLink({ href: url.value })
59+
} else {
60+
chain = chain.extendMarkRange('link').setLink({ href: url.value })
61+
62+
if (isEmpty) {
63+
chain = chain.insertContent({ type: 'text', text: url.value })
64+
}
65+
}
66+
67+
chain.run()
68+
open.value = false
69+
}
70+
71+
function removeLink() {
72+
props.editor
73+
.chain()
74+
.focus()
75+
.extendMarkRange('link')
76+
.unsetLink()
77+
.setMeta('preventAutolink', true)
78+
.run()
79+
80+
url.value = ''
81+
open.value = false
82+
}
83+
84+
function openLink() {
85+
if (!url.value) return
86+
window.open(url.value, '_blank', 'noopener,noreferrer')
87+
}
88+
89+
function handleKeyDown(event: KeyboardEvent) {
90+
if (event.key === 'Enter') {
91+
event.preventDefault()
92+
setLink()
93+
}
94+
}
95+
</script>
96+
97+
<template>
98+
<UPopover v-model:open="open" :ui="{ content: 'p-0.5' }">
99+
<UTooltip text="Link">
100+
<UButton
101+
icon="i-lucide-link"
102+
color="neutral"
103+
active-color="primary"
104+
variant="ghost"
105+
active-variant="soft"
106+
size="sm"
107+
:active="active"
108+
:disabled="disabled"
109+
aria-label="Link"
110+
@click="openPopover"
111+
/>
112+
</UTooltip>
113+
114+
<template #content>
115+
<UInput
116+
v-model="url"
117+
autofocus
118+
name="url"
119+
type="url"
120+
variant="none"
121+
placeholder="Paste a link..."
122+
@keydown="handleKeyDown"
123+
>
124+
<div class="flex items-center mr-0.5">
125+
<UButton
126+
icon="i-lucide-corner-down-left"
127+
variant="ghost"
128+
size="sm"
129+
:disabled="!url && !active"
130+
title="Apply link"
131+
@click="setLink"
132+
/>
133+
<USeparator orientation="vertical" class="h-6 mx-1" />
134+
<UButton
135+
icon="i-lucide-external-link"
136+
color="neutral"
137+
variant="ghost"
138+
size="sm"
139+
:disabled="!url && !active"
140+
title="Open in new window"
141+
@click="openLink"
142+
/>
143+
<UButton
144+
icon="i-lucide-trash"
145+
color="neutral"
146+
variant="ghost"
147+
size="sm"
148+
:disabled="!url && !active"
149+
title="Remove link"
150+
@click="removeLink"
151+
/>
152+
</div>
153+
</UInput>
154+
</template>
155+
</UPopover>
156+
</template>

app/constants/editoro.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ export function createEditorToolbarItems(t: Translator) {
1010
{ kind: 'mark', mark: 'bold', icon: 'i-lucide-bold', tooltip: { text: t('toolbar.bold') } },
1111
{ kind: 'mark', mark: 'italic', icon: 'i-lucide-italic', tooltip: { text: t('toolbar.italic') } },
1212
{ kind: 'mark', mark: 'strike', icon: 'i-lucide-strikethrough', tooltip: { text: t('toolbar.strike') } },
13-
{ kind: 'mark', mark: 'code', icon: 'i-lucide-code', tooltip: { text: t('toolbar.code') } }
13+
{ kind: 'mark', mark: 'code', icon: 'i-lucide-code', tooltip: { text: t('toolbar.code') } },
14+
{ 'kind': 'link', 'icon': 'i-lucide-link', 'slot': 'link', 'tooltip': { text: t('toolbar.link') }, 'aria-label': t('toolbar.link') }
1415
],
1516
[
1617
{ kind: 'heading', level: 1, label: 'H1', tooltip: { text: t('toolbar.h1') } },
@@ -26,7 +27,7 @@ export function createEditorToolbarItems(t: Translator) {
2627
{ kind: 'horizontalRule', icon: 'i-lucide-minus', tooltip: { text: t('toolbar.divider') } }
2728
],
2829
[
29-
{ kind: 'toggleRawMode', icon: 'i-lucide-file-text', label: 'Raw', tooltip: { text: t('toolbar.raw') }, 'aria-label': t('toolbar.raw') }
30+
{ 'kind': 'toggleRawMode', 'icon': 'i-lucide-file-text', 'label': 'Raw', 'tooltip': { text: t('toolbar.raw') }, 'aria-label': t('toolbar.raw') }
3031
],
3132
[
3233
{ 'kind': 'uploadImage', 'icon': 'i-lucide-image-plus', 'tooltip': { text: t('toolbar.uploadImage') }, 'aria-label': t('toolbar.uploadImage') }

i18n/locales/en.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@
4545
"selectedFormat": "Selected format: .{ext}",
4646
"rawPlaceholder": "Raw markdown text...",
4747
"editorPlaceholder": "Start writing...",
48+
"linkUrl": "Link URL",
49+
"applyLink": "Apply",
50+
"removeLink": "Remove link",
4851
"saveStatus": "Save status",
4952
"pin": "Pin file",
5053
"unpin": "Unpin file",
@@ -70,6 +73,7 @@
7073
"italic": "Italic",
7174
"strike": "Strike",
7275
"code": "Code",
76+
"link": "Link",
7377
"h1": "Heading 1",
7478
"h2": "Heading 2",
7579
"h3": "Heading 3",

i18n/locales/ru.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@
4545
"selectedFormat": "Выбранный формат: .{ext}",
4646
"rawPlaceholder": "Сырой markdown-текст...",
4747
"editorPlaceholder": "Начните писать...",
48+
"linkUrl": "URL ссылки",
49+
"applyLink": "Применить",
50+
"removeLink": "Удалить ссылку",
4851
"saveStatus": "Статус сохранения",
4952
"pin": "Закрепить файл",
5053
"unpin": "Открепить файл",
@@ -70,6 +73,7 @@
7073
"italic": "Курсив",
7174
"strike": "Зачеркнутый",
7275
"code": "Код",
76+
"link": "Ссылка",
7377
"h1": "Заголовок 1",
7478
"h2": "Заголовок 2",
7579
"h3": "Заголовок 3",

0 commit comments

Comments
 (0)