diff --git a/lib/Service/AttachmentService.php b/lib/Service/AttachmentService.php
index 6aaa9eec15f..44fbdf552ed 100755
--- a/lib/Service/AttachmentService.php
+++ b/lib/Service/AttachmentService.php
@@ -640,6 +640,11 @@ private function getShareFolder(string $shareToken): ?Folder {
public function cleanupAttachments(int $fileId): int {
$textFile = $this->rootFolder->getFirstNodeById($fileId);
if ($textFile instanceof File) {
+ if ($textFile->getStorage()->instanceOfStorage(\OCA\Collectives\Mount\CollectiveStorage::class)) {
+ // Don't cleanup attachments for Collectives pages
+ return 0;
+ }
+
if ($textFile->getMimeType() === 'text/markdown') {
// get IDs of the files inside the attachment dir
try {
diff --git a/src/editor.js b/src/editor.js
index 0976e2c440d..48622fc9a68 100644
--- a/src/editor.js
+++ b/src/editor.js
@@ -13,6 +13,7 @@ import {
OPEN_LINK_HANDLER,
} from './components/Editor.provider.ts'
import { ACTION_ATTACHMENT_PROMPT } from './components/Editor/MediaHandler.provider.js'
+import { encodeAttachmentFilename } from './helpers/attachmentFilename.ts'
import { openLink } from './helpers/links.js'
// eslint-disable-next-line import/no-unresolved, n/no-missing-import
import 'vite/modulepreload-polyfill'
@@ -70,6 +71,11 @@ class TextEditorEmbed {
return this
}
+ onAttachmentsUpdated(onAttachmentsUpdatedCallback = () => {}) {
+ subscribe('text:editor:attachments:updated', onAttachmentsUpdatedCallback)
+ return this
+ }
+
render(el) {
el.innerHTML = ''
const element = document.createElement('div')
@@ -138,6 +144,55 @@ class TextEditorEmbed {
.run()
}
+ replaceAttachmentFilename(pageId, oldName, newName) {
+ const oldSrc =
+ '.attachments.' + pageId + '/' + encodeAttachmentFilename(oldName)
+ const newSrc =
+ '.attachments.' + pageId + '/' + encodeAttachmentFilename(newName)
+ const { view, state } = this.#getEditorComponent().editor
+ const { doc, schema, tr } = state
+ let modified = false
+
+ doc.descendants((node, pos) => {
+ if (!node.type === schema.nodes.image || node.attrs.src !== oldSrc) {
+ return
+ }
+
+ tr.setNodeMarkup(pos, undefined, {
+ ...node.attrs,
+ src: newSrc,
+ alt: node.attrs.alt.replace(oldName, newName),
+ })
+ modified = true
+ })
+
+ if (modified) {
+ view.dispatch(tr)
+ this.save()
+ }
+ }
+
+ removeAttachmentReferences(pageId, name) {
+ const src = '.attachments.' + pageId + '/' + encodeAttachmentFilename(name)
+ const { view, state } = this.#getEditorComponent().editor
+ const { doc, schema, tr } = state
+ let modified = false
+
+ doc.descendants((node, pos) => {
+ if (!node.type === schema.nodes.image || node.attrs.src !== src) {
+ return
+ }
+
+ tr.delete(pos, pos + node.nodeSize)
+ modified = true
+ })
+
+ if (modified) {
+ view.dispatch(tr)
+ this.save()
+ }
+ }
+
focus() {
this.#getEditorComponent().editor?.commands.focus()
}
@@ -201,6 +256,7 @@ window.OCA.Text.createEditor = async function ({
onMentionInsert = undefined,
openLinkHandler = undefined,
onSearch = undefined,
+ onAttachmentsUpdated = ({ attachmentSrcs }) => {},
}) {
const { default: MarkdownContentEditor } = await import(
/* webpackChunkName: "editor" */ './components/Editor/MarkdownContentEditor.vue'
@@ -286,6 +342,7 @@ window.OCA.Text.createEditor = async function ({
.onUpdate(onUpdate)
.onOutlineToggle(onOutlineToggle)
.onSearch(onSearch)
+ .onAttachmentsUpdated(onAttachmentsUpdated)
.render(el)
}
diff --git a/src/helpers/attachmentFilename.ts b/src/helpers/attachmentFilename.ts
new file mode 100644
index 00000000000..81c66305e60
--- /dev/null
+++ b/src/helpers/attachmentFilename.ts
@@ -0,0 +1,15 @@
+/**
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+/**
+ * Encode filename the same way as at `insertAttachment` in MediaHandler.vue
+ *
+ * @param filename - The filename to encode
+ */
+export function encodeAttachmentFilename(filename: string) {
+ return encodeURIComponent(filename).replace(/[!'()*]/g, (c) => {
+ return '%' + c.charCodeAt(0).toString(16).toUpperCase()
+ })
+}
diff --git a/src/nodes/Image.js b/src/nodes/Image.js
index 231c609f7de..93491b55847 100644
--- a/src/nodes/Image.js
+++ b/src/nodes/Image.js
@@ -3,12 +3,17 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
+import { emit } from '@nextcloud/event-bus'
import TiptapImage from '@tiptap/extension-image'
import { defaultMarkdownSerializer } from '@tiptap/pm/markdown'
-import { Plugin } from '@tiptap/pm/state'
+import { Plugin, PluginKey } from '@tiptap/pm/state'
import { VueNodeViewRenderer } from '@tiptap/vue-2'
+import extractAttachmentSrcs from '../plugins/extractAttachmentSrcs.ts'
import ImageView from './ImageView.vue'
+const imageFileDropPluginKey = new PluginKey('imageFileDrop')
+const imageExtractAttachmentsKey = new PluginKey('imageExtractAttachments')
+
const Image = TiptapImage.extend({
selectable: false,
@@ -41,6 +46,7 @@ const Image = TiptapImage.extend({
addProseMirrorPlugins() {
return [
new Plugin({
+ key: imageFileDropPluginKey,
props: {
handleDrop: (view, event, slice) => {
// only catch the drop if it contains files
@@ -82,6 +88,31 @@ const Image = TiptapImage.extend({
},
},
}),
+ new Plugin({
+ key: imageExtractAttachmentsKey,
+ state: {
+ init(_, { doc }) {
+ const attachmentSrcs = extractAttachmentSrcs(doc)
+ emit('text:editor:attachments:updated', { attachmentSrcs })
+ return { attachmentSrcs }
+ },
+ apply(tr, value, _oldState, newState) {
+ if (!tr.docChanged) {
+ return value
+ }
+ const attachmentSrcs = extractAttachmentSrcs(newState.doc)
+ if (
+ JSON.stringify(attachmentSrcs)
+ === JSON.stringify(value?.attachmentSrcs)
+ ) {
+ return value
+ }
+
+ emit('text:editor:attachments:updated', { attachmentSrcs })
+ return { attachmentSrcs }
+ },
+ },
+ }),
]
},
diff --git a/src/plugins/extractAttachmentSrcs.ts b/src/plugins/extractAttachmentSrcs.ts
new file mode 100644
index 00000000000..abde6604f7b
--- /dev/null
+++ b/src/plugins/extractAttachmentSrcs.ts
@@ -0,0 +1,31 @@
+/**
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { Node } from '@tiptap/pm/model'
+
+/**
+ * Extract attachment src attributes from doc
+ *
+ * @param doc - the prosemirror doc
+ * @return src attributes of attachments found in the doc
+ */
+export default function extractAttachmentSrcs(doc: Node) {
+ const attachmentSrcs: string[] = []
+
+ doc.descendants((node) => {
+ if (node.type.name !== 'image' && node.type.name !== 'imageInline') {
+ return
+ }
+
+ // ignore empty src
+ if (!node.attrs.src) {
+ return
+ }
+
+ attachmentSrcs.push(node.attrs.src)
+ })
+
+ return attachmentSrcs
+}
diff --git a/src/tests/plugins/extractAttachmentSrcs.spec.js b/src/tests/plugins/extractAttachmentSrcs.spec.js
new file mode 100644
index 00000000000..22b64f2aad4
--- /dev/null
+++ b/src/tests/plugins/extractAttachmentSrcs.spec.js
@@ -0,0 +1,41 @@
+/**
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import Image from '../../nodes/Image.js'
+import extractAttachmentSrcs from '../../plugins/extractAttachmentSrcs.ts'
+import createCustomEditor from '../testHelpers/createCustomEditor.ts'
+
+describe('extractAttachmentSrcs', () => {
+ it('returns an empty array for an empty doc', () => {
+ const doc = prepareDoc('')
+ const attachmentSrcs = extractAttachmentSrcs(doc)
+ expect(attachmentSrcs).toEqual([])
+ })
+
+ it('returns headings', () => {
+ const content =
+ '
'
+ const doc = prepareDoc(content)
+ const attachmentSrcs = extractAttachmentSrcs(doc)
+ expect(attachmentSrcs).toEqual([
+ '.attachments.123/test.pdf',
+ '.attachments.456/test2.png',
+ ])
+ })
+
+ it('ignores an empty src', () => {
+ const content = '
'
+ const doc = prepareDoc(content)
+ const attachmentSrcs = extractAttachmentSrcs(doc)
+ expect(attachmentSrcs).toEqual([])
+ })
+})
+
+const prepareDoc = (content) => {
+ const editor = createCustomEditor(content, [Image])
+ const doc = editor.state.doc
+ editor.destroy()
+ return doc
+}
diff --git a/tests/stub.php b/tests/stub.php
index d08c0201581..cd14233ef89 100644
--- a/tests/stub.php
+++ b/tests/stub.php
@@ -12,6 +12,24 @@ class BaseResponse {
}
}
+namespace OC\Files\Storage\Wrapper {
+ use OCP\Files\Storage\IStorage;
+
+ class Wrapper implements IStorage {
+ public function __construct(array $parameters) {
+ }
+ }
+}
+
+namespace OCA\Collectives\Mount {
+
+ use OC\Files\Storage\Wrapper\Wrapper;
+ use OCP\Files\Storage\IConstructableStorage;
+
+ class CollectiveStorage extends Wrapper implements IConstructableStorage {
+ }
+}
+
namespace OCA\Files\Event {
class LoadAdditionalScriptsEvent extends \OCP\EventDispatcher\Event {
}