| 1 |
<template> |
| 2 |
<IvyPopover |
| 3 |
ref="popoverRef" |
| 4 |
class="ivyforms-link-popover" |
| 5 |
trigger="click" |
| 6 |
popper-background="level-2-foreground" |
| 7 |
width="250" |
| 8 |
@show="onShow" |
| 9 |
@hide="onHide" |
| 10 |
> |
| 11 |
<div class="ivyforms-flex ivyforms-gap-8 ivyforms-mt-4"> |
| 12 |
<IvyTextInput |
| 13 |
v-model="href" |
| 14 |
placeholder="Enter Link" |
| 15 |
autofocus |
| 16 |
:error="linkError" |
| 17 |
@blur="validateLink" |
| 18 |
@input="clearError" |
| 19 |
/> |
| 20 |
</div> |
| 21 |
<div v-if="linkError" class="ivyforms-error-message ivyforms-ml-8 ivyforms-mt-4"> |
| 22 |
{{ linkError }} |
| 23 |
</div> |
| 24 |
|
| 25 |
<IvyCheckbox v-model="openInNewTab" class="ivyforms-ml-8"> |
| 26 |
{{ getLabel('open_in_new_tab') }} |
| 27 |
</IvyCheckbox> |
| 28 |
|
| 29 |
<div |
| 30 |
class="ivyforms-flex ivyforms-justify-content-between ivyforms-align-self-stretch ivyforms-gap-16 ivyforms-mt-4" |
| 31 |
> |
| 32 |
<IvyButtonAction |
| 33 |
class="" |
| 34 |
priority="white" |
| 35 |
type="fill" |
| 36 |
size="d" |
| 37 |
:disabled="!editor.isActive('link')" |
| 38 |
@click.stop.prevent="unlink" |
| 39 |
> |
| 40 |
{{ getLabel('clear') }} |
| 41 |
</IvyButtonAction> |
| 42 |
|
| 43 |
<IvyButtonAction class="" size="d" @click.prevent="applyLink"> |
| 44 |
{{ getLabel('save') }} |
| 45 |
</IvyButtonAction> |
| 46 |
</div> |
| 47 |
|
| 48 |
<template #reference> |
| 49 |
<IvyButtonOption |
| 50 |
size="s" |
| 51 |
type="ghost" |
| 52 |
priority="white" |
| 53 |
icon-start-category="text-formatting" |
| 54 |
icon-start="link" |
| 55 |
:active="editor.isActive('link')" |
| 56 |
@click.stop.prevent |
| 57 |
/> |
| 58 |
</template> |
| 59 |
</IvyPopover> |
| 60 |
</template> |
| 61 |
|
| 62 |
<script setup lang="ts"> |
| 63 |
import { ref, unref } from 'vue' |
| 64 |
import { Editor } from '@tiptap/vue-3' |
| 65 |
import IvyButtonOption from '@/views/_components/button/IvyButtonOption.vue' |
| 66 |
import IvyButtonAction from '@/views/_components/button/IvyButtonAction.vue' |
| 67 |
import IvyTextInput from '@/views/_components/input/IvyTextInput.vue' |
| 68 |
import IvyCheckbox from '@/views/_components/checkbox/IvyCheckbox.vue' |
| 69 |
import { useLabels } from '@/composables/useLabels' |
| 70 |
const { getLabel } = useLabels() |
| 71 |
|
| 72 |
const props = defineProps<{ |
| 73 |
editor: Editor |
| 74 |
}>() |
| 75 |
|
| 76 |
const popoverRef = ref(null) |
| 77 |
const href = ref<string>('') |
| 78 |
const openInNewTab = ref<boolean>(false) |
| 79 |
const linkError = ref<string>('') |
| 80 |
|
| 81 |
const isValidUrl = (url: string): boolean => { |
| 82 |
if (!url || url.trim() === '') { |
| 83 |
return false |
| 84 |
} |
| 85 |
|
| 86 |
// Allow relative URLs (starting with /, #, or ./) |
| 87 |
if (url.startsWith('/') || url.startsWith('#') || url.startsWith('./') || url.startsWith('../')) { |
| 88 |
return true |
| 89 |
} |
| 90 |
|
| 91 |
// Check for valid URL pattern |
| 92 |
const urlPattern = /^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([/\w .-]*)*\/?$/i |
| 93 |
const emailPattern = /^mailto:[^\s@]+@[^\s@]+\.[^\s@]+$/i |
| 94 |
const telPattern = /^tel:\+?[\d\s\-()]+$/i |
| 95 |
|
| 96 |
return urlPattern.test(url) || emailPattern.test(url) || telPattern.test(url) |
| 97 |
} |
| 98 |
|
| 99 |
const validateLink = () => { |
| 100 |
if (!href.value) { |
| 101 |
linkError.value = getLabel('link_required') || 'Link is required' |
| 102 |
return false |
| 103 |
} |
| 104 |
|
| 105 |
if (!isValidUrl(href.value)) { |
| 106 |
linkError.value = getLabel('invalid_link') |
| 107 |
return false |
| 108 |
} |
| 109 |
|
| 110 |
linkError.value = '' |
| 111 |
return true |
| 112 |
} |
| 113 |
|
| 114 |
const clearError = () => { |
| 115 |
if (linkError.value) { |
| 116 |
linkError.value = '' |
| 117 |
} |
| 118 |
} |
| 119 |
|
| 120 |
const applyLink = () => { |
| 121 |
if (!validateLink()) { |
| 122 |
return |
| 123 |
} |
| 124 |
|
| 125 |
let finalHref = href.value.trim() |
| 126 |
|
| 127 |
// Auto-add https:// if no protocol is specified and it's not a relative/special URL |
| 128 |
if ( |
| 129 |
!finalHref.startsWith('http://') && |
| 130 |
!finalHref.startsWith('https://') && |
| 131 |
!finalHref.startsWith('mailto:') && |
| 132 |
!finalHref.startsWith('tel:') && |
| 133 |
!finalHref.startsWith('/') && |
| 134 |
!finalHref.startsWith('#') && |
| 135 |
!finalHref.startsWith('./') && |
| 136 |
!finalHref.startsWith('../') |
| 137 |
) { |
| 138 |
finalHref = 'https://' + finalHref |
| 139 |
} |
| 140 |
|
| 141 |
props.editor |
| 142 |
.chain() |
| 143 |
.focus() |
| 144 |
.extendMarkRange('link') |
| 145 |
.setLink({ |
| 146 |
href: finalHref, |
| 147 |
target: openInNewTab.value === true ? '_blank' : '_self', |
| 148 |
}) |
| 149 |
.run() |
| 150 |
unref(popoverRef).hide() |
| 151 |
} |
| 152 |
|
| 153 |
const unlink = () => { |
| 154 |
props.editor.chain().focus().extendMarkRange('link').unsetLink().run() |
| 155 |
unref(popoverRef).hide() |
| 156 |
} |
| 157 |
|
| 158 |
const onShow = () => { |
| 159 |
const currentValue = props.editor.getAttributes('link') |
| 160 |
href.value = currentValue.href |
| 161 |
openInNewTab.value = currentValue.target === '_blank' |
| 162 |
} |
| 163 |
|
| 164 |
const onHide = () => { |
| 165 |
href.value = '' |
| 166 |
openInNewTab.value = false |
| 167 |
linkError.value = '' |
| 168 |
} |
| 169 |
</script> |
| 170 |
|
| 171 |
<style lang="scss" scoped> |
| 172 |
.ivyforms-error-message { |
| 173 |
color: var(--map-status-error-symbol-0); |
| 174 |
font-size: 12px; |
| 175 |
line-height: 1.4; |
| 176 |
} |
| 177 |
</style> |
| 178 |
|