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 = '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