diff --git a/playwright/e2e/create-table-api.spec.ts b/playwright/e2e/create-table-api.spec.ts
new file mode 100644
index 00000000000..cf6620ccbbe
--- /dev/null
+++ b/playwright/e2e/create-table-api.spec.ts
@@ -0,0 +1,87 @@
+/**
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { expect, mergeTests } from '@playwright/test'
+import { test as randomUserTest } from '../support/fixtures/random-user'
+import { test as uploadFileTest } from '../support/fixtures/upload-file'
+
+const test = mergeTests(uploadFileTest, randomUserTest)
+
+test.describe('createTable API', () => {
+ test.beforeEach(async ({ open, page }) => {
+ await open()
+
+ // Load the editor API bundle
+ await page.addScriptTag({
+ url: '/apps/text/js/text-editor.mjs',
+ type: 'module',
+ })
+ })
+
+ test('renders table editor', async ({ page }) => {
+ await page.evaluate(async () => {
+ const container = document.createElement('div')
+ container.id = 'test-table'
+ document.body.appendChild(container)
+
+ // @ts-expect-error - OCA.Text is a global
+ await window.OCA.Text.createTable({
+ el: container,
+ content: '| A | B |\n|---|---|\n| 1 | 2 |',
+ readOnly: false,
+ })
+ })
+
+ await expect(page.locator('#test-table table')).toBeVisible()
+ await expect(page.locator('#test-table th').first()).toContainText('A')
+ await expect(page.locator('#test-table td').first()).toContainText('1')
+ })
+
+ test('allows editing when not readonly', async ({ page }) => {
+ await page.evaluate(async () => {
+ const container = document.createElement('div')
+ container.id = 'test-editable'
+ container.style.position = 'fixed'
+ container.style.top = '10px'
+ container.style.left = '10px'
+ container.style.zIndex = '10000'
+ container.style.background = 'white'
+ document.body.appendChild(container)
+
+ // @ts-expect-error - OCA.Text is a global
+ await window.OCA.Text.createTable({
+ el: container,
+ content: '| A |\n|---|\n| x |',
+ readOnly: false,
+ })
+ })
+
+ const cell = page.locator('#test-editable .ProseMirror td').first()
+ await cell.click({ force: true })
+ await page.waitForTimeout(100)
+ await cell.selectText()
+ await page.keyboard.type('edited')
+
+ await expect(cell).toContainText('edited')
+ })
+
+ test('prevents editing when readonly', async ({ page }) => {
+ await page.evaluate(async () => {
+ const container = document.createElement('div')
+ container.id = 'test-readonly'
+ document.body.appendChild(container)
+
+ // @ts-expect-error - OCA.Text is a global
+ await window.OCA.Text.createTable({
+ el: container,
+ content: '| A |\n|---|---|\n| 1 |',
+ readOnly: true,
+ })
+ })
+
+ const editable = page.locator('#test-readonly [contenteditable]').first()
+ await expect(editable).toHaveAttribute('contenteditable', 'false')
+ })
+})
diff --git a/src/components/Editor/PlainTableContentEditor.vue b/src/components/Editor/PlainTableContentEditor.vue
new file mode 100644
index 00000000000..520376d5893
--- /dev/null
+++ b/src/components/Editor/PlainTableContentEditor.vue
@@ -0,0 +1,136 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/editor.js b/src/editor.js
index 536cfdccb28..4c5506377ad 100644
--- a/src/editor.js
+++ b/src/editor.js
@@ -281,3 +281,47 @@ window.OCA.Text.createEditor = async function ({
.onSearch(onSearch)
.render(el)
}
+
+window.OCA.Text.createTable = async function ({
+ // Element to render the editor to
+ el,
+
+ content = '',
+
+ readOnly = false,
+ autofocus = true,
+
+ onCreate = ({ markdown }) => {},
+ onLoaded = () => {},
+ onUpdate = ({ markdown }) => {},
+}) {
+ const { default: PlainTableContentEditor } = await import(
+ './components/Editor/PlainTableContentEditor.vue'
+ )
+
+ const data = Vue.observable({
+ readOnly,
+ content,
+ })
+
+ const vm = new Vue({
+ data() {
+ return data
+ },
+ render: (h) => {
+ return h(PlainTableContentEditor, {
+ props: {
+ content: data.content,
+ readOnly: data.readOnly,
+ showOutlineOutside: false,
+ },
+ })
+ },
+ })
+
+ return new TextEditorEmbed(vm, data)
+ .onCreate(onCreate)
+ .onLoaded(onLoaded)
+ .onUpdate(onUpdate)
+ .render(el)
+}
diff --git a/src/extensions/PlainTable.js b/src/extensions/PlainTable.js
new file mode 100644
index 00000000000..4240dae36bf
--- /dev/null
+++ b/src/extensions/PlainTable.js
@@ -0,0 +1,21 @@
+/**
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { Extension } from '@tiptap/core'
+
+import EditableTable from './../nodes/EditableTable.js'
+import PlainTableDocument from './../nodes/PlainTableDocument.js'
+import Keymap from './Keymap.js'
+import Markdown from './Markdown.js'
+/* eslint-disable import/no-named-as-default */
+import Text from '@tiptap/extension-text'
+
+export default Extension.create({
+ name: 'PlainTable',
+
+ addExtensions() {
+ return [Markdown, PlainTableDocument, EditableTable, Keymap, Text]
+ },
+})
diff --git a/src/extensions/index.js b/src/extensions/index.js
index ae56e067906..800d9685324 100644
--- a/src/extensions/index.js
+++ b/src/extensions/index.js
@@ -9,6 +9,7 @@ import FocusTrap from './FocusTrap.js'
import KeepSyntax from './KeepSyntax.js'
import Markdown from './Markdown.js'
import Mention from './Mention.js'
+import PlainTable from './PlainTable.js'
import PlainText from './PlainText.js'
import RichText from './RichText.js'
import UserColor from './UserColor.js'
@@ -20,6 +21,7 @@ export {
KeepSyntax,
Markdown,
Mention,
+ PlainTable,
PlainText,
RichText,
UserColor,
diff --git a/src/nodes/PlainTableDocument.js b/src/nodes/PlainTableDocument.js
new file mode 100644
index 00000000000..6564d3639f9
--- /dev/null
+++ b/src/nodes/PlainTableDocument.js
@@ -0,0 +1,11 @@
+/**
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { Node } from '@tiptap/core'
+
+export default Node.create({
+ name: 'doc',
+ content: 'table',
+})