From b19b72c0d6bde110f919848935281d3bf916f2de Mon Sep 17 00:00:00 2001 From: habibayman Date: Sat, 14 Jun 2025 20:20:45 +0300 Subject: [PATCH 01/30] feat(texteditor): implement code block action --- .../TipTapEditor/TipTapEditor.vue | 19 ++++++++++++++++++- .../TipTapEditor/components/EditorToolbar.vue | 1 + .../TipTapEditor/composables/useEditor.js | 10 +++++++++- .../composables/useToolbarActions.js | 5 ++++- .../extensions/CodeBlockNoSpellcheck.js | 7 +++++++ package.json | 1 + 6 files changed, 40 insertions(+), 3 deletions(-) create mode 100644 contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/extensions/CodeBlockNoSpellcheck.js diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue index 405f7654de..7ce28326ed 100644 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue @@ -37,7 +37,7 @@ .editor-container { width: 1000px; - margin: 40px auto; + margin: 80px auto; font-family: 'Noto Sans', -apple-system, @@ -96,4 +96,21 @@ margin: 4px 0; } + .editor-container code { + display: block; + padding: 10px; + font-family: 'Courier New', Courier, monospace; + font-size: 14px; + color: #ffffff; + white-space: pre-wrap; + background: #000000; + border-radius: 4px; + } + + .editor-container .ProseMirror pre, + .editor-container .ProseMirror code { + -webkit-spellcheck: false; + -ms-spellcheck: false; + } + diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/EditorToolbar.vue b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/EditorToolbar.vue index 44f8c23a40..0fe74832a8 100644 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/EditorToolbar.vue +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/EditorToolbar.vue @@ -110,6 +110,7 @@ :key="tool.name" :title="tool.title" :icon="tool.icon" + :is-active="tool.isActive" @click="tool.handler" /> diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/composables/useEditor.js b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/composables/useEditor.js index 95375cbda0..cb82f38593 100644 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/composables/useEditor.js +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/composables/useEditor.js @@ -3,6 +3,7 @@ import { Editor } from '@tiptap/vue-2'; import StarterKitExtension from '@tiptap/starter-kit'; import UnderlineExtension from '@tiptap/extension-underline'; import { Small } from '../extensions/SmallTextExtension'; +import { CodeBlockNoSpellcheck } from '../extensions/CodeBlockNoSpellcheck'; export function useEditor() { const editor = ref(null); @@ -10,7 +11,14 @@ export function useEditor() { const initializeEditor = () => { editor.value = new Editor({ - extensions: [StarterKitExtension, UnderlineExtension, Small], + extensions: [ + StarterKitExtension.configure({ + codeBlock: false, // Disable default code block to use the extended version + }), + CodeBlockNoSpellcheck, + UnderlineExtension, + Small, + ], content: '

', editorProps: { attributes: { diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/composables/useToolbarActions.js b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/composables/useToolbarActions.js index 1eeacf871b..6e5eb9ac62 100644 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/composables/useToolbarActions.js +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/composables/useToolbarActions.js @@ -162,7 +162,9 @@ export function useToolbarActions() { }; const handleCodeBlock = () => { - // TipTap code block logic may be added here + if (editor?.value) { + editor.value.chain().focus().toggleCode().run(); + } }; const handleFormatChange = format => { @@ -316,6 +318,7 @@ export function useToolbarActions() { title: codeBlock$(), icon: require('../../assets/icon-codeblock.svg'), handler: handleCodeBlock, + isActive: isMarkActive('code'), }, ]); diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/extensions/CodeBlockNoSpellcheck.js b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/extensions/CodeBlockNoSpellcheck.js new file mode 100644 index 0000000000..0b5b6fabcf --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/extensions/CodeBlockNoSpellcheck.js @@ -0,0 +1,7 @@ +import { CodeBlock } from '@tiptap/extension-code-block'; + +export const CodeBlockNoSpellcheck = CodeBlock.extend({ + renderHTML({ HTMLAttributes }) { + return ['pre', { spellcheck: 'false' }, ['code', HTMLAttributes, 0]]; + }, +}); diff --git a/package.json b/package.json index 366ee8e8e6..fefaa174ba 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "dependencies": { "@sentry/vue": "^7.112.2", "@tiptap/core": "^2.14.0", + "@tiptap/extension-code-block": "^2.14.0", "@tiptap/extension-underline": "^2.14.0", "@tiptap/starter-kit": "^2.13.0", "@tiptap/vue-2": "^2.13.0", From 4bbc4776f42cae7645a2fa6b059df089fec0e79e Mon Sep 17 00:00:00 2001 From: habibayman Date: Sat, 14 Jun 2025 21:37:28 +0300 Subject: [PATCH 02/30] implement super/sub script actions --- .../TipTapEditor/components/EditorToolbar.vue | 2 ++ .../TipTapEditor/composables/useEditor.js | 4 +++ .../composables/useToolbarActions.js | 12 +++++-- .../TipTapEditor/assets/icon-subscriptRTL.svg | 8 +++++ .../assets/icon-superscriptRTL.svg | 7 ++++ package.json | 2 ++ pnpm-lock.yaml | 35 ++++++++++++++++--- 7 files changed, 64 insertions(+), 6 deletions(-) create mode 100644 contentcuration/contentcuration/frontend/shared/views/TipTapEditor/assets/icon-subscriptRTL.svg create mode 100644 contentcuration/contentcuration/frontend/shared/views/TipTapEditor/assets/icon-superscriptRTL.svg diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/EditorToolbar.vue b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/EditorToolbar.vue index 0fe74832a8..14bed89c1d 100644 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/EditorToolbar.vue +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/EditorToolbar.vue @@ -94,6 +94,8 @@ :key="script.name" :title="script.title" :icon="script.icon" + :rtl-icon="script.rtlIcon" + :is-active="script.isActive" @click="script.handler" /> diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/composables/useEditor.js b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/composables/useEditor.js index cb82f38593..fe1530f6d0 100644 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/composables/useEditor.js +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/composables/useEditor.js @@ -2,6 +2,8 @@ import { ref, onMounted, onUnmounted } from 'vue'; import { Editor } from '@tiptap/vue-2'; import StarterKitExtension from '@tiptap/starter-kit'; import UnderlineExtension from '@tiptap/extension-underline'; +import { Superscript } from '@tiptap/extension-superscript'; +import { Subscript } from '@tiptap/extension-subscript'; import { Small } from '../extensions/SmallTextExtension'; import { CodeBlockNoSpellcheck } from '../extensions/CodeBlockNoSpellcheck'; @@ -18,6 +20,8 @@ export function useEditor() { CodeBlockNoSpellcheck, UnderlineExtension, Small, + Superscript, + Subscript, ], content: '

', editorProps: { diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/composables/useToolbarActions.js b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/composables/useToolbarActions.js index 6e5eb9ac62..8b2c208276 100644 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/composables/useToolbarActions.js +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/composables/useToolbarActions.js @@ -142,11 +142,15 @@ export function useToolbarActions() { }; const handleSubscript = () => { - // TipTap subscript logic will be added here + if (editor?.value) { + editor.value.chain().focus().toggleSubscript().run(); + } }; const handleSuperscript = () => { - // TipTap superscript logic will be added here + if (editor?.value) { + editor.value.chain().focus().toggleSuperscript().run(); + } }; const handleInsertImage = () => { @@ -284,13 +288,17 @@ export function useToolbarActions() { name: 'subscript', title: subscript$(), icon: require('../../assets/icon-subscript.svg'), + rtlIcon: require('../../assets/icon-subscriptRTL.svg'), handler: handleSubscript, + isActive: isMarkActive('subscript'), }, { name: 'superscript', title: superscript$(), icon: require('../../assets/icon-superscript.svg'), + rtlIcon: require('../../assets/icon-superscriptRTL.svg'), handler: handleSuperscript, + isActive: isMarkActive('superscript'), }, ]); diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/assets/icon-subscriptRTL.svg b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/assets/icon-subscriptRTL.svg new file mode 100644 index 0000000000..f24cea89a9 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/assets/icon-subscriptRTL.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/assets/icon-superscriptRTL.svg b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/assets/icon-superscriptRTL.svg new file mode 100644 index 0000000000..1a7212cf72 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/assets/icon-superscriptRTL.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/package.json b/package.json index fefaa174ba..70e615ffb4 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,8 @@ "@sentry/vue": "^7.112.2", "@tiptap/core": "^2.14.0", "@tiptap/extension-code-block": "^2.14.0", + "@tiptap/extension-subscript": "^2.14.0", + "@tiptap/extension-superscript": "^2.14.0", "@tiptap/extension-underline": "^2.14.0", "@tiptap/starter-kit": "^2.13.0", "@tiptap/vue-2": "^2.13.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 526d34b208..3abcf106b1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,7 +13,16 @@ importers: version: 7.120.3(vue@2.7.16) '@tiptap/core': specifier: ^2.14.0 - version: 2.22.3(@tiptap/pm@2.22.3) + version: 2.14.0(@tiptap/pm@2.13.0) + '@tiptap/extension-code-block': + specifier: ^2.14.0 + version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.13.0))(@tiptap/pm@2.13.0) + '@tiptap/extension-subscript': + specifier: ^2.14.0 + version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.13.0)) + '@tiptap/extension-superscript': + specifier: ^2.14.0 + version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.13.0)) '@tiptap/extension-underline': specifier: ^2.14.0 version: 2.22.3(@tiptap/core@2.22.3(@tiptap/pm@2.22.3)) @@ -1516,8 +1525,18 @@ packages: peerDependencies: '@tiptap/core': ^2.7.0 - '@tiptap/extension-text-style@2.22.3': - resolution: {integrity: sha512-M3FLOUPcO8fR+rM97mR2gQ54KFkdlAUQtEPKQpO1f312gtcVdBNxgq0WgqTnBY7thWLyqQSKiAsL6y88+JddSA==} + '@tiptap/extension-subscript@2.14.0': + resolution: {integrity: sha512-1gQucSZ6WqhKukc8YA7ZfQzBYaVY00F6G7+trD2iWSm6EpiabaUVP0vMjuonIiujTioEwe04KmZuC9ZLbEU9dQ==} + peerDependencies: + '@tiptap/core': ^2.7.0 + + '@tiptap/extension-superscript@2.14.0': + resolution: {integrity: sha512-BnsqY9TxN15KxxoX1rulL0sV0Wu3umD4Un0E90LZ5G/QRrVUeohAuOiraqRJ4GnJPVJBR2H0+7Sg5sKqYuIpnQ==} + peerDependencies: + '@tiptap/core': ^2.7.0 + + '@tiptap/extension-text-style@2.13.0': + resolution: {integrity: sha512-gUXzgBCYcY3lk5AJ9DvWolphNBw5b2TLbKyJ0ZBWDjdNL7nF87iKKcNBGAXh2bYugvSi7dlPeSv7OQELyBm8Ug==} peerDependencies: '@tiptap/core': ^2.7.0 @@ -9146,7 +9165,15 @@ snapshots: dependencies: '@tiptap/core': 2.22.3(@tiptap/pm@2.22.3) - '@tiptap/extension-text-style@2.22.3(@tiptap/core@2.22.3(@tiptap/pm@2.22.3))': + '@tiptap/extension-subscript@2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.13.0))': + dependencies: + '@tiptap/core': 2.14.0(@tiptap/pm@2.13.0) + + '@tiptap/extension-superscript@2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.13.0))': + dependencies: + '@tiptap/core': 2.14.0(@tiptap/pm@2.13.0) + + '@tiptap/extension-text-style@2.13.0(@tiptap/core@2.14.0(@tiptap/pm@2.13.0))': dependencies: '@tiptap/core': 2.22.3(@tiptap/pm@2.22.3) From 5e31d02a99fc33df830b8d8f8a904e05f5130f18 Mon Sep 17 00:00:00 2001 From: habibayman Date: Mon, 16 Jun 2025 20:29:37 +0300 Subject: [PATCH 03/30] test - feat(texteditor): simplified image handling logic --- .../TipTapEditor/TipTapEditor.vue | 118 ++++++- .../TipTapEditor/components/EditorToolbar.vue | 28 +- .../TipTapEditor/components/ImageNodeView.vue | 181 ++++++++++ .../TipTapEditor/components/ImageUpload.vue | 318 ++++++++++++++++++ .../components/toolbar/ToolbarButton.vue | 4 +- .../TipTapEditor/composables/useEditor.js | 5 + .../TipTapEditor/extensions/Image.js | 43 +++ .../TipTapEditor/TipTapEditor/utils/image.js | 15 + .../views/TipTapEditor/assets/icon-edit.svg | 3 + .../views/TipTapEditor/assets/icon-trash.svg | 3 + 10 files changed, 698 insertions(+), 20 deletions(-) create mode 100644 contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/ImageNodeView.vue create mode 100644 contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/ImageUpload.vue create mode 100644 contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/extensions/Image.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/utils/image.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/TipTapEditor/assets/icon-edit.svg create mode 100644 contentcuration/contentcuration/frontend/shared/views/TipTapEditor/assets/icon-trash.svg diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue index 7ce28326ed..33f6f1e84e 100644 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue @@ -1,34 +1,93 @@ @@ -51,6 +110,37 @@ border-radius: 8px; } + .image-upload-modal-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.image-upload-modal-content { + position: relative; + background: white; + padding: 2rem; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0,0,0,0.15); +} + +.close-modal { + position: absolute; + top: 10px; + right: 10px; + background: transparent; + border: none; + font-size: 1.5rem; + cursor: pointer; +} + diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/EditorToolbar.vue b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/EditorToolbar.vue index 14bed89c1d..350483ce7c 100644 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/EditorToolbar.vue +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/EditorToolbar.vue @@ -113,7 +113,7 @@ :title="tool.title" :icon="tool.icon" :is-active="tool.isActive" - @click="tool.handler" + @click="onToolClick(tool)" /> @@ -139,9 +139,16 @@ PasteDropdown, ToolbarDivider, }, - setup() { - const { handleCopy, historyActions, textActions, listActions, scriptActions, insertTools } = - useToolbarActions(); + setup(props, { emit }) { + const { + handleCopy, + historyActions, + textActions, + listActions, + scriptActions, + insertTools, + t, + } = useToolbarActions(); const { copy$, @@ -155,8 +162,21 @@ insertTools$, } = getTipTapEditorStrings(); + + const onToolClick = (tool) => { + // If the button is the 'image' button, emit an event to the parent + if (tool.name === 'image') { + emit('insert-image'); + } else { + // For all other buttons, call their original handler + tool.handler(); + } + }; + return { handleCopy, + onToolClick, + t, historyActions, textActions, listActions, diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/ImageNodeView.vue b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/ImageNodeView.vue new file mode 100644 index 0000000000..7e95fb0a14 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/ImageNodeView.vue @@ -0,0 +1,181 @@ + + + + + + \ No newline at end of file diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/ImageUpload.vue b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/ImageUpload.vue new file mode 100644 index 0000000000..51dc6529f5 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/ImageUpload.vue @@ -0,0 +1,318 @@ + + + + + \ No newline at end of file diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/toolbar/ToolbarButton.vue b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/toolbar/ToolbarButton.vue index 97af38d3ec..6f227e4a1e 100644 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/toolbar/ToolbarButton.vue +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/toolbar/ToolbarButton.vue @@ -133,8 +133,8 @@ } .toolbar-icon { - width: 19px; - height: 19px; + width: 21px; + height: 21px; opacity: 0.7; } diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/composables/useEditor.js b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/composables/useEditor.js index fe1530f6d0..e6cb7d533b 100644 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/composables/useEditor.js +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/composables/useEditor.js @@ -6,6 +6,7 @@ import { Superscript } from '@tiptap/extension-superscript'; import { Subscript } from '@tiptap/extension-subscript'; import { Small } from '../extensions/SmallTextExtension'; import { CodeBlockNoSpellcheck } from '../extensions/CodeBlockNoSpellcheck'; +import { Image } from '../extensions/Image'; export function useEditor() { const editor = ref(null); @@ -22,6 +23,10 @@ export function useEditor() { Small, Superscript, Subscript, + Image.configure({ + inline: false, // Ensure images are treated as block elements + allowBase64: true, // Allow base64 images for local uploads + }), ], content: '

', editorProps: { diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/extensions/Image.js b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/extensions/Image.js new file mode 100644 index 0000000000..472ba86244 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/extensions/Image.js @@ -0,0 +1,43 @@ +import { Node, mergeAttributes } from '@tiptap/core'; +import { VueNodeViewRenderer } from '@tiptap/vue-2'; +import ImageNodeView from '../components/ImageNodeView.vue'; + +export const Image = Node.create({ + name: 'image', + + group: 'block', + + draggable: true, + + addAttributes() { + return { + src: { default: null }, + alt: { default: null }, + width: { default: null }, + height: { default: null }, + }; + }, + + parseHTML() { + return [{ tag: 'img[src]' }]; + }, + + renderHTML({ HTMLAttributes }) { + return ['img', mergeAttributes(HTMLAttributes)]; + }, + + addNodeView() { + return VueNodeViewRenderer(ImageNodeView); + }, + + addCommands() { + return { + setImage: (options) => ({ commands }) => { + return commands.insertContent({ + type: this.name, + attrs: options, + }); + }, + }; + }, +}); \ No newline at end of file diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/utils/image.js b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/utils/image.js new file mode 100644 index 0000000000..3af7c9e16e --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/utils/image.js @@ -0,0 +1,15 @@ +export const readFileAsDataURL = (file) => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.onload = () => { + resolve(reader.result); + }; + + reader.onerror = () => { + reject(new Error("Failed to read file.")); + }; + + reader.readAsDataURL(file); + }); +}; \ No newline at end of file diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/assets/icon-edit.svg b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/assets/icon-edit.svg new file mode 100644 index 0000000000..63c2ca84f5 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/assets/icon-edit.svg @@ -0,0 +1,3 @@ + + + diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/assets/icon-trash.svg b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/assets/icon-trash.svg new file mode 100644 index 0000000000..1e6141d04b --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/assets/icon-trash.svg @@ -0,0 +1,3 @@ + + + From 90c872ac3f7f8fac9911b392ac72bbc42686d36e Mon Sep 17 00:00:00 2001 From: habibayman Date: Tue, 17 Jun 2025 03:50:49 +0300 Subject: [PATCH 04/30] feat(texteditor): implement full image handling actions --- .../TipTapEditor/TipTapEditor.vue | 180 ++++---- .../TipTapEditor/components/EditorToolbar.vue | 19 +- .../TipTapEditor/components/ImageNodeView.vue | 181 -------- .../TipTapEditor/components/ImageUpload.vue | 318 -------------- .../components/image/ImageDropZone.vue | 67 +++ .../components/image/ImageNodeView.vue | 204 +++++++++ .../components/image/ImageUploadModal.vue | 392 ++++++++++++++++++ .../composables/useImageHandling.js | 85 ++++ .../TipTapEditor/extensions/Image.js | 18 +- .../TipTapEditor/services/imageService.js | 71 ++++ .../TipTapEditor/TipTapEditor/utils/image.js | 15 - 11 files changed, 924 insertions(+), 626 deletions(-) delete mode 100644 contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/ImageNodeView.vue delete mode 100644 contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/ImageUpload.vue create mode 100644 contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/image/ImageDropZone.vue create mode 100644 contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/image/ImageNodeView.vue create mode 100644 contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/image/ImageUploadModal.vue create mode 100644 contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/composables/useImageHandling.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/services/imageService.js delete mode 100644 contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/utils/image.js diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue index 33f6f1e84e..a0d8d93b78 100644 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue @@ -1,15 +1,22 @@ @@ -111,35 +103,35 @@ } .image-upload-modal-overlay { - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: rgba(0, 0, 0, 0.5); - display: flex; - align-items: center; - justify-content: center; - z-index: 1000; -} - -.image-upload-modal-content { - position: relative; - background: white; - padding: 2rem; - border-radius: 8px; - box-shadow: 0 4px 12px rgba(0,0,0,0.15); -} - -.close-modal { - position: absolute; - top: 10px; - right: 10px; - background: transparent; - border: none; - font-size: 1.5rem; - cursor: pointer; -} + position: fixed; + top: 0; + left: 0; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.5); + } + + .image-upload-modal-content { + position: relative; + padding: 2rem; + background: white; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + } + + .close-modal { + position: absolute; + top: 10px; + right: 10px; + font-size: 1.5rem; + cursor: pointer; + background: transparent; + border: 0; + } diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/EditorToolbar.vue b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/EditorToolbar.vue index 350483ce7c..ffd080d1b7 100644 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/EditorToolbar.vue +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/EditorToolbar.vue @@ -148,7 +148,7 @@ scriptActions, insertTools, t, - } = useToolbarActions(); + } = useToolbarActions(emit); const { copy$, @@ -163,15 +163,14 @@ } = getTipTapEditorStrings(); - const onToolClick = (tool) => { - // If the button is the 'image' button, emit an event to the parent - if (tool.name === 'image') { - emit('insert-image'); - } else { - // For all other buttons, call their original handler - tool.handler(); - } - }; + const onToolClick = tool => { + if (tool.name === 'image') { + emit('insert-image'); + } else { + // For all other buttons, call their original handler + tool.handler(); + } + }; return { handleCopy, diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/ImageNodeView.vue b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/ImageNodeView.vue deleted file mode 100644 index 7e95fb0a14..0000000000 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/ImageNodeView.vue +++ /dev/null @@ -1,181 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/ImageUpload.vue b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/ImageUpload.vue deleted file mode 100644 index 51dc6529f5..0000000000 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/ImageUpload.vue +++ /dev/null @@ -1,318 +0,0 @@ - - - - - \ No newline at end of file diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/image/ImageDropZone.vue b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/image/ImageDropZone.vue new file mode 100644 index 0000000000..c1d914b196 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/image/ImageDropZone.vue @@ -0,0 +1,67 @@ + + + + + + + diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/image/ImageNodeView.vue b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/image/ImageNodeView.vue new file mode 100644 index 0000000000..22afde0cb2 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/image/ImageNodeView.vue @@ -0,0 +1,204 @@ + + + + + + + + + diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/image/ImageUploadModal.vue b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/image/ImageUploadModal.vue new file mode 100644 index 0000000000..ef78fd3c79 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/image/ImageUploadModal.vue @@ -0,0 +1,392 @@ + + + + + + + diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/composables/useImageHandling.js b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/composables/useImageHandling.js new file mode 100644 index 0000000000..5b979d2c53 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/composables/useImageHandling.js @@ -0,0 +1,85 @@ +import { ref, onMounted, onUnmounted } from 'vue'; + +export function useImageHandling(editor) { + const modalMode = ref(null); // 'create' or 'edit' + const modalInitialData = ref({}); + const editingNodePos = ref(null); + + const openCreateModal = (file = null) => { + modalInitialData.value = { file }; + modalMode.value = 'create'; + }; + + const openEditModal = ({ pos, attrs }) => { + editingNodePos.value = pos; + modalInitialData.value = { ...attrs }; + modalMode.value = 'edit'; + }; + + const closeModal = () => { + modalMode.value = null; + modalInitialData.value = {}; + editingNodePos.value = null; + }; + + const handleInsert = async data => { + // console.log('Inserting image with data:', data); + // console.log('Editor state:', editor?.value); + if (!data.src || !editor?.value) return; + + if (editor?.value) { + editor.value.chain().focus().setImage(data).run(); + } + + closeModal(); + }; + + const handleUpdate = newAttrs => { + if (editingNodePos.value !== null && editor?.value) { + editor.value + .chain() + .focus() + .updateAttributes('image', newAttrs) + .setNodeSelection(editingNodePos.value) + .run(); + } + closeModal(); + }; + + const handleRemove = () => { + if (editingNodePos.value !== null && editor?.value) { + const node = editor.value.state.doc.nodeAt(editingNodePos.value); + if (node) { + editor.value + .chain() + .focus() + .deleteRange({ from: editingNodePos.value, to: editingNodePos.value + node.nodeSize }) + .run(); + } + } + closeModal(); + }; + + // Handle editor events more safely + onMounted(() => { + if (editor?.value) { + editor.value.on('open-image-editor', openEditModal); + } + }); + + onUnmounted(() => { + if (editor?.value) { + editor.value.off('open-image-editor', openEditModal); + } + }); + + return { + modalMode, + modalInitialData, + openCreateModal, + closeModal, + handleInsert, + handleUpdate, + handleRemove, + }; +} diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/extensions/Image.js b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/extensions/Image.js index 472ba86244..edd3e0d916 100644 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/extensions/Image.js +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/extensions/Image.js @@ -1,6 +1,6 @@ import { Node, mergeAttributes } from '@tiptap/core'; import { VueNodeViewRenderer } from '@tiptap/vue-2'; -import ImageNodeView from '../components/ImageNodeView.vue'; +import ImageNodeView from '../components/image/ImageNodeView.vue'; export const Image = Node.create({ name: 'image', @@ -32,12 +32,14 @@ export const Image = Node.create({ addCommands() { return { - setImage: (options) => ({ commands }) => { - return commands.insertContent({ - type: this.name, - attrs: options, - }); - }, + setImage: + options => + ({ commands }) => { + return commands.insertContent({ + type: this.name, + attrs: options, + }); + }, }; }, -}); \ No newline at end of file +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/services/imageService.js b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/services/imageService.js new file mode 100644 index 0000000000..ac6a33440a --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/services/imageService.js @@ -0,0 +1,71 @@ +const MAX_FILE_SIZE_MB = 10; // I think I need review on this value, I just picked what seemed reasonable +const ACCEPTED_MIME_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/svg+xml']; + +/** + * Validates a file based on type and size. + * @param {File} file - The file to validate. + * @returns {{isValid: boolean, error?: string}} + */ +export function validateFile(file) { + if (!file) { + return { isValid: false, error: 'No file provided.' }; + } + if (!ACCEPTED_MIME_TYPES.includes(file.type)) { + return { + isValid: false, + error: `Invalid file type. Please use: ${ACCEPTED_MIME_TYPES.join(', ')}`, + }; + } + if (file.size > MAX_FILE_SIZE_MB * 1024 * 1024) { + return { isValid: false, error: `File is too large. Maximum size is ${MAX_FILE_SIZE_MB}MB.` }; + } + return { isValid: true }; +} + +/** + * Reads a file and returns it as a Data URL. + * @param {File} file - The image file. + * @returns {Promise} A promise that resolves with the Data URL. + */ +function readFileAsDataURL(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result); + reader.onerror = error => reject(error); + reader.readAsDataURL(file); + }); +} + +/** + * Gets the natural dimensions of an image from its source. + * @param {string} src - The image source (e.g., a Data URL). + * @returns {Promise<{width: number, height: number}>} + */ +export function getImageDimensions(src) { + return new Promise((resolve, reject) => { + const img = new window.Image(); + img.onload = () => resolve({ width: img.width, height: img.height }); + img.onerror = error => reject(error); + img.src = src; + }); +} + +/** + * Fully processes a file: validates, reads, and gets dimensions. + * @param {File} file - The file to process. + * @returns {Promise<{src: string, width: number, height: number, file: File}>} + */ +export async function processFile(file) { + const validation = validateFile(file); + if (!validation.isValid) { + throw new Error(validation.error); + } + + try { + const src = await readFileAsDataURL(file); + const { width, height } = await getImageDimensions(src); + return { src, width, height, file }; + } catch (error) { + throw new Error('Failed to process the image file.'); + } +} diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/utils/image.js b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/utils/image.js deleted file mode 100644 index 3af7c9e16e..0000000000 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/utils/image.js +++ /dev/null @@ -1,15 +0,0 @@ -export const readFileAsDataURL = (file) => { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - - reader.onload = () => { - resolve(reader.result); - }; - - reader.onerror = () => { - reject(new Error("Failed to read file.")); - }; - - reader.readAsDataURL(file); - }); -}; \ No newline at end of file From 776d01774483779be5be7a9241d6e5a31133cae0 Mon Sep 17 00:00:00 2001 From: habibayman Date: Sun, 29 Jun 2025 05:49:54 +0300 Subject: [PATCH 05/30] feat(texteditor)[image]: enhance a11y --- .../TipTapEditor/TipTapEditor.vue | 4 +- .../components/image/ImageDropZone.vue | 15 ++- .../components/image/ImageNodeView.vue | 118 +++++++++++++----- .../components/image/ImageUploadModal.vue | 97 +++++++++----- .../TipTapEditor/composables/useEditor.js | 5 +- .../composables/useImageHandling.js | 2 - .../TipTapEditor/extensions/Image.js | 4 +- 7 files changed, 171 insertions(+), 74 deletions(-) diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue index a0d8d93b78..3285b6b05a 100644 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue @@ -60,7 +60,9 @@ handleRemove, } = useImageHandling(editor); + // Allow dropping files directly onto the editor const handleDrop = event => { + // console.log('File dropped:', event); const file = event.dataTransfer?.files[0]; if (file) { openCreateModal(file); @@ -69,9 +71,9 @@ return { isReady, - handleDrop, modalMode, modalInitialData, + handleDrop, openCreateModal, closeModal, handleInsert, diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/image/ImageDropZone.vue b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/image/ImageDropZone.vue index c1d914b196..c969b26bc4 100644 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/image/ImageDropZone.vue +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/image/ImageDropZone.vue @@ -39,7 +39,12 @@ } }; - return { isDragging, onDragOver, onDragLeave, onDrop }; + return { + isDragging, + onDragOver, + onDragLeave, + onDrop, + }; }, }); @@ -52,8 +57,8 @@ padding: 2rem; color: #757575; text-align: center; - border: 1px dashed #bdbdbd; - border-radius: 4px; + cursor: pointer; + border: 1px solid #bdbdbd; transition: background-color 0.2s, border-color 0.2s; @@ -64,4 +69,8 @@ border-color: #3498db; } + .drop-zone:focus { + outline: 2px solid #3498db; + } + diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/image/ImageNodeView.vue b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/image/ImageNodeView.vue index 22afde0cb2..69e22adb17 100644 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/image/ImageNodeView.vue +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/image/ImageNodeView.vue @@ -1,8 +1,3 @@ - -