diff --git a/cypress/component/GridCanvas.cy.js b/cypress/component/GridCanvas.cy.js new file mode 100644 index 0000000000..80bdf91a26 --- /dev/null +++ b/cypress/component/GridCanvas.cy.js @@ -0,0 +1,60 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { h } from 'vue' +import GridCanvas from '../../src/modules/grid/GridCanvas.vue' + +const layout = [ + { widgetId: 'w1', gridX: 0, gridY: 0, gridWidth: 12, gridHeight: 2 }, + { widgetId: 'w2', gridX: 0, gridY: 2, gridWidth: 6, gridHeight: 3 }, +] + +/** + * @param {object} props props of the canvas + */ +function mountCanvas(props) { + cy.mount(GridCanvas, { + props, + slots: { widget: ({ item }) => h('span', { 'data-cy': 'slotWidget' }, item.widgetId) }, + }).then(({ wrapper }) => cy.wrap(wrapper).as('wrapper')) +} + +describe('GridCanvas', () => { + it('places every widget of the layout where it is stored', () => { + cy.viewport(1400, 800) + mountCanvas({ layout, editable: false }) + + cy.get('[data-cy="gridItem"]').should('have.length', 2) + cy.get('[data-cy="gridItem"][gs-id="w2"]').should('have.attr', 'gs-w', '6') + cy.get('[data-cy="slotWidget"]').first().should('have.text', 'w1') + cy.get('.grid-canvas__stack--editing').should('not.exist') + }) + + it('can be arranged in edit mode on a wide screen', () => { + cy.viewport(1400, 800) + mountCanvas({ layout, editable: true }) + + cy.get('.grid-canvas__stack--editing').should('exist') + cy.get('[data-cy="gridNarrowHint"]').should('not.exist') + }) + + it('is only shown, not arranged, on a narrow screen', () => { + cy.viewport(420, 800) + mountCanvas({ layout, editable: true }) + + cy.get('[data-cy="gridNarrowHint"]').should('be.visible') + cy.get('.grid-canvas__stack--editing').should('not.exist') + }) + + it('adds a widget that appears in the layout', () => { + cy.viewport(1400, 800) + mountCanvas({ layout, editable: true }) + + cy.get('@wrapper').then(wrapper => wrapper.setProps({ layout: [...layout, { widgetId: 'w3', gridX: 6, gridY: 2, gridWidth: 6, gridHeight: 3 }] })) + cy.get('[data-cy="gridItem"]').should('have.length', 3) + cy.get('@wrapper').should(wrapper => { + expect(wrapper.vm.grid.engine.nodes.map(node => node.id)).to.include('w3') + }) + }) +}) diff --git a/cypress/component/GridWidget.cy.js b/cypress/component/GridWidget.cy.js new file mode 100644 index 0000000000..85612b444c --- /dev/null +++ b/cypress/component/GridWidget.cy.js @@ -0,0 +1,72 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import GridWidget from '../../src/modules/grid/GridWidget.vue' +import { useTablesStore } from '../../src/store/store.js' + +const nodes = { 4: { id: 4, node_type: 0, node_id: 40, permissions: 1 } } + +/** + * @param {object} widget the widget to show + * @param {boolean} editable whether the page is edited + * @param {object|null} layoutItem where the widget sits + */ +function mountWidget(widget, editable, layoutItem = null) { + cy.intercept('GET', '**/apps/tables/api/2/widget-types', { ocs: { data: [] } }) + cy.mount(GridWidget, { props: { widget, nodes, editable, layoutItem } }).then(({ wrapper }) => cy.wrap(wrapper).as('wrapper')) +} + +describe('GridWidget', () => { + it('shows the title, icon and description of a widget', () => { + mountWidget({ key: 'w1', id: 1, type: 'header', title: 'Welcome', icon: 'star', description: 'What we agreed', settings: { heading: 'Projects', subheading: 'This quarter', textAlign: 'end' }, nodeRelId: null }, false) + + cy.get('[data-cy="gridWidget"] h2').should('contain.text', 'Welcome') + cy.get('[data-cy="gridWidget"] h2 svg').should('exist') + cy.get('[data-cy="gridWidgetDescription"]').should('have.text', 'What we agreed') + cy.get('.header-widget').should('contain.text', 'Projects').and('contain.text', 'This quarter').and('have.css', 'text-align', 'end') + }) + + it('shows no heading for a widget without a title, also in edit mode, where its menu floats', () => { + mountWidget({ key: 'w2', id: 2, type: 'text', title: '', settings: { text: 'First\n\nSecond' }, nodeRelId: null }, false) + + cy.get('[data-cy="gridWidget"] h2').should('not.exist') + cy.get('.text-widget__paragraph').should('have.length', 2) + + mountWidget({ key: 'w2', id: 2, type: 'text', title: '', settings: { text: 'First' }, nodeRelId: null }, true) + cy.get('[data-cy="gridWidget"] h2').should('not.exist') + cy.get('.grid-widget__header--floating button[aria-label="Widget actions"]').should('be.visible') + }) + + it('titles a data widget without a title after its table, which then shows no title of its own', () => { + cy.intercept('GET', '**/apps/tables/**', { statusCode: 404, body: {} }) + mountWidget({ key: 'w5', id: 5, type: 'data', title: '', settings: {}, nodeRelId: 4 }, false) + cy.then(() => { + useTablesStore().tables = [{ id: 40, title: 'Tasks', emoji: '✅' }] + }) + + cy.get('[data-cy="gridWidget"] h2').should('contain.text', '✅ Tasks') + cy.get('[data-cy="gridWidget"] h1').should('not.exist') + }) + + it('asks editors to pick a table or view for a data widget without one, and shows viewers an empty state', () => { + mountWidget({ key: 'w3', id: 3, type: 'data', title: '', settings: {}, nodeRelId: 9 }, true) + cy.get('[data-cy="dataWidgetPickSource"]').should('be.visible') + cy.get('[data-cy="dataWidgetNoSource"]').should('not.exist') + + mountWidget({ key: 'w4', id: 4, type: 'data', title: 'Tasks', settings: {}, nodeRelId: null }, false) + cy.get('[data-cy="dataWidgetNoSource"]').should('contain.text', 'No table or view to show') + cy.get('[data-cy="dataWidgetPickSource"]').should('not.exist') + }) + + it('moves and resizes a widget from its menu, so the grid can be arranged with a keyboard', () => { + mountWidget({ key: 'w6', id: 6, type: 'text', title: 'Notes', settings: { text: '' }, nodeRelId: null }, true, { widgetId: 'w6', gridX: 0, gridY: 0, gridWidth: 6, gridHeight: 2 }) + + cy.get('[data-cy="gridWidget"] button[aria-label="Widget actions"]').click() + cy.get('[data-cy="widgetMoveLeft"]').should('have.class', 'action--disabled') + cy.get('[data-cy="widgetMoveRight"] button').click() + cy.get('@wrapper').then(wrapper => { + expect(wrapper.emitted('move')[0][1]).to.deep.equal({ gridX: 1 }) + }) + }) +}) diff --git a/cypress/component/gridLayout.cy.js b/cypress/component/gridLayout.cy.js new file mode 100644 index 0000000000..ac3cd47b4d --- /dev/null +++ b/cypress/component/gridLayout.cy.js @@ -0,0 +1,115 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { reactive } from 'vue' +import { + GRID_BREAKPOINT_MEDIUM, + GRID_BREAKPOINT_SINGLE, + GRID_COLUMNS, + applyNodePositions, + cloneGrid, + columnsForWidth, + gridToWidgets, + nextFreeRow, + pageToGrid, + widgetTarget, +} from '../../src/modules/grid/gridLayout.js' +import { isHexColor } from '../../src/shared/utils/gridColors.js' + +const widgetTypes = [ + { type: 'header', hasSource: false, settings: { heading: { type: 'string', default: '' }, textAlign: { type: 'enum', default: 'start' } } }, + { type: 'text', hasSource: false, settings: { text: { type: 'text', default: '' } } }, + { type: 'data', hasSource: true, settings: {} }, +] + +const nodes = { + 4: { id: 4, node_type: 0, node_id: 40, permissions: 1 }, + 5: { id: 5, node_type: 1, node_id: 50, permissions: 1 }, +} + +const page = { + id: 2, + pageType: 'grid', + widgets: [ + { id: 11, type: 'header', title: null, gridX: 0, gridY: 0, gridWidth: 12, gridHeight: 2, settings: { heading: 'Hi', textAlign: 'center' }, source: null }, + { id: 12, type: 'data', title: 'Tasks', icon: 'check', description: 'Open work', gridX: 0, gridY: 2, gridWidth: 6, gridHeight: 5, settings: [], source: { nodeRelId: 4 } }, + { id: 13, type: 'data', title: null, gridX: 6, gridY: 2, gridWidth: 6, gridHeight: 5, settings: [], source: { nodeRelId: 5 } }, + { id: 14, type: 'data', title: null, gridX: 0, gridY: 7, gridWidth: 12, gridHeight: 5, settings: [], source: null }, + ], +} + +describe('grid layout', () => { + it('copies a grid deeply, also when it is reactive', () => { + const stored = reactive({ widgets: [{ key: 'w1', settings: { heading: 'Hi' } }], layout: [{ widgetId: 'w1', gridX: 0 }] }) + + const copy = cloneGrid(stored) + copy.widgets[0].settings.heading = 'Changed' + copy.layout[0].gridX = 4 + + expect(stored.widgets[0].settings.heading).to.equal('Hi') + expect(stored.layout[0].gridX).to.equal(0) + expect(cloneGrid(undefined)).to.deep.equal({ widgets: [], layout: [] }) + }) + + it('reads a page into widgets with the source of each data widget', () => { + const grid = pageToGrid(page) + + expect(grid.widgets.map(widget => widget.key)).to.deep.equal(['w11', 'w12', 'w13', 'w14']) + expect(grid.widgets[0]).to.deep.equal({ key: 'w11', id: 11, type: 'header', title: '', icon: null, description: '', settings: { heading: 'Hi', textAlign: 'center' }, nodeRelId: null }) + expect(grid.widgets[1]).to.deep.include({ title: 'Tasks', icon: 'check', description: 'Open work', settings: {}, nodeRelId: 4 }) + expect(grid.layout[2]).to.deep.equal({ widgetId: 'w13', gridX: 6, gridY: 2, gridWidth: 6, gridHeight: 5 }) + expect(widgetTarget(grid.widgets[1], nodes)).to.deep.equal({ type: 'table', id: 40 }) + expect(widgetTarget(grid.widgets[2], nodes)).to.deep.equal({ type: 'view', id: 50 }) + expect(widgetTarget(grid.widgets[3], nodes)).to.equal(null) + // The table or view left the application + expect(widgetTarget(grid.widgets[1], { 5: nodes[5] })).to.equal(null) + }) + + it('saves widgets with their id, and a source only for types that have one', () => { + const grid = pageToGrid(page) + grid.widgets.push({ key: 'new-1', id: null, type: 'text', title: ' Notes ', icon: 'star', description: ' Read me ', settings: { text: 'A' }, nodeRelId: null }) + grid.layout.push({ widgetId: 'new-1', gridX: 0, gridY: 12, gridWidth: 6, gridHeight: 2 }) + grid.widgets.push({ key: 'unplaced', id: null, type: 'text', title: '', settings: {}, nodeRelId: null }) + + const widgets = gridToWidgets(grid, widgetTypes) + + expect(widgets).to.have.length(5) + expect(widgets[0]).to.deep.equal({ id: 11, type: 'header', title: null, icon: null, description: null, gridX: 0, gridY: 0, gridWidth: 12, gridHeight: 2, settings: { heading: 'Hi', textAlign: 'center' } }) + expect(widgets[1]).to.deep.include({ id: 12, title: 'Tasks', icon: 'check', description: 'Open work', source: { nodeRelId: 4 } }) + expect(widgets[3]).to.deep.include({ id: 14, source: null }) + expect(widgets[4]).to.deep.equal({ type: 'text', title: 'Notes', icon: 'star', description: 'Read me', gridX: 0, gridY: 12, gridWidth: 6, gridHeight: 2, settings: { text: 'A' } }) + }) + + it('places a new widget below the lowest one', () => { + expect(nextFreeRow([])).to.equal(0) + expect(nextFreeRow([{ gridY: 0, gridHeight: 2 }, { gridY: 3, gridHeight: 4 }, { gridY: 1 }])).to.equal(7) + }) + + it('shows 12, 6 or 1 columns by canvas width', () => { + expect(columnsForWidth(GRID_BREAKPOINT_SINGLE - 1)).to.equal(1) + expect(columnsForWidth(GRID_BREAKPOINT_SINGLE)).to.equal(6) + expect(columnsForWidth(GRID_BREAKPOINT_MEDIUM - 1)).to.equal(6) + expect(columnsForWidth(GRID_BREAKPOINT_MEDIUM)).to.equal(GRID_COLUMNS) + }) + + it('applies only the positions of the nodes that changed', () => { + const layout = [ + { widgetId: 'w1', gridX: 0, gridY: 0, gridWidth: 6, gridHeight: 2 }, + { widgetId: 'w2', gridX: 6, gridY: 0, gridWidth: 6, gridHeight: 2 }, + ] + + const updated = applyNodePositions(layout, [{ id: 'w2', x: 0, y: 2, w: 12, h: 3 }]) + + expect(updated[0]).to.equal(layout[0]) + expect(updated[1]).to.deep.equal({ widgetId: 'w2', gridX: 0, gridY: 2, gridWidth: 12, gridHeight: 3 }) + }) + + it('accepts only hex colors, like the server', () => { + expect(isHexColor('#abc')).to.equal(true) + expect(isHexColor('#a1b2c3')).to.equal(true) + expect(isHexColor('red')).to.equal(false) + expect(isHexColor('url(evil)')).to.equal(false) + expect(isHexColor(null)).to.equal(false) + }) +}) diff --git a/package-lock.json b/package-lock.json index fb673f8071..60afeb619f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "@vueuse/core": "^15.0.0", "debounce": "^3.0.0", "dompurify": "^3.4.17", + "gridstack": "^13.3.0", "papaparse": "^5.7.0", "pinia": "^4.1.0", "vue": "^3.5.44", @@ -8189,6 +8190,22 @@ "dev": true, "license": "ISC" }, + "node_modules/gridstack": { + "version": "13.3.0", + "resolved": "https://registry.npmjs.org/gridstack/-/gridstack-13.3.0.tgz", + "integrity": "sha512-1tqFd3/hb3CSOJWFG8aFleCGWT6Gqx/hnjRT78ocnixp0XPk5Aa6Uxkm0qn9VHFwLrEdOFb+ZXtQZT+a/IdS3g==", + "funding": [ + { + "type": "paypal", + "url": "https://www.paypal.me/alaind831" + }, + { + "type": "venmo", + "url": "https://www.venmo.com/adumesny" + } + ], + "license": "MIT" + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", diff --git a/package.json b/package.json index 0f0d24f5cb..e67632f3e8 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "@vueuse/core": "^15.0.0", "debounce": "^3.0.0", "dompurify": "^3.4.17", + "gridstack": "^13.3.0", "papaparse": "^5.7.0", "pinia": "^4.1.0", "vue": "^3.5.44", diff --git a/src/modules/grid/GridCanvas.vue b/src/modules/grid/GridCanvas.vue new file mode 100644 index 0000000000..9394a0856f --- /dev/null +++ b/src/modules/grid/GridCanvas.vue @@ -0,0 +1,204 @@ + + + + + + diff --git a/src/modules/grid/GridWidget.vue b/src/modules/grid/GridWidget.vue new file mode 100644 index 0000000000..74a1fa7f97 --- /dev/null +++ b/src/modules/grid/GridWidget.vue @@ -0,0 +1,255 @@ + + + + + + diff --git a/src/modules/grid/gridLayout.js b/src/modules/grid/gridLayout.js new file mode 100644 index 0000000000..7e7c04282a --- /dev/null +++ b/src/modules/grid/gridLayout.js @@ -0,0 +1,180 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { NODE_TYPE_TABLE, NODE_TYPE_VIEW } from '../../shared/constants.ts' + +/** Number of columns of a grid page. */ +export const GRID_COLUMNS = 12 + +/** Smallest number of columns a widget spans. */ +export const GRID_MIN_WIDTH = 2 + +/** Height of one grid row. In pixels on purpose: GridStack takes a number, not a CSS variable. */ +export const GRID_CELL_HEIGHT = 80 + +/** Space between widgets in pixels: two grid baselines of 4px. */ +export const GRID_MARGIN = 8 + +/** Below this canvas width in pixels the grid shows one column. */ +export const GRID_BREAKPOINT_SINGLE = 560 + +/** Below this canvas width in pixels the grid shows six columns. */ +export const GRID_BREAKPOINT_MEDIUM = 960 + +/** + * Deep copy of plain data. A JSON round-trip also copies reactive proxies, which + * structuredClone refuses. + * + * @param {object|Array|string|number|boolean|null|undefined} value plain data + * @return {object|Array|string|number|boolean|null} the copy + */ +function copy(value) { + return JSON.parse(JSON.stringify(value ?? null)) +} + +/** + * @param {Array} widgetTypes the types loaded from the server + * @param {string} type widget type + * @return {object|null} that type, or null when the server does not know it + */ +export function findWidgetType(widgetTypes, type) { + return widgetTypes.find(widgetType => widgetType.type === type) ?? null +} + +/** + * Deep copy of a grid so edits never touch the stored page until saved. + * + * @param {object} grid grid as built by pageToGrid + * @return {{widgets: Array, layout: Array}} the copy + */ +export function cloneGrid(grid) { + return { + widgets: copy(grid?.widgets ?? []), + layout: copy(grid?.layout ?? []), + } +} + +/** + * The row below the lowest widget, where a new widget is placed. + * + * @param {Array} layout layout items + * @return {number} the row + */ +export function nextFreeRow(layout) { + return layout.reduce((max, item) => Math.max(max, (item.gridY ?? 0) + (item.gridHeight ?? 1)), 0) +} + +/** + * How many columns the grid shows on a canvas of this width: 12 on a wide one, 6 on a + * medium one and one on a phone. + * + * @param {number} width width of the canvas in pixels + * @return {number} the number of columns + */ +export function columnsForWidth(width) { + if (width < GRID_BREAKPOINT_SINGLE) { + return 1 + } + return width < GRID_BREAKPOINT_MEDIUM ? 6 : GRID_COLUMNS +} + +/** + * The layout with the positions of the GridStack nodes that changed applied to it. + * + * @param {Array} layout layout items + * @param {Array<{id: string, x: number, y: number, w: number, h: number}>} nodes the changed nodes + * @return {Array} the updated layout + */ +export function applyNodePositions(layout, nodes) { + const positions = Object.fromEntries(nodes.map(node => [node.id, node])) + return layout.map(item => { + const node = positions[item.widgetId] + return node ? { ...item, gridX: node.x, gridY: node.y, gridWidth: node.w, gridHeight: node.h } : item + }) +} + +/** + * The grid of a page: its widgets and where they sit. Widgets are keyed by `key`, which + * is their id for saved widgets; a data widget keeps the node relation of its source. + * + * @param {object} page a page as the API returns it + * @return {{widgets: Array, layout: Array}} the grid + */ +export function pageToGrid(page) { + const widgets = page?.widgets ?? [] + return { + widgets: widgets.map(widget => ({ + key: 'w' + widget.id, + id: widget.id, + type: widget.type, + title: widget.title ?? '', + icon: widget.icon ?? null, + description: widget.description ?? '', + // An empty settings map arrives as a JSON array + settings: Array.isArray(widget.settings) ? {} : copy(widget.settings ?? {}), + nodeRelId: widget.source?.nodeRelId ?? null, + })), + layout: widgets.map(widget => ({ + widgetId: 'w' + widget.id, + gridX: widget.gridX, + gridY: widget.gridY, + gridWidth: widget.gridWidth, + gridHeight: widget.gridHeight, + })), + } +} + +/** + * The table or view a data widget shows. + * + * @param {object} widget a widget of the grid + * @param {object} nodes the nodes of the application by relation id, as in context.nodes + * @return {{type: string, id: number}|null} the target, or null when the widget has no source + * or its table or view left the application + */ +export function widgetTarget(widget, nodes) { + const node = widget?.nodeRelId ? nodes?.[widget.nodeRelId] : null + if (!node) { + return null + } + const nodeType = parseInt(node.node_type) + if (nodeType !== NODE_TYPE_TABLE && nodeType !== NODE_TYPE_VIEW) { + return null + } + return { type: nodeType === NODE_TYPE_VIEW ? 'view' : 'table', id: parseInt(node.node_id) } +} + +/** + * The widgets of a grid as the API saves them. A saved widget keeps its id; a widget of + * a type with a source names the node relation it shows, or null. + * + * @param {{widgets: Array, layout: Array}} grid the grid + * @param {Array} widgetTypes the types loaded from the server + * @return {Array} the widgets to save + */ +export function gridToWidgets(grid, widgetTypes) { + const positions = Object.fromEntries(grid.layout.map(item => [item.widgetId, item])) + return grid.widgets.filter(widget => positions[widget.key]).map(widget => { + const { gridX, gridY, gridWidth, gridHeight } = positions[widget.key] + const saved = { + type: widget.type, + title: widget.title?.trim() || null, + icon: widget.icon || null, + description: widget.description?.trim() || null, + gridX, + gridY, + gridWidth, + gridHeight, + settings: copy(widget.settings ?? {}), + } + if (widget.id) { + saved.id = widget.id + } + if (findWidgetType(widgetTypes, widget.type)?.hasSource) { + saved.source = widget.nodeRelId ? { nodeRelId: widget.nodeRelId } : null + } + return saved + }) +} diff --git a/src/modules/grid/widgetRegistry.js b/src/modules/grid/widgetRegistry.js new file mode 100644 index 0000000000..bd958d46d1 --- /dev/null +++ b/src/modules/grid/widgetRegistry.js @@ -0,0 +1,30 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import HeaderWidget from './widgets/HeaderWidget.vue' +import TextWidget from './widgets/TextWidget.vue' +import DataWidget from './widgets/DataWidget.vue' + +export const WIDGET_TYPE_HEADER = 'header' +export const WIDGET_TYPE_TEXT = 'text' +export const WIDGET_TYPE_DATA = 'data' + +/** + * The component that renders each widget type. Everything else about a type, + * its title, default size and the schema of its content, comes from the server + * (see GridWidgetTypes.php) so the form and the validation share one definition. + */ +const widgetComponents = { + [WIDGET_TYPE_HEADER]: HeaderWidget, + [WIDGET_TYPE_TEXT]: TextWidget, + [WIDGET_TYPE_DATA]: DataWidget, +} + +/** + * @param {string} type widget type + * @return {object|null} the component that renders it + */ +export function getWidgetComponent(type) { + return widgetComponents[type] ?? null +} diff --git a/src/modules/grid/widgets/DataWidget.vue b/src/modules/grid/widgets/DataWidget.vue new file mode 100644 index 0000000000..67d2cde917 --- /dev/null +++ b/src/modules/grid/widgets/DataWidget.vue @@ -0,0 +1,230 @@ + + + + + + diff --git a/src/modules/grid/widgets/HeaderWidget.vue b/src/modules/grid/widgets/HeaderWidget.vue new file mode 100644 index 0000000000..3159f3d1a2 --- /dev/null +++ b/src/modules/grid/widgets/HeaderWidget.vue @@ -0,0 +1,73 @@ + + + + + + diff --git a/src/modules/grid/widgets/TextWidget.vue b/src/modules/grid/widgets/TextWidget.vue new file mode 100644 index 0000000000..8ca3a64853 --- /dev/null +++ b/src/modules/grid/widgets/TextWidget.vue @@ -0,0 +1,54 @@ + + + + + + diff --git a/src/modules/main/sections/ElementTitle.vue b/src/modules/main/sections/ElementTitle.vue index a7d2f856be..1bd3e486a7 100644 --- a/src/modules/main/sections/ElementTitle.vue +++ b/src/modules/main/sections/ElementTitle.vue @@ -3,8 +3,8 @@ - SPDX-License-Identifier: AGPL-3.0-or-later -->