Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 44 additions & 1 deletion packages/super-editor/src/components/SuperEditor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,14 @@ const contextMenuDisabled = computed(() => {
return active?.options ? Boolean(active.options.disableContextMenu) : Boolean(props.options.disableContextMenu);
});

/**
* Computed property that determines if web layout mode is active (OOXML ST_View 'web').
* @returns {boolean} True if viewOptions.layout is 'web'
*/
const isWebLayout = computed(() => {
return props.options.viewOptions?.layout === 'web';
});

/**
* Reactive ruler visibility state.
* Uses a ref with a deep watcher to ensure proper reactivity when options.rulers changes.
Expand Down Expand Up @@ -126,6 +134,12 @@ watch(
* falling back to 8.5in (letter size).
*/
const containerStyle = computed(() => {
// Web layout mode: no min-width, let CSS handle responsive width
if (isWebLayout.value) {
return {};
}

// Print layout mode: use fixed page dimensions
// Default: 8.5 inches at 96 DPI = 816px (letter size)
let maxWidth = 8.5 * 96;

Expand Down Expand Up @@ -1040,7 +1054,7 @@ onBeforeUnmount(() => {
</script>

<template>
<div class="super-editor-container" :style="containerStyle">
<div class="super-editor-container" :class="{ 'web-layout': isWebLayout }" :style="containerStyle">
<!-- Ruler: teleport to external container if specified, otherwise render inline -->
<Teleport v-if="options.rulerContainer && rulersVisible && !!activeEditor" :to="options.rulerContainer">
<div class="ruler-host" :style="rulerHostStyle">
Expand Down Expand Up @@ -1141,6 +1155,35 @@ onBeforeUnmount(() => {
flex-direction: column;
}

/* Web layout mode (OOXML ST_View 'web'): content reflows to fit container */
.super-editor-container.web-layout {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@harbournick lmk if this should be removed

min-height: unset;
min-width: unset;
width: 100%;
}

.super-editor-container.web-layout .super-editor {
width: 100%;
}

.super-editor-container.web-layout .editor-element {
width: 100%;
}

/* Web layout: ensure editor fills screen width and content reflows (WCAG AA) */
.super-editor-container.web-layout :deep(.ProseMirror) {
width: 100%;
max-width: 100%;
overflow-wrap: break-word;
}

.super-editor-container.web-layout :deep(.ProseMirror p),
.super-editor-container.web-layout :deep(.ProseMirror div),
.super-editor-container.web-layout :deep(.ProseMirror li) {
max-width: 100%;
overflow-wrap: break-word;
}

.ruler-host {
display: flex;
justify-content: center;
Expand Down
44 changes: 39 additions & 5 deletions packages/super-editor/src/core/Editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,13 @@ import { ProseMirrorRenderer } from './renderers/ProseMirrorRenderer.js';
declare const __APP_VERSION__: string;
declare const version: string | undefined;

/**
* Constants for layout calculations
*/
const PIXELS_PER_INCH = 96;
const MAX_HEIGHT_BUFFER_PX = 50;
const MAX_WIDTH_BUFFER_PX = 20;

/**
* Image storage structure used by the image extension
*/
Expand Down Expand Up @@ -261,6 +268,7 @@ export class Editor extends EventEmitter<EditorEventMap> {
isCommentsEnabled: false,
isNewFile: false,
scale: 1,
viewOptions: { layout: 'print' },
annotations: false,
isInternal: false,
externalExtensions: [],
Expand Down Expand Up @@ -1011,6 +1019,13 @@ export class Editor extends EventEmitter<EditorEventMap> {
}
}

/**
* Check if web layout mode is enabled (OOXML ST_View 'web')
*/
isWebLayout(): boolean {
return this.options.viewOptions?.layout === 'web';
}

/**
* Focus the editor.
*/
Expand Down Expand Up @@ -1831,19 +1846,38 @@ export class Editor extends EventEmitter<EditorEventMap> {
}

/**
* Get the maximum content size
* Get the maximum content size based on page dimensions and margins
* @returns Size object with width and height in pixels, or empty object if no page size
* @note In web layout mode, returns empty object to skip content constraints.
* CSS max-width: 100% handles responsive display while preserving full resolution.
*/
getMaxContentSize(): { width?: number; height?: number } {
if (!this.converter) return {};

// In web layout mode: skip constraints, let CSS handle responsive sizing
// This preserves full image resolution while CSS max-width: 100% handles display
if (this.isWebLayout()) {
return {};
}

const { pageSize = {}, pageMargins = {} } = this.converter.pageStyles ?? {};
const { width, height } = pageSize;
const { top = 0, bottom = 0, left = 0, right = 0 } = pageMargins;

// All sizes are in inches so we multiply by 96 to get pixels
if (!width || !height) return {};

const maxHeight = height * 96 - top * 96 - bottom * 96 - 50;
const maxWidth = width * 96 - left * 96 - right * 96 - 20;
// Print layout mode: use document margins (inches converted to pixels)
const getMarginPx = (side: 'top' | 'bottom' | 'left' | 'right'): number => {
return (pageMargins?.[side] ?? 0) * PIXELS_PER_INCH;
};

const topPx = getMarginPx('top');
const bottomPx = getMarginPx('bottom');
const leftPx = getMarginPx('left');
const rightPx = getMarginPx('right');

// All sizes are in inches so we multiply by PIXELS_PER_INCH to get pixels
const maxHeight = height * PIXELS_PER_INCH - topPx - bottomPx - MAX_HEIGHT_BUFFER_PX;
const maxWidth = width * PIXELS_PER_INCH - leftPx - rightPx - MAX_WIDTH_BUFFER_PX;
return {
width: maxWidth,
height: maxHeight,
Expand Down
180 changes: 180 additions & 0 deletions packages/super-editor/src/core/Editor.webLayout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import { describe, it, expect, vi, beforeEach, beforeAll } from 'vitest';
import { Editor } from './Editor.js';
import { loadTestDataForEditorTests } from '@tests/helpers/helpers.js';
import { getStarterExtensions } from '@extensions/index.js';

/**
* Tests for web layout mode (OOXML ST_View 'web').
*
* Web layout mode enables responsive document rendering where content
* reflows to fit the container width, similar to web pages. This contrasts
* with print layout mode which maintains fixed page dimensions.
*
* Key behaviors tested:
* - isWebLayout() detection
* - getMaxContentSize() behavior in web vs print layout
*/

let blankDocData: { docx: unknown; media: unknown; mediaFiles: unknown; fonts: unknown };

beforeAll(async () => {
blankDocData = await loadTestDataForEditorTests('blank-doc.docx');
});

function createTestEditor(options: Partial<Parameters<(typeof Editor)['prototype']['constructor']>[0]> = {}) {
return new Editor({
isHeadless: true,
deferDocumentLoad: true,
mode: 'docx',
extensions: getStarterExtensions(),
suppressDefaultDocxStyles: true,
...options,
});
}

function getBlankDocOptions() {
return {
mode: 'docx' as const,
content: blankDocData.docx,
mediaFiles: blankDocData.mediaFiles,
fonts: blankDocData.fonts,
};
}

describe('Editor Web Layout Mode', () => {
describe('isWebLayout()', () => {
it('returns true when viewOptions.layout is "web"', () => {
const editor = createTestEditor({
viewOptions: { layout: 'web' },
});

expect(editor.isWebLayout()).toBe(true);
});

it('returns false when viewOptions.layout is "print"', () => {
const editor = createTestEditor({
viewOptions: { layout: 'print' },
});

expect(editor.isWebLayout()).toBe(false);
});

it('returns false when viewOptions is undefined', () => {
const editor = createTestEditor({
viewOptions: undefined,
});

expect(editor.isWebLayout()).toBe(false);
});

it('returns false when viewOptions.layout is undefined', () => {
const editor = createTestEditor({
viewOptions: {},
});

expect(editor.isWebLayout()).toBe(false);
});
});

describe('getMaxContentSize()', () => {
describe('web layout mode', () => {
it('returns empty object to skip image constraints', async () => {
const editor = createTestEditor({
viewOptions: { layout: 'web' },
});
await editor.open(undefined, getBlankDocOptions());

const size = editor.getMaxContentSize();

// Web layout skips constraints - CSS handles responsive sizing
expect(size).toEqual({});
});

it('returns empty object even when document has page size defined', async () => {
const editor = createTestEditor({
viewOptions: { layout: 'web' },
});
await editor.open(undefined, getBlankDocOptions());

// Verify document has page styles (blank-doc.docx has standard Letter size)
expect(editor.converter?.pageStyles?.pageSize).toBeDefined();

// But web layout still returns empty - let CSS handle it
expect(editor.getMaxContentSize()).toEqual({});
});
});

describe('print layout mode', () => {
it('returns calculated dimensions based on page size and margins', async () => {
const editor = createTestEditor({
viewOptions: { layout: 'print' },
});
await editor.open(undefined, getBlankDocOptions());

const size = editor.getMaxContentSize();

// Print layout should return numeric dimensions
expect(size.width).toBeDefined();
expect(size.height).toBeDefined();
expect(typeof size.width).toBe('number');
expect(typeof size.height).toBe('number');
expect(size.width).toBeGreaterThan(0);
expect(size.height).toBeGreaterThan(0);
});

it('accounts for page margins in calculations', async () => {
const editor = createTestEditor({
viewOptions: { layout: 'print' },
});
await editor.open(undefined, getBlankDocOptions());

const { pageSize = {}, pageMargins = {} } = editor.converter?.pageStyles ?? {};
const PIXELS_PER_INCH = 96;

// Get the actual calculated size
const size = editor.getMaxContentSize();

// Verify margins are subtracted from page dimensions
if (pageSize.width && pageSize.height) {
const expectedMaxWidth =
pageSize.width * PIXELS_PER_INCH -
(pageMargins.left ?? 0) * PIXELS_PER_INCH -
(pageMargins.right ?? 0) * PIXELS_PER_INCH -
20; // MAX_WIDTH_BUFFER_PX

const expectedMaxHeight =
pageSize.height * PIXELS_PER_INCH -
(pageMargins.top ?? 0) * PIXELS_PER_INCH -
(pageMargins.bottom ?? 0) * PIXELS_PER_INCH -
50; // MAX_HEIGHT_BUFFER_PX

expect(size.width).toBe(expectedMaxWidth);
expect(size.height).toBe(expectedMaxHeight);
}
});
});

describe('edge cases', () => {
it('returns empty object when converter is not initialized', () => {
const editor = createTestEditor({
viewOptions: { layout: 'print' },
});
// Don't call open() - converter won't be initialized

expect(editor.getMaxContentSize()).toEqual({});
});

it('returns empty object by default (no viewOptions)', async () => {
const editor = createTestEditor();
await editor.open(undefined, getBlankDocOptions());

// Default behavior - print layout with calculated dimensions
const size = editor.getMaxContentSize();

// Default should be print layout (calculated dimensions)
expect(typeof size.width).toBe('number');
expect(typeof size.height).toBe('number');
});
});
});
});
Loading