Skip to content
Open
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
afc8d81
Implement auto width feature for canvas layout and update related act…
micanipho Aug 19, 2026
1efea30
Refactor canvas zoom handling and add auto width support for sidebar
micanipho Aug 20, 2026
21e7e38
Enhance canvas auto width handling and improve pinch/zoom functionality
micanipho Aug 20, 2026
d6fba27
Refactor canvas width handling: update appsettings, enhance mobile dr…
micanipho Aug 24, 2026
7be7733
Update tooltip for custom screen size input and enhance percentage wi…
micanipho Aug 24, 2026
6579bbb
Merge remote-tracking branch 'upstream/main' into nhlakanipho/en/5012
micanipho Aug 24, 2026
a0dd770
Add invariant check for auto zoom state in canvas reducer
micanipho Aug 25, 2026
3d554b8
feat(canvas): implement percentage-based canvas width management
micanipho Aug 28, 2026
870c2d0
Merge remote-tracking branch 'upstream/main' into nhlakanipho/en/5012…
micanipho Aug 28, 2026
14e456e
Merge branch 'nhlakanipho/en/5012-canvas-percentage' into nhlakanipho…
micanipho Aug 28, 2026
897a8f5
refactor: adjust canvas margin and wrap WorkArea in a div for layout …
micanipho Aug 28, 2026
27eb46c
refactor: improve commit logic in DimensionFieldWrapper to ensure typ…
micanipho Aug 31, 2026
38e3623
Merge remote-tracking branch 'upstream/main' into nhlakanipho/en/5012…
micanipho Aug 31, 2026
c9087c0
fix(canvas): fill the pane exactly instead of stopping short of it
micanipho Aug 31, 2026
a7ddfa6
fix(canvas): publish the measured canvas width before paint
micanipho Aug 31, 2026
42f2d82
refactor(canvas): improve comments and code clarity across multiple f…
micanipho Aug 31, 2026
943d35e
style(canvas): cut the rationale out of the canvas comments
micanipho Aug 31, 2026
a8046d6
fix(dimensions): bound vw only where it is rewritten against the canvas
micanipho Aug 31, 2026
8e75b02
refactor(canvas): update canvas configuration and dimensions handling…
micanipho Sep 1, 2026
98c3827
enhan: enhance dimensions handling in container components
micanipho Sep 1, 2026
4c57002
feat(canvas): add calculateAutoZoom function for dynamic canvas fitting
micanipho Sep 1, 2026
ba6765d
refactor: enhance canvas context API with autoZoom and canvas measure…
micanipho Sep 1, 2026
07be365
fix(designer): stop the Width settings field rewriting stored config
micanipho Sep 2, 2026
e47f6f7
fix(designer): gate canvas measurements to the canvas subtree
micanipho Sep 2, 2026
d0f6dea
fix(designer): accept only plain lengths for script-set designerWidth
micanipho Sep 2, 2026
7ea29b0
fix(designer): republish the surviving canvas measurement on sibling …
micanipho Sep 2, 2026
c72500f
fix(canvas): resolve the initial activeDevice from the viewport, not …
micanipho Sep 2, 2026
9ce8ced
fix(canvas): publish the content-box width, not the border-box width
micanipho Sep 2, 2026
b85338c
perf(canvas): cut per-tick work in the canvas provider and container
micanipho Sep 2, 2026
cb0ac59
fix(designer): reject malformed percent widths; use bare vitest globals
micanipho Sep 2, 2026
020df64
fix(designer): drop the over-wide width notification entirely
micanipho Sep 2, 2026
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
58 changes: 54 additions & 4 deletions shesha-reactjs/src/components/formDesigner/styles/styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,17 @@ export const useStyles = (): typeof useStylesResponse => {
export const useMainStyles = createStyles(({ css, cx, token, prefixCls, iconPrefixCls }) => {
const { SIDEBAR_BTN_HEIGHT, TOOLBAR_HEIGHT, HEADER_HEIGHT } = LAYOUT_CONSTANTS;

// Height the canvas can give the form: the viewport, less the chrome above it (header, toolbar,
// sidebar buttons - the same allowance the side panels use) and less the canvas's own padding and
// bottom margin. Applied as a *minimum*, so the form fills the canvas and can still grow past it.
//
// Exceeding it is what produced the scrollbar reports: a bare 100vh here is taller than the pane
// by the chrome, which the old height pins absorbed by clipping. Once those were released so tall
// forms could scroll, the same overshoot grew the canvas past its pane, and then the page.
// paddingLG * 4 = the canvas padding (top + bottom) plus its bottom margin, which is paddingLG * 2.
const canvasChrome = sheshaStyles.paddingLG * 4;
const formAvailableHeight = `calc(100vh - ${HEADER_HEIGHT} - ${TOOLBAR_HEIGHT} - ${SIDEBAR_BTN_HEIGHT} - ${canvasChrome}px)`;

const {
shaHelpIcon,
shaDragging,
Expand Down Expand Up @@ -132,6 +143,20 @@ export const useMainStyles = createStyles(({ css, cx, token, prefixCls, iconPref
const designerPage = "sha-designer-page";

const formDesigner = cx(formDesignerClassName, css`
/* Bounded to the editor that hosts the designer, so the canvas pane is the element that
scrolls. Without this the frame grows with the form and the whole designer scrolls -
toolbar and tabs included - instead of just the canvas. Scoped to the designer: the
sidebar container is shared, and its embedded uses size to their own content. */
height: 100%;

.sha-designer-main-area {
height: 100%;

> .sidebar-container {
height: 100%;
}
}

.${shaHelpIcon} {
cursor: help;
font-size: 14px;
Expand Down Expand Up @@ -361,7 +386,10 @@ export const useMainStyles = createStyles(({ css, cx, token, prefixCls, iconPref

.${designerWorkArea}{
background-color: white;
height: calc(100vh - ${HEADER_HEIGHT} - ${TOOLBAR_HEIGHT} - ${SIDEBAR_BTN_HEIGHT});
/* min-height, not height: this fills the canvas when the form is short, but a form with
more content than fits has to be able to grow past it - otherwise the overflow is
cut by the canvas instead of becoming something the wrapper can scroll. */
min-height: ${formAvailableHeight};
.${shaComponentsContainer} {

.${shaDropHintContainer} {
Expand All @@ -384,11 +412,33 @@ export const useMainStyles = createStyles(({ css, cx, token, prefixCls, iconPref
}
}

/* Space between components on the canvas. A flex gap, not margins on the
components: margin-top/bottom is exactly what the styling box already writes onto
.sha-component, so a margin here would override whatever spacing a component was
configured with instead of being additional to it. A gap composes with it.
Designer-only - this class is applied in designer mode only, so a rendered form keeps
precisely the spacing it was configured with. */
.${shaComponentsContainer}.vertical > .sha-components-container-inner {
display: flex;
flex-direction: column;
row-gap: ${sheshaStyles.paddingMD}px;

/* Flex would otherwise compress a component taller than the space available. */
> * {
flex-shrink: 0;
}
}

> div {
height: 100%;
/* Same reason as the work area above - a hard 100% here re-pins what that just
released, because these wrap the component list that actually grows. */
min-height: 100%;
> div:not(.sha-drop-hint):not(.sha-drop-hint-container) {
min-height: 100vh;
height: 100%;
/* The same allowance as the work area, not a bare 100vh: this list is nested
inside it, so a full-viewport minimum overshoots the canvas by all the chrome
above it. A percentage cannot be used here - the ancestors have a min-height
rather than a definite height, so a percentage would resolve to auto. */
min-height: ${formAvailableHeight};
}

> .sha-components-container-inner:not(:has(.sha-component)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,12 @@ import { Button, InputNumber, Space, Tooltip } from "antd";
import { ExpandOutlined, MinusOutlined, PlusOutlined } from "@ant-design/icons";
import { useCanvas } from "@/providers";
import { DeviceOptions } from "./mobileDropdown";
import { DEFAULT_OPTIONS } from "@/providers/canvas/utils";
import { clampZoom } from "@/providers/canvas/utils";
import { DEFAULT_OPTIONS } from "@/providers/canvas/constants";
import { useDebouncedCallback } from "use-debounce";

const ZOOM_LEVELS = DEFAULT_OPTIONS.zoomLevels;

const clampZoom = (zoom: number): number =>
Math.max(DEFAULT_OPTIONS.minZoom, Math.min(DEFAULT_OPTIONS.maxZoom, zoom));

const getNextZoomLevel = (currentZoom: number): number =>
ZOOM_LEVELS.find((level) => level > currentZoom) ?? DEFAULT_OPTIONS.maxZoom;

Expand All @@ -20,7 +18,7 @@ const getPrevZoomLevel = (currentZoom: number): number =>

export const CanvasConfig: FC = () => {
const { styles } = useStyles();
const { setCanvasZoom, setCanvasAutoZoom, autoZoom, zoom } = useCanvas();
const { setCanvasZoom, setCanvasAutoZoom, autoZoom, autoWidth, zoom } = useCanvas();

// Local state so the user can type freely before committing the value
const [inputZoom, setInputZoom] = useState<number | null>(zoom);
Expand Down Expand Up @@ -97,12 +95,15 @@ export const CanvasConfig: FC = () => {
<Space orientation="horizontal" size={5} style={{ flexWrap: "nowrap" }}>
<DeviceOptions />
<Space orientation="horizontal" size={2} style={{ flexWrap: "nowrap" }}>
<Tooltip title={autoZoom ? "Auto" : "Manual"}>
<Tooltip title={autoWidth ? "Auto zoom is not needed while the canvas fills the available space" : autoZoom ? "Auto" : "Manual"}>
<Button
size="small"
type="default"
icon={<ExpandOutlined size={14} />}
style={autoZoom ? { color: 'var(--ant-button-default-hover-color)', borderColor: 'var(--ant-button-default-hover-color)' } : {}}
// Auto zoom fits a fixed-width canvas into the pane. With the "Canvas" resolution the
// canvas already fills the pane at whatever zoom is chosen, so there is nothing to fit.
disabled={autoWidth}
style={autoZoom && !autoWidth ? { color: 'var(--ant-button-default-hover-color)', borderColor: 'var(--ant-button-default-hover-color)' } : {}}
title={autoZoom ? "Auto" : "Manual"}
onClick={() => {
setCanvasAutoZoom();
Expand All @@ -119,7 +120,13 @@ export const CanvasConfig: FC = () => {
controls={false}
style={{ width: 56 }}
formatter={(value) => `${value}%`}
parser={(value) => Number((value ?? '').replace('%', ''))}
// An emptied field must parse to NaN, not 0: 0 is a "real" value that survives to
// blur and gets clamped up to minZoom, so clearing the box would jump the canvas to
// 10% instead of restoring the zoom that was there.
parser={(value) => {
const raw = (value ?? '').replace('%', '').trim();
return raw === '' ? Number.NaN : Number(raw);
}}
onFocus={handleInputFocus}
onChange={handleInputZoomChange}
onBlur={commitInputZoom}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,30 +1,53 @@
import { FC } from 'react';
import { Tooltip } from 'antd';
import { App, Tooltip } from 'antd';
import { useCanvas } from '@/providers';
import CustomDropdown from '@/designer-components/_settings/utils/CustomDropdown';
import { CANVAS_PRESET_SENTINEL, defaultDesignerWidth, getDeviceTypeByWidth, screenSizeOptions } from '@/providers/canvas/utils';
import { CANVAS_PRESET_SENTINEL, getDeviceTypeByWidth, MAX_CANVAS_WIDTH_PERCENT, parseCanvasWidthPercent, screenSizeOptions } from '@/providers/canvas/utils';
import { isDefined } from '@/utils/nullables';

export const DeviceOptions: FC = () => {
const { setCanvasWidth, designerWidth } = useCanvas();
const { message } = App.useApp();
const { setCanvasWidth, setCanvasAutoWidth, setCanvasWidthPercent, designerWidth, autoWidth, widthPercent } = useCanvas();

// When the actual width matches the screen's available width, show the Canvas sentinel so it displays as "Canvas" in the dropdown
const displayValue = designerWidth === defaultDesignerWidth ? CANVAS_PRESET_SENTINEL : designerWidth;
// In "Canvas" mode the stored width is whatever was last measured, so show the sentinel rather
// than that number - otherwise the dropdown reads as a device preset that happens to match. A
// percentage below the maximum is shown as itself, so the canvas size on screen is accounted for.
const displayValue = !autoWidth
? designerWidth
: widthPercent < MAX_CANVAS_WIDTH_PERCENT
? `${widthPercent}%`
: CANVAS_PRESET_SENTINEL;

return (
<CustomDropdown
placeholder="Select a device"
optionFilterProp="label"
style={{ width: '120px' }}
size="small"
customTooltip='Add a custom screen size e.g "1024px".'
customTooltip={`Add a custom screen size e.g "1024px". A percentage sizes the canvas to that share of the available space; ${MAX_CANVAS_WIDTH_PERCENT}% is the maximum, so anything larger is applied as ${MAX_CANVAS_WIDTH_PERCENT}%.`}
popupMatchSelectWidth={false}
onChange={(val) => {
// The responsive "Canvas" option fills the available space; treat it as desktop.
if (val === CANVAS_PRESET_SENTINEL || val.includes('%')) {
setCanvasWidth(defaultDesignerWidth, 'desktop');
// The responsive "Canvas" option fills the available space.
if (val === CANVAS_PRESET_SENTINEL) {
setCanvasAutoWidth(true);
return;
}

// A percentage takes that share of the available space. Above the maximum there is nothing
// left to expand into, so the value is overridden - and the user is told, rather than being
// left to wonder why the canvas did not grow to what they typed.
const parsed = parseCanvasWidthPercent(val);
if (isDefined(parsed)) {
if (parsed.wasClamped)
message.warning(`${val.trim()} is wider than the available space. Applied ${parsed.percent}% instead, which is the maximum.`);

setCanvasWidthPercent(parsed.percent);
return;
}

const value = parseInt(val, 10);
// Ignore custom entries that are not a usable width ("abc" would pin the canvas to "NaNpx")
if (!Number.isFinite(value) || value <= 0) return;
setCanvasWidth(value, getDeviceTypeByWidth(value));
}}
value={displayValue}
Expand Down
70 changes: 62 additions & 8 deletions shesha-reactjs/src/components/sidebarContainer/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import {
useState,
useMemo,
useCallback,
useRef,
useLayoutEffect,
} from 'react';
import classNames from 'classnames';

Expand All @@ -14,13 +16,18 @@ import { SidebarPanel } from './sidebarPanel';
import { useStyles } from './styles/styles';
import { SizableColumns } from '../sizableColumns';
import { getPanelSizes } from './utilis';
import { calculateAutoZoom, DEFAULT_OPTIONS, defaultDesignerWidth, usePinchZoom } from '@/providers/canvas/utils';
import { calculateAutoZoom, getCanvasLayoutWidth, usePinchZoom } from '@/providers/canvas/utils';
import { DEFAULT_OPTIONS, defaultDesignerWidth } from '@/providers/canvas/constants';
import { IViewType } from '@/providers/canvas/contexts';
import { useShaFormInstance } from '@/providers/form/providers/shaFormProvider';
import { useCanvas } from '@/providers/canvas';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import { SIDEBAR_COLLAPSE } from '../mainLayout/constant';
import { isDefined, isNullOrWhiteSpace } from '@/utils/nullables';

// useLayoutEffect warns when it runs on the server, where there is nothing to measure anyway.
const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;

export interface ISidebarContainerProps extends PropsWithChildren {
leftSidebarProps?: ISidebarProps | undefined;
rightSidebarProps?: ISidebarProps | undefined;
Expand Down Expand Up @@ -57,8 +64,12 @@ export const SidebarContainer: FC<ISidebarContainerProps> = ({
const storeKey = isNullOrWhiteSpace(storeName) ? 'sidebarContainer.transient' : storeName;
const [isOpenLeftStore, setIsOpenLeftStore] = useLocalStorage(`${storeKey}.isOpenLeft`, false);
const [isOpenRightStore, setIsOpenRightStore] = useLocalStorage(`${storeKey}.isOpenRight`, false);
const { zoom, setCanvasZoom, setViewType, designerWidth, autoZoom, configTreePanelSize } = useCanvas();
const { zoom, setCanvasZoom, setViewType, setAvailableCanvasWidth, designerWidth, autoZoom, autoWidth, widthPercent, configTreePanelSize } = useCanvas();
const [isSidebarCollapsed] = useLocalStorage(SIDEBAR_COLLAPSE, false);
// Content-box width of the area between the sidebars, measured rather than derived from the
// window, so panel drags, collapses and the vertical scrollbar are all accounted for.
const mainAreaRef = useRef<HTMLDivElement>(null);
const [availableWidth, setAvailableWidth] = useState(0);

const isOpenLeft = isNullOrWhiteSpace(storeName) ? isOpenLeftLocal : isOpenLeftStore;
const isOpenRight = isNullOrWhiteSpace(storeName) ? isOpenRightLocal : isOpenRightStore;
Expand Down Expand Up @@ -102,9 +113,12 @@ export const SidebarContainer: FC<ISidebarContainerProps> = ({
return () => window.removeEventListener('resize', handleResize);
}, []);

// Keep the "Canvas" preset in sync with the space actually available. Auto zoom is skipped in
// this mode: the canvas already fills its pane at any zoom, and letting auto zoom react to a
// width that is itself derived from the zoom would feed back on itself.
useEffect(() => {
if (canZoom) {
if (autoZoom) {
if (autoZoom && !autoWidth) {
const newZoom = calculateAutoZoom({
currentZoom: zoom,
designerWidth,
Expand All @@ -119,7 +133,7 @@ export const SidebarContainer: FC<ISidebarContainerProps> = ({
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [canZoom, autoZoom, windowSize.width, designerWidth, currentSizes, configTreePanelSize, setCanvasZoom, viewType, isSidebarCollapsed]);
}, [canZoom, autoZoom, autoWidth, windowSize.width, designerWidth, currentSizes, configTreePanelSize, setCanvasZoom, viewType, isSidebarCollapsed]);

useEffect(() => {
setCurrentSizes(getPanelSizes(isOpenLeft, isOpenRight, leftSidebarProps, rightSidebarProps, allowFullCollapse).sizes);
Expand All @@ -130,6 +144,44 @@ export const SidebarContainer: FC<ISidebarContainerProps> = ({
);

const isDesigner = formMode === 'designer';
const isZoomableCanvas = isDesigner && canZoom;

// Track the width available to the canvas. The content-box excludes the vertical scrollbar, so
// the canvas never has to give up a horizontal scrollbar to make room for it.
//
// Deliberately a layout effect: until the pane is measured, `canvasWidth` falls back to
// `designerWidth`, which in "Canvas" mode is whatever width was last pinned by a device preset.
// Measuring after paint would show the canvas at that stale width for a frame. Running before
// paint means the browser only ever paints the measured width.
useIsomorphicLayoutEffect(() => {
const mainArea = mainAreaRef.current;
if (!isZoomableCanvas || !mainArea) return undefined;

const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
const width = entry.contentBoxSize[0]?.inlineSize ?? entry.contentRect.width;
setAvailableWidth((prev) => (Math.abs(prev - width) < 1 ? prev : width));
}
});
observer.observe(mainArea);
setAvailableWidth(mainArea.clientWidth);

return () => observer.disconnect();
}, [isZoomableCanvas]);

// In "Canvas" mode the canvas is laid out at availableWidth / zoom, so once the CSS zoom is
// applied it renders exactly as wide as its pane: no horizontal scrollbar, components re-wrap
// to the space they have, and each one keeps the scale the user selected.
const canvasWidth = isZoomableCanvas && autoWidth && availableWidth > 0
? getCanvasLayoutWidth(availableWidth, zoom, widthPercent)
: designerWidth;

// Publish it so vw-based component sizing and anything else reading the canvas width agree with
// what is on screen. Guarded in the reducer so it is a no-op outside "Canvas" mode.
useEffect(() => {
if (isZoomableCanvas && autoWidth && availableWidth > 0)
setAvailableCanvasWidth(canvasWidth);
}, [isZoomableCanvas, autoWidth, availableWidth, canvasWidth, setAvailableCanvasWidth]);

const renderSidebar = (side: SidebarPanelPosition): ReactNode => {
const sidebarProps = side === 'left' ? leftSidebarProps : rightSidebarProps;
Expand Down Expand Up @@ -168,9 +220,11 @@ export const SidebarContainer: FC<ISidebarContainerProps> = ({
{renderSidebar('left')}

<div
ref={mainAreaRef}
className={classNames(
styles.sidebarContainerMainArea,
styles.canvasWrapper,
{ [styles.canvasAutoWidth]: isZoomableCanvas && autoWidth },
{ 'both-open': leftSidebarProps?.open === true && rightSidebarProps?.open === true },
{ 'left-only-open': leftSidebarProps?.open === true && rightSidebarProps?.open !== true },
{ 'right-only-open': rightSidebarProps?.open === true && leftSidebarProps?.open !== true },
Expand All @@ -184,10 +238,10 @@ export const SidebarContainer: FC<ISidebarContainerProps> = ({
ref={canvasRef}
className={classNames(
styles.sidebarContainerMainAreaBody,
{ [styles.designerCanvas]: isDesigner && canZoom },
{ [styles.designerCanvas]: isZoomableCanvas },
)}
style={isDesigner && canZoom ? {
width: designerWidth,
style={isZoomableCanvas ? {
width: canvasWidth,
zoom: `${zoom}%`,
} : {}}
>
Expand All @@ -197,7 +251,7 @@ export const SidebarContainer: FC<ISidebarContainerProps> = ({
{renderSidebar('right')}
</SizableColumns>
{/* Dedicated popup container for canvas components - applies zoom transformation */}
{isDesigner && canZoom && (
{isZoomableCanvas && (
<div
id="canvas-popup-container"
className={styles.canvasPopupContainer}
Expand Down
Loading
Loading