LinkExt.vue 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273
  1. <script setup lang="ts">
  2. import VividMenuItem from "../../components/VividMenuItem.vue";
  3. import { PropType, ref } from "vue";
  4. import VividLinkModal from "./VividLinkModal.vue";
  5. import { useLink, VividLinkOptions } from "./link";
  6. import { getAttributes, getMarkRange } from "@tiptap/core";
  7. import {
  8. NCard,
  9. NInputGroup,
  10. NInput,
  11. NRadioGroup,
  12. NRadio,
  13. NForm,
  14. NFormItem,
  15. NButton,
  16. NSpace,
  17. } from "naive-ui";
  18. import {
  19. injectExtension,
  20. onEditorCreated,
  21. useEditorInstance,
  22. } from "@lib/core/extension/utils/common";
  23. import { UploadFunction } from "@lib/core/extension/types";
  24. import { EditorView } from "prosemirror-view";
  25. import { MarkType } from "@tiptap/pm/model";
  26. import tippy, { Instance } from "tippy.js";
  27. import { TextSelection } from "@tiptap/pm/state";
  28. const props = defineProps({
  29. options: {
  30. type: Object as PropType<Partial<VividLinkOptions>>,
  31. required: false,
  32. },
  33. handleUpload: {
  34. type: Function as PropType<UploadFunction>,
  35. required: false,
  36. },
  37. });
  38. const editorInstance = useEditorInstance();
  39. const root = ref<any>();
  40. const HTL = ref<any>(null);
  41. const isEdit = ref(false);
  42. const href = ref("");
  43. const target = ref("_blank");
  44. let tippyInstance: Instance;
  45. function handleOpenLink() {
  46. if (editorInstance.value.isActive("link")) {
  47. editorInstance.value.chain().focus().unsetLink().run();
  48. } else {
  49. HTL.value!.open();
  50. }
  51. }
  52. onEditorCreated(() => {
  53. editorInstance.value.storage.link = {
  54. openLink: handleOpenLink,
  55. };
  56. });
  57. function setLink(text: string, href: string, target: string) {
  58. console.log(text, href, target);
  59. if (text) {
  60. editorInstance.value
  61. .chain()
  62. // .extendMarkRange("link")
  63. .insertContent({
  64. type: "text",
  65. text: text,
  66. marks: [
  67. {
  68. type: "link",
  69. attrs: {
  70. href: href,
  71. target: target,
  72. },
  73. },
  74. ],
  75. })
  76. .setLink({ href: href })
  77. .focus()
  78. .run();
  79. } else {
  80. editorInstance.value
  81. .chain()
  82. .setLink({ href: href, target: target })
  83. .focus()
  84. .run();
  85. }
  86. }
  87. function handleLinkClick(view: EditorView, pos: number, event: MouseEvent, type: MarkType) {
  88. if (!view.editable) {
  89. return false;
  90. }
  91. if (event.button !== 0) {
  92. return false;
  93. }
  94. let a = event.target as HTMLElement;
  95. const els: HTMLElement[] = [];
  96. while (a.nodeName !== "DIV") {
  97. els.push(a);
  98. a = a.parentNode as HTMLElement;
  99. }
  100. if (!els.find((value) => value.nodeName === "A")) {
  101. return false;
  102. }
  103. const attrs = getAttributes(view.state, type.name);
  104. const link = event.target as HTMLLinkElement;
  105. const node = view.state.doc.nodeAt(pos);
  106. if (node) {
  107. const linkNode = node.marks.filter((e) => e.type.name === "link");
  108. if (linkNode.length) {
  109. const { schema, doc, tr } = view.state;
  110. const range = getMarkRange(doc.resolve(pos), schema.marks.link);
  111. if (!range) return false;
  112. const $start = doc.resolve(range.from);
  113. const $end = doc.resolve(range.to);
  114. const transaction = tr.setSelection(new TextSelection($start, $end));
  115. view.dispatch(transaction);
  116. destroyTooltip();
  117. createTooltip(link, attrs);
  118. return true;
  119. }
  120. }
  121. return false;
  122. }
  123. function createTooltip(linkElement: HTMLLinkElement, attrs: Record<string, any>) {
  124. if (!root.value) {
  125. return;
  126. }
  127. href.value = linkElement?.href ?? attrs.href;
  128. target.value = linkElement?.target ?? attrs.target;
  129. const container = document.createElement("div");
  130. container.append(root.value);
  131. tippyInstance = tippy("body", {
  132. duration: 0,
  133. getReferenceClientRect: () => linkElement.getBoundingClientRect(),
  134. content: container,
  135. interactive: true,
  136. trigger: "manual",
  137. placement: "bottom-start",
  138. })[0];
  139. tippyInstance.show();
  140. }
  141. function destroyTooltip() {
  142. if (tippyInstance) {
  143. tippyInstance.destroy();
  144. }
  145. isEdit.value = false;
  146. return false;
  147. }
  148. injectExtension(
  149. useLink({
  150. handleClick: handleLinkClick,
  151. handleKeyDown: destroyTooltip,
  152. protocols: ["ftp", "mailto", "http", "https"],
  153. autolink: false,
  154. }),
  155. );
  156. function onCancel() {
  157. destroyTooltip();
  158. }
  159. function unsetLink() {
  160. editorInstance.value.chain().focus().unsetLink().run();
  161. destroyTooltip();
  162. }
  163. function onOk() {
  164. editorInstance.value
  165. .chain()
  166. .extendMarkRange("link")
  167. .setLink({ href: href.value, target: target.value })
  168. .focus()
  169. .run();
  170. destroyTooltip();
  171. }
  172. function openLink() {
  173. window.open(href.value, target.value);
  174. }
  175. </script>
  176. <template>
  177. <div>
  178. <slot>
  179. <vivid-menu-item
  180. icon="link"
  181. title="超链接"
  182. :action="handleOpenLink"
  183. :is-active="() => editorInstance?.isActive('link')"
  184. />
  185. <vivid-link-modal ref="HTL" @ok="setLink" :handleUpload="handleUpload"/>
  186. </slot>
  187. <div style="display: none">
  188. <div ref="root">
  189. <n-card size="small" class="link-card" v-if="!isEdit">
  190. <div class="link-pop">
  191. <div class="link-href" @click="openLink">
  192. {{ href }}
  193. </div>
  194. <n-button text @click="isEdit = true">
  195. <i class="ri-lg ri-edit-circle-line"></i>
  196. </n-button>
  197. <n-button text type="error" @click="unsetLink">
  198. <i class="ri-lg ri-delete-bin-5-line"></i>
  199. </n-button>
  200. </div>
  201. </n-card>
  202. <n-card size="small" class="link-card" v-else>
  203. <n-form label-placement="left" label-width="auto">
  204. <n-form-item label="链接地址" :show-feedback="false">
  205. <n-input-group>
  206. <n-input v-model:value="href" />
  207. </n-input-group>
  208. </n-form-item>
  209. <n-form-item label="打开方式" :show-feedback="false">
  210. <n-radio-group v-model:value="target">
  211. <n-space>
  212. <n-radio value="_self"> 当前窗口</n-radio>
  213. <n-radio value="_blank"> 新窗口</n-radio>
  214. </n-space>
  215. </n-radio-group>
  216. </n-form-item>
  217. </n-form>
  218. <template #footer>
  219. <n-space justify="end">
  220. <n-button @click="onCancel" size="small"> 取消</n-button>
  221. <n-button type="info" @click="onOk" size="small"> 确定</n-button>
  222. </n-space>
  223. </template>
  224. </n-card>
  225. </div>
  226. </div>
  227. </div>
  228. </template>
  229. <style scoped>
  230. .link-card {
  231. width: 400px;
  232. box-shadow: 0 6px 16px -9px rgba(0, 0, 0, 0.08),
  233. 0 9px 28px 0 rgba(0, 0, 0, 0.05),
  234. 0 12px 48px 16px rgba(0, 0, 0, 0.03);
  235. border-radius: 10px;
  236. }
  237. .link-pop {
  238. display: flex;
  239. align-items: center;
  240. gap: 12px;
  241. width: 100%;
  242. }
  243. .link-href {
  244. flex: 1;
  245. word-break: keep-all;
  246. overflow: hidden;
  247. text-overflow: ellipsis;
  248. text-decoration: underline;
  249. cursor: pointer;
  250. }
  251. </style>