diff --git a/package.json b/package.json index fba4ef5..3599472 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chatwoot/prosemirror-schema", - "version": "1.3.15", + "version": "1.3.16", "description": "Schema setup for using prosemirror in chatwoot. Based on 👉 https://github.com/ProseMirror/prosemirror-example-setup/", "main": "dist/index.es.js", "scripts": { diff --git a/src/index.js b/src/index.js index 0dde28e..b52903d 100644 --- a/src/index.js +++ b/src/index.js @@ -55,8 +55,8 @@ export const buildEditor = ({ gapCursor(), schema.nodes.table ? tableEditing() : null, schema.nodes.table ? tableControlsPlugin(schema) : null, - // Message editors (image, no table): keep each image alone on its line. - schema.nodes.image && !schema.nodes.table ? isolateImagesPlugin() : null, + // editor with images (messages and articles): keep each image alone on its line. + schema.nodes.image ? isolateImagesPlugin() : null, Placeholder(placeholder), menuBar({ floating: true, diff --git a/src/keymap.js b/src/keymap.js index 7b3b9f0..6850d25 100644 --- a/src/keymap.js +++ b/src/keymap.js @@ -101,7 +101,7 @@ export function baseKeyMaps(schema) { selectNodeBackward, ]; // Message editors (image, no table): let Backspace clear an image above. - if (schema.nodes.image && !schema.nodes.table) { + if (schema.nodes.image) { backspaceList.unshift(deleteImageBackward); } const backspaceComands = chainCommands(...backspaceList); diff --git a/src/plugins/isolateImages.js b/src/plugins/isolateImages.js index 8ad4215..49449f4 100644 --- a/src/plugins/isolateImages.js +++ b/src/plugins/isolateImages.js @@ -1,4 +1,52 @@ -import { Plugin } from "prosemirror-state"; +import { EditorState, Plugin } from "prosemirror-state"; + +/** + * Positions at which paragraphs must be split so that every image ends up alone + * in its own paragraph (no text to its left or right). Returned deduped and + * sorted descending so callers can apply them without remapping earlier + * positions. (Adjacent images share a boundary, hence the dedupe.) + * + * @param {Node} doc - The ProseMirror document to inspect. + * @returns {number[]} - Split positions, sorted high → low. + */ +function imageIsolationSplits(doc) { + const splits = []; + doc.descendants((node, pos) => { + if (node.type.name !== "paragraph" || node.childCount < 2) return; + + const contentStart = pos + 1; + const contentEnd = pos + node.nodeSize - 1; + + node.forEach((child, offset) => { + if (child.type.name !== "image") return; + const imageStart = contentStart + offset; + const imageEnd = imageStart + child.nodeSize; + if (imageStart > contentStart) splits.push(imageStart); + if (imageEnd < contentEnd) splits.push(imageEnd); + }); + }); + + return [...new Set(splits)].sort((a, b) => b - a); +} + +/** + * Normalizes a document so every image sits alone in its own paragraph. Apply + * at parse time so freshly parsed markdown matches the editor's runtime layout + * (and therefore serializes back to identical markdown). Without this, a + * parse → serialize round-trip keeps images inline while the live editor + * isolates them, so the two outputs diverge. + * + * @param {Node} doc - The ProseMirror document to normalize. + * @returns {Node} - The document with images isolated (unchanged if none apply). + */ +export function isolateImagesInDoc(doc) { + const splits = imageIsolationSplits(doc); + if (!splits.length) return doc; + + const { tr } = EditorState.create({ doc }); + splits.forEach(p => tr.split(p)); + return tr.doc; +} /** * Keeps every image on its own line — no text to its left or right. @@ -13,28 +61,11 @@ export default function isolateImagesPlugin() { appendTransaction(transactions, _oldState, newState) { if (!transactions.some(tr => tr.docChanged)) return null; - const splits = []; - newState.doc.descendants((node, pos) => { - if (node.type.name !== "paragraph" || node.childCount < 2) return; - - const contentStart = pos + 1; - const contentEnd = pos + node.nodeSize - 1; - - node.forEach((child, offset) => { - if (child.type.name !== "image") return; - const imageStart = contentStart + offset; - const imageEnd = imageStart + child.nodeSize; - if (imageStart > contentStart) splits.push(imageStart); - if (imageEnd < contentEnd) splits.push(imageEnd); - }); - }); - + const splits = imageIsolationSplits(newState.doc); if (!splits.length) return null; const tr = newState.tr; - // Dedupe (adjacent images share a boundary) and apply from the end so - // earlier positions stay valid. - [...new Set(splits)].sort((a, b) => b - a).forEach(p => tr.split(p)); + splits.forEach(p => tr.split(p)); return tr; }, }); diff --git a/src/schema/markdown/articleParser.js b/src/schema/markdown/articleParser.js index 447cd11..336cb1d 100644 --- a/src/schema/markdown/articleParser.js +++ b/src/schema/markdown/articleParser.js @@ -7,6 +7,7 @@ import { baseMarksMdToPmMapping, filterMdToPmSchemaMapping, } from './parser'; +import { isolateImagesInDoc } from '../../plugins/isolateImages'; export const articleSchemaToMdMapping = { nodes: { @@ -101,6 +102,8 @@ export class ArticleMarkdownTransformer { } parse(content) { - return this.markdownParser.parse(content); + // Isolate images the same way the live editor does (isolateImagesPlugin), + // so a parse → serialize round-trip matches the editor's output. + return isolateImagesInDoc(this.markdownParser.parse(content)); } } diff --git a/src/schema/markdown/messageParser.js b/src/schema/markdown/messageParser.js index 995df0e..bac5cc8 100644 --- a/src/schema/markdown/messageParser.js +++ b/src/schema/markdown/messageParser.js @@ -6,6 +6,7 @@ import { baseMarksMdToPmMapping, filterMdToPmSchemaMapping, } from './parser'; +import { isolateImagesInDoc } from '../../plugins/isolateImages'; export const messageSchemaToMdMapping = { nodes: { ...baseSchemaToMdMapping.nodes }, @@ -69,6 +70,8 @@ export class MessageMarkdownTransformer { } parse(content) { - return this.markdownParser.parse(content); + // Isolate images the same way the live editor does (isolateImagesPlugin), + // so a parse → serialize round-trip matches the editor's output. + return isolateImagesInDoc(this.markdownParser.parse(content)); } } diff --git a/src/styles/article.scss b/src/styles/article.scss index a91913d..f550099 100644 --- a/src/styles/article.scss +++ b/src/styles/article.scss @@ -15,97 +15,146 @@ } .ProseMirror { - p { - font-size: var(--font-size-medium); - line-height: 1.75rem; - margin-bottom: var(--space-medium); - } + font-size: var(--font-size-default); // 16px + line-height: 1.75; - strong code { - font-size: var(--font-size-medium); + p { + font-size: var(--font-size-default); + margin-top: 20px; // 1.25em — no matching --space-* var + margin-bottom: 20px; } pre code { - font-size: var(--font-size-default); - font-weight: var(--font-weight-medium); - line-height: 1.6; + font-size: inherit; + font-weight: inherit; + line-height: inherit; } blockquote { - padding-left: var(--space-normal); - margin: var(--space-medium) 0; + margin-top: 25.6px; // 1.6em — matches prose default + margin-bottom: 25.6px; + padding-inline-start: var(--space-normal); + font-style: italic; } a { font-weight: var(--font-weight-medium); - font-size: var(--font-size-medium); + font-size: var(--font-size-default); } hr { border-color: var(--s-100); - margin-bottom: var(--space-big); + margin-top: var(--space-larger); // 48px (~3em from prose default) + margin-bottom: var(--space-larger); } h1 { - font-size: var(--font-size-big); - font-weight: var(--font-weight-heavy); - margin-bottom: var(--space-medium); + font-size: 36px; // 2.25em (prose default; no matching --font-size-* var) + font-weight: 620; + line-height: 1.1111111; + margin-top: 0; + margin-bottom: var(--space-large); // 32px } h2 { - font-size: var(--font-size-large); - font-weight: var(--font-weight-black); - margin-bottom: var(--space-medium); + font-size: var(--font-size-big); // 24px (1.5em) + font-weight: 620; + line-height: 1.3333333; + margin-top: var(--space-large); // 32px (2em) + margin-bottom: var(--space-normal); // 16px (1em) + } + + h3 { + font-size: var(--font-size-large); // 20px (1.25em) + font-weight: 620; + line-height: 1.6; + margin-top: var(--space-medium); // 24px (~1.6em) + margin-bottom: var(--space-slab); // 12px (0.6em) } - h3, h4 { - font-size: var(--font-size-medium); - font-weight: var(--font-weight-bold); - margin-bottom: var(--space-normal); + font-size: var(--font-size-default); // 16px + font-weight: 620; + line-height: 1.5; + margin-top: var(--space-medium); // 24px (1.5em) + margin-bottom: var(--space-small); // 8px (0.5em) } h5 { - font-size: var(--font-size-medium); + font-size: var(--font-size-default); + font-weight: 620; + margin-top: var(--space-normal); margin-bottom: var(--space-small); } h6 { - font-size: var(--font-size-medium); + font-size: var(--font-size-default); + font-weight: 620; + margin-top: var(--space-normal); margin-bottom: var(--space-smaller); } + // Reset spacing on the first child so the editor doesn't start with a gap. + > :first-child { + margin-top: 0; + } + + > :last-child { + margin-bottom: 0; + } + .ProseMirror-icon svg { width: var(--space-medium) !important; } li::marker { - font-size: var(--font-size-medium); + font-size: var(--font-size-default); } ul, ol { - margin-top: var(--space-medium); - margin-bottom: var(--space-medium); - padding-left: var(--space-large); + margin-top: 20px; + margin-bottom: 20px; + padding-inline-start: 26px; // 1.625em from prose default } + ul, ul ul, ul ul ul { list-style-type: disc; } + ol, ol ol, ol ol ol { list-style-type: decimal; } + li { - ul, - ol { - margin-top: var(--space-normal); - margin-bottom: var(--space-normal); - } - p { - padding-left: var(--space-medium); - margin-top: var(--space-small); - margin-bottom: var(--space-small); - } + margin-top: var(--space-small); // 8px (0.5em) + margin-bottom: var(--space-small); + padding-inline-start: 6px; // 0.375em + } + + // Nested lists get tighter vertical rhythm matching prose default. + ul ul, + ul ol, + ol ul, + ol ol { + margin-top: var(--space-slab); // 12px (0.75em) + margin-bottom: var(--space-slab); + } + + // Portal collapses li > p margins (`[&_li>p]:m-0`). Mirror that here. + li > p { + margin-top: 0; + margin-bottom: 0; } pre { - padding: var(--space-normal); - margin-bottom: var(--space-normal); + font-size: var(--font-size-small); // 14px (0.875em) + line-height: 1.7142857; + margin-top: var(--space-medium); // 24px (~1.71em) + margin-bottom: var(--space-medium); + padding: var(--space-slab) var(--space-normal); // ~12px 16px + border-radius: var(--border-radius-medium); + } + + p code, + li code, + strong code { + font-size: var(--font-size-small); // 14px (0.875em) }