diff --git a/blocks/edit/da-editor/da-editor.css b/blocks/edit/da-editor/da-editor.css index 1ad82d21..c191bc0d 100644 --- a/blocks/edit/da-editor/da-editor.css +++ b/blocks/edit/da-editor/da-editor.css @@ -8,6 +8,7 @@ --editor-btn-bg-color: #EFEFEF; --editor-btn-bg-color-hover: var(--s2-blue-900); + position: relative; grid-area: editor; } @@ -981,3 +982,29 @@ da-diff-deleted, da-diff-added { .focal-point-icon:hover svg { color: #0265dc; } + +.table-select-handle { + position: absolute; + width: 20px; + height: 20px; + background-color: #fff; + background-image: url('/blocks/edit/img/select-handle.svg'); + background-repeat: no-repeat; + background-position: center; + border: 1px solid #ccc; + border-radius: 4px; + z-index: 100; + cursor: pointer; + display: none; + align-items: center; + justify-content: center; +} + +.table-select-handle.is-visible { + display: flex; +} + +.table-select-handle:hover { + background-color: #f0f7ff; + border-color: var(--s2-blue-800); +} diff --git a/blocks/edit/img/select-handle.svg b/blocks/edit/img/select-handle.svg new file mode 100644 index 00000000..fcb14001 --- /dev/null +++ b/blocks/edit/img/select-handle.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/blocks/edit/prose/index.js b/blocks/edit/prose/index.js index 42ffdd0d..6185b2fb 100644 --- a/blocks/edit/prose/index.js +++ b/blocks/edit/prose/index.js @@ -29,6 +29,7 @@ import { linkItem } from './plugins/menu/linkItem.js'; import codemark from './plugins/codemark.js'; import imageDrop from './plugins/imageDrop.js'; import imageFocalPoint from './plugins/imageFocalPoint.js'; +import tableSelectHandle from './plugins/tableSelectHandle.js'; import linkConverter from './plugins/linkConverter.js'; import linkTextSync from './plugins/linkTextSync.js'; import sectionPasteHandler from './plugins/sectionPasteHandler.js'; @@ -348,6 +349,7 @@ export default function initProse({ path, permissions }) { trackCursorAndChanges(), slashMenu(), linkMenu(), + tableSelectHandle(), imageDrop(schema), linkConverter(schema), linkTextSync(), @@ -382,7 +384,7 @@ export default function initProse({ path, permissions }) { 'Shift-Tab': liftListItem(schema.nodes.list_item), }), gapCursor(), - tableEditing(), + tableEditing({ allowTableNodeSelection: true }), ]; if (canWrite) { diff --git a/blocks/edit/prose/plugins/tableSelectHandle.js b/blocks/edit/prose/plugins/tableSelectHandle.js new file mode 100644 index 00000000..304a87bf --- /dev/null +++ b/blocks/edit/prose/plugins/tableSelectHandle.js @@ -0,0 +1,135 @@ +import { Plugin, NodeSelection } from 'da-y-wrapper'; + +const HANDLE_OFFSET = 6; + +function getTablePos(view, tableEl) { + const pos = view.posAtDOM(tableEl, 0); + + if (pos === null) { + return null; + } + + const $pos = view.state.doc.resolve(pos); + for (let d = $pos.depth; d >= 0; d -= 1) { + if ($pos.node(d).type.name === 'table') { + return $pos.before(d); + } + } + + return null; +} + +/** + * Allows selecting an entire table by clicking an icon in the top left corner. + */ +export default function tableSelectHandle() { + let handle = null; + let currentTable = null; + let currentWrapper = null; + + function showHandle(wrapper, editorRect) { + if (!handle || !wrapper) { + return; + } + const rect = wrapper.getBoundingClientRect(); + handle.style.left = `${rect.left - editorRect.left + HANDLE_OFFSET}px`; + handle.style.top = `${rect.top - editorRect.top + HANDLE_OFFSET}px`; + handle.classList.add('is-visible'); + } + + function hideHandle() { + handle?.classList.remove('is-visible'); + currentTable = null; + currentWrapper = null; + } + + function createHandle(view) { + const el = document.createElement('div'); + el.className = 'table-select-handle'; + el.contentEditable = 'false'; + + el.addEventListener('mousedown', (e) => { + if (!currentTable) { + return; + } + + e.preventDefault(); + e.stopPropagation(); + + const tablePos = getTablePos(view, currentTable); + + if (tablePos !== null) { + const sel = NodeSelection.create(view.state.doc, tablePos); + view.dispatch(view.state.tr.setSelection(sel)); + view.focus(); + } + }); + + el.addEventListener('mouseleave', (e) => { + if (e.relatedTarget && currentWrapper?.contains(e.relatedTarget)) { + return; + } + + hideHandle(); + }); + + return el; + } + + return new Plugin({ + view(editorView) { + handle = createHandle(editorView); + const container = editorView.dom.parentElement; + + if (container) { + container.appendChild(handle); + } + + const onMouseOver = (e) => { + const wrapper = e.target.closest('.tableWrapper'); + + if (!wrapper || wrapper === currentWrapper) { + return; + } + + currentWrapper = wrapper; + currentTable = wrapper.querySelector('table'); + const editorRect = editorView.dom.getBoundingClientRect(); + showHandle(wrapper, editorRect); + }; + + const onMouseOut = (e) => { + const wrapper = e.target.closest('.tableWrapper'); + + if (!wrapper) { + return; + } + + const related = e.relatedTarget; + + if (related === handle || wrapper.contains(related)) { + return; + } + + hideHandle(); + }; + + editorView.dom.addEventListener('mouseover', onMouseOver); + editorView.dom.addEventListener('mouseout', onMouseOut); + + return { + update() { + if (currentWrapper && !currentWrapper.isConnected) { + hideHandle(); + } + }, + destroy() { + editorView.dom.removeEventListener('mouseover', onMouseOver); + editorView.dom.removeEventListener('mouseout', onMouseOut); + handle?.remove(); + handle = null; + }, + }; + }, + }); +} diff --git a/test/unit/blocks/edit/prose/plugins/tableSelectHandle.test.js b/test/unit/blocks/edit/prose/plugins/tableSelectHandle.test.js new file mode 100644 index 00000000..771ec123 --- /dev/null +++ b/test/unit/blocks/edit/prose/plugins/tableSelectHandle.test.js @@ -0,0 +1,129 @@ +import { expect } from '@esm-bundle/chai'; +import sinon from 'sinon'; + +describe('tableSelectHandle Plugin', () => { + let tableSelectHandle; + let plugin; + + before(async () => { + const mod = await import('../../../../../../blocks/edit/prose/plugins/tableSelectHandle.js'); + tableSelectHandle = mod.default; + }); + + beforeEach(() => { + plugin = tableSelectHandle(); + }); + + describe('View initialization', () => { + let mockEditorView; + let container; + + beforeEach(() => { + container = document.createElement('div'); + const editorDom = document.createElement('div'); + editorDom.className = 'ProseMirror'; + container.appendChild(editorDom); + document.body.appendChild(container); + + mockEditorView = { + dom: editorDom, + state: { + doc: { + resolve: () => ({ + depth: 1, + node: () => ({ type: { name: 'table' } }), + before: () => 0, + }), + }, + tr: { setSelection: sinon.stub().returnsThis() }, + selection: { content: () => ({ content: {} }) }, + }, + dispatch: sinon.stub(), + posAtDOM: () => 0, + }; + }); + + afterEach(() => { + container.remove(); + }); + + it('creates and appends handle element', () => { + const viewReturn = plugin.spec.view(mockEditorView); + + const handle = container.querySelector('.table-select-handle'); + expect(handle).to.exist; + expect(handle.classList.contains('is-visible')).to.be.false; + + viewReturn.destroy(); + }); + + it('removes handle on destroy', () => { + const viewReturn = plugin.spec.view(mockEditorView); + + let handle = container.querySelector('.table-select-handle'); + expect(handle).to.exist; + + viewReturn.destroy(); + + handle = container.querySelector('.table-select-handle'); + expect(handle).to.be.null; + }); + }); + + describe('Mouse events', () => { + let mockEditorView; + let container; + let editorDom; + + beforeEach(() => { + container = document.createElement('div'); + editorDom = document.createElement('div'); + editorDom.className = 'ProseMirror'; + container.appendChild(editorDom); + document.body.appendChild(container); + + mockEditorView = { + dom: editorDom, + state: { + doc: { + resolve: () => ({ + depth: 1, + node: () => ({ type: { name: 'table' } }), + before: () => 0, + }), + }, + tr: { setSelection: sinon.stub().returnsThis() }, + selection: { content: () => ({ content: {} }) }, + }, + dispatch: sinon.stub(), + posAtDOM: () => 0, + }; + }); + + afterEach(() => { + container.remove(); + }); + + it('shows handle on mouseover of tableWrapper', () => { + const viewReturn = plugin.spec.view(mockEditorView); + + const tableWrapper = document.createElement('div'); + tableWrapper.className = 'tableWrapper'; + const table = document.createElement('table'); + tableWrapper.appendChild(table); + editorDom.appendChild(tableWrapper); + + const event = new MouseEvent('mouseover', { + bubbles: true, + target: tableWrapper, + }); + Object.defineProperty(event, 'target', { value: tableWrapper }); + editorDom.dispatchEvent(event); + + const handle = container.querySelector('.table-select-handle'); + expect(handle.classList.contains('is-visible')).to.be.true; + + viewReturn.destroy(); + }); + }); +});