From 73162ae756a2d8a0dcb9b10e0c363a88d78dcc0a Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Fri, 20 Feb 2026 20:18:37 -0300 Subject: [PATCH 1/4] fix(css): scope ProseMirror CSS to prevent bleeding into host apps (SD-1850) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SuperDoc's CSS was leaking into host applications in three ways: 1. A bare `a { text-decoration: auto }` selector overrode ALL links on the page, not just those inside the editor. 2. All `.ProseMirror` selectors (~55) were unscoped, affecting any ProseMirror editor on the page — not just SuperDoc's hidden instance. 3. A `.sr-only` utility class collided with Bootstrap/Tailwind definitions. This follows the CKEditor 5 model (the industry standard): scope all content styles under a parent class. SuperDoc already did this for ~90% of its CSS — this commit closes the remaining gaps. Changes: - Remove bare `a` element selector from global.css - Scope all `.ProseMirror` and `.ProseMirror-*` selectors under `.super-editor` in prosemirror.css - Scope `.sd-*` selectors in extension CSS files (structured-content, document-section, noderesizer, ai, page-number) - Rename keyframes `aiTextAppear` → `superdoc-aiTextAppear` and `ai-pulse` → `superdoc-ai-pulse` to avoid collisions - Rename `.sr-only` → `.superdoc-sr-only` in DomPainter styles - Add CSS bleed prevention lint test that structurally verifies no unscoped selectors exist (catches future regressions) --- .../painters/dom/src/index.test.ts | 4 +- .../painters/dom/src/renderer.ts | 2 +- .../layout-engine/painters/dom/src/styles.ts | 2 +- .../src/assets/styles/elements/ai.css | 16 +- .../assets/styles/elements/page-number.css | 12 +- .../assets/styles/elements/prosemirror.css | 127 +++++----- .../styles/extensions/document-section.css | 14 +- .../assets/styles/extensions/noderesizer.css | 24 +- .../styles/extensions/structured-content.css | 30 +-- .../src/assets/styles/layout/global.css | 4 - .../src/tests/css-no-bleed.test.js | 235 ++++++++++++++++++ 11 files changed, 352 insertions(+), 118 deletions(-) create mode 100644 packages/super-editor/src/tests/css-no-bleed.test.js diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts index 4a682ff56a..0940a45039 100644 --- a/packages/layout-engine/painters/dom/src/index.test.ts +++ b/packages/layout-engine/painters/dom/src/index.test.ts @@ -6539,7 +6539,7 @@ describe('Link accessibility - Focus styles', () => { const styleTag = document.querySelector('[data-superdoc-link-styles]'); expect(styleTag).toBeTruthy(); expect(styleTag?.textContent).toContain(':focus-visible'); - expect(styleTag?.textContent).toContain('sr-only'); + expect(styleTag?.textContent).toContain('superdoc-sr-only'); }); it('should not inject styles twice', () => { @@ -6954,7 +6954,7 @@ describe('Link accessibility - Tooltip aria-describedby', () => { // Look for the description element in the mount, not document const descElem = mount.querySelector(`#${describedBy}`); expect(descElem?.textContent).toBe('Visit our homepage for more information'); - expect(descElem?.className).toContain('sr-only'); + expect(descElem?.className).toContain('superdoc-sr-only'); }); it('should maintain title attribute for visual tooltip', () => { diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 191c35b3df..ee92278766 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -4047,7 +4047,7 @@ export class DomPainter { const descId = `link-desc-${linkId}`; const descElem = this.doc.createElement('span'); descElem.id = descId; - descElem.className = 'sr-only'; // Screen reader only class + descElem.className = 'superdoc-sr-only'; // Screen reader only class descElem.textContent = tooltip; // Insert description element after the link diff --git a/packages/layout-engine/painters/dom/src/styles.ts b/packages/layout-engine/painters/dom/src/styles.ts index c711b85f18..0b936c1ff4 100644 --- a/packages/layout-engine/painters/dom/src/styles.ts +++ b/packages/layout-engine/painters/dom/src/styles.ts @@ -185,7 +185,7 @@ const LINK_AND_TOC_STYLES = ` } /* Screen reader only content (WCAG SC 1.3.1) */ -.sr-only { +.superdoc-sr-only { position: absolute; width: 1px; height: 1px; diff --git a/packages/super-editor/src/assets/styles/elements/ai.css b/packages/super-editor/src/assets/styles/elements/ai.css index 282815d057..59f412bc6a 100644 --- a/packages/super-editor/src/assets/styles/elements/ai.css +++ b/packages/super-editor/src/assets/styles/elements/ai.css @@ -38,7 +38,7 @@ } /* AI text appear animation */ -@keyframes aiTextAppear { +@keyframes superdoc-aiTextAppear { from { opacity: 0; transform: translateY(5px); @@ -49,27 +49,27 @@ } } -.sd-ai-text-appear { +.super-editor .sd-ai-text-appear { display: inline; opacity: 0; - animation: aiTextAppear 0.7s ease-out forwards; + animation: superdoc-aiTextAppear 0.7s ease-out forwards; animation-fill-mode: both; will-change: opacity, transform; /* Ensure each mark is treated as a separate animation context */ contain: content; } -.sd-ai-loader { +.super-editor .sd-ai-loader { display: flex; justify-content: flex-start; } -.sd-ai-loader > img { +.super-editor .sd-ai-loader > img { width: fit-content; height: 40px; } -@keyframes ai-pulse { +@keyframes superdoc-ai-pulse { 0% { background-color: rgba(99, 102, 241, 0.1); } @@ -81,6 +81,6 @@ } } -.sd-ai-highlight-pulse { - animation: ai-pulse 1.5s ease-in-out infinite; +.super-editor .sd-ai-highlight-pulse { + animation: superdoc-ai-pulse 1.5s ease-in-out infinite; } diff --git a/packages/super-editor/src/assets/styles/elements/page-number.css b/packages/super-editor/src/assets/styles/elements/page-number.css index ffacbb5e1d..7ae846bd60 100644 --- a/packages/super-editor/src/assets/styles/elements/page-number.css +++ b/packages/super-editor/src/assets/styles/elements/page-number.css @@ -1,20 +1,20 @@ -.sd-editor-auto-page-number, -.sd-editor-auto-total-pages { +.super-editor .sd-editor-auto-page-number, +.super-editor .sd-editor-auto-total-pages { transition: all 250ms ease; border-bottom: 1px solid #9a9a9a; cursor: not-allowed; } -.sd-editor-auto-page-number:hover, -.sd-editor-auto-total-pages:hover { +.super-editor .sd-editor-auto-page-number:hover, +.super-editor .sd-editor-auto-total-pages:hover { border-bottom-color: #4f4f4f; } -.sd-editor-auto-page-number-content { +.super-editor .sd-editor-auto-page-number-content { pointer-events: none; } -.ProseMirror.view-mode { +.super-editor .ProseMirror.view-mode { .sd-editor-auto-page-number, .sd-editor-auto-total-pages { border: none; diff --git a/packages/super-editor/src/assets/styles/elements/prosemirror.css b/packages/super-editor/src/assets/styles/elements/prosemirror.css index af738f2d72..9379d7babe 100644 --- a/packages/super-editor/src/assets/styles/elements/prosemirror.css +++ b/packages/super-editor/src/assets/styles/elements/prosemirror.css @@ -1,14 +1,17 @@ /** * Basic ProseMirror styles. * https://github.com/ProseMirror/prosemirror-view/blob/master/style/prosemirror.css + * + * All selectors are scoped under .super-editor to prevent bleeding into host apps. + * See: SD-1850 */ -.ProseMirror { +.super-editor .ProseMirror { position: relative; /* We use "all: revert" to isolate the editor content from external stylesheets. This makes the "contenteditable" not editable on Safari devices. So we need to keep this. */ -webkit-user-modify: read-write-plaintext-only; } -.ProseMirror { +.super-editor .ProseMirror { word-wrap: break-word; white-space: pre-wrap; white-space: break-spaces; @@ -18,35 +21,35 @@ z-index: 0; /* Needed to place images behind text with lower z-index */ } -.ProseMirror pre { +.super-editor .ProseMirror pre { white-space: pre-wrap; } -.ProseMirror ol, -.ProseMirror ul { +.super-editor .ProseMirror ol, +.super-editor .ProseMirror ul { margin-block-start: 0; margin-block-end: 0; margin-inline-start: 0; margin-inline-end: 0; } -.ProseMirror ol, -.ProseMirror ul { +.super-editor .ProseMirror ol, +.super-editor .ProseMirror ul { padding-inline-start: 0; padding-left: 0; list-style: none; } -.ProseMirror li::marker { +.super-editor .ProseMirror li::marker { content: none; } -.ProseMirror li::marker { +.super-editor .ProseMirror li::marker { padding: 0; margin: 0; } -.ProseMirror li > p { +.super-editor .ProseMirror li > p { margin: 0; padding: 0; display: inline-block; @@ -56,44 +59,44 @@ * Hide marker for indented lists. * If a list-item contains a list but doesn't contain a "p" tag with text. */ -.ProseMirror ol { +.super-editor .ProseMirror ol { margin: 0; } -.ProseMirror li:has(> ul:first-child, > ol:first-child):not(:has(> p)) { +.super-editor .ProseMirror li:has(> ul:first-child, > ol:first-child):not(:has(> p)) { list-style-type: none; } -.ProseMirror li:has(> ul:first-child, > ol:first-child):not(:has(> p))::marker { +.super-editor .ProseMirror li:has(> ul:first-child, > ol:first-child):not(:has(> p))::marker { content: ''; } -.ProseMirror-hideselection *::selection { +.super-editor .ProseMirror-hideselection *::selection { background: transparent; } -.ProseMirror-hideselection *::-moz-selection { +.super-editor .ProseMirror-hideselection *::-moz-selection { background: transparent; } -.ProseMirror-hideselection * { +.super-editor .ProseMirror-hideselection * { caret-color: transparent; } /* See https://github.com/ProseMirror/prosemirror/issues/1421#issuecomment-1759320191 */ -.ProseMirror [draggable][contenteditable='false'] { +.super-editor .ProseMirror [draggable][contenteditable='false'] { user-select: text; } -.ProseMirror-selectednode { +.super-editor .ProseMirror-selectednode { outline: 2px solid #8cf; } /* Make sure li selections wrap around markers */ -li.ProseMirror-selectednode { +.super-editor li.ProseMirror-selectednode { outline: none; } -li.ProseMirror-selectednode:after { +.super-editor li.ProseMirror-selectednode:after { content: ''; position: absolute; left: -32px; @@ -104,24 +107,24 @@ li.ProseMirror-selectednode:after { pointer-events: none; } -.ProseMirror img { +.super-editor .ProseMirror img { height: auto; max-width: 100%; } /* Protect against generic img rules */ -img.ProseMirror-separator { +.super-editor img.ProseMirror-separator { display: inline !important; border: none !important; margin: 0 !important; } -.ProseMirror .sd-editor-tab { +.super-editor .ProseMirror .sd-editor-tab { display: inline-block; vertical-align: text-bottom; } -.ProseMirror u .sd-editor-tab { +.super-editor .ProseMirror u .sd-editor-tab { white-space: pre; border-bottom: 1px solid #000; margin-bottom: 1.5px; @@ -132,12 +135,12 @@ Tables https://github.com/ProseMirror/prosemirror-tables/blob/master/style/tables.css https://github.com/ProseMirror/prosemirror-tables/blob/master/demo/index.html */ -.ProseMirror.resize-cursor { +.super-editor .ProseMirror.resize-cursor { cursor: ew-resize; cursor: col-resize; } -.ProseMirror .tableWrapper { +.super-editor .ProseMirror .tableWrapper { --table-border-width: 1px; --offset: 2px; @@ -151,7 +154,7 @@ https://github.com/ProseMirror/prosemirror-tables/blob/master/demo/index.html width: calc(100% + (var(--table-border-width) + var(--offset))); } -.ProseMirror table { +.super-editor .ProseMirror table { border-collapse: collapse; border-spacing: 0; table-layout: auto; @@ -159,12 +162,12 @@ https://github.com/ProseMirror/prosemirror-tables/blob/master/demo/index.html /* width: 100%; */ } -.ProseMirror tr { +.super-editor .ProseMirror tr { position: relative; } -.ProseMirror td, -.ProseMirror th { +.super-editor .ProseMirror td, +.super-editor .ProseMirror th { min-width: 0; position: relative; vertical-align: top; @@ -172,24 +175,24 @@ https://github.com/ProseMirror/prosemirror-tables/blob/master/demo/index.html overflow-wrap: anywhere; } -.ProseMirror td[data-placeholder], -.ProseMirror th[data-placeholder] { +.super-editor .ProseMirror td[data-placeholder], +.super-editor .ProseMirror th[data-placeholder] { padding: 0 !important; border: 0 !important; background: transparent !important; } -.ProseMirror td[data-placeholder] > *, -.ProseMirror th[data-placeholder] > * { +.super-editor .ProseMirror td[data-placeholder] > *, +.super-editor .ProseMirror th[data-placeholder] > * { display: none !important; } -.ProseMirror th { +.super-editor .ProseMirror th { font-weight: bold; text-align: left; } -.ProseMirror table .column-resize-handle { +.super-editor .ProseMirror table .column-resize-handle { position: absolute; right: -2px; top: 0; @@ -200,7 +203,7 @@ https://github.com/ProseMirror/prosemirror-tables/blob/master/demo/index.html pointer-events: none; } -.ProseMirror table .selectedCell:after { +.super-editor .ProseMirror table .selectedCell:after { position: absolute; content: ''; left: 0; @@ -214,24 +217,24 @@ https://github.com/ProseMirror/prosemirror-tables/blob/master/demo/index.html /* Tables - end */ /* Track changes */ -.ProseMirror .track-insert-dec, -.ProseMirror .track-delete-dec, -.ProseMirror .track-format-dec { +.super-editor .ProseMirror .track-insert-dec, +.super-editor .ProseMirror .track-delete-dec, +.super-editor .ProseMirror .track-format-dec { pointer-events: none; } -.ProseMirror .track-insert-dec.hidden, -.ProseMirror .track-delete-dec.hidden { +.super-editor .ProseMirror .track-insert-dec.hidden, +.super-editor .ProseMirror .track-delete-dec.hidden { display: none; } -.ProseMirror .track-insert-dec.highlighted { +.super-editor .ProseMirror .track-insert-dec.highlighted { border-top: 1px dashed var(--sd-track-insert-border, #00853d); border-bottom: 1px dashed var(--sd-track-insert-border, #00853d); background-color: var(--sd-track-insert-bg, #399c7222); } -.ProseMirror .track-delete-dec.highlighted { +.super-editor .ProseMirror .track-delete-dec.highlighted { border-top: 1px dashed var(--sd-track-delete-border, #cb0e47); border-bottom: 1px dashed var(--sd-track-delete-border, #cb0e47); background-color: var(--sd-track-delete-bg, #cb0e4722); @@ -239,21 +242,21 @@ https://github.com/ProseMirror/prosemirror-tables/blob/master/demo/index.html text-decoration-thickness: 2px !important; } -.ProseMirror .track-format-dec.highlighted { +.super-editor .ProseMirror .track-format-dec.highlighted { border-bottom: 2px solid var(--sd-track-format-border, gold); } -.ProseMirror .track-delete-widget { +.super-editor .ProseMirror .track-delete-widget { visibility: hidden; } /* Track changes - end */ /* Collaboration cursors */ -.ProseMirror > .ProseMirror-yjs-cursor:first-child { +.super-editor .ProseMirror > .ProseMirror-yjs-cursor:first-child { margin-top: 16px; } -.ProseMirror-yjs-cursor { +.super-editor .ProseMirror-yjs-cursor { position: relative; margin-left: -1px; margin-right: -1px; @@ -264,7 +267,7 @@ https://github.com/ProseMirror/prosemirror-tables/blob/master/demo/index.html pointer-events: none; } -.ProseMirror-yjs-cursor > div { +.super-editor .ProseMirror-yjs-cursor > div { position: absolute; top: -1.05em; left: -1px; @@ -323,7 +326,7 @@ https://github.com/ProseMirror/prosemirror-tables/blob/master/demo/index.html /* Presentation Editor Remote Cursors - end */ /* Footnotes */ -.sd-footnote-ref { +.super-editor .sd-footnote-ref { font-size: 0.75em; line-height: 1; vertical-align: super; @@ -331,13 +334,13 @@ https://github.com/ProseMirror/prosemirror-tables/blob/master/demo/index.html } /* Image placeholder */ -.ProseMirror placeholder { +.super-editor .ProseMirror placeholder { display: inline; border: 1px solid #ccc; color: #ccc; } -.ProseMirror placeholder:after { +.super-editor .ProseMirror placeholder:after { content: '☁'; font-size: 200%; line-height: 0.1; @@ -345,14 +348,14 @@ https://github.com/ProseMirror/prosemirror-tables/blob/master/demo/index.html } /* Gapcursor */ -.ProseMirror-gapcursor { +.super-editor .ProseMirror-gapcursor { display: none; pointer-events: none; position: absolute; margin: 0; } -.ProseMirror-gapcursor:after { +.super-editor .ProseMirror-gapcursor:after { content: ''; display: block; position: absolute; @@ -368,18 +371,18 @@ https://github.com/ProseMirror/prosemirror-tables/blob/master/demo/index.html } } -.ProseMirror-focused .ProseMirror-gapcursor { +.super-editor .ProseMirror-focused .ProseMirror-gapcursor { display: block; } -.ProseMirror div[data-type='contentBlock'] { +.super-editor .ProseMirror div[data-type='contentBlock'] { position: absolute; outline: none; user-select: none; z-index: -1; } -.ProseMirror div[data-horizontal-rule='true'] { +.super-editor .ProseMirror div[data-horizontal-rule='true'] { position: relative; z-index: auto; display: inline-block; @@ -388,23 +391,23 @@ https://github.com/ProseMirror/prosemirror-tables/blob/master/demo/index.html align-self: flex-end; } -.sd-editor-dropcap { +.super-editor .sd-editor-dropcap { float: left; display: flex; align-items: baseline; margin-top: -5px; } -.ProseMirror-search-match { +.super-editor .ProseMirror-search-match { background-color: #ffff0054; } -.ProseMirror-active-search-match { +.super-editor .ProseMirror-active-search-match { background-color: #ff6a0054; } -.ProseMirror span.sd-custom-selection::selection { +.super-editor .ProseMirror span.sd-custom-selection::selection { background: transparent; } -.sd-custom-selection { +.super-editor .sd-custom-selection { background-color: #d9d9d9; border-radius: 0.1em; } diff --git a/packages/super-editor/src/assets/styles/extensions/document-section.css b/packages/super-editor/src/assets/styles/extensions/document-section.css index 1e55c9d160..6511e0c3e0 100644 --- a/packages/super-editor/src/assets/styles/extensions/document-section.css +++ b/packages/super-editor/src/assets/styles/extensions/document-section.css @@ -1,10 +1,10 @@ -.sd-document-section-block { +.super-editor .sd-document-section-block { background-color: #fafafa; border: 1px solid #ababab; border-radius: 4px; position: relative; } -.sd-document-section-block-info { +.super-editor .sd-document-section-block-info { position: absolute; top: -19px; left: -1px; @@ -22,27 +22,27 @@ background-color: #fafafa; } -.sd-document-section-block:hover { +.super-editor .sd-document-section-block:hover { border-radius: 0 4px 4px 4px; } -.sd-document-section-block:hover .sd-document-section-block-info { +.super-editor .sd-document-section-block:hover .sd-document-section-block-info { display: flex; align-items: center; } -.sd-document-section-block-info span { +.super-editor .sd-document-section-block-info span { max-width: 100%; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } -.ProseMirror.view-mode .sd-document-section-block { +.super-editor .ProseMirror.view-mode .sd-document-section-block { background: none; border: none; } -.ProseMirror.view-mode .sd-document-section-block-info { +.super-editor .ProseMirror.view-mode .sd-document-section-block-info { display: none; } diff --git a/packages/super-editor/src/assets/styles/extensions/noderesizer.css b/packages/super-editor/src/assets/styles/extensions/noderesizer.css index 3f9990925a..9cf6f2592d 100644 --- a/packages/super-editor/src/assets/styles/extensions/noderesizer.css +++ b/packages/super-editor/src/assets/styles/extensions/noderesizer.css @@ -1,12 +1,12 @@ /* Resize handles container */ -.sd-editor-resize-container { +.super-editor .sd-editor-resize-container { position: absolute; pointer-events: none; z-index: 11; } /* Resize handles */ -.sd-editor-resize-handle { +.super-editor .sd-editor-resize-handle { position: absolute; width: 12px; height: 12px; @@ -18,53 +18,53 @@ transition: all 0.1s ease; } -.sd-editor-resize-handle:hover { +.super-editor .sd-editor-resize-handle:hover { background-color: #228be6; transform: scale(1.1); box-shadow: 0 0 6px rgba(0, 0, 0, 0.4); } /* Handle positions */ -.sd-editor-resize-handle-nw { +.super-editor .sd-editor-resize-handle-nw { top: -6px; left: -6px; cursor: nwse-resize; } -.sd-editor-resize-handle-ne { +.super-editor .sd-editor-resize-handle-ne { top: -6px; right: -6px; cursor: nesw-resize; } -.sd-editor-resize-handle-sw { +.super-editor .sd-editor-resize-handle-sw { bottom: -6px; left: -6px; cursor: nesw-resize; } -.sd-editor-resize-handle-se { +.super-editor .sd-editor-resize-handle-se { bottom: -6px; right: -6px; cursor: nwse-resize; } /* Hide handles when editor loses focus */ -.ProseMirror:not(.ProseMirror-focused) .sd-editor-resize-container { +.super-editor .ProseMirror:not(.ProseMirror-focused) .sd-editor-resize-container { display: none; } /* Smooth transitions for resizing */ -.sd-editor-resizable-wrapper * { +.super-editor .sd-editor-resizable-wrapper * { transition: none; } -.sd-editor-resizable-wrapper *:not([style*='width']) { +.super-editor .sd-editor-resizable-wrapper *:not([style*='width']) { transition: all 0.2s ease; } /* Resize feedback indicator */ -.sd-editor-resizable-wrapper::after { +.super-editor .sd-editor-resizable-wrapper::after { content: 'Drag corners to resize'; position: absolute; bottom: -25px; @@ -82,6 +82,6 @@ z-index: 12; } -.sd-editor-resizable-wrapper:hover::after { +.super-editor .sd-editor-resizable-wrapper:hover::after { opacity: 1; } diff --git a/packages/super-editor/src/assets/styles/extensions/structured-content.css b/packages/super-editor/src/assets/styles/extensions/structured-content.css index 4fbd438271..8d80d5d434 100644 --- a/packages/super-editor/src/assets/styles/extensions/structured-content.css +++ b/packages/super-editor/src/assets/styles/extensions/structured-content.css @@ -1,5 +1,5 @@ -.sd-structured-content, -.sd-structured-content-block { +.super-editor .sd-structured-content, +.super-editor .sd-structured-content-block { padding: 1px; box-sizing: border-box; border-radius: 4px; @@ -7,24 +7,24 @@ position: relative; } -.sd-structured-content:has(img), -.sd-structured-content:has(img) .sd-structured-content__content { +.super-editor .sd-structured-content:has(img), +.super-editor .sd-structured-content:has(img) .sd-structured-content__content { display: inline-block; } /* Hover (not selected): light grey background, no handle */ -.sd-structured-content:not(.ProseMirror-selectednode):hover, -.sd-structured-content-block:not(.ProseMirror-selectednode):hover { +.super-editor .sd-structured-content:not(.ProseMirror-selectednode):hover, +.super-editor .sd-structured-content-block:not(.ProseMirror-selectednode):hover { background-color: #f2f2f2; } /* Selected: border + handle visible */ -.sd-structured-content.ProseMirror-selectednode, -.sd-structured-content-block.ProseMirror-selectednode { +.super-editor .sd-structured-content.ProseMirror-selectednode, +.super-editor .sd-structured-content-block.ProseMirror-selectednode { border-color: #629be7; } -.sd-structured-content-draggable { +.super-editor .sd-structured-content-draggable { font-size: 10px; align-items: center; justify-content: center; @@ -46,7 +46,7 @@ display: none; } -.sd-structured-content-draggable span { +.super-editor .sd-structured-content-draggable span { max-width: 100%; overflow: hidden; white-space: nowrap; @@ -54,18 +54,18 @@ } /* Handle only visible when selected */ -.sd-structured-content.ProseMirror-selectednode .sd-structured-content-draggable, -.sd-structured-content-block.ProseMirror-selectednode .sd-structured-content-draggable { +.super-editor .sd-structured-content.ProseMirror-selectednode .sd-structured-content-draggable, +.super-editor .sd-structured-content-block.ProseMirror-selectednode .sd-structured-content-draggable { display: inline-flex; } -.ProseMirror.view-mode .sd-structured-content, -.ProseMirror.view-mode .sd-structured-content-block { +.super-editor .ProseMirror.view-mode .sd-structured-content, +.super-editor .ProseMirror.view-mode .sd-structured-content-block { padding: 0; border: none; } -.ProseMirror.view-mode .sd-structured-content-draggable { +.super-editor .ProseMirror.view-mode .sd-structured-content-draggable { display: none; } diff --git a/packages/super-editor/src/assets/styles/layout/global.css b/packages/super-editor/src/assets/styles/layout/global.css index 503c5885fe..ba6b428a27 100644 --- a/packages/super-editor/src/assets/styles/layout/global.css +++ b/packages/super-editor/src/assets/styles/layout/global.css @@ -11,10 +11,6 @@ outline: none; } -a { - text-decoration: auto; -} - .super-editor a { color: initial; text-decoration: auto; diff --git a/packages/super-editor/src/tests/css-no-bleed.test.js b/packages/super-editor/src/tests/css-no-bleed.test.js new file mode 100644 index 0000000000..d33af8254a --- /dev/null +++ b/packages/super-editor/src/tests/css-no-bleed.test.js @@ -0,0 +1,235 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync, readdirSync } from 'fs'; +import { resolve, join } from 'path'; + +/** + * CSS Bleed Prevention Tests (SD-1850) + * + * Structural lint tests that parse CSS files and verify no selectors + * can bleed into host applications. This prevents regressions if someone + * adds an unscoped selector in the future. + */ + +const SUPER_EDITOR_STYLES_DIR = resolve(__dirname, '../assets/styles'); + +/** + * Recursively find all .css files in a directory. + */ +function findCssFiles(dir, prefix = '') { + const results = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const rel = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + results.push(...findCssFiles(join(dir, entry.name), rel)); + } else if (entry.name.endsWith('.css')) { + results.push(rel); + } + } + return results; +} + +/** + * Parse top-level CSS selectors from a CSS file string. + * Returns an array of selector strings (only top-level, not inside @media/@keyframes). + */ +function extractTopLevelSelectors(cssText) { + const selectors = []; + let depth = 0; + let current = ''; + let inComment = false; + let inAtRule = false; + + for (let i = 0; i < cssText.length; i++) { + const char = cssText[i]; + const next = cssText[i + 1]; + + // Track comments + if (!inComment && char === '/' && next === '*') { + inComment = true; + i++; + continue; + } + if (inComment && char === '*' && next === '/') { + inComment = false; + i++; + continue; + } + if (inComment) continue; + + // Track @ rules (keyframes, media, etc.) + if (depth === 0 && char === '@') { + inAtRule = true; + current = '@'; + continue; + } + + if (char === '{') { + if (depth === 0) { + const trimmed = current.trim(); + if (trimmed && !inAtRule) { + // Split comma-separated selectors + const parts = trimmed.split(',').map((s) => s.trim()); + selectors.push(...parts); + } + current = ''; + } + depth++; + continue; + } + + if (char === '}') { + depth--; + if (depth === 0) { + inAtRule = false; + current = ''; + } + continue; + } + + if (depth === 0) { + current += char; + } + } + + return selectors; +} + +/** + * Bare HTML element selectors that should NEVER appear at top-level without a parent scope. + * These are the most dangerous: they affect all elements of that type on the page. + */ +const BARE_ELEMENT_PATTERN = + /^(a|p|div|span|table|tr|td|th|ul|ol|li|h[1-6]|img|pre|blockquote|code|input|button|select|textarea|form|label|section|article|nav|header|footer|main|aside)\s*(\{|,|:|\[|>|\+|~|$)/; + +/** + * Check if a selector is scoped under a SuperDoc parent class. + */ +function isScopedSelector(selector) { + // Already scoped under .super-editor, .superdoc, .sd-, .presentation-editor, .tippy-box + const scopedPrefixes = ['.super-editor', '.superdoc', '.sd-', '.presentation-editor', '.tippy-box', '.toolbar-icon']; + + return scopedPrefixes.some((prefix) => selector.startsWith(prefix)); +} + +describe('CSS Bleed Prevention (SD-1850)', () => { + it('should not have bare HTML element selectors at top level', () => { + const cssFiles = findCssFiles(SUPER_EDITOR_STYLES_DIR); + const violations = []; + + for (const file of cssFiles) { + const fullPath = join(SUPER_EDITOR_STYLES_DIR, file); + const cssText = readFileSync(fullPath, 'utf8'); + const selectors = extractTopLevelSelectors(cssText); + + for (const selector of selectors) { + if (BARE_ELEMENT_PATTERN.test(selector)) { + violations.push({ file, selector }); + } + } + } + + if (violations.length > 0) { + const message = violations.map((v) => ` ${v.file}: "${v.selector}"`).join('\n'); + expect.fail( + `Found bare HTML element selectors that will bleed into host apps:\n${message}\n\nScope them under .super-editor (e.g., ".super-editor a { ... }")`, + ); + } + }); + + it('should scope all .ProseMirror selectors under .super-editor', () => { + const cssFiles = findCssFiles(SUPER_EDITOR_STYLES_DIR); + const violations = []; + + for (const file of cssFiles) { + const fullPath = join(SUPER_EDITOR_STYLES_DIR, file); + const cssText = readFileSync(fullPath, 'utf8'); + const selectors = extractTopLevelSelectors(cssText); + + for (const selector of selectors) { + // Check for .ProseMirror selectors not scoped under .super-editor + const isProseMirrorSelector = + selector.startsWith('.ProseMirror') || + selector.startsWith('li.ProseMirror') || + selector.startsWith('img.ProseMirror'); + + if (isProseMirrorSelector && !selector.startsWith('.super-editor')) { + // Allow .ProseMirror inside already-scoped selectors (e.g., .superdoc-field .ProseMirror) + if (!isScopedSelector(selector)) { + violations.push({ file, selector }); + } + } + } + } + + if (violations.length > 0) { + const message = violations.map((v) => ` ${v.file}: "${v.selector}"`).join('\n'); + expect.fail( + `Found .ProseMirror selectors not scoped under .super-editor:\n${message}\n\nPrefix with ".super-editor" (e.g., ".super-editor .ProseMirror { ... }")`, + ); + } + }); + + it('should not have generic utility class names at top level', () => { + const cssFiles = findCssFiles(SUPER_EDITOR_STYLES_DIR); + const violations = []; + + // Generic class names that are commonly used by frameworks (Bootstrap, Tailwind, etc.) + const genericClassNames = ['.sr-only', '.visually-hidden', '.hidden', '.clearfix', '.container', '.row', '.col']; + + for (const file of cssFiles) { + const fullPath = join(SUPER_EDITOR_STYLES_DIR, file); + const cssText = readFileSync(fullPath, 'utf8'); + const selectors = extractTopLevelSelectors(cssText); + + for (const selector of selectors) { + for (const generic of genericClassNames) { + if ( + selector === generic || + selector.startsWith(generic + ' ') || + selector.startsWith(generic + ':') || + selector.startsWith(generic + '.') + ) { + violations.push({ file, selector, generic }); + } + } + } + } + + if (violations.length > 0) { + const message = violations.map((v) => ` ${v.file}: "${v.selector}" (conflicts with "${v.generic}")`).join('\n'); + expect.fail( + `Found generic utility class names that will conflict with host app frameworks:\n${message}\n\nPrefix with "superdoc-" or "sd-" namespace`, + ); + } + }); + + it('extractTopLevelSelectors should parse basic CSS correctly', () => { + const css = ` + .foo { color: red; } + .bar, .baz { color: blue; } + @keyframes spin { to { transform: rotate(360deg); } } + .qux { color: green; } + `; + const selectors = extractTopLevelSelectors(css); + expect(selectors).toContain('.foo'); + expect(selectors).toContain('.bar'); + expect(selectors).toContain('.baz'); + expect(selectors).toContain('.qux'); + // @keyframes should not produce selectors + expect(selectors).not.toContain('@keyframes spin'); + }); + + it('extractTopLevelSelectors should ignore nested selectors', () => { + const css = ` + .parent { + .child { color: red; } + } + a { color: blue; } + `; + const selectors = extractTopLevelSelectors(css); + expect(selectors).toContain('.parent'); + expect(selectors).toContain('a'); + // .child is nested, should not appear + expect(selectors).not.toContain('.child'); + }); +}); From d329461b33d8354521a1888186b24a117f7d99dd Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Sat, 21 Feb 2026 22:23:19 -0300 Subject: [PATCH 2/4] fix(css): address review feedback for SD-1850 scoping - Rename ProseMirror-cursor-blink keyframe to superdoc-cursor-blink - Scope .toolbar-icon__icon--ai selectors under .super-editor - Add @keyframes name lint test requiring superdoc-/sd- prefix - Remove .toolbar-icon from scopedPrefixes allowlist --- .../layout-bridge/src/incrementalLayout.ts | 123 +++++++++++++++--- .../src/assets/styles/elements/ai.css | 8 +- .../assets/styles/elements/prosemirror.css | 4 +- .../src/tests/css-no-bleed.test.js | 30 ++++- 4 files changed, 137 insertions(+), 28 deletions(-) diff --git a/packages/layout-engine/layout-bridge/src/incrementalLayout.ts b/packages/layout-engine/layout-bridge/src/incrementalLayout.ts index c67389d741..89cc9191cb 100644 --- a/packages/layout-engine/layout-bridge/src/incrementalLayout.ts +++ b/packages/layout-engine/layout-bridge/src/incrementalLayout.ts @@ -6,6 +6,7 @@ import type { SectionMetadata, ParagraphBlock, ColumnLayout, + SectionBreakBlock, } from '@superdoc/contracts'; import { layoutDocument, @@ -748,6 +749,13 @@ export async function incrementalLayout( // Perf summary emitted at the end of the function. + // Per-section constraints: each block is measured at its own section's content width. + // This prevents text clipping in mixed-orientation documents (SD-1962) where the old + // global-max approach measured all blocks at the widest section's width, causing line + // breaks to be too wide for narrower sections. + const perSectionConstraints = computePerSectionConstraints(options, nextBlocks); + + // Global max constraints are still used for cache invalidation comparison. const { measurementWidth, measurementHeight } = resolveMeasurementConstraints(options, nextBlocks); if (measurementWidth <= 0 || measurementHeight <= 0) { @@ -765,7 +773,6 @@ export async function incrementalLayout( : null; const measureStart = performance.now(); - const constraints = { maxWidth: measurementWidth, maxHeight: measurementHeight }; const measures: Measure[] = []; let cacheHits = 0; let cacheMisses = 0; @@ -773,12 +780,18 @@ export async function incrementalLayout( let cacheLookupTime = 0; let actualMeasureTime = 0; - for (const block of nextBlocks) { + for (let blockIndex = 0; blockIndex < nextBlocks.length; blockIndex++) { + const block = nextBlocks[blockIndex]; if (block.kind === 'sectionBreak') { measures.push({ kind: 'sectionBreak' }); continue; } + // Use per-section constraints for this block's measurement. + const sectionConstraints = perSectionConstraints[blockIndex]; + const blockMeasureWidth = sectionConstraints.maxWidth; + const blockMeasureHeight = sectionConstraints.maxHeight; + if (canReusePreviousMeasures && dirty.stableBlockIds.has(block.id)) { const previousMeasure = previousMeasuresById?.get(block.id); if (previousMeasure) { @@ -790,7 +803,7 @@ export async function incrementalLayout( // Time the cache lookup (includes hashRuns computation) const lookupStart = performance.now(); - const cached = measureCache.get(block, measurementWidth, measurementHeight); + const cached = measureCache.get(block, blockMeasureWidth, blockMeasureHeight); cacheLookupTime += performance.now() - lookupStart; if (cached) { @@ -801,10 +814,10 @@ export async function incrementalLayout( // Time the actual DOM measurement const measureBlockStart = performance.now(); - const measurement = await measureBlock(block, constraints); + const measurement = await measureBlock(block, sectionConstraints); actualMeasureTime += performance.now() - measureBlockStart; - measureCache.set(block, measurementWidth, measurementHeight, measurement); + measureCache.set(block, blockMeasureWidth, blockMeasureHeight, measurement); measures.push(measurement); cacheMisses++; } @@ -1104,14 +1117,16 @@ export async function incrementalLayout( // Invalidate cache for affected blocks measureCache.invalidate(Array.from(tokenResult.affectedBlockIds)); - // Re-measure affected blocks + // Re-measure affected blocks using per-section constraints const remeasureStart = performance.now(); + const currentPerSectionConstraints = computePerSectionConstraints(options, currentBlocks); currentMeasures = await remeasureAffectedBlocks( currentBlocks, currentMeasures, tokenResult.affectedBlockIds, - constraints, + currentPerSectionConstraints, measureBlock, + measureCache, ); const remeasureEnd = performance.now(); const remeasureTime = remeasureEnd - remeasureStart; @@ -1893,20 +1908,84 @@ const DEFAULT_MARGINS = { top: 72, right: 72, bottom: 72, left: 72 }; export const normalizeMargin = (value: number | undefined, fallback: number): number => Number.isFinite(value) ? (value as number) : fallback; +/** + * Computes measurement constraints for each block based on its section's properties. + * + * In mixed-orientation documents (e.g., portrait + landscape sections), each section has a + * different content width. Measuring ALL blocks at the maximum width (the old approach) + * causes text line breaks to be computed for wider cells than actually rendered, leading to + * text clipping in table cells with `overflow: hidden` (SD-1962). + * + * This function returns a per-block constraint array so each block is measured at its own + * section's content width. Section breaks act as state transitions: each break defines the + * constraints for subsequent content blocks until the next break. + * + * @param options - Layout options containing default page size, margins, and columns + * @param blocks - Array of flow blocks (content + section breaks) + * @returns Array parallel to `blocks` with per-block measurement constraints. + * Section break entries have the constraints of the section they introduce. + */ +function computePerSectionConstraints( + options: LayoutOptions, + blocks: FlowBlock[], +): Array<{ maxWidth: number; maxHeight: number }> { + const pageSize = options.pageSize ?? DEFAULT_PAGE_SIZE; + const defaultMargins = { + top: normalizeMargin(options.margins?.top, DEFAULT_MARGINS.top), + right: normalizeMargin(options.margins?.right, DEFAULT_MARGINS.right), + bottom: normalizeMargin(options.margins?.bottom, DEFAULT_MARGINS.bottom), + left: normalizeMargin(options.margins?.left, DEFAULT_MARGINS.left), + }; + const computeColumnWidth = (contentWidth: number, columns?: { count: number; gap?: number }): number => { + if (!columns || columns.count <= 1) return contentWidth; + const gap = Math.max(0, columns.gap ?? 0); + const totalGap = gap * (columns.count - 1); + return (contentWidth - totalGap) / columns.count; + }; + + const defaultContentWidth = pageSize.w - (defaultMargins.left + defaultMargins.right); + const defaultContentHeight = pageSize.h - (defaultMargins.top + defaultMargins.bottom); + const defaultConstraints = { + maxWidth: computeColumnWidth(defaultContentWidth, options.columns), + maxHeight: defaultContentHeight, + }; + + let current = defaultConstraints; + const result: Array<{ maxWidth: number; maxHeight: number }> = []; + + for (const block of blocks) { + if (block.kind === 'sectionBreak') { + const sb = block as SectionBreakBlock; + const sectionPageSize = sb.pageSize ?? pageSize; + const sectionMargins = { + top: normalizeMargin(sb.margins?.top, defaultMargins.top), + right: normalizeMargin(sb.margins?.right, defaultMargins.right), + bottom: normalizeMargin(sb.margins?.bottom, defaultMargins.bottom), + left: normalizeMargin(sb.margins?.left, defaultMargins.left), + }; + const contentWidth = sectionPageSize.w - (sectionMargins.left + sectionMargins.right); + const contentHeight = sectionPageSize.h - (sectionMargins.top + sectionMargins.bottom); + if (contentWidth > 0 && contentHeight > 0) { + current = { + maxWidth: computeColumnWidth(contentWidth, sb.columns ?? options.columns), + maxHeight: contentHeight, + }; + } + } + result.push(current); + } + + return result; +} + /** * Resolves the maximum measurement constraints (width and height) needed for measuring blocks * across all sections in a document. * * This function scans the entire document (including all section breaks) to determine the * widest column configuration and tallest content area that will be encountered during layout. - * All blocks must be measured at these maximum constraints to ensure they fit correctly when - * placed in any section, preventing remeasurement during pagination. - * - * Why maximum constraints are needed: - * - Documents can have multiple sections with different page sizes, margins, and column counts - * - Each section may have a different effective column width (e.g., 2 columns vs 3 columns) - * - Blocks measured too narrow will overflow when placed in wider sections - * - Blocks measured at maximum width will fit in all sections (may have extra space in narrower ones) + * The result is used for cache invalidation and backward-compatible comparison (see + * `canReusePreviousMeasures`). Actual per-block measurement uses `computePerSectionConstraints`. * * Algorithm: * 1. Start with base content width/height from options.pageSize and options.margins @@ -2054,7 +2133,7 @@ function buildNumberingContext(layout: Layout, sections: SectionMetadata[]): Num * @param blocks - Current blocks array (with resolved tokens) * @param measures - Current measures array (parallel to blocks) * @param affectedBlockIds - Set of block IDs that need re-measurement - * @param constraints - Measurement constraints (width, height) + * @param perBlockConstraints - Per-block measurement constraints (parallel to blocks) * @param measureBlock - Function to measure a block * @returns Updated measures array with re-measured blocks */ @@ -2062,8 +2141,9 @@ async function remeasureAffectedBlocks( blocks: FlowBlock[], measures: Measure[], affectedBlockIds: Set, - constraints: { maxWidth: number; maxHeight: number }, + perBlockConstraints: Array<{ maxWidth: number; maxHeight: number }>, measureBlock: (block: FlowBlock, constraints: { maxWidth: number; maxHeight: number }) => Promise, + measureCache?: MeasureCache, ): Promise { const updatedMeasures: Measure[] = [...measures]; @@ -2076,14 +2156,15 @@ async function remeasureAffectedBlocks( } try { - // Re-measure the block - const newMeasure = await measureBlock(block, constraints); + // Re-measure the block with its section's constraints + const newMeasure = await measureBlock(block, perBlockConstraints[i]); // Update in the measures array updatedMeasures[i] = newMeasure; - // Cache the new measurement - measureCache.set(block, constraints.maxWidth, constraints.maxHeight, newMeasure); + // Cache the new measurement using per-block section constraints + const blockConstraints = perBlockConstraints[i]; + measureCache?.set(block, blockConstraints.maxWidth, blockConstraints.maxHeight, newMeasure); } catch (error) { // Error handling per plan: log warning, keep prior layout for block console.warn(`[incrementalLayout] Failed to re-measure block ${block.id} after token resolution:`, error); diff --git a/packages/super-editor/src/assets/styles/elements/ai.css b/packages/super-editor/src/assets/styles/elements/ai.css index 59f412bc6a..b4cabe4eeb 100644 --- a/packages/super-editor/src/assets/styles/elements/ai.css +++ b/packages/super-editor/src/assets/styles/elements/ai.css @@ -1,16 +1,16 @@ /* Custom toolbar styling */ /* AI button icon styling with gradient */ -.toolbar-icon__icon--ai { +.super-editor .toolbar-icon__icon--ai { position: relative; z-index: 1; } -.toolbar-icon__icon--ai svg { +.super-editor .toolbar-icon__icon--ai svg { fill: transparent; } -.toolbar-icon__icon--ai::before { +.super-editor .toolbar-icon__icon--ai::before { content: ''; position: absolute; top: 0; @@ -33,7 +33,7 @@ transition: filter 0.2s ease; } -.toolbar-icon__icon--ai:hover::before { +.super-editor .toolbar-icon__icon--ai:hover::before { filter: brightness(1.3); } diff --git a/packages/super-editor/src/assets/styles/elements/prosemirror.css b/packages/super-editor/src/assets/styles/elements/prosemirror.css index 9379d7babe..942d698cf2 100644 --- a/packages/super-editor/src/assets/styles/elements/prosemirror.css +++ b/packages/super-editor/src/assets/styles/elements/prosemirror.css @@ -362,10 +362,10 @@ https://github.com/ProseMirror/prosemirror-tables/blob/master/demo/index.html top: -2px; width: 20px; border-top: 1px solid black; - animation: ProseMirror-cursor-blink 1.1s steps(2, start) infinite; + animation: superdoc-cursor-blink 1.1s steps(2, start) infinite; } -@keyframes ProseMirror-cursor-blink { +@keyframes superdoc-cursor-blink { to { visibility: hidden; } diff --git a/packages/super-editor/src/tests/css-no-bleed.test.js b/packages/super-editor/src/tests/css-no-bleed.test.js index d33af8254a..a79b41af3d 100644 --- a/packages/super-editor/src/tests/css-no-bleed.test.js +++ b/packages/super-editor/src/tests/css-no-bleed.test.js @@ -106,7 +106,7 @@ const BARE_ELEMENT_PATTERN = */ function isScopedSelector(selector) { // Already scoped under .super-editor, .superdoc, .sd-, .presentation-editor, .tippy-box - const scopedPrefixes = ['.super-editor', '.superdoc', '.sd-', '.presentation-editor', '.tippy-box', '.toolbar-icon']; + const scopedPrefixes = ['.super-editor', '.superdoc', '.sd-', '.presentation-editor', '.tippy-box']; return scopedPrefixes.some((prefix) => selector.startsWith(prefix)); } @@ -232,4 +232,32 @@ describe('CSS Bleed Prevention (SD-1850)', () => { // .child is nested, should not appear expect(selectors).not.toContain('.child'); }); + + it("should namespace all @keyframes names with superdoc- or sd-", () => { + const cssFiles = findCssFiles(SUPER_EDITOR_STYLES_DIR); + const violations = []; + + for (const file of cssFiles) { + const fullPath = join(SUPER_EDITOR_STYLES_DIR, file); + const cssText = readFileSync(fullPath, "utf8"); + const keyframePattern = /@keyframes\s+([\w-]+)/g; + let match; + + while ((match = keyframePattern.exec(cssText)) !== null) { + const name = match[1]; + if (!name.startsWith("superdoc-") && !name.startsWith("sd-")) { + violations.push({ file, keyframe: name }); + } + } + } + + if (violations.length > 0) { + const message = violations + .map((v) => ` ${v.file}: @keyframes ${v.keyframe}`) + .join("\n"); + expect.fail( + `Found @keyframes names not prefixed with "superdoc-" or "sd-":\n${message}\n\nRename to start with "superdoc-" or "sd-" to avoid collisions with host apps`, + ); + } + }); }); From 1feaaeaf1153f53f0533cfad3f72fafefc40de9f Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Sun, 22 Feb 2026 07:33:51 -0300 Subject: [PATCH 3/4] refactor: move per-section measurement constraints to SD-1962 branch The per-section measurement constraints change belongs to the SD-1962 table pagination fix, not the CSS scoping work. Reverting this file to match main; the change lives in tadeu/sd-1962-nested-table-pagination. --- .../layout-bridge/src/incrementalLayout.ts | 123 +++--------------- 1 file changed, 21 insertions(+), 102 deletions(-) diff --git a/packages/layout-engine/layout-bridge/src/incrementalLayout.ts b/packages/layout-engine/layout-bridge/src/incrementalLayout.ts index 89cc9191cb..c67389d741 100644 --- a/packages/layout-engine/layout-bridge/src/incrementalLayout.ts +++ b/packages/layout-engine/layout-bridge/src/incrementalLayout.ts @@ -6,7 +6,6 @@ import type { SectionMetadata, ParagraphBlock, ColumnLayout, - SectionBreakBlock, } from '@superdoc/contracts'; import { layoutDocument, @@ -749,13 +748,6 @@ export async function incrementalLayout( // Perf summary emitted at the end of the function. - // Per-section constraints: each block is measured at its own section's content width. - // This prevents text clipping in mixed-orientation documents (SD-1962) where the old - // global-max approach measured all blocks at the widest section's width, causing line - // breaks to be too wide for narrower sections. - const perSectionConstraints = computePerSectionConstraints(options, nextBlocks); - - // Global max constraints are still used for cache invalidation comparison. const { measurementWidth, measurementHeight } = resolveMeasurementConstraints(options, nextBlocks); if (measurementWidth <= 0 || measurementHeight <= 0) { @@ -773,6 +765,7 @@ export async function incrementalLayout( : null; const measureStart = performance.now(); + const constraints = { maxWidth: measurementWidth, maxHeight: measurementHeight }; const measures: Measure[] = []; let cacheHits = 0; let cacheMisses = 0; @@ -780,18 +773,12 @@ export async function incrementalLayout( let cacheLookupTime = 0; let actualMeasureTime = 0; - for (let blockIndex = 0; blockIndex < nextBlocks.length; blockIndex++) { - const block = nextBlocks[blockIndex]; + for (const block of nextBlocks) { if (block.kind === 'sectionBreak') { measures.push({ kind: 'sectionBreak' }); continue; } - // Use per-section constraints for this block's measurement. - const sectionConstraints = perSectionConstraints[blockIndex]; - const blockMeasureWidth = sectionConstraints.maxWidth; - const blockMeasureHeight = sectionConstraints.maxHeight; - if (canReusePreviousMeasures && dirty.stableBlockIds.has(block.id)) { const previousMeasure = previousMeasuresById?.get(block.id); if (previousMeasure) { @@ -803,7 +790,7 @@ export async function incrementalLayout( // Time the cache lookup (includes hashRuns computation) const lookupStart = performance.now(); - const cached = measureCache.get(block, blockMeasureWidth, blockMeasureHeight); + const cached = measureCache.get(block, measurementWidth, measurementHeight); cacheLookupTime += performance.now() - lookupStart; if (cached) { @@ -814,10 +801,10 @@ export async function incrementalLayout( // Time the actual DOM measurement const measureBlockStart = performance.now(); - const measurement = await measureBlock(block, sectionConstraints); + const measurement = await measureBlock(block, constraints); actualMeasureTime += performance.now() - measureBlockStart; - measureCache.set(block, blockMeasureWidth, blockMeasureHeight, measurement); + measureCache.set(block, measurementWidth, measurementHeight, measurement); measures.push(measurement); cacheMisses++; } @@ -1117,16 +1104,14 @@ export async function incrementalLayout( // Invalidate cache for affected blocks measureCache.invalidate(Array.from(tokenResult.affectedBlockIds)); - // Re-measure affected blocks using per-section constraints + // Re-measure affected blocks const remeasureStart = performance.now(); - const currentPerSectionConstraints = computePerSectionConstraints(options, currentBlocks); currentMeasures = await remeasureAffectedBlocks( currentBlocks, currentMeasures, tokenResult.affectedBlockIds, - currentPerSectionConstraints, + constraints, measureBlock, - measureCache, ); const remeasureEnd = performance.now(); const remeasureTime = remeasureEnd - remeasureStart; @@ -1908,84 +1893,20 @@ const DEFAULT_MARGINS = { top: 72, right: 72, bottom: 72, left: 72 }; export const normalizeMargin = (value: number | undefined, fallback: number): number => Number.isFinite(value) ? (value as number) : fallback; -/** - * Computes measurement constraints for each block based on its section's properties. - * - * In mixed-orientation documents (e.g., portrait + landscape sections), each section has a - * different content width. Measuring ALL blocks at the maximum width (the old approach) - * causes text line breaks to be computed for wider cells than actually rendered, leading to - * text clipping in table cells with `overflow: hidden` (SD-1962). - * - * This function returns a per-block constraint array so each block is measured at its own - * section's content width. Section breaks act as state transitions: each break defines the - * constraints for subsequent content blocks until the next break. - * - * @param options - Layout options containing default page size, margins, and columns - * @param blocks - Array of flow blocks (content + section breaks) - * @returns Array parallel to `blocks` with per-block measurement constraints. - * Section break entries have the constraints of the section they introduce. - */ -function computePerSectionConstraints( - options: LayoutOptions, - blocks: FlowBlock[], -): Array<{ maxWidth: number; maxHeight: number }> { - const pageSize = options.pageSize ?? DEFAULT_PAGE_SIZE; - const defaultMargins = { - top: normalizeMargin(options.margins?.top, DEFAULT_MARGINS.top), - right: normalizeMargin(options.margins?.right, DEFAULT_MARGINS.right), - bottom: normalizeMargin(options.margins?.bottom, DEFAULT_MARGINS.bottom), - left: normalizeMargin(options.margins?.left, DEFAULT_MARGINS.left), - }; - const computeColumnWidth = (contentWidth: number, columns?: { count: number; gap?: number }): number => { - if (!columns || columns.count <= 1) return contentWidth; - const gap = Math.max(0, columns.gap ?? 0); - const totalGap = gap * (columns.count - 1); - return (contentWidth - totalGap) / columns.count; - }; - - const defaultContentWidth = pageSize.w - (defaultMargins.left + defaultMargins.right); - const defaultContentHeight = pageSize.h - (defaultMargins.top + defaultMargins.bottom); - const defaultConstraints = { - maxWidth: computeColumnWidth(defaultContentWidth, options.columns), - maxHeight: defaultContentHeight, - }; - - let current = defaultConstraints; - const result: Array<{ maxWidth: number; maxHeight: number }> = []; - - for (const block of blocks) { - if (block.kind === 'sectionBreak') { - const sb = block as SectionBreakBlock; - const sectionPageSize = sb.pageSize ?? pageSize; - const sectionMargins = { - top: normalizeMargin(sb.margins?.top, defaultMargins.top), - right: normalizeMargin(sb.margins?.right, defaultMargins.right), - bottom: normalizeMargin(sb.margins?.bottom, defaultMargins.bottom), - left: normalizeMargin(sb.margins?.left, defaultMargins.left), - }; - const contentWidth = sectionPageSize.w - (sectionMargins.left + sectionMargins.right); - const contentHeight = sectionPageSize.h - (sectionMargins.top + sectionMargins.bottom); - if (contentWidth > 0 && contentHeight > 0) { - current = { - maxWidth: computeColumnWidth(contentWidth, sb.columns ?? options.columns), - maxHeight: contentHeight, - }; - } - } - result.push(current); - } - - return result; -} - /** * Resolves the maximum measurement constraints (width and height) needed for measuring blocks * across all sections in a document. * * This function scans the entire document (including all section breaks) to determine the * widest column configuration and tallest content area that will be encountered during layout. - * The result is used for cache invalidation and backward-compatible comparison (see - * `canReusePreviousMeasures`). Actual per-block measurement uses `computePerSectionConstraints`. + * All blocks must be measured at these maximum constraints to ensure they fit correctly when + * placed in any section, preventing remeasurement during pagination. + * + * Why maximum constraints are needed: + * - Documents can have multiple sections with different page sizes, margins, and column counts + * - Each section may have a different effective column width (e.g., 2 columns vs 3 columns) + * - Blocks measured too narrow will overflow when placed in wider sections + * - Blocks measured at maximum width will fit in all sections (may have extra space in narrower ones) * * Algorithm: * 1. Start with base content width/height from options.pageSize and options.margins @@ -2133,7 +2054,7 @@ function buildNumberingContext(layout: Layout, sections: SectionMetadata[]): Num * @param blocks - Current blocks array (with resolved tokens) * @param measures - Current measures array (parallel to blocks) * @param affectedBlockIds - Set of block IDs that need re-measurement - * @param perBlockConstraints - Per-block measurement constraints (parallel to blocks) + * @param constraints - Measurement constraints (width, height) * @param measureBlock - Function to measure a block * @returns Updated measures array with re-measured blocks */ @@ -2141,9 +2062,8 @@ async function remeasureAffectedBlocks( blocks: FlowBlock[], measures: Measure[], affectedBlockIds: Set, - perBlockConstraints: Array<{ maxWidth: number; maxHeight: number }>, + constraints: { maxWidth: number; maxHeight: number }, measureBlock: (block: FlowBlock, constraints: { maxWidth: number; maxHeight: number }) => Promise, - measureCache?: MeasureCache, ): Promise { const updatedMeasures: Measure[] = [...measures]; @@ -2156,15 +2076,14 @@ async function remeasureAffectedBlocks( } try { - // Re-measure the block with its section's constraints - const newMeasure = await measureBlock(block, perBlockConstraints[i]); + // Re-measure the block + const newMeasure = await measureBlock(block, constraints); // Update in the measures array updatedMeasures[i] = newMeasure; - // Cache the new measurement using per-block section constraints - const blockConstraints = perBlockConstraints[i]; - measureCache?.set(block, blockConstraints.maxWidth, blockConstraints.maxHeight, newMeasure); + // Cache the new measurement + measureCache.set(block, constraints.maxWidth, constraints.maxHeight, newMeasure); } catch (error) { // Error handling per plan: log warning, keep prior layout for block console.warn(`[incrementalLayout] Failed to re-measure block ${block.id} after token resolution:`, error); From 53f43a3029402e74d658c281326d26d989f3e1f8 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Mon, 23 Feb 2026 18:52:49 -0800 Subject: [PATCH 4/4] chore: use correct selector scoping --- .../assets/styles/elements/prosemirror.css | 126 +++++++++--------- .../assets/styles/extensions/noderesizer.css | 24 ++-- .../src/tests/css-scoping-regressions.test.js | 39 ++++++ 3 files changed, 114 insertions(+), 75 deletions(-) diff --git a/packages/super-editor/src/assets/styles/elements/prosemirror.css b/packages/super-editor/src/assets/styles/elements/prosemirror.css index 942d698cf2..064e21bf61 100644 --- a/packages/super-editor/src/assets/styles/elements/prosemirror.css +++ b/packages/super-editor/src/assets/styles/elements/prosemirror.css @@ -2,16 +2,16 @@ * Basic ProseMirror styles. * https://github.com/ProseMirror/prosemirror-view/blob/master/style/prosemirror.css * - * All selectors are scoped under .super-editor to prevent bleeding into host apps. + * All selectors are scoped under .sd-editor-scoped to prevent bleeding into host apps. * See: SD-1850 */ -.super-editor .ProseMirror { +.sd-editor-scoped .ProseMirror { position: relative; /* We use "all: revert" to isolate the editor content from external stylesheets. This makes the "contenteditable" not editable on Safari devices. So we need to keep this. */ -webkit-user-modify: read-write-plaintext-only; } -.super-editor .ProseMirror { +.sd-editor-scoped .ProseMirror { word-wrap: break-word; white-space: pre-wrap; white-space: break-spaces; @@ -21,35 +21,35 @@ z-index: 0; /* Needed to place images behind text with lower z-index */ } -.super-editor .ProseMirror pre { +.sd-editor-scoped .ProseMirror pre { white-space: pre-wrap; } -.super-editor .ProseMirror ol, -.super-editor .ProseMirror ul { +.sd-editor-scoped .ProseMirror ol, +.sd-editor-scoped .ProseMirror ul { margin-block-start: 0; margin-block-end: 0; margin-inline-start: 0; margin-inline-end: 0; } -.super-editor .ProseMirror ol, -.super-editor .ProseMirror ul { +.sd-editor-scoped .ProseMirror ol, +.sd-editor-scoped .ProseMirror ul { padding-inline-start: 0; padding-left: 0; list-style: none; } -.super-editor .ProseMirror li::marker { +.sd-editor-scoped .ProseMirror li::marker { content: none; } -.super-editor .ProseMirror li::marker { +.sd-editor-scoped .ProseMirror li::marker { padding: 0; margin: 0; } -.super-editor .ProseMirror li > p { +.sd-editor-scoped .ProseMirror li > p { margin: 0; padding: 0; display: inline-block; @@ -59,44 +59,44 @@ * Hide marker for indented lists. * If a list-item contains a list but doesn't contain a "p" tag with text. */ -.super-editor .ProseMirror ol { +.sd-editor-scoped .ProseMirror ol { margin: 0; } -.super-editor .ProseMirror li:has(> ul:first-child, > ol:first-child):not(:has(> p)) { +.sd-editor-scoped .ProseMirror li:has(> ul:first-child, > ol:first-child):not(:has(> p)) { list-style-type: none; } -.super-editor .ProseMirror li:has(> ul:first-child, > ol:first-child):not(:has(> p))::marker { +.sd-editor-scoped .ProseMirror li:has(> ul:first-child, > ol:first-child):not(:has(> p))::marker { content: ''; } -.super-editor .ProseMirror-hideselection *::selection { +.sd-editor-scoped .ProseMirror-hideselection *::selection { background: transparent; } -.super-editor .ProseMirror-hideselection *::-moz-selection { +.sd-editor-scoped .ProseMirror-hideselection *::-moz-selection { background: transparent; } -.super-editor .ProseMirror-hideselection * { +.sd-editor-scoped .ProseMirror-hideselection * { caret-color: transparent; } /* See https://github.com/ProseMirror/prosemirror/issues/1421#issuecomment-1759320191 */ -.super-editor .ProseMirror [draggable][contenteditable='false'] { +.sd-editor-scoped .ProseMirror [draggable][contenteditable='false'] { user-select: text; } -.super-editor .ProseMirror-selectednode { +.sd-editor-scoped .ProseMirror-selectednode { outline: 2px solid #8cf; } /* Make sure li selections wrap around markers */ -.super-editor li.ProseMirror-selectednode { +.sd-editor-scoped li.ProseMirror-selectednode { outline: none; } -.super-editor li.ProseMirror-selectednode:after { +.sd-editor-scoped li.ProseMirror-selectednode:after { content: ''; position: absolute; left: -32px; @@ -107,24 +107,24 @@ pointer-events: none; } -.super-editor .ProseMirror img { +.sd-editor-scoped .ProseMirror img { height: auto; max-width: 100%; } /* Protect against generic img rules */ -.super-editor img.ProseMirror-separator { +.sd-editor-scoped img.ProseMirror-separator { display: inline !important; border: none !important; margin: 0 !important; } -.super-editor .ProseMirror .sd-editor-tab { +.sd-editor-scoped .ProseMirror .sd-editor-tab { display: inline-block; vertical-align: text-bottom; } -.super-editor .ProseMirror u .sd-editor-tab { +.sd-editor-scoped .ProseMirror u .sd-editor-tab { white-space: pre; border-bottom: 1px solid #000; margin-bottom: 1.5px; @@ -135,12 +135,12 @@ Tables https://github.com/ProseMirror/prosemirror-tables/blob/master/style/tables.css https://github.com/ProseMirror/prosemirror-tables/blob/master/demo/index.html */ -.super-editor .ProseMirror.resize-cursor { +.sd-editor-scoped .ProseMirror.resize-cursor { cursor: ew-resize; cursor: col-resize; } -.super-editor .ProseMirror .tableWrapper { +.sd-editor-scoped .ProseMirror .tableWrapper { --table-border-width: 1px; --offset: 2px; @@ -154,7 +154,7 @@ https://github.com/ProseMirror/prosemirror-tables/blob/master/demo/index.html width: calc(100% + (var(--table-border-width) + var(--offset))); } -.super-editor .ProseMirror table { +.sd-editor-scoped .ProseMirror table { border-collapse: collapse; border-spacing: 0; table-layout: auto; @@ -162,12 +162,12 @@ https://github.com/ProseMirror/prosemirror-tables/blob/master/demo/index.html /* width: 100%; */ } -.super-editor .ProseMirror tr { +.sd-editor-scoped .ProseMirror tr { position: relative; } -.super-editor .ProseMirror td, -.super-editor .ProseMirror th { +.sd-editor-scoped .ProseMirror td, +.sd-editor-scoped .ProseMirror th { min-width: 0; position: relative; vertical-align: top; @@ -175,24 +175,24 @@ https://github.com/ProseMirror/prosemirror-tables/blob/master/demo/index.html overflow-wrap: anywhere; } -.super-editor .ProseMirror td[data-placeholder], -.super-editor .ProseMirror th[data-placeholder] { +.sd-editor-scoped .ProseMirror td[data-placeholder], +.sd-editor-scoped .ProseMirror th[data-placeholder] { padding: 0 !important; border: 0 !important; background: transparent !important; } -.super-editor .ProseMirror td[data-placeholder] > *, -.super-editor .ProseMirror th[data-placeholder] > * { +.sd-editor-scoped .ProseMirror td[data-placeholder] > *, +.sd-editor-scoped .ProseMirror th[data-placeholder] > * { display: none !important; } -.super-editor .ProseMirror th { +.sd-editor-scoped .ProseMirror th { font-weight: bold; text-align: left; } -.super-editor .ProseMirror table .column-resize-handle { +.sd-editor-scoped .ProseMirror table .column-resize-handle { position: absolute; right: -2px; top: 0; @@ -203,7 +203,7 @@ https://github.com/ProseMirror/prosemirror-tables/blob/master/demo/index.html pointer-events: none; } -.super-editor .ProseMirror table .selectedCell:after { +.sd-editor-scoped .ProseMirror table .selectedCell:after { position: absolute; content: ''; left: 0; @@ -217,24 +217,24 @@ https://github.com/ProseMirror/prosemirror-tables/blob/master/demo/index.html /* Tables - end */ /* Track changes */ -.super-editor .ProseMirror .track-insert-dec, -.super-editor .ProseMirror .track-delete-dec, -.super-editor .ProseMirror .track-format-dec { +.sd-editor-scoped .ProseMirror .track-insert-dec, +.sd-editor-scoped .ProseMirror .track-delete-dec, +.sd-editor-scoped .ProseMirror .track-format-dec { pointer-events: none; } -.super-editor .ProseMirror .track-insert-dec.hidden, -.super-editor .ProseMirror .track-delete-dec.hidden { +.sd-editor-scoped .ProseMirror .track-insert-dec.hidden, +.sd-editor-scoped .ProseMirror .track-delete-dec.hidden { display: none; } -.super-editor .ProseMirror .track-insert-dec.highlighted { +.sd-editor-scoped .ProseMirror .track-insert-dec.highlighted { border-top: 1px dashed var(--sd-track-insert-border, #00853d); border-bottom: 1px dashed var(--sd-track-insert-border, #00853d); background-color: var(--sd-track-insert-bg, #399c7222); } -.super-editor .ProseMirror .track-delete-dec.highlighted { +.sd-editor-scoped .ProseMirror .track-delete-dec.highlighted { border-top: 1px dashed var(--sd-track-delete-border, #cb0e47); border-bottom: 1px dashed var(--sd-track-delete-border, #cb0e47); background-color: var(--sd-track-delete-bg, #cb0e4722); @@ -242,21 +242,21 @@ https://github.com/ProseMirror/prosemirror-tables/blob/master/demo/index.html text-decoration-thickness: 2px !important; } -.super-editor .ProseMirror .track-format-dec.highlighted { +.sd-editor-scoped .ProseMirror .track-format-dec.highlighted { border-bottom: 2px solid var(--sd-track-format-border, gold); } -.super-editor .ProseMirror .track-delete-widget { +.sd-editor-scoped .ProseMirror .track-delete-widget { visibility: hidden; } /* Track changes - end */ /* Collaboration cursors */ -.super-editor .ProseMirror > .ProseMirror-yjs-cursor:first-child { +.sd-editor-scoped .ProseMirror > .ProseMirror-yjs-cursor:first-child { margin-top: 16px; } -.super-editor .ProseMirror-yjs-cursor { +.sd-editor-scoped .ProseMirror-yjs-cursor { position: relative; margin-left: -1px; margin-right: -1px; @@ -267,7 +267,7 @@ https://github.com/ProseMirror/prosemirror-tables/blob/master/demo/index.html pointer-events: none; } -.super-editor .ProseMirror-yjs-cursor > div { +.sd-editor-scoped .ProseMirror-yjs-cursor > div { position: absolute; top: -1.05em; left: -1px; @@ -326,7 +326,7 @@ https://github.com/ProseMirror/prosemirror-tables/blob/master/demo/index.html /* Presentation Editor Remote Cursors - end */ /* Footnotes */ -.super-editor .sd-footnote-ref { +.sd-editor-scoped .sd-footnote-ref { font-size: 0.75em; line-height: 1; vertical-align: super; @@ -334,13 +334,13 @@ https://github.com/ProseMirror/prosemirror-tables/blob/master/demo/index.html } /* Image placeholder */ -.super-editor .ProseMirror placeholder { +.sd-editor-scoped .ProseMirror placeholder { display: inline; border: 1px solid #ccc; color: #ccc; } -.super-editor .ProseMirror placeholder:after { +.sd-editor-scoped .ProseMirror placeholder:after { content: '☁'; font-size: 200%; line-height: 0.1; @@ -348,14 +348,14 @@ https://github.com/ProseMirror/prosemirror-tables/blob/master/demo/index.html } /* Gapcursor */ -.super-editor .ProseMirror-gapcursor { +.sd-editor-scoped .ProseMirror-gapcursor { display: none; pointer-events: none; position: absolute; margin: 0; } -.super-editor .ProseMirror-gapcursor:after { +.sd-editor-scoped .ProseMirror-gapcursor:after { content: ''; display: block; position: absolute; @@ -371,18 +371,18 @@ https://github.com/ProseMirror/prosemirror-tables/blob/master/demo/index.html } } -.super-editor .ProseMirror-focused .ProseMirror-gapcursor { +.sd-editor-scoped .ProseMirror-focused .ProseMirror-gapcursor { display: block; } -.super-editor .ProseMirror div[data-type='contentBlock'] { +.sd-editor-scoped .ProseMirror div[data-type='contentBlock'] { position: absolute; outline: none; user-select: none; z-index: -1; } -.super-editor .ProseMirror div[data-horizontal-rule='true'] { +.sd-editor-scoped .ProseMirror div[data-horizontal-rule='true'] { position: relative; z-index: auto; display: inline-block; @@ -391,23 +391,23 @@ https://github.com/ProseMirror/prosemirror-tables/blob/master/demo/index.html align-self: flex-end; } -.super-editor .sd-editor-dropcap { +.sd-editor-scoped .sd-editor-dropcap { float: left; display: flex; align-items: baseline; margin-top: -5px; } -.super-editor .ProseMirror-search-match { +.sd-editor-scoped .ProseMirror-search-match { background-color: #ffff0054; } -.super-editor .ProseMirror-active-search-match { +.sd-editor-scoped .ProseMirror-active-search-match { background-color: #ff6a0054; } -.super-editor .ProseMirror span.sd-custom-selection::selection { +.sd-editor-scoped .ProseMirror span.sd-custom-selection::selection { background: transparent; } -.super-editor .sd-custom-selection { +.sd-editor-scoped .sd-custom-selection { background-color: #d9d9d9; border-radius: 0.1em; } diff --git a/packages/super-editor/src/assets/styles/extensions/noderesizer.css b/packages/super-editor/src/assets/styles/extensions/noderesizer.css index 9cf6f2592d..c256c40d74 100644 --- a/packages/super-editor/src/assets/styles/extensions/noderesizer.css +++ b/packages/super-editor/src/assets/styles/extensions/noderesizer.css @@ -1,12 +1,12 @@ /* Resize handles container */ -.super-editor .sd-editor-resize-container { +.sd-editor-resize-container.sd-editor-scoped { position: absolute; pointer-events: none; z-index: 11; } /* Resize handles */ -.super-editor .sd-editor-resize-handle { +.sd-editor-scoped .sd-editor-resize-handle { position: absolute; width: 12px; height: 12px; @@ -18,53 +18,53 @@ transition: all 0.1s ease; } -.super-editor .sd-editor-resize-handle:hover { +.sd-editor-scoped .sd-editor-resize-handle:hover { background-color: #228be6; transform: scale(1.1); box-shadow: 0 0 6px rgba(0, 0, 0, 0.4); } /* Handle positions */ -.super-editor .sd-editor-resize-handle-nw { +.sd-editor-scoped .sd-editor-resize-handle-nw { top: -6px; left: -6px; cursor: nwse-resize; } -.super-editor .sd-editor-resize-handle-ne { +.sd-editor-scoped .sd-editor-resize-handle-ne { top: -6px; right: -6px; cursor: nesw-resize; } -.super-editor .sd-editor-resize-handle-sw { +.sd-editor-scoped .sd-editor-resize-handle-sw { bottom: -6px; left: -6px; cursor: nesw-resize; } -.super-editor .sd-editor-resize-handle-se { +.sd-editor-scoped .sd-editor-resize-handle-se { bottom: -6px; right: -6px; cursor: nwse-resize; } /* Hide handles when editor loses focus */ -.super-editor .ProseMirror:not(.ProseMirror-focused) .sd-editor-resize-container { +.sd-editor-scoped .ProseMirror:not(.ProseMirror-focused) .sd-editor-resize-container { display: none; } /* Smooth transitions for resizing */ -.super-editor .sd-editor-resizable-wrapper * { +.sd-editor-scoped .sd-editor-resizable-wrapper * { transition: none; } -.super-editor .sd-editor-resizable-wrapper *:not([style*='width']) { +.sd-editor-scoped .sd-editor-resizable-wrapper *:not([style*='width']) { transition: all 0.2s ease; } /* Resize feedback indicator */ -.super-editor .sd-editor-resizable-wrapper::after { +.sd-editor-scoped .sd-editor-resizable-wrapper::after { content: 'Drag corners to resize'; position: absolute; bottom: -25px; @@ -82,6 +82,6 @@ z-index: 12; } -.super-editor .sd-editor-resizable-wrapper:hover::after { +.sd-editor-scoped .sd-editor-resizable-wrapper:hover::after { opacity: 1; } diff --git a/packages/super-editor/src/tests/css-scoping-regressions.test.js b/packages/super-editor/src/tests/css-scoping-regressions.test.js index 4a3cdd7ad2..c61180e206 100644 --- a/packages/super-editor/src/tests/css-scoping-regressions.test.js +++ b/packages/super-editor/src/tests/css-scoping-regressions.test.js @@ -177,4 +177,43 @@ describe('CSS Scoping Regressions', () => { host.remove(); } }); + + it('noderesizer stylesheet should define overlay selectors that do not require .super-editor ancestry', () => { + const cssText = readFileSync(NODE_RESIZER_CSS_PATH, 'utf8'); + const selectors = extractTopLevelSelectors(cssText); + + const overlayContainerSelectors = selectors.filter( + (selector) => + selector.includes('.sd-editor-resize-container') && !selector.includes(':hover') && !selector.includes('::'), + ); + const overlayHandleSelectors = selectors.filter( + (selector) => + selector.includes('.sd-editor-resize-handle') && !selector.includes(':hover') && !selector.includes('::'), + ); + + expect(overlayContainerSelectors.length).toBeGreaterThan(0); + expect(overlayHandleSelectors.length).toBeGreaterThan(0); + expect(overlayContainerSelectors.some((selector) => !selector.includes('.super-editor'))).toBe(true); + expect(overlayHandleSelectors.some((selector) => !selector.includes('.super-editor'))).toBe(true); + }); + + it('prosemirror stylesheet should define core selectors that do not require .super-editor ancestry', () => { + const cssText = readFileSync(PROSEMIRROR_CSS_PATH, 'utf8'); + const selectors = extractTopLevelSelectors(cssText); + + const proseMirrorRootSelectors = selectors.filter(targetsProseMirrorRoot); + const proseMirrorListSelectors = selectors.filter( + (selector) => selector.includes('.ProseMirror ol') || selector.includes('.ProseMirror ul'), + ); + const proseMirrorSelectedNodeSelectors = selectors.filter((selector) => + selector.includes('.ProseMirror-selectednode'), + ); + + expect(proseMirrorRootSelectors.length).toBeGreaterThan(0); + expect(proseMirrorListSelectors.length).toBeGreaterThan(0); + expect(proseMirrorSelectedNodeSelectors.length).toBeGreaterThan(0); + expect(proseMirrorRootSelectors.some((selector) => !selector.includes('.super-editor'))).toBe(true); + expect(proseMirrorListSelectors.some((selector) => !selector.includes('.super-editor'))).toBe(true); + expect(proseMirrorSelectedNodeSelectors.some((selector) => !selector.includes('.super-editor'))).toBe(true); + }); });