From e0a85215662ba86b605bf5ec0e592fdf96fd1ecc Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Wed, 25 Feb 2026 22:31:01 +0000 Subject: [PATCH] enhance columns --- .../components/columns/columns-menu.tsx | 98 ++++++++++++++++++- .../src/features/editor/styles/columns.css | 12 ++- .../editor-ext/src/lib/columns/columns.ts | 45 ++++++++- 3 files changed, 149 insertions(+), 6 deletions(-) diff --git a/apps/client/src/features/editor/components/columns/columns-menu.tsx b/apps/client/src/features/editor/components/columns/columns-menu.tsx index 5c9e7607..b0c94c90 100644 --- a/apps/client/src/features/editor/components/columns/columns-menu.tsx +++ b/apps/client/src/features/editor/components/columns/columns-menu.tsx @@ -1,7 +1,7 @@ import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react"; -import React, { useCallback, useState } from "react"; -import { Node as PMNode } from "prosemirror-model"; +import React, { useCallback, useRef, useState } from "react"; +import { DOMSerializer, Node as PMNode } from "prosemirror-model"; import { EditorMenuProps, ShouldShowProps, @@ -16,6 +16,8 @@ import { IconLayoutSidebar, IconLayoutSidebarRight, IconLayoutAlignCenter, + IconCopy, + IconTrash, } from "@tabler/icons-react"; import { isTextSelected } from "@docmost/editor-ext"; import type { WidthMode, ColumnsLayout } from "@docmost/editor-ext"; @@ -67,6 +69,8 @@ function getPresetsForCount(count: number): LayoutPreset[] { export function ColumnsMenu({ editor }: EditorMenuProps) { const { t } = useTranslation(); const [isCountOpen, setIsCountOpen] = useState(false); + const [copied, setCopied] = useState(false); + const copyTimerRef = useRef>(); const nodesWithMenus = [ "callout", @@ -187,6 +191,68 @@ export function ColumnsMenu({ editor }: EditorMenuProps) { [editor], ); + const handleCopy = useCallback(() => { + const { state } = editor; + const parent = findParentNode( + (node: PMNode) => node.type.name === "columns", + )(state.selection); + if (!parent) return; + + const serializer = DOMSerializer.fromSchema(state.schema); + const dom = serializer.serializeNode(parent.node); + const wrapper = document.createElement("div"); + wrapper.appendChild(dom); + + const onSuccess = () => { + clearTimeout(copyTimerRef.current); + setCopied(true); + copyTimerRef.current = setTimeout(() => setCopied(false), 1500); + }; + + if (navigator.clipboard?.write) { + navigator.clipboard + .write([ + new ClipboardItem({ + "text/html": new Blob([wrapper.innerHTML], { type: "text/html" }), + "text/plain": new Blob([parent.node.textContent], { + type: "text/plain", + }), + }), + ]) + .then(onSuccess) + .catch(execCommandFallback); + } else { + execCommandFallback(); + } + + function execCommandFallback() { + wrapper.style.position = "fixed"; + wrapper.style.left = "-9999px"; + document.body.appendChild(wrapper); + const range = document.createRange(); + range.selectNodeContents(wrapper); + const sel = window.getSelection(); + sel?.removeAllRanges(); + sel?.addRange(range); + document.execCommand("copy"); + sel?.removeAllRanges(); + document.body.removeChild(wrapper); + editor.view.focus(); + onSuccess(); + } + }, [editor]); + + const handleDelete = useCallback(() => { + const { state } = editor; + const parent = findParentNode( + (node: PMNode) => node.type.name === "columns", + )(state.selection); + if (!parent) return; + const { tr } = state; + tr.delete(parent.pos, parent.pos + parent.node.nodeSize); + editor.view.dispatch(tr); + }, [editor]); + const columnCount = editorState?.columnCount || 2; const currentLayout = editorState?.layout || "two_equal"; const presets = getPresetsForCount(columnCount); @@ -259,6 +325,34 @@ export function ColumnsMenu({ editor }: EditorMenuProps) { ))} + +
+ + + + {copied ? ( + + ) : ( + + )} + + + + + + + +
); diff --git a/apps/client/src/features/editor/styles/columns.css b/apps/client/src/features/editor/styles/columns.css index fac034f6..ff836067 100644 --- a/apps/client/src/features/editor/styles/columns.css +++ b/apps/client/src/features/editor/styles/columns.css @@ -1,12 +1,17 @@ div[data-type="columns"] { display: flex; margin: 0.75rem 0; - padding: 0.5em; + padding: 0.5em 0; } div[data-type="columns"] > div[data-type="column"] { flex: 1; min-width: 0; + padding-right: 1rem; +} + +div[data-type="columns"] > div[data-type="column"]:last-child { + padding-right: 0; } div[data-type="columns"] > div[data-type="column"] + div[data-type="column"] { @@ -16,6 +21,9 @@ div[data-type="columns"] > div[data-type="column"] + div[data-type="column"] { } div[data-type="columns"]:hover + > div[data-type="column"] + + div[data-type="column"], +div[data-type="columns"].has-focus > div[data-type="column"] + div[data-type="column"] { border-left: 1px solid @@ -66,7 +74,7 @@ div[data-type="columns"][data-layout="three_with_sidebars"] } /* Stack columns vertically on small viewports */ -@media (max-width: 820px) { +@media (max-width: 680px) { div[data-type="columns"] { flex-direction: column; } diff --git a/packages/editor-ext/src/lib/columns/columns.ts b/packages/editor-ext/src/lib/columns/columns.ts index f2682a73..9b5c93d1 100644 --- a/packages/editor-ext/src/lib/columns/columns.ts +++ b/packages/editor-ext/src/lib/columns/columns.ts @@ -1,6 +1,7 @@ import { Node, mergeAttributes, findParentNode } from "@tiptap/core"; import { Fragment, Node as PMNode } from "prosemirror-model"; -import { TextSelection } from "prosemirror-state"; +import { Plugin, PluginKey, TextSelection } from "prosemirror-state"; +import { Decoration, DecorationSet } from "prosemirror-view"; export type ColumnsLayout = | "two_equal" @@ -173,7 +174,21 @@ export const Columns = Node.create({ } let mergedContent = columnsNode.child(count - 1).content; for (let j = count; j < currentCount; j++) { - mergedContent = mergedContent.append(columnsNode.child(j).content); + const col = columnsNode.child(j); + const nonEmpty: PMNode[] = []; + col.content.forEach((child) => { + if ( + child.type.name !== "paragraph" || + child.content.size > 0 + ) { + nonEmpty.push(child); + } + }); + if (nonEmpty.length > 0) { + mergedContent = mergedContent.append( + Fragment.from(nonEmpty), + ); + } } newChildren.push(columnType.create(null, mergedContent)); } @@ -184,6 +199,9 @@ export const Columns = Node.create({ Fragment.from(newChildren), ); tr.replaceWith(parentPos, parentPos + columnsNode.nodeSize, newNode); + tr.setSelection( + TextSelection.near(tr.doc.resolve(parentPos + 1), 1), + ); return true; }, @@ -193,4 +211,27 @@ export const Columns = Node.create({ commands.updateAttributes("columns", { layout }), }; }, + + addProseMirrorPlugins() { + return [ + new Plugin({ + key: new PluginKey("columnsFocus"), + props: { + decorations: (state) => { + const parent = findParentNode( + (node) => node.type.name === "columns", + )(state.selection); + if (!parent) return DecorationSet.empty; + return DecorationSet.create(state.doc, [ + Decoration.node( + parent.pos, + parent.pos + parent.node.nodeSize, + { class: "has-focus" }, + ), + ]); + }, + }, + }), + ]; + }, });