diff --git a/apps/client/src/features/editor/components/slash-menu/menu-items.ts b/apps/client/src/features/editor/components/slash-menu/menu-items.ts index f56d7f04..89dd02e6 100644 --- a/apps/client/src/features/editor/components/slash-menu/menu-items.ts +++ b/apps/client/src/features/editor/components/slash-menu/menu-items.ts @@ -20,6 +20,7 @@ import { IconCalendar, IconAppWindow, IconSitemap, + IconLayoutColumns, } from "@tabler/icons-react"; import { CommandProps, @@ -243,6 +244,51 @@ const CommandGroups: SlashMenuGroupedItemsType = { .insertTable({ rows: 3, cols: 3, withHeaderRow: true }) .run(), }, + { + title: "Columns", + description: "Insert 2 columns layout.", + searchTerms: ["columns", "layout", "grid", "side by side"], + icon: IconLayoutColumns, + command: ({ editor, range }: CommandProps) => { + editor + .chain() + .focus() + .deleteRange(range) + .insertContent({ + type: "column_container", + content: [ + { + type: "column", + attrs: { colWidth: 200 }, + content: [ + { + type: "paragraph", + }, + ], + }, + { + type: "column", + attrs: { colWidth: 200 }, + content: [ + { + type: "paragraph", + }, + ], + }, + { + type: "column", + attrs: { colWidth: 200 }, + content: [ + { + type: "paragraph", + }, + ], + }, + ], + }) + .run(); + }, + }, { title: "Toggle block", description: "Insert collapsible block.", diff --git a/apps/client/src/features/editor/extensions/extensions.ts b/apps/client/src/features/editor/extensions/extensions.ts index ecdea2e7..5b7b521a 100644 --- a/apps/client/src/features/editor/extensions/extensions.ts +++ b/apps/client/src/features/editor/extensions/extensions.ts @@ -46,6 +46,7 @@ import { Heading, Highlight, UniqueID, + ColumnsExtension, } from "@docmost/editor-ext"; import { randomElement, @@ -229,6 +230,7 @@ export const mainExtensions = [ Subpages.configure({ view: SubpagesView, }), + ColumnsExtension, MarkdownClipboard.configure({ transformPastedText: true, }), diff --git a/apps/client/src/features/editor/styles/column.css b/apps/client/src/features/editor/styles/column.css new file mode 100644 index 00000000..8eb1fe16 --- /dev/null +++ b/apps/client/src/features/editor/styles/column.css @@ -0,0 +1,123 @@ +.resize-cursor { + cursor: col-resize; +} + +.prosemirror-column-container { + display: flex; + flex-direction: row; + width: calc(100% - 8px); + gap: 12px; + margin: 16px 0; +} + +.prosemirror-column-container.has-focus .prosemirror-column, +.prosemirror-column-container:hover .prosemirror-column { + background-color: rgba(100, 106, 115, 0.05); +} + +.prosemirror-column-container .prosemirror-column { + position: relative; + border-radius: 8px; + min-width: 50px; + padding: 12px; + background-color: transparent; + transition: background-color 0.2s ease; +} + +.prosemirror-column-container +.prosemirror-column +> :not(div.grid-resize-handle):nth-child(1), +.prosemirror-column-container +.prosemirror-column +> div.grid-resize-handle ++ :nth-child(2) { + margin-top: 0; +} + +.prosemirror-column-container .prosemirror-column > :nth-last-child(1) { + margin-bottom: 0; +} + +.prosemirror-column-container .prosemirror-column .grid-resize-handle { + position: absolute; + right: -7px; + top: 0; + bottom: 0; + width: 2px; + z-index: 20; + background-color: #336df4; + pointer-events: none; +} + +.prosemirror-column-container +.prosemirror-column +.grid-resize-handle +.circle-button { + top: -8px; + left: -9px; + width: 12px; + height: 12px; + background-color: #007bff; + border: 4px solid white; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + position: relative; + pointer-events: auto; + cursor: pointer; + transition: transform 0.1s ease-in-out; +} + +.prosemirror-column-container +.prosemirror-column +.grid-resize-handle +.circle-button:hover { + transform: scale(1.35); +} + +.prosemirror-column-container +.prosemirror-column +.grid-resize-handle +.circle-button +.plus { + position: relative; + width: 8px; + height: 8px; +} + +.prosemirror-column-container +.prosemirror-column +.grid-resize-handle +.circle-button +.plus::before, +.prosemirror-column-container +.prosemirror-column +.grid-resize-handle +.circle-button +.plus::after { + content: ''; + position: absolute; + background-color: white; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +} + +.prosemirror-column-container +.prosemirror-column +.grid-resize-handle +.circle-button +.plus::before { + width: 8px; + height: 2px; +} + +.prosemirror-column-container +.prosemirror-column +.grid-resize-handle +.circle-button +.plus::after { + width: 24px; + height: 8px; +} diff --git a/apps/client/src/features/editor/styles/index.css b/apps/client/src/features/editor/styles/index.css index e32a606f..e2ede9cb 100644 --- a/apps/client/src/features/editor/styles/index.css +++ b/apps/client/src/features/editor/styles/index.css @@ -13,3 +13,4 @@ @import "./mention.css"; @import "./ordered-list.css"; @import "./highlight.css"; +@import "./column.css"; diff --git a/apps/server/src/collaboration/collaboration.util.ts b/apps/server/src/collaboration/collaboration.util.ts index 06133c3f..b73492cf 100644 --- a/apps/server/src/collaboration/collaboration.util.ts +++ b/apps/server/src/collaboration/collaboration.util.ts @@ -35,6 +35,7 @@ import { Subpages, Highlight, UniqueID, + ColumnsExtension, addUniqueIdsToDoc, } from '@docmost/editor-ext'; import { generateText, getSchema, JSONContent } from '@tiptap/core'; @@ -88,6 +89,7 @@ export const tiptapExtensions = [ Embed, Mention, Subpages, + ColumnsExtension ] as any; export function jsonToHtml(tiptapJson: any) { diff --git a/packages/editor-ext/src/index.ts b/packages/editor-ext/src/index.ts index 3ff99083..48b4ee7a 100644 --- a/packages/editor-ext/src/index.ts +++ b/packages/editor-ext/src/index.ts @@ -23,3 +23,4 @@ export * from "./lib/subpages"; export * from "./lib/highlight"; export * from "./lib/heading/heading"; export * from "./lib/unique-id"; +export * from "./lib/columns"; diff --git a/packages/editor-ext/src/lib/columns/dom.ts b/packages/editor-ext/src/lib/columns/dom.ts new file mode 100644 index 00000000..a5c3f37a --- /dev/null +++ b/packages/editor-ext/src/lib/columns/dom.ts @@ -0,0 +1,152 @@ +import { EditorState } from '@tiptap/pm/state'; +import { Decoration, DecorationSet, EditorView } from '@tiptap/pm/view'; +import { gridResizingPluginKey } from './state'; +import { + draggedWidth, + findBoundaryPosition, + getColumnInfoAtPos, + updateColumnNodeWidth, +} from './utils'; + +function updateActiveHandle(view: EditorView, value: number) { + view.dispatch( + view.state.tr.setMeta(gridResizingPluginKey, { + setHandle: value, + }), + ); +} + +export function handleMouseMove( + view: EditorView, + event: MouseEvent, + handleWidth: number, +): boolean { + const pluginState = gridResizingPluginKey.getState(view.state); + if (!pluginState) return false; + + // TODO: limit call? + + if (pluginState.dragging) return false; + + const boundaryPos = findBoundaryPosition(view, event, handleWidth); + if (boundaryPos !== pluginState.activeHandle) { + updateActiveHandle(view, boundaryPos); + } + return false; +} + +export function handleMouseLeave(view: EditorView) { + const pluginState = gridResizingPluginKey.getState(view.state); + if (!pluginState) return false; + if (pluginState.activeHandle > -1 && !pluginState.dragging) { + updateActiveHandle(view, -1); + } + return false; +} + +export function handleMouseDown( + view: EditorView, + event: MouseEvent, + columnMinWidth: number, +): boolean { + const pluginState = gridResizingPluginKey.getState(view.state); + if (!pluginState) return false; + if (pluginState.activeHandle === -1) return false; + if (pluginState.dragging) return false; + + const columnInfo = getColumnInfoAtPos(view, pluginState.activeHandle); + if (!columnInfo) return false; + + const { domWidth, $pos, node } = columnInfo; + const nodeAttrs = { ...(node.attrs || {}) }; + const nodePos = $pos.before(); + + view.dispatch( + view.state.tr.setMeta(gridResizingPluginKey, { + setDragging: { startX: event.clientX, startWidth: domWidth }, + }), + ); + + const win = view.dom.ownerDocument.defaultView || window; + + const finish = (e: MouseEvent) => { + win.removeEventListener('mouseup', finish); + win.removeEventListener('mousemove', move); + + const pluginState = gridResizingPluginKey.getState(view.state); + if (!pluginState?.dragging) return; + + const finalWidth = draggedWidth(pluginState.dragging, e, columnMinWidth); + updateColumnNodeWidth(view, nodePos, nodeAttrs, finalWidth); + view.dispatch( + view.state.tr.setMeta(gridResizingPluginKey, { + setDragging: null, + }), + ); + }; + + const move = (e: MouseEvent) => { + if (!e.buttons) { + finish(e); + return; + } + const pluginState = gridResizingPluginKey.getState(view.state); + if (!pluginState?.dragging) return; + + const newWidth = draggedWidth(pluginState.dragging, e, columnMinWidth); + updateColumnNodeWidth(view, nodePos, nodeAttrs, newWidth); + }; + + win.addEventListener('mouseup', finish); + win.addEventListener('mousemove', move); + + updateColumnNodeWidth(view, nodePos, nodeAttrs, domWidth); + + event.preventDefault(); + return true; +} + +export function handleGridDecorations( + state: EditorState, + boundaryPos: number, +): DecorationSet { + const decorations = []; + const $pos = state.doc.resolve(boundaryPos); + if ($pos.nodeAfter !== null) { + const widget = document.createElement('div'); + widget.className = 'grid-resize-handle'; + const circleButton = document.createElement('div'); + circleButton.className = 'circle-button'; + widget.appendChild(circleButton); + const plusIcon = document.createElement('div'); + plusIcon.className = 'plus'; + circleButton.appendChild(plusIcon); + decorations.push(Decoration.widget(boundaryPos, widget)); + } + return DecorationSet.create(state.doc, decorations); +} + +export function handleMouseUp(view: EditorView, event: MouseEvent): boolean { + const div = event.target as HTMLElement; + if (!div) return false; + if (div.className !== 'circle-button' && div.className !== 'plus') + return false; + const column = div.closest('.prosemirror-column'); + if (!column) return false; + const boundryPos = view.posAtDOM(column, 0); + if (!boundryPos) return false; + const $pos = view.state.doc.resolve(boundryPos); + const { state } = view; + view.dispatch( + state.tr.insert( + $pos.pos + $pos.parent.nodeSize - 1, + state.schema.nodes.column.create( + { + colWidth: 100, + }, + state.schema.nodes.paragraph.create(), + ), + ), + ); + return true; +} diff --git a/packages/editor-ext/src/lib/columns/index.ts b/packages/editor-ext/src/lib/columns/index.ts new file mode 100644 index 00000000..88f1271d --- /dev/null +++ b/packages/editor-ext/src/lib/columns/index.ts @@ -0,0 +1,4 @@ +export * from './schema'; +export * from './resize'; +export * from './keymap'; +export * from './tiptap'; diff --git a/packages/editor-ext/src/lib/columns/keymap.ts b/packages/editor-ext/src/lib/columns/keymap.ts new file mode 100644 index 00000000..62b0b364 --- /dev/null +++ b/packages/editor-ext/src/lib/columns/keymap.ts @@ -0,0 +1,63 @@ +import { liftTarget, canSplit } from "@tiptap/pm/transform"; +import { TextSelection, Command } from "@tiptap/pm/state"; +import { + splitBlock, + chainCommands, + newlineInCode, + createParagraphNear, +} from "@tiptap/pm/commands"; +import { keymap } from "@tiptap/pm/keymap"; +import { ResolvedPos } from "@tiptap/pm/model"; + +function findParentColumn($pos: ResolvedPos) { + for (let depth = $pos.depth; depth > 0; depth--) { + const node = $pos.node(depth); + if (node.type.name === "column") { + return { node, depth }; + } + } + return null; +} + +export const liftEmptyBlock: Command = (state, dispatch) => { + const { $cursor } = state.selection as TextSelection; + if (!$cursor || $cursor.parent.content.size) return false; + if ("column" === $cursor.node($cursor.depth - 1).type.name) return false; + if ($cursor.depth > 1 && $cursor.after() != $cursor.end(-1)) { + const before = $cursor.before(); + if (canSplit(state.doc, before)) { + if (dispatch) dispatch(state.tr.split(before).scrollIntoView()); + return true; + } + } + const range = $cursor.blockRange(), + target = range && liftTarget(range); + if (target == null) return false; + if (dispatch) dispatch(state.tr.lift(range!, target).scrollIntoView()); + return true; +}; + +export const columnsKeymap = keymap({ + Enter: chainCommands( + newlineInCode, + createParagraphNear, + liftEmptyBlock, + splitBlock, + ), + "Mod-a": (state, dispatch, view) => { + const { selection } = state; + const { $from } = selection; + const found = findParentColumn($from); + if (found) { + const { depth } = found; + const start = $from.start(depth); + const end = $from.end(depth); + const tr = state.tr.setSelection( + TextSelection.create(state.doc, start, end), + ); + if (dispatch) dispatch(tr); + return true; + } + return false; + }, +} as { [key: string]: Command }); diff --git a/packages/editor-ext/src/lib/columns/resize.ts b/packages/editor-ext/src/lib/columns/resize.ts new file mode 100644 index 00000000..587fa3a6 --- /dev/null +++ b/packages/editor-ext/src/lib/columns/resize.ts @@ -0,0 +1,62 @@ +import { Plugin } from '@tiptap/pm/state'; +import { + handleGridDecorations, + handleMouseDown, + handleMouseLeave, + handleMouseMove, + handleMouseUp, +} from './dom'; +import { GridResizeState, gridResizingPluginKey } from './state'; + +export function gridResizingPlugin(options?: { + handleWidth?: number; + columnMinWidth?: number; +}) { + const handleWidth = options?.handleWidth ?? 2; + const columnMinWidth = options?.columnMinWidth ?? 50; + + return new Plugin({ + key: gridResizingPluginKey, + + state: { + init: () => new GridResizeState(-1, false), + apply: (tr, prev) => { + return prev.apply(tr); + }, + }, + + props: { + attributes: (state): Record => { + const pluginState = gridResizingPluginKey.getState(state); + if (pluginState && pluginState.activeHandle > -1) { + return { class: 'resize-cursor' }; + } + return {}; + }, + + // The main event handlers + handleDOMEvents: { + mousemove: (view, event: MouseEvent) => { + return handleMouseMove(view, event, handleWidth); + }, + mouseleave: (view) => { + return handleMouseLeave(view); + }, + mousedown: (view, event: MouseEvent) => { + return handleMouseDown(view, event, columnMinWidth); + }, + mouseup: (view, event: MouseEvent) => { + return handleMouseUp(view, event); + }, + }, + + decorations: (state) => { + const pluginState = gridResizingPluginKey.getState(state); + if (!pluginState) return null; + if (pluginState.activeHandle === -1) return null; + + return handleGridDecorations(state, pluginState.activeHandle); + }, + }, + }); +} diff --git a/packages/editor-ext/src/lib/columns/schema.ts b/packages/editor-ext/src/lib/columns/schema.ts new file mode 100644 index 00000000..b87f9d10 --- /dev/null +++ b/packages/editor-ext/src/lib/columns/schema.ts @@ -0,0 +1,51 @@ +import { NodeSpec } from '@tiptap/pm/model'; + +export type ColumnNodes = Record<'column' | 'column_container', NodeSpec>; + +export function columnNodes(): ColumnNodes { + return { + column: { + group: 'block', + content: 'block+', + attrs: { + colWidth: { default: 200 }, + }, + parseDOM: [ + { + tag: 'div.prosemirror-column', + getAttrs(dom) { + if (!(dom instanceof HTMLElement)) return false; + const width = dom.style.width.replace('px', '') || 200; + return { + colWidth: width, + }; + }, + }, + ], + toDOM(node) { + const { colWidth } = node.attrs; + const style = colWidth ? `width: ${colWidth}px;` : ''; + return [ + 'div', + { + class: 'prosemirror-column', + style, + }, + 0, + ]; + }, + }, + column_container: { + group: 'block', + content: 'column+', + parseDOM: [ + { + tag: 'div.prosemirror-column-container', + }, + ], + toDOM() { + return ['div', { class: 'prosemirror-column-container' }, 0]; + }, + }, + }; +} diff --git a/packages/editor-ext/src/lib/columns/state.ts b/packages/editor-ext/src/lib/columns/state.ts new file mode 100644 index 00000000..c8ae72bf --- /dev/null +++ b/packages/editor-ext/src/lib/columns/state.ts @@ -0,0 +1,33 @@ +import { PluginKey, Transaction } from '@tiptap/pm/state'; + +export const gridResizingPluginKey = new PluginKey( + 'gridResizingPlugin', +); + +export type Dragging = { + startX: number; + startWidth: number; +}; + +export class GridResizeState { + constructor( + public activeHandle: number, + public dragging: Dragging | false, + ) {} + + apply(tr: Transaction): GridResizeState { + const action = tr.getMeta(gridResizingPluginKey); + if (!action) return this; + + if (typeof action.setHandle === 'number') { + return new GridResizeState(action.setHandle, false); + } + if (action.setDragging !== undefined) { + return new GridResizeState(this.activeHandle, action.setDragging); + } + if (this.activeHandle > -1 && tr.docChanged) { + // remap when doc changes + } + return this; + } +} diff --git a/packages/editor-ext/src/lib/columns/tiptap.ts b/packages/editor-ext/src/lib/columns/tiptap.ts new file mode 100644 index 00000000..1b4d9fb4 --- /dev/null +++ b/packages/editor-ext/src/lib/columns/tiptap.ts @@ -0,0 +1,84 @@ +import { Node, mergeAttributes, Extension } from '@tiptap/core'; +import { columnsKeymap } from './keymap'; +import { gridResizingPlugin } from './resize'; + +const Column = Node.create({ + name: 'column', + + group: 'block', + content: 'block+', + + addAttributes() { + return { + colWidth: { + default: 200, + parseHTML: (element) => { + const width = (element as HTMLElement).style.width.replace('px', ''); + return Number(width) || 200; + }, + renderHTML: (attributes) => { + const style = attributes.colWidth + ? `width: ${attributes.colWidth}px;` + : ''; + return { style }; + }, + }, + }; + }, + + parseHTML() { + return [ + { + tag: 'div.prosemirror-column', + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + 'div', + mergeAttributes(HTMLAttributes, { class: 'prosemirror-column' }), + 0, + ]; + }, +}); + +const ColumnContainer = Node.create({ + name: 'column_container', + + group: 'block', + content: 'column+', + + parseHTML() { + return [ + { + tag: 'div.prosemirror-column-container', + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + 'div', + mergeAttributes(HTMLAttributes, { + class: 'prosemirror-column-container', + }), + 0, + ]; + }, +}); + +export const ColumnsExtension = Extension.create({ + name: 'columns', + + addExtensions() { + return [Column, ColumnContainer]; + }, + + addProseMirrorPlugins() { + return [ + gridResizingPlugin({ handleWidth: 2, columnMinWidth: 50 }), + columnsKeymap, + ]; + }, +}); diff --git a/packages/editor-ext/src/lib/columns/utils.ts b/packages/editor-ext/src/lib/columns/utils.ts new file mode 100644 index 00000000..0e841388 --- /dev/null +++ b/packages/editor-ext/src/lib/columns/utils.ts @@ -0,0 +1,75 @@ +import { EditorView } from '@tiptap/pm/view'; +import { type Dragging } from './state'; + +export function findBoundaryPosition( + view: EditorView, + event: MouseEvent, + handleWidth: number, +): number { + const gridDOM = event + .composedPath() + .find((el) => + (el as HTMLElement).classList?.contains('prosemirror-column-container'), + ) as HTMLElement | undefined; + if (!gridDOM) return -1; + + const children = Array.from(gridDOM.children).filter((el) => + el.classList.contains('prosemirror-column'), + ); + for (let i = 0; i < children.length; i++) { + const colEl = children[i] as HTMLElement; + const rect = colEl.getBoundingClientRect(); + if ( + event.clientX >= rect.right - handleWidth - 2 && + event.clientX <= rect.right + 10 + handleWidth + ) { + const pos = view.posAtDOM(colEl, 0); + if (pos != null) { + return pos; + } + } + } + + return -1; +} + +export function draggedWidth( + dragging: Dragging, + event: MouseEvent, + minWidth: number, +): number { + const offset = event.clientX - dragging.startX; + return Math.max(minWidth, dragging.startWidth + offset); +} + +export function updateColumnNodeWidth( + view: EditorView, + pos: number, + attrs: Record, + width: number, +) { + view.dispatch( + view.state.tr.setNodeMarkup(pos, undefined, { + ...attrs, + colWidth: width - 12 * 2, + }), + ); +} + +export function getColumnInfoAtPos(view: EditorView, boundaryPos: number) { + const $pos = view.state.doc.resolve(boundaryPos); + const node = $pos.parent; + if (!node || node.type.name !== 'column') return null; + + const dom = view.domAtPos($pos.pos); + if (!dom.node) return null; + + const columnEl = + dom.node instanceof HTMLElement + ? dom.node + : (dom.node.childNodes[dom.offset] as HTMLElement); + + const domWidth = columnEl.offsetWidth; + + return { $pos, node, columnEl, domWidth }; +}