From 2d8b4704953ee0f0ce2c303b41b1cf747ff99419 Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Fri, 8 May 2026 21:40:37 +0100 Subject: [PATCH] feat(editor): indentation (#2174) * switch to default codeblock tab handling * feat(editor): indentation --- .../features/editor/extensions/extensions.ts | 13 +- .../src/features/editor/styles/indent.css | 14 ++ .../src/features/editor/styles/index.css | 1 + apps/server/package.json | 3 +- .../src/collaboration/collaboration.util.ts | 4 +- .../helpers/prosemirror/indent-schema.spec.ts | 63 +++++ apps/server/src/ee | 2 +- packages/editor-ext/src/index.ts | 1 + .../custom-code-block/custom-code-block.ts | 30 +-- packages/editor-ext/src/lib/indent.ts | 223 ++++++++++++++++++ 10 files changed, 328 insertions(+), 26 deletions(-) create mode 100644 apps/client/src/features/editor/styles/indent.css create mode 100644 apps/server/src/common/helpers/prosemirror/indent-schema.spec.ts create mode 100644 packages/editor-ext/src/lib/indent.ts diff --git a/apps/client/src/features/editor/extensions/extensions.ts b/apps/client/src/features/editor/extensions/extensions.ts index df1ca38d..23be85aa 100644 --- a/apps/client/src/features/editor/extensions/extensions.ts +++ b/apps/client/src/features/editor/extensions/extensions.ts @@ -10,7 +10,9 @@ import { Typography } from "@tiptap/extension-typography"; import { TextStyle } from "@tiptap/extension-text-style"; import { Color } from "@tiptap/extension-color"; import { Youtube } from "@tiptap/extension-youtube"; -import SlashCommand, { SlashCommandExtension as Command } from "@/features/editor/extensions/slash-command"; +import SlashCommand, { + SlashCommandExtension as Command, +} from "@/features/editor/extensions/slash-command"; import renderItems from "@/features/editor/components/slash-menu/render-items"; import getSuggestionItems from "@/features/editor/components/slash-menu/menu-items"; import { Collaboration, isChangeOrigin } from "@tiptap/extension-collaboration"; @@ -46,6 +48,7 @@ import { Subpages, Heading, Highlight, + Indent, UniqueID, SharedStorage, Columns, @@ -201,6 +204,7 @@ export const mainExtensions = [ showOnlyWhenEditable: true, }), TextAlign.configure({ types: ["heading", "paragraph"] }), + Indent, TaskList, TaskItem.configure({ nested: true, @@ -311,6 +315,8 @@ export const mainExtensions = [ view: CodeBlockView, //@ts-ignore lowlight, + enableTabIndentation: true, + tabSize: 2, HTMLAttributes: { spellcheck: false, }, @@ -405,7 +411,10 @@ const TEMPLATE_EXCLUDED_SLASH_ITEMS = new Set([ const TemplateSlashCommand = Command.configure({ suggestion: { items: ({ query }: { query: string }) => - getSuggestionItems({ query, excludeItems: TEMPLATE_EXCLUDED_SLASH_ITEMS }), + getSuggestionItems({ + query, + excludeItems: TEMPLATE_EXCLUDED_SLASH_ITEMS, + }), render: renderItems, }, }); diff --git a/apps/client/src/features/editor/styles/indent.css b/apps/client/src/features/editor/styles/indent.css new file mode 100644 index 00000000..cd2bd585 --- /dev/null +++ b/apps/client/src/features/editor/styles/indent.css @@ -0,0 +1,14 @@ +.ProseMirror { + --indent-step: 2rem; +} + +.ProseMirror [data-indent="1"] { padding-inline-start: calc(var(--indent-step) * 1); } +.ProseMirror [data-indent="2"] { padding-inline-start: calc(var(--indent-step) * 2); } +.ProseMirror [data-indent="3"] { padding-inline-start: calc(var(--indent-step) * 3); } +.ProseMirror [data-indent="4"] { padding-inline-start: calc(var(--indent-step) * 4); } +.ProseMirror [data-indent="5"] { padding-inline-start: calc(var(--indent-step) * 5); } +.ProseMirror [data-indent="6"] { padding-inline-start: calc(var(--indent-step) * 6); } +.ProseMirror [data-indent="7"] { padding-inline-start: calc(var(--indent-step) * 7); } +.ProseMirror [data-indent="8"] { padding-inline-start: calc(var(--indent-step) * 8); } +.ProseMirror [data-indent="9"] { padding-inline-start: calc(var(--indent-step) * 9); } +.ProseMirror [data-indent="10"] { padding-inline-start: calc(var(--indent-step) * 10); } diff --git a/apps/client/src/features/editor/styles/index.css b/apps/client/src/features/editor/styles/index.css index 7ec4be9b..7abfe108 100644 --- a/apps/client/src/features/editor/styles/index.css +++ b/apps/client/src/features/editor/styles/index.css @@ -13,5 +13,6 @@ @import "./mention.css"; @import "./ordered-list.css"; @import "./highlight.css"; +@import "./indent.css"; @import "./columns.css"; @import "./status.css"; diff --git a/apps/server/package.json b/apps/server/package.json index d748e991..90333e1d 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -163,10 +163,11 @@ "rootDir": "src", "testRegex": ".*\\.spec\\.ts$", "transform": { + "happy-dom.+\\.js$": ["babel-jest", { "presets": [["@babel/preset-env", { "targets": { "node": "current" } }]] }], "^.+\\.(t|j)s$": "ts-jest" }, "transformIgnorePatterns": [ - "/node_modules/(?!(\\.pnpm/)?(nanoid|uuid|image-dimensions|marked)(@|/))" + "/node_modules/(?!(\\.pnpm/)?(nanoid|uuid|image-dimensions|marked|happy-dom)(@|/))" ], "collectCoverageFrom": [ "**/*.(t|j)s" diff --git a/apps/server/src/collaboration/collaboration.util.ts b/apps/server/src/collaboration/collaboration.util.ts index 64e349ae..5787e2e3 100644 --- a/apps/server/src/collaboration/collaboration.util.ts +++ b/apps/server/src/collaboration/collaboration.util.ts @@ -34,6 +34,7 @@ import { Mention, Subpages, Highlight, + Indent, UniqueID, Columns, Column, @@ -62,10 +63,11 @@ export const tiptapExtensions = [ }), Heading, UniqueID.configure({ - types: ['heading', 'paragraph'], + types: ['heading', 'paragraph', 'transclusionSource'], }), Comment, TextAlign.configure({ types: ['heading', 'paragraph'] }), + Indent, TaskList, TaskItem.configure({ nested: true, diff --git a/apps/server/src/common/helpers/prosemirror/indent-schema.spec.ts b/apps/server/src/common/helpers/prosemirror/indent-schema.spec.ts new file mode 100644 index 00000000..6e11c27b --- /dev/null +++ b/apps/server/src/common/helpers/prosemirror/indent-schema.spec.ts @@ -0,0 +1,63 @@ +import { htmlToJson, jsonToHtml } from '../../../collaboration/collaboration.util'; + +const findFirstChild = ( + json: any, + type: string, +): any | undefined => { + if (!json || typeof json !== 'object') return undefined; + if (json.type === type) return json; + if (Array.isArray(json.content)) { + for (const child of json.content) { + const found = findFirstChild(child, type); + if (found) return found; + } + } + return undefined; +}; + +describe('indent attribute round-trip', () => { + it('parses data-indent on a paragraph into the indent attribute', () => { + const html = '

Hello

'; + const json = htmlToJson(html); + const paragraph = findFirstChild(json, 'paragraph'); + expect(paragraph).toBeDefined(); + expect(paragraph.attrs.indent).toBe(3); + }); + + it('parses data-indent on a heading into the indent attribute', () => { + const html = '

Heading

'; + const json = htmlToJson(html); + const heading = findFirstChild(json, 'heading'); + expect(heading).toBeDefined(); + expect(heading.attrs.indent).toBe(2); + expect(heading.attrs.level).toBe(2); + }); + + it('clamps out-of-range data-indent values', () => { + const html = '

Too deep

'; + const json = htmlToJson(html); + const paragraph = findFirstChild(json, 'paragraph'); + expect(paragraph.attrs.indent).toBe(8); + }); + + it('renders nonzero indent back to data-indent on HTML serialization', () => { + const html = '

Round-trip

'; + const json = htmlToJson(html); + const out = jsonToHtml(json); + expect(out).toContain('data-indent="4"'); + }); + + it('omits data-indent for indent zero', () => { + const html = '

No indent

'; + const json = htmlToJson(html); + const out = jsonToHtml(json); + expect(out).not.toContain('data-indent'); + }); + + it('preserves indent through HTML → JSON → HTML', () => { + const original = '

Five deep

'; + const json = htmlToJson(original); + const final = jsonToHtml(json); + expect(final).toContain('data-indent="5"'); + }); +}); diff --git a/apps/server/src/ee b/apps/server/src/ee index 64795229..326df8c1 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit 6479522986f62b30f06f3e65eeec83b713114941 +Subproject commit 326df8c1541049ecc552e14afa939f78e0ae113a diff --git a/packages/editor-ext/src/index.ts b/packages/editor-ext/src/index.ts index 22a67f82..354b1a61 100644 --- a/packages/editor-ext/src/index.ts +++ b/packages/editor-ext/src/index.ts @@ -23,6 +23,7 @@ export * from "./lib/embed-provider"; export * from "./lib/subpages"; export * from "./lib/transclusion"; export * from "./lib/highlight"; +export * from "./lib/indent"; export * from "./lib/heading/heading"; export * from "./lib/unique-id"; export * from "./lib/shared-storage"; diff --git a/packages/editor-ext/src/lib/custom-code-block/custom-code-block.ts b/packages/editor-ext/src/lib/custom-code-block/custom-code-block.ts index ba9fe9c1..4c4b6ef7 100644 --- a/packages/editor-ext/src/lib/custom-code-block/custom-code-block.ts +++ b/packages/editor-ext/src/lib/custom-code-block/custom-code-block.ts @@ -1,8 +1,8 @@ -import type { CodeBlockOptions } from "@tiptap/extension-code-block"; -import CodeBlock from "@tiptap/extension-code-block"; +import type { CodeBlockOptions } from '@tiptap/extension-code-block'; +import CodeBlock from '@tiptap/extension-code-block'; -import { LowlightPlugin } from "./lowlight-plugin.js"; -import { ReactNodeViewRenderer } from "@tiptap/react"; +import { LowlightPlugin } from './lowlight-plugin.js'; +import { ReactNodeViewRenderer } from '@tiptap/react'; export interface CodeBlockLowlightOptions extends CodeBlockOptions { /** @@ -12,7 +12,7 @@ export interface CodeBlockLowlightOptions extends CodeBlockOptions { view: any; } -const TAB_CHAR = "\u00A0\u00A0"; +const TAB_CHAR = '\u00A0\u00A0'; /** * This extension allows you to highlight code blocks with lowlight. @@ -25,7 +25,7 @@ export const CustomCodeBlock = CodeBlock.extend({ return { ...this.parent?.(), lowlight: {}, - languageClassPrefix: "language-", + languageClassPrefix: 'language-', exitOnTripleEnter: true, exitOnArrowDown: true, defaultLanguage: null, @@ -37,20 +37,8 @@ export const CustomCodeBlock = CodeBlock.extend({ addKeyboardShortcuts() { return { ...this.parent?.(), - Tab: () => { - if (this.editor.isActive("codeBlock")) { - this.editor - .chain() - .command(({ tr }) => { - tr.insertText(TAB_CHAR); - return true; - }) - .run(); - return true; - } - }, - "Mod-a": () => { - if (this.editor.isActive("codeBlock")) { + 'Mod-a': () => { + if (this.editor.isActive('codeBlock')) { const { state } = this.editor; const { $from } = state.selection; @@ -60,7 +48,7 @@ export const CustomCodeBlock = CodeBlock.extend({ for (depth = $from.depth; depth > 0; depth--) { const node = $from.node(depth); - if (node.type.name === "codeBlock") { + if (node.type.name === 'codeBlock') { codeBlockNode = node; codeBlockPos = $from.start(depth) - 1; break; diff --git a/packages/editor-ext/src/lib/indent.ts b/packages/editor-ext/src/lib/indent.ts new file mode 100644 index 00000000..2718e497 --- /dev/null +++ b/packages/editor-ext/src/lib/indent.ts @@ -0,0 +1,223 @@ +import { Extension } from '@tiptap/core'; +import { + Plugin, + PluginKey, + type EditorState, + type Transaction, +} from '@tiptap/pm/state'; + +export type IndentOptions = { + types: string[]; + min: number; + max: number; +}; + +declare module '@tiptap/core' { + interface Commands { + indent: { + indent: () => ReturnType; + outdent: () => ReturnType; + }; + } +} + +// Containers whose descendants must never carry an `indent` attribute. These +// nodes own their own Tab semantics (list nesting, cell navigation, literal +// tab) and visually conflict with our indent padding, so paragraphs and +// headings inside them stay flat +const NON_INDENTABLE_ANCESTORS = new Set([ + 'listItem', + 'taskItem', + 'tableCell', + 'tableHeader', + 'codeBlock', +]); + +const clampIndent = (value: number, min: number, max: number): number => { + if (!Number.isFinite(value)) return min; + return Math.max(min, Math.min(max, Math.trunc(value))); +}; + +const hasNonIndentableAncestor = ( + doc: EditorState['doc'], + pos: number, +): boolean => { + const $pos = doc.resolve(pos); + for (let depth = $pos.depth; depth >= 0; depth--) { + if (NON_INDENTABLE_ANCESTORS.has($pos.node(depth).type.name)) { + return true; + } + } + return false; +}; + +export const Indent = Extension.create({ + name: 'indent', + + priority: 1000, + + addOptions() { + return { + types: ['paragraph', 'heading'], + min: 0, + max: 8, + }; + }, + + addGlobalAttributes() { + return [ + { + types: this.options.types, + attributes: { + indent: { + default: this.options.min, + keepOnSplit: true, + parseHTML: (element) => { + const raw = element.getAttribute('data-indent'); + if (raw === null) return this.options.min; + return clampIndent( + parseInt(raw, 10), + this.options.min, + this.options.max, + ); + }, + renderHTML: (attributes) => { + const value = attributes.indent; + if (value <= this.options.min) return {}; + return { 'data-indent': String(value) }; + }, + }, + }, + }, + ]; + }, + + addCommands() { + return { + indent: + () => + ({ state, tr, dispatch }) => { + return updateIndent(state, tr, dispatch, this.options, +1); + }, + outdent: + () => + ({ state, tr, dispatch }) => { + return updateIndent(state, tr, dispatch, this.options, -1); + }, + }; + }, + + addKeyboardShortcuts() { + const isInIndentableBlock = (): boolean => { + const { $from } = this.editor.state.selection; + if (!this.options.types.includes($from.parent.type.name)) return false; + for (let depth = $from.depth - 1; depth >= 0; depth--) { + if (NON_INDENTABLE_ANCESTORS.has($from.node(depth).type.name)) { + return false; + } + } + return true; + }; + + return { + Tab: () => { + if (!isInIndentableBlock()) return false; + this.editor.commands.indent(); + return true; + }, + 'Shift-Tab': () => { + if (!isInIndentableBlock()) return false; + this.editor.commands.outdent(); + return true; + }, + Backspace: () => { + const { $from, empty } = this.editor.state.selection; + if (!empty) return false; + if ($from.parentOffset !== 0) return false; + if (!isInIndentableBlock()) return false; + if ($from.parent.attrs.indent <= this.options.min) return false; + this.editor.commands.outdent(); + return true; + }, + }; + }, + + addProseMirrorPlugins() { + const types = new Set(this.options.types); + const min = this.options.min; + + return [ + new Plugin({ + key: new PluginKey('indentNormalizer'), + appendTransaction: (transactions, _oldState, newState) => { + if (!transactions.some((tr) => tr.docChanged)) return null; + + const tr = newState.tr; + let modified = false; + + newState.doc.descendants((node, pos) => { + // Containers: descend so we can find paragraph/heading children. + if (!types.has(node.type.name)) return true; + + if (node.attrs.indent <= min) return false; + + if (hasNonIndentableAncestor(newState.doc, pos)) { + tr.setNodeMarkup( + pos, + undefined, + { ...node.attrs, indent: min }, + node.marks, + ); + modified = true; + } + + // paragraph/heading don't contain other paragraphs/headings — + // never descend into their inline content. + return false; + }); + + if (!modified) return null; + // Normalisation must not show up as a separate undo step; + // otherwise undo would re-introduce the illegal indent. + return tr.setMeta('addToHistory', false); + }, + }), + ]; + }, +}); + +function updateIndent( + state: EditorState, + tr: Transaction, + dispatch: ((tr: Transaction) => void) | undefined, + options: IndentOptions, + delta: number, +): boolean { + const { selection } = state; + const { from, to } = selection; + const types = new Set(options.types); + let updated = false; + + state.doc.nodesBetween(from, to, (node, pos) => { + // Skip non-block nodes (text, inline atoms) up front. + if (!node.type.isBlock) return false; + + // Don't descend into containers whose children must stay flat — handles + // multi-block selections that span across e.g. a list-item or table-cell. + if (NON_INDENTABLE_ANCESTORS.has(node.type.name)) return false; + + if (!types.has(node.type.name)) return true; + + const current = node.attrs.indent as number; + const next = clampIndent(current + delta, options.min, options.max); + if (next === current) return false; + + tr.setNodeMarkup(pos, undefined, { ...node.attrs, indent: next }); + updated = true; + return false; + }); + + if (!updated) return false; + if (dispatch) dispatch(tr); + return true; +}