diff --git a/packages/super-editor/src/assets/styles/extensions/_all.css b/packages/super-editor/src/assets/styles/extensions/_all.css index 7ea72b9a82..2435cec8ec 100644 --- a/packages/super-editor/src/assets/styles/extensions/_all.css +++ b/packages/super-editor/src/assets/styles/extensions/_all.css @@ -3,3 +3,4 @@ @import './placeholder.css'; @import './mention.css'; @import './comments.css'; +@import './noderesizer.css'; diff --git a/packages/super-editor/src/assets/styles/extensions/noderesizer.css b/packages/super-editor/src/assets/styles/extensions/noderesizer.css new file mode 100644 index 0000000000..3f9990925a --- /dev/null +++ b/packages/super-editor/src/assets/styles/extensions/noderesizer.css @@ -0,0 +1,87 @@ +/* Resize handles container */ +.sd-editor-resize-container { + position: absolute; + pointer-events: none; + z-index: 11; +} + +/* Resize handles */ +.sd-editor-resize-handle { + position: absolute; + width: 12px; + height: 12px; + background-color: #4dabf7; + border: 2px solid #fff; + border-radius: 50%; + box-shadow: 0 0 4px rgba(0, 0, 0, 0.3); + pointer-events: auto; + transition: all 0.1s ease; +} + +.sd-editor-resize-handle:hover { + background-color: #228be6; + transform: scale(1.1); + box-shadow: 0 0 6px rgba(0, 0, 0, 0.4); +} + +/* Handle positions */ +.sd-editor-resize-handle-nw { + top: -6px; + left: -6px; + cursor: nwse-resize; +} + +.sd-editor-resize-handle-ne { + top: -6px; + right: -6px; + cursor: nesw-resize; +} + +.sd-editor-resize-handle-sw { + bottom: -6px; + left: -6px; + cursor: nesw-resize; +} + +.sd-editor-resize-handle-se { + bottom: -6px; + right: -6px; + cursor: nwse-resize; +} + +/* Hide handles when editor loses focus */ +.ProseMirror:not(.ProseMirror-focused) .sd-editor-resize-container { + display: none; +} + +/* Smooth transitions for resizing */ +.sd-editor-resizable-wrapper * { + transition: none; +} + +.sd-editor-resizable-wrapper *:not([style*='width']) { + transition: all 0.2s ease; +} + +/* Resize feedback indicator */ +.sd-editor-resizable-wrapper::after { + content: 'Drag corners to resize'; + position: absolute; + bottom: -25px; + left: 50%; + transform: translateX(-50%); + background-color: rgba(77, 171, 247, 0.9); + color: white; + font-size: 11px; + padding: 4px 8px; + border-radius: 4px; + white-space: nowrap; + pointer-events: none; + opacity: 0; + transition: opacity 0.3s ease; + z-index: 12; +} + +.sd-editor-resizable-wrapper:hover::after { + opacity: 1; +} diff --git a/packages/super-editor/src/extensions/image/image.js b/packages/super-editor/src/extensions/image/image.js index 2c971366bc..8bec8cf47d 100644 --- a/packages/super-editor/src/extensions/image/image.js +++ b/packages/super-editor/src/extensions/image/image.js @@ -1,4 +1,4 @@ -import { Node, Attribute } from '@core/index.js'; +import { Attribute, Node } from '@core/index.js'; import { ImagePlaceholderPlugin } from './imageHelpers/imagePlaceholderPlugin.js'; import { ImagePositionPlugin } from './imageHelpers/imagePositionPlugin.js'; @@ -41,7 +41,7 @@ export const Image = Node.create({ alt: { default: null, }, - + id: { rendered: false }, title: { @@ -64,7 +64,7 @@ export const Image = Node.create({ default: null, rendered: false, }, - + isAnchor: { rendered: false }, simplePos: { rendered: false }, wrapText: { rendered: false }, @@ -73,17 +73,17 @@ export const Image = Node.create({ default: {}, renderDOM: ({ size }) => { let style = ''; - let { width, height } = size ?? {}; + const { width, height } = size ?? {}; if (width) style += `width: ${width}px;`; - if (height) style += `height: auto;`; + if (height) style += 'height: auto;'; return { style }; }, }, - + padding: { default: {}, renderDOM: ({ padding, marginOffset }) => { - let { left = 0, top = 0, bottom = 0, right = 0 } = padding ?? {}; + const { left = 0, top = 0, bottom = 0, right = 0 } = padding ?? {}; let style = ''; if (left && !marginOffset?.left) style += `margin-left: ${left}px;`; if (top && !marginOffset?.top) style += `margin-top: ${top}px;`; @@ -96,7 +96,7 @@ export const Image = Node.create({ marginOffset: { default: {}, renderDOM: ({ marginOffset }) => { - let { left = 0, top = 0 } = marginOffset ?? {}; + const { left = 0, top = 0 } = marginOffset ?? {}; let style = ''; if (left) style += `margin-left: ${left}px;`; if (top) style += `margin-top: ${top}px;`; @@ -141,6 +141,6 @@ export const Image = Node.create({ }, addPmPlugins() { - return [ImagePlaceholderPlugin(), ImagePositionPlugin({editor: this.editor })]; + return [ImagePlaceholderPlugin(), ImagePositionPlugin({ editor: this.editor })]; }, }); diff --git a/packages/super-editor/src/extensions/image/imageHelpers/imagePositionPlugin.js b/packages/super-editor/src/extensions/image/imageHelpers/imagePositionPlugin.js index 9aaefa815f..a5deb75af0 100644 --- a/packages/super-editor/src/extensions/image/imageHelpers/imagePositionPlugin.js +++ b/packages/super-editor/src/extensions/image/imageHelpers/imagePositionPlugin.js @@ -28,10 +28,7 @@ export const ImagePositionPlugin = ({ editor }) => { if (shouldUpdate) { shouldUpdate = false; const decorations = getImagePositionDecorations(lastState, view); - const updateTransaction = view.state.tr.setMeta( - ImagePositionPluginKey, - { decorations } - ); + const updateTransaction = view.state.tr.setMeta(ImagePositionPluginKey, { decorations }); view.dispatch(updateTransaction); } if (pagination?.isReadyToInit) { @@ -57,7 +54,7 @@ const getImagePositionDecorations = (state, view) => { let className = ''; const { vRelativeFrom, alignH } = node.attrs.anchorData; const { size, padding } = node.attrs; - + const pageBreak = findPreviousDomNodeWithClass(view, pos, 'pagination-break-wrapper'); if (pageBreak) { switch (alignH) { @@ -71,8 +68,11 @@ const getImagePositionDecorations = (state, view) => { style += 'display: block; margin-left: auto; margin-right: auto; '; break; } - style += vRelativeFrom === 'margin' ? `position: absolute; top: ${pageBreak?.offsetTop + pageBreak?.offsetHeight}px; ` : ''; - + style += + vRelativeFrom === 'margin' + ? `position: absolute; top: ${pageBreak?.offsetTop + pageBreak?.offsetHeight}px; ` + : ''; + if (vRelativeFrom === 'margin') { const nextPos = view.posAtDOM(pageBreak, 1); const imageBlock = document.createElement('div'); @@ -94,7 +94,7 @@ const getImagePositionDecorations = (state, view) => { const findPreviousDomNodeWithClass = (view, pos, className) => { let { node } = view.domAtPos(pos); - + // If you get a text node, go to its parent if (node.nodeType === 3) { node = node.parentNode; @@ -117,4 +117,4 @@ const findPreviousDomNodeWithClass = (view, pos, className) => { } return null; // Not found -} +}; diff --git a/packages/super-editor/src/extensions/image/imageHelpers/index.js b/packages/super-editor/src/extensions/image/imageHelpers/index.js index 10863837bc..61bf200623 100644 --- a/packages/super-editor/src/extensions/image/imageHelpers/index.js +++ b/packages/super-editor/src/extensions/image/imageHelpers/index.js @@ -3,3 +3,4 @@ export * from './startImageUpload.js'; export * from './handleImageUpload.js'; export * from './imagePlaceholderPlugin.js'; export * from './processUploadedImage.js'; +export * from './imagePositionPlugin.js'; diff --git a/packages/super-editor/src/extensions/image/imageHelpers/processUploadedImage.js b/packages/super-editor/src/extensions/image/imageHelpers/processUploadedImage.js index 30d93a4248..5d918f8fb4 100644 --- a/packages/super-editor/src/extensions/image/imageHelpers/processUploadedImage.js +++ b/packages/super-editor/src/extensions/image/imageHelpers/processUploadedImage.js @@ -9,18 +9,14 @@ export const processUploadedImage = (fileData, editor) => { img.onload = () => { const canvas = document.createElement('canvas'); - const { width, height } = getAllowedImageDimensions( - img.width, - img.height, - editor, - ); + const { width, height } = getAllowedImageDimensions(img.width, img.height, editor); canvas.width = width; canvas.height = height; const ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0, width, height); - if (typeof fileData === 'string' ) { + if (typeof fileData === 'string') { const resizedBase64 = canvas.toDataURL(); resolve(resizedBase64); } else { @@ -47,13 +43,13 @@ export const getAllowedImageDimensions = (width, height, editor) => { const aspectRatio = width / height; if (height > maxHeight) { - adjustedHeight = maxHeight; - adjustedWidth = Math.round(maxHeight * aspectRatio); + adjustedHeight = maxHeight; + adjustedWidth = Math.round(maxHeight * aspectRatio); } if (adjustedWidth > maxWidth) { - adjustedWidth = maxWidth; - adjustedHeight = Math.round(maxWidth / aspectRatio); + adjustedWidth = maxWidth; + adjustedHeight = Math.round(maxWidth / aspectRatio); } return { width: adjustedWidth, height: adjustedHeight }; diff --git a/packages/super-editor/src/extensions/image/index.js b/packages/super-editor/src/extensions/image/index.js index cf09068aaa..4ba10ca8a2 100644 --- a/packages/super-editor/src/extensions/image/index.js +++ b/packages/super-editor/src/extensions/image/index.js @@ -1 +1,2 @@ export * from './image'; +export * from './imageHelpers'; diff --git a/packages/super-editor/src/extensions/index.js b/packages/super-editor/src/extensions/index.js index be0f8e5038..360a56b7e7 100644 --- a/packages/super-editor/src/extensions/index.js +++ b/packages/super-editor/src/extensions/index.js @@ -58,6 +58,7 @@ import { TrackChanges } from './track-changes/index.js'; import { Pagination } from './pagination/index.js'; import { LinkedStyles } from './linked-styles/linked-styles.js'; import { Search } from './search/index.js'; +import { NodeResizer } from './noderesizer/index.js'; // Helpers import { trackChangesHelpers } from './track-changes/index.js'; @@ -100,6 +101,7 @@ const getRichTextExtensions = () => { TrackFormat, AiPlugin, Image, + NodeResizer, ]; }; @@ -165,6 +167,7 @@ const getStarterExtensions = () => { ContentBlock, Search, StructuredContent, + NodeResizer, ]; }; @@ -227,4 +230,5 @@ export { AiPlugin, Search, StructuredContent, + NodeResizer, }; diff --git a/packages/super-editor/src/extensions/noderesizer/index.js b/packages/super-editor/src/extensions/noderesizer/index.js new file mode 100644 index 0000000000..91fd9cee04 --- /dev/null +++ b/packages/super-editor/src/extensions/noderesizer/index.js @@ -0,0 +1 @@ +export * from './noderesizer.js'; diff --git a/packages/super-editor/src/extensions/noderesizer/noderesizer.js b/packages/super-editor/src/extensions/noderesizer/noderesizer.js new file mode 100644 index 0000000000..a6375d0e56 --- /dev/null +++ b/packages/super-editor/src/extensions/noderesizer/noderesizer.js @@ -0,0 +1,315 @@ +import { Plugin, PluginKey } from 'prosemirror-state'; +import { Decoration, DecorationSet } from 'prosemirror-view'; +import { Extension } from '@core/Extension.js'; + +/** + * Plugin key for the resize plugin + */ +export const NodeResizerKey = new PluginKey('node-resizer'); + +const nodeResizer = (nodeNames = ['image']) => { + // Track the resize state + let resizeState = { + dragging: false, + startX: 0, + startY: 0, + startWidth: 0, + startHeight: 0, + handle: null, + pos: null, + resizableElement: null, + aspectRatio: 1, + }; + + // Store references to resize handles container and editor view + let resizeContainer = null; + let editorView = null; + let globalClickHandler = null; + let globalMousedownHandler = null; + + return new Plugin({ + key: NodeResizerKey, + + state: { + init() { + return DecorationSet.empty; + }, + + apply(tr, oldState, _, newState) { + // Skip if the transaction is from this plugin + if (tr.getMeta(NodeResizerKey)) { + return oldState; + } + + const decorations = []; + const { selection } = newState; + + // Only create decoration if one of the resizable nodes is selected + if (nodeNames.includes(selection.node?.type.name)) { + decorations.push( + Decoration.node(selection.from, selection.to, { + nodeName: 'span', + class: 'sd-editor-resizable-wrapper', + 'data-pos': selection.from, + }), + ); + } + + return DecorationSet.create(newState.doc, decorations); + }, + }, + + props: { + decorations(state) { + return this.getState(state); + }, + }, + + view(view) { + editorView = view; + + // Add global click handler + globalClickHandler = (event) => { + if ( + !event.target.closest('.sd-editor-resizable-wrapper') && + !event.target.closest('.sd-editor-resize-container') + ) { + hideResizeHandles(); + } + }; + + document.addEventListener('click', globalClickHandler); + + // Add global mousedown handler + globalMousedownHandler = (event) => { + if (event.target.closest('.sd-editor-resize-handle')) { + event.preventDefault(); + event.stopPropagation(); + startResize(editorView, event, event.target); + return true; + } + }; + + document.addEventListener('mousedown', globalMousedownHandler); + + return { + update(view, prevState) { + // Show/hide resize handles based on selection changes + const { selection } = view.state; + const prevSelection = prevState.selection; + + if (selection.from !== prevSelection.from || selection.to !== prevSelection.to) { + setTimeout(() => { + const selectedResizableWrapper = document.querySelector('.sd-editor-resizable-wrapper'); + if (selectedResizableWrapper) { + showResizeHandles(view, selectedResizableWrapper); + } else { + hideResizeHandles(); + } + }, 10); + } + }, + + destroy() { + hideResizeHandles(); + cleanupEventListeners(); + if (globalClickHandler) { + document.removeEventListener('click', globalClickHandler); + globalClickHandler = null; + } + if (globalMousedownHandler) { + document.removeEventListener('mousedown', globalMousedownHandler); + globalMousedownHandler = null; + } + editorView = null; + }, + }; + }, + }); + + function showResizeHandles(view, wrapper) { + hideResizeHandles(); + + const pos = Number.parseInt(wrapper.getAttribute('data-pos'), 10); + + const node = view.state.doc.nodeAt(pos); + if (!nodeNames.includes(node?.type.name)) return; + + // Create resize container + resizeContainer = document.createElement('div'); + resizeContainer.className = 'sd-editor-resize-container'; + resizeContainer.style.position = 'absolute'; + resizeContainer.style.pointerEvents = 'none'; + resizeContainer.style.zIndex = '1000'; + + // Create handles + const handles = ['nw', 'ne', 'sw', 'se']; + for (const handle of handles) { + const handleEl = document.createElement('div'); + handleEl.className = `sd-editor-resize-handle sd-editor-resize-handle-${handle}`; + handleEl.setAttribute('data-handle', handle); + handleEl.setAttribute('data-pos', pos); + handleEl.style.pointerEvents = 'auto'; + resizeContainer.appendChild(handleEl); + } + + // Position the container relative to the resizable element + document.body.appendChild(resizeContainer); + updateHandlePositions(wrapper.firstElementChild); + } + + function hideResizeHandles() { + if (resizeContainer?.parentNode) { + resizeContainer.parentNode.removeChild(resizeContainer); + resizeContainer = null; + } + } + + function updateHandlePositions(resizableElement) { + if (!resizeContainer || !resizableElement) return; + + const rect = resizableElement.getBoundingClientRect(); + const scrollTop = window.pageYOffset || document.documentElement.scrollTop; + const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft; + + resizeContainer.style.left = `${rect.left + scrollLeft}px`; + resizeContainer.style.top = `${rect.top + scrollTop}px`; + resizeContainer.style.width = `${rect.width}px`; + resizeContainer.style.height = `${rect.height}px`; + } + + function startResize(view, event, handleElement) { + if (!view.hasFocus()) return; + const handle = handleElement.getAttribute('data-handle'); + const pos = Number.parseInt(handleElement.getAttribute('data-pos'), 10); + if (view.state.selection.from !== pos || !nodeNames.includes(view.state.selection.node?.type.name)) return; + + const resizableElement = view.nodeDOM(pos); + + if (!resizableElement) return; + + const rect = resizableElement.getBoundingClientRect(); + + resizeState = { + dragging: true, + startX: event.clientX, + startY: event.clientY, + startWidth: rect.width, + startHeight: rect.height, + handle, + pos, + resizableElement, + aspectRatio: rect.width / rect.height, + }; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + document.body.style.cursor = getResizeCursor(handle); + document.body.style.userSelect = 'none'; + } + + function handleMouseMove(event) { + if (!resizeState.dragging) return; + + event.preventDefault(); + event.stopPropagation(); + + let deltaX = event.clientX - resizeState.startX; + let deltaY = event.clientY - resizeState.startY; + + // Adjust delta based on handle position + if (resizeState.handle.includes('w')) deltaX = -deltaX; + if (resizeState.handle.includes('n')) deltaY = -deltaY; + + // Calculate new dimensions maintaining aspect ratio + const newWidth = Math.max(20, resizeState.startWidth + deltaX); + + // Apply the new size immediately for visual feedback + if (resizeState.resizableElement) { + resizeState.resizableElement.style.width = `${newWidth}px`; + resizeState.resizableElement.style.height = 'auto'; + + // Update handle positions + updateHandlePositions(resizeState.resizableElement); + } + } + + function handleMouseUp(event) { + if (!resizeState.dragging) return; + + cleanupEventListeners(); + + let deltaX = event.clientX - resizeState.startX; + let deltaY = event.clientY - resizeState.startY; + + // Adjust delta based on handle position + if (resizeState.handle.includes('w')) deltaX = -deltaX; + if (resizeState.handle.includes('n')) deltaY = -deltaY; + + // Calculate final dimensions + const newWidth = Math.max(20, resizeState.startWidth + deltaX); + const newHeight = newWidth / resizeState.aspectRatio; + + // Update the document + if (editorView && resizeState.pos < editorView.state.doc.content.size) { + const tr = editorView.state.tr; + const node = tr.doc.nodeAt(resizeState.pos); + + if (nodeNames.includes(node?.type.name)) { + const attrs = { + ...node.attrs, + size: { + ...node.attrs.size, + width: Math.round(newWidth), + height: Math.round(newHeight), + }, + }; + + tr.setNodeMarkup(resizeState.pos, null, attrs); + tr.setMeta(NodeResizerKey, { action: 'resize' }); + editorView.dispatch(tr); + } + } + + // Reset resize state + resizeState = { + dragging: false, + startX: 0, + startY: 0, + startWidth: 0, + startHeight: 0, + handle: null, + pos: null, + resizableElement: null, + aspectRatio: 1, + }; + } + + function cleanupEventListeners() { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + } + + function getResizeCursor(handle) { + switch (handle) { + case 'nw': + case 'se': + return 'nwse-resize'; + case 'ne': + case 'sw': + return 'nesw-resize'; + default: + return 'default'; + } + } +}; + +export const NodeResizer = Extension.create({ + name: 'nodeResizer', + + addPmPlugins() { + return [nodeResizer(['image'])]; + }, +}); diff --git a/packages/super-editor/src/extensions/popover-plugin/popover-plugin.js b/packages/super-editor/src/extensions/popover-plugin/popover-plugin.js index 3ea0b38b66..3c9c053572 100644 --- a/packages/super-editor/src/extensions/popover-plugin/popover-plugin.js +++ b/packages/super-editor/src/extensions/popover-plugin/popover-plugin.js @@ -1,5 +1,5 @@ import { createApp } from 'vue'; -import { Plugin, PluginKey, TextSelection } from 'prosemirror-state'; +import { Plugin, PluginKey } from 'prosemirror-state'; import { Extension } from '@core/Extension.js'; import tippy from 'tippy.js'; diff --git a/packages/superdoc/CHANGELOG.md b/packages/superdoc/CHANGELOG.md index 039fcf3303..966a85375c 100644 --- a/packages/superdoc/CHANGELOG.md +++ b/packages/superdoc/CHANGELOG.md @@ -14,7 +14,23 @@ ## [0.12.34-next.2](https://github.com/Harbour-Enterprises/SuperDoc/compare/v0.12.34-next.1...v0.12.34-next.2) (2025-06-06) -## [0.12.34-next.1](https://github.com/Harbour-Enterprises/SuperDoc/compare/v0.12.31-next.5...v0.12.34-next.1) (2025-06-06) + +### Features + +* added extra accessibility features ([6a0f9d8](https://github.com/Harbour-Enterprises/SuperDoc/commit/6a0f9d80ae281f21ebc39beb8a14be027ef24619)) + + + +## [0.12.33](https://github.com/Harbour-Enterprises/SuperDoc/compare/v0.12.34-next.2...v0.12.35) (2025-06-06) + + +### Features + +* (accessibility) simulate click with enter ([f9cd0b4](https://github.com/Harbour-Enterprises/SuperDoc/commit/f9cd0b4f5f8148ad0d0afad091288b32333f4cd3)) +* added initial toolbar keyboard navigation ([e37d79b](https://github.com/Harbour-Enterprises/SuperDoc/commit/e37d79bd284265f9b22f9a1ec23d05e58c21263a)) +* added roving tabindex ([6c77015](https://github.com/Harbour-Enterprises/SuperDoc/commit/6c770155532b805888d37bc91cb4d120129d541f)) + +## [0.12.33](https://github.com/Harbour-Enterprises/SuperDoc/compare/v0.12.31-next.5...v0.12.33) (2025-06-06) ## [0.12.31-next.5](https://github.com/Harbour-Enterprises/SuperDoc/compare/v0.12.31-next.4...v0.12.31-next.5) (2025-06-06)