Updated project to React 19

This commit is contained in:
Richard Osborne
2025-12-07 17:32:53 +01:00
parent 2153baf627
commit 8fed72d025
70 changed files with 4534 additions and 5309 deletions

View File

@@ -1,23 +1,24 @@
const path = require('path');
import type { StorybookConfig } from '@storybook/react-webpack5';
import path from 'path';
const editorDir = path.join(__dirname, '../../noodl-editor');
const coreLibDir = path.join(__dirname, '../');
module.exports = {
const config: StorybookConfig = {
stories: ['../src/**/*.stories.mdx', '../src/**/*.stories.@(ts|tsx)'],
addons: [
'@storybook/addon-links',
'@storybook/addon-essentials',
'@storybook/addon-interactions',
'@storybook/preset-create-react-app',
'@storybook/addon-measure'
],
framework: '@storybook/react',
core: {
builder: '@storybook/builder-webpack5'
framework: {
name: '@storybook/react-webpack5',
options: {}
},
webpackFinal: (config) => {
const destinationPath = path.resolve(__dirname, '../../noodl-editor');
const addExternalPath = (rules) => {
const addExternalPath = (rules: any[]) => {
for (let i = 0; i < rules.length; i++) {
const rule = rules[i];
if (rule.test && RegExp(rule.test).test('.tsx')) {
@@ -32,17 +33,20 @@ module.exports = {
}
};
addExternalPath(config.module.rules);
if (config.module?.rules) {
addExternalPath(config.module.rules as any[]);
config.module.rules.push({
test: /\.ts$/,
use: [
{
loader: require.resolve('ts-loader')
}
]
});
config.module.rules.push({
test: /\.ts$/,
use: [
{
loader: require.resolve('ts-loader')
}
]
});
}
config.resolve = config.resolve || {};
config.resolve.alias = {
...config.resolve.alias,
'@noodl-core-ui': path.join(coreLibDir, 'src'),
@@ -56,5 +60,10 @@ module.exports = {
};
return config;
},
typescript: {
reactDocgen: 'react-docgen-typescript'
}
};
export default config;

View File

@@ -2,8 +2,8 @@
"name": "@noodl/noodl-core-ui",
"version": "2.7.0",
"scripts": {
"start": "start-storybook -p 6006 -s public",
"build": "build-storybook -s public"
"start": "storybook dev -p 6006",
"build": "storybook build"
},
"eslintConfig": {
"extends": [
@@ -42,14 +42,20 @@
"react-dom": "19.0.0"
},
"devDependencies": {
"@storybook/addon-essentials": "^8.6.14",
"@storybook/addon-interactions": "^8.6.14",
"@storybook/addon-links": "^8.6.14",
"@storybook/addon-measure": "^8.6.14",
"@storybook/react": "^8.6.14",
"@storybook/react-webpack5": "^8.6.14",
"@types/jest": "^27.5.2",
"@types/node": "^16.11.42",
"@types/react": "19.0.0",
"@types/react-dom": "19.0.0",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"babel-plugin-named-exports-order": "^0.0.2",
"prop-types": "^15.8.1",
"sass": "^1.90.0",
"storybook": "^9.1.3",
"storybook": "^8.6.14",
"ts-loader": "^9.5.4",
"typescript": "^4.9.5",
"web-vitals": "^3.5.2",

View File

@@ -1,5 +1,5 @@
import classNames from 'classnames';
import React, { ChangeEventHandler, cloneElement, FocusEventHandler, MouseEventHandler } from 'react';
import React, { ChangeEventHandler, cloneElement, FocusEventHandler, isValidElement, MouseEventHandler, ReactElement } from 'react';
import { InputNotification } from '@noodl-types/globalInputTypes';
@@ -113,7 +113,7 @@ export function Checkbox({
</div>
)}
{children && <div className={css['ChildContainer']}>{cloneElement(children, { isChecked })}</div>}
{children && isValidElement(children) && <div className={css['ChildContainer']}>{cloneElement(children as ReactElement<{ isChecked?: boolean }>, { isChecked })}</div>}
{label && <InputLabelSection label={label} />}
</label>
);

View File

@@ -89,7 +89,7 @@ export function CoreBaseDialog({
}, 50);
}, [isVisible]);
const dialogRef = useRef<HTMLDivElement>();
const dialogRef = useRef<HTMLDivElement>(null);
const [dialogPosition, setDialogPosition] = useState({
x: 0,
y: 0,

View File

@@ -45,7 +45,7 @@ export function Carousel({ activeIndex, items, indicator }: CarouselProps) {
<div style={{ overflow: 'hidden' }}>
<HStack UNSAFE_style={{ width: items.length * 100 + '%' }}>
{items.map((item, index) => (
<VStack key={index} ref={(ref) => (sliderRefs.current[index] = ref)} UNSAFE_style={{ width: '100%' }}>
<VStack key={index} ref={(ref) => { sliderRefs.current[index] = ref; }} UNSAFE_style={{ width: '100%' }}>
{item.slot}
</VStack>
))}

View File

@@ -91,6 +91,10 @@ export function Columns({
}}
>
{toArray(children).map((child, i) => {
// Skip non-element children (null, undefined, boolean)
if (!React.isValidElement(child)) {
return child;
}
return (
<div
className="column-item"

View File

@@ -1,5 +1,5 @@
import classNames from 'classnames';
import React, { cloneElement, MouseEventHandler } from 'react';
import React, { cloneElement, isValidElement, MouseEventHandler, ReactElement } from 'react';
import { Icon, IconName, IconSize } from '@noodl-core-ui/components/common/Icon';
import { BaseDialog, BaseDialogProps } from '@noodl-core-ui/components/layout/BaseDialog';
@@ -100,7 +100,10 @@ export function MenuDialog({
{item.label}
</Label>
</div>
{item.component && cloneElement(item.component(() => onClose()))}
{item.component && (() => {
const component = item.component(() => onClose());
return isValidElement(component) ? cloneElement(component as ReactElement) : component;
})()}
<div className={css['EndSlot']}>
{item.endSlot && typeof item.endSlot === 'string' && <Text>{item.endSlot}</Text>}
{item.endSlot && typeof item.endSlot !== 'string' && item.endSlot}

View File

@@ -1,92 +1,91 @@
import { Slot } from '@noodl-core-ui/types/global';
import classNames from 'classnames';
import React, { CSSProperties, useLayoutEffect, useRef, useState } from 'react';
import css from './PopupSection.module.scss';
/**
* TODO:
* - Remove style prop and replace with "isInactive" prop
*/
export interface PopupSectionProps {
children?: Slot;
title?: string;
className?: string;
maxContentHeight?: number;
hasBottomBorder?: boolean;
hasYPadding?: boolean;
isCenteringChildren?: boolean;
style?: CSSProperties;
contentContainerStyle?: CSSProperties;
}
export function PopupSection({
children,
title,
className,
maxContentHeight,
hasBottomBorder,
hasYPadding,
isCenteringChildren,
style,
contentContainerStyle
}: PopupSectionProps) {
const contentRef = useRef<HTMLDivElement>();
const [shouldScroll, setShouldScroll] = useState(false);
const [hasBeenCalculated, setHasBeenCalculated] = useState(false);
function checkIfShouldScroll() {
if (!contentRef.current || !maxContentHeight) return false;
if (contentRef.current.getBoundingClientRect().height >= maxContentHeight) return true;
return false;
}
useLayoutEffect(() => {
if (contentRef.current) {
setShouldScroll(checkIfShouldScroll());
setHasBeenCalculated(true);
}
}, [contentRef, children]);
return (
<section
className={classNames([css['Root'], hasBottomBorder && css['has-bottom-border'], className])}
style={{
...style,
overflowY: hasBeenCalculated ? undefined : 'hidden',
height: hasBeenCalculated ? undefined : 0
}}
>
{title && (
<header className={css['Header']}>
<h2 className={css['Title']}>{title}</h2>
</header>
)}
{children ? (
<div
className={classNames([
css['Content'],
hasYPadding && css['has-y-padding'],
isCenteringChildren && css['is-centering-children']
])}
ref={contentRef}
style={{
...contentContainerStyle,
height: shouldScroll ? maxContentHeight : undefined,
// @ts-expect-error
overflowY: shouldScroll ? 'overlay' : undefined
}}
>
{children}
</div>
) : (
<div ref={contentRef} />
)}
</section>
);
}
import { Slot } from '@noodl-core-ui/types/global';
import classNames from 'classnames';
import React, { CSSProperties, useLayoutEffect, useRef, useState } from 'react';
import css from './PopupSection.module.scss';
/**
* TODO:
* - Remove style prop and replace with "isInactive" prop
*/
export interface PopupSectionProps {
children?: Slot;
title?: string;
className?: string;
maxContentHeight?: number;
hasBottomBorder?: boolean;
hasYPadding?: boolean;
isCenteringChildren?: boolean;
style?: CSSProperties;
contentContainerStyle?: CSSProperties;
}
export function PopupSection({
children,
title,
className,
maxContentHeight,
hasBottomBorder,
hasYPadding,
isCenteringChildren,
style,
contentContainerStyle
}: PopupSectionProps) {
const contentRef = useRef<HTMLDivElement>(null);
const [shouldScroll, setShouldScroll] = useState(false);
const [hasBeenCalculated, setHasBeenCalculated] = useState(false);
function checkIfShouldScroll() {
if (!contentRef.current || !maxContentHeight) return false;
if (contentRef.current.getBoundingClientRect().height >= maxContentHeight) return true;
return false;
}
useLayoutEffect(() => {
if (contentRef.current) {
setShouldScroll(checkIfShouldScroll());
setHasBeenCalculated(true);
}
}, [contentRef, children]);
return (
<section
className={classNames([css['Root'], hasBottomBorder && css['has-bottom-border'], className])}
style={{
...style,
overflowY: hasBeenCalculated ? undefined : 'hidden',
height: hasBeenCalculated ? undefined : 0
}}
>
{title && (
<header className={css['Header']}>
<h2 className={css['Title']}>{title}</h2>
</header>
)}
{children ? (
<div
className={classNames([
css['Content'],
hasYPadding && css['has-y-padding'],
isCenteringChildren && css['is-centering-children']
])}
ref={contentRef}
style={{
...contentContainerStyle,
height: shouldScroll ? maxContentHeight : undefined,
overflowY: shouldScroll ? 'overlay' : undefined
}}
>
{children}
</div>
) : (
<div ref={contentRef} />
)}
</section>
);
}

View File

@@ -39,7 +39,7 @@ export function PropertyPanelSelectInput({
hasSmallText
}: PropertyPanelSelectInputProps) {
const [isSelectCollapsed, setIsSelectCollapsed] = useState(true);
const rootRef = useRef<HTMLDivElement>();
const rootRef = useRef<HTMLDivElement>(null);
const displayValue = properties?.options.find((option) => option.value === value)?.label;

View File

@@ -50,7 +50,7 @@ export function PropertyPanelSliderInput({
}
const thumbPercentage = useMemo(
() => linearMap(parseInt(value.toString()), properties.min, properties.max, 0, 100),
() => linearMap(parseInt(value.toString()), Number(properties.min), Number(properties.max), 0, 100),
[value, properties]
);

View File

@@ -1,24 +1,18 @@
import React, {
JSXElementConstructor,
ReactChild,
ReactElement,
ReactFragment,
ReactPortal,
ReactText
} from 'react';
export interface UnsafeStyleProps {
UNSAFE_className?: string;
UNSAFE_style?: React.CSSProperties;
}
// FIXME: add generics to be able to specify what exact components are allowed?
export type SingleSlot =
| ReactElement<TSFixme, TSFixme>
| ReactFragment
| ReactPortal
| boolean
| null
| undefined;
export type Slot = SingleSlot | SingleSlot[];
import React, { ReactElement, ReactPortal } from 'react';
export interface UnsafeStyleProps {
UNSAFE_className?: string;
UNSAFE_style?: React.CSSProperties;
}
// FIXME: add generics to be able to specify what exact components are allowed?
// Note: ReactFragment removed in React 19, using React.ReactNode for fragments
export type SingleSlot =
| ReactElement<TSFixme, TSFixme>
| Iterable<React.ReactNode>
| ReactPortal
| boolean
| null
| undefined;
export type Slot = SingleSlot | SingleSlot[];

View File

@@ -103,8 +103,8 @@
"@types/checksum": "^0.1.35",
"@types/jasmine": "^4.6.5",
"@types/jquery": "^3.5.33",
"@types/react": "^19.0.00",
"@types/react-dom": "^19.0.0",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@types/remarkable": "^2.0.8",
"@types/rimraf": "^3.0.2",
"@types/split2": "^3.2.1",

View File

@@ -0,0 +1,234 @@
/**
* Project Backup Utility
*
* Provides automatic backup functionality before project upgrades or migrations.
* Creates timestamped backups of project.json files to prevent data loss.
*
* @module noodl-editor
* @since 2.0.0 (OpenNoodl)
*/
import { filesystem } from '@noodl/platform';
import path from 'path';
export interface BackupResult {
success: boolean;
backupPath?: string;
error?: string;
timestamp?: string;
}
export interface BackupOptions {
/** Maximum number of backups to keep per project (default: 5) */
maxBackups?: number;
/** Custom prefix for backup file names */
prefix?: string;
}
const DEFAULT_MAX_BACKUPS = 5;
const BACKUP_FOLDER_NAME = '.noodl-backups';
/**
* Creates a backup of the project.json file before migration/upgrade.
*
* @param projectDir - The directory containing the project
* @param options - Backup configuration options
* @returns BackupResult with success status and backup path
*
* @example
* ```typescript
* const result = await createProjectBackup('/path/to/project');
* if (result.success) {
* console.log('Backup created at:', result.backupPath);
* }
* ```
*/
export async function createProjectBackup(
projectDir: string,
options: BackupOptions = {}
): Promise<BackupResult> {
const { maxBackups = DEFAULT_MAX_BACKUPS, prefix = 'backup' } = options;
try {
const projectJsonPath = path.join(projectDir, 'project.json');
const backupDir = path.join(projectDir, BACKUP_FOLDER_NAME);
// Check if project.json exists
const exists = await filesystem.exists(projectJsonPath);
if (!exists) {
return {
success: false,
error: 'project.json not found in project directory'
};
}
// Create backup directory if it doesn't exist
await filesystem.makeDirectory(backupDir);
// Generate timestamp for backup filename
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const backupFileName = `${prefix}-${timestamp}.json`;
const backupPath = path.join(backupDir, backupFileName);
// Read original project.json
const projectContent = await filesystem.readFile(projectJsonPath);
// Write backup file
await filesystem.writeFile(backupPath, projectContent);
// Clean up old backups if we exceed maxBackups
await cleanupOldBackups(backupDir, prefix, maxBackups);
return {
success: true,
backupPath,
timestamp
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : String(error)
};
}
}
/**
* Lists all available backups for a project.
*
* @param projectDir - The directory containing the project
* @returns Array of backup file paths sorted by date (newest first)
*/
export async function listProjectBackups(projectDir: string): Promise<string[]> {
try {
const backupDir = path.join(projectDir, BACKUP_FOLDER_NAME);
const exists = await filesystem.exists(backupDir);
if (!exists) {
return [];
}
const files = await filesystem.listDirectory(backupDir);
const backupFiles = files
.filter(f => !f.isDirectory && f.name.endsWith('.json'))
.map(f => f.name)
.sort()
.reverse(); // Newest first
return backupFiles.map(f => path.join(backupDir, f));
} catch {
return [];
}
}
/**
* Restores a project from a backup file.
*
* @param projectDir - The directory containing the project
* @param backupPath - Path to the backup file to restore
* @returns BackupResult with success status
*/
export async function restoreProjectBackup(
projectDir: string,
backupPath: string
): Promise<BackupResult> {
try {
const projectJsonPath = path.join(projectDir, 'project.json');
// Verify backup exists
const backupExists = await filesystem.exists(backupPath);
if (!backupExists) {
return {
success: false,
error: 'Backup file not found'
};
}
// Create a backup of the current state before restoring
// (so user can undo the restore if needed)
const preRestoreBackup = await createProjectBackup(projectDir, {
prefix: 'pre-restore'
});
if (!preRestoreBackup.success) {
console.warn('Could not create pre-restore backup:', preRestoreBackup.error);
}
// Read backup content
const backupContent = await filesystem.readFile(backupPath);
// Write to project.json
await filesystem.writeFile(projectJsonPath, backupContent);
return {
success: true,
backupPath
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : String(error)
};
}
}
/**
* Cleans up old backups, keeping only the most recent ones.
*
* @param backupDir - Directory containing backups
* @param prefix - Prefix to filter backups by
* @param maxBackups - Maximum number of backups to keep
*/
async function cleanupOldBackups(
backupDir: string,
prefix: string,
maxBackups: number
): Promise<void> {
try {
const files = await filesystem.listDirectory(backupDir);
// Filter to only backups with our prefix and sort by name (which includes timestamp)
const backupFileNames = files
.filter(f => !f.isDirectory && f.name.startsWith(prefix) && f.name.endsWith('.json'))
.map(f => f.name)
.sort()
.reverse(); // Newest first
// Remove old backups
if (backupFileNames.length > maxBackups) {
const filesToDelete = backupFileNames.slice(maxBackups);
for (const fileName of filesToDelete) {
const filePath = path.join(backupDir, fileName);
await filesystem.removeFile(filePath);
}
}
} catch (error) {
console.warn('Failed to cleanup old backups:', error);
}
}
/**
* Gets the most recent backup for a project.
*
* @param projectDir - The directory containing the project
* @returns Path to the most recent backup, or null if none exist
*/
export async function getLatestBackup(projectDir: string): Promise<string | null> {
const backups = await listProjectBackups(projectDir);
return backups.length > 0 ? backups[0] : null;
}
/**
* Validates a backup file by attempting to parse it as JSON.
*
* @param backupPath - Path to the backup file
* @returns true if backup is valid JSON, false otherwise
*/
export async function validateBackup(backupPath: string): Promise<boolean> {
try {
const content = await filesystem.readFile(backupPath);
JSON.parse(content);
return true;
} catch {
return false;
}
}

View File

@@ -188,7 +188,7 @@ function CommentForeground(props) {
function CommentControls(props) {
const [showColorPicker, setShowColorPicker] = useState(false);
const colorPickerRef = useRef();
const colorPickerRef = useRef<HTMLDivElement>(null);
const color = getColor(props);

View File

@@ -18,8 +18,7 @@ function DeployPopupChild() {
backgroundColor: '#444444',
position: 'relative',
maxHeight: `calc(90vh - 40px)`,
// @ts-expect-error https://github.com/frenic/csstype/issues/62
overflowY: 'overlay',
overflowY: 'overlay' as React.CSSProperties['overflowY'],
overflowX: 'hidden'
}}
>

View File

@@ -72,12 +72,12 @@ export function EditorTopbar({
deployIsDisabled
}: EditorTopbarProps) {
const urlBarRef = useRef<HTMLInputElement>(null);
const deployButtonRef = useRef();
const warningButtonRef = useRef();
const urlInputRef = useRef();
const zoomLevelTrigger = useRef();
const screenSizeTrigger = useRef();
const previewLayoutTrigger = useRef();
const deployButtonRef = useRef<HTMLSpanElement>(null);
const warningButtonRef = useRef<HTMLDivElement>(null);
const urlInputRef = useRef<HTMLDivElement>(null);
const zoomLevelTrigger = useRef<HTMLDivElement>(null);
const screenSizeTrigger = useRef<HTMLDivElement>(null);
const previewLayoutTrigger = useRef<HTMLDivElement>(null);
const [isDeployVisible, setIsDeployVisible] = useState(false);
const [isWarningsDialogVisible, setIsWarningsDialogVisible] = useState(false);
const [isZoomDialogVisible, setIsZoomDialogVisible] = useState(false);
@@ -162,7 +162,7 @@ export function EditorTopbar({
onClose: () => createNewNodePanel.dispose()
});
}
const rootRef = useRef<HTMLDivElement>();
const rootRef = useRef<HTMLDivElement>(null);
const bounds = useTrackBounds(rootRef);
const isSmall = bounds?.width < 850;

View File

@@ -1,4 +1,4 @@
import algoliasearch from 'algoliasearch/lite';
import { liteClient as algoliasearch } from 'algoliasearch/lite';
import React, { useRef, useState } from 'react';
import { InstantSearch, Hits, Highlight, useSearchBox, Configure } from 'react-instantsearch';
import { platform } from '@noodl/platform';
@@ -17,7 +17,7 @@ import { Title, TitleSize } from '@noodl-core-ui/components/typography/Title';
import css from './HelpCenter.module.scss';
export function HelpCenter() {
const rootRef = useRef();
const rootRef = useRef<HTMLDivElement>(null);
const [version] = useState(platform.getVersion().slice(0, 3));
const [isDialogVisible, setIsDialogVisible] = useState(false);
const [isSearchModalVisible, setIsSearchModalVisible] = useState(false);

View File

@@ -103,7 +103,7 @@ interface ItemProps {
function Item({ item, onSwitchToComponent }: ItemProps) {
let icon = getIconFromItem(item);
const itemRef = useRef<HTMLDivElement>();
const itemRef = useRef<HTMLDivElement>(null);
// change a visual component icon to be a regular component icon in the trail
// @ts-expect-error fix this when we refactor the component sidebar to not use the old HTML templates

View File

@@ -1,5 +1,5 @@
import classNames from 'classnames';
import React, { ReactChild, ReactNode, useEffect, useState } from 'react';
import React, { ReactNode, useEffect, useState } from 'react';
import { NodeType } from '@noodl-constants/NodeType';
import { Collapsible } from '@noodl-core-ui/components/layout/Collapsible';

View File

@@ -1,4 +1,4 @@
import React, { ReactChild, ReactNode } from 'react';
import React, { ReactNode } from 'react';
import css from './NodePickerSection.module.scss';
interface NodePickerSectionProps {

View File

@@ -1,10 +1,10 @@
import { Text, TextSize, TextType } from '@noodl-core-ui/components/typography/Text';
import React, { ReactChild } from 'react';
import React, { ReactNode } from 'react';
import css from './NodePickerSubCategory.module.scss';
interface NodePickerSubCategoryProps {
title: string;
children: ReactChild;
children: ReactNode;
}
export default function NodePickerSubCategory({ title, children }: NodePickerSubCategoryProps) {

View File

@@ -22,7 +22,7 @@ export function SidePanel() {
// All the panel data
const [activeId, setActiveId] = useState(null);
const [panels, setPanels] = useState<Record<string, React.ReactChild>>({});
const [panels, setPanels] = useState<Record<string, React.ReactElement>>({});
useEffect(() => {
// ---

View File

@@ -1,6 +1,6 @@
import { ipcRenderer } from 'electron';
import React from 'react';
import ReactDOM from 'react-dom';
import { createRoot, Root } from 'react-dom/client';
import { platform } from '@noodl/platform';
import { EventDispatcher } from '../../../../shared/utils/EventDispatcher';
@@ -19,6 +19,8 @@ export class CanvasView extends View {
inspectMode: boolean;
selectedNodeId: string | null;
private root: Root | null = null;
props: {
deviceName?: string;
zoom?: number;
@@ -152,7 +154,10 @@ export class CanvasView extends View {
return this.el;
}
renderReact() {
ReactDOM.render(React.createElement(VisualCanvas, this.props), this.el[0]);
if (!this.root) {
this.root = createRoot(this.el[0]);
}
this.root.render(React.createElement(VisualCanvas, this.props as any));
}
setCurrentRoute(route: string) {
const protocol = process.env.ssl ? 'https://' : 'http://';
@@ -171,7 +176,10 @@ export class CanvasView extends View {
});
}
ReactDOM.unmountComponentAtNode(this.el[0]);
if (this.root) {
this.root.unmount();
this.root = null;
}
ipcRenderer.off('editor-api-response', this._onEditorApiResponse);
}
refresh() {

View File

@@ -1,6 +1,6 @@
import { getCurrentWindow, screen } from '@electron/remote';
import React from 'react';
import ReactDOM from 'react-dom';
import { createRoot, Root } from 'react-dom/client';
import { MenuDialog, MenuDialogWidth } from '@noodl-core-ui/components/popups/MenuDialog';
@@ -11,6 +11,8 @@ export function showInspectMenu(items: TSFixme) {
const screenPoint = screen.getCursorScreenPoint();
const [winX, winY] = getCurrentWindow().getPosition();
let root: Root | null = null;
const popout = PopupLayer.instance.showPopout({
content: { el: $(container) },
arrowColor: 'transparent',
@@ -20,11 +22,15 @@ export function showInspectMenu(items: TSFixme) {
},
position: 'top',
onClose: () => {
ReactDOM.unmountComponentAtNode(container);
if (root) {
root.unmount();
root = null;
}
}
});
ReactDOM.render(
root = createRoot(container);
root.render(
<MenuDialog
title="Nodes"
width={MenuDialogWidth.Large}
@@ -34,7 +40,6 @@ export function showInspectMenu(items: TSFixme) {
PopupLayer.instance.hidePopout(popout);
}}
items={items}
/>,
container
/>
);
}

View File

@@ -1,6 +1,6 @@
import { ipcRenderer } from 'electron';
import React from 'react';
import ReactDOM from 'react-dom';
import { createRoot, Root } from 'react-dom/client';
import { ComponentModel } from '@noodl-models/componentmodel';
import { NodeGraphModel, NodeGraphNode } from '@noodl-models/nodegraphmodel';
@@ -24,6 +24,7 @@ export class CreateNewNodePanel extends View {
attachToRoot: boolean;
pos: IVector2;
runtimeType: string;
root: Root | null = null;
static shouldShow(context: { component: ComponentModel; parentModel: NodeGraphNode }) {
const nodeTypes = NodeLibrary.instance.getNodeTypes();
@@ -55,11 +56,14 @@ export class CreateNewNodePanel extends View {
}
dispose() {
ReactDOM.unmountComponentAtNode(this.el[0]);
if (this.root) {
this.root.unmount();
this.root = null;
}
ipcRenderer.send('viewer-show');
}
renderReact(div) {
renderReact(div: HTMLElement) {
const props = {
model: this.model,
parentModel: this.parentModel,
@@ -72,8 +76,10 @@ export class CreateNewNodePanel extends View {
ipcRenderer.send('viewer-hide');
// ... then render the picker
ReactDOM.unmountComponentAtNode(div);
ReactDOM.render(React.createElement(NodePicker, props), div);
if (!this.root) {
this.root = createRoot(div);
}
this.root.render(React.createElement(NodePicker, props));
}
render() {

View File

@@ -22,7 +22,7 @@ function anyToString(value: unknown) {
}
export function CodeDiffDialog({ diff, onClose }: CodeDiffDialogProps) {
const codeEditorRef = useRef();
const codeEditorRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!codeEditorRef.current) {

View File

@@ -1,5 +1,5 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { createRoot, Root } from 'react-dom/client';
import { App } from '@noodl-models/app';
import { KeyCode, KeyMod } from '@noodl-utils/keyboard/KeyCode';
@@ -30,6 +30,7 @@ export class LessonLayer {
steps: ILessonStep[];
el: TSFixme;
refreshTimeout: NodeJS.Timeout;
root: Root | null = null;
constructor() {
this.keyboardCommands = [
@@ -116,12 +117,16 @@ export class LessonLayer {
}
};
ReactDOM.render(React.createElement(LessonLayerView, props), this.div);
if (!this.root) {
this.root = createRoot(this.div);
}
this.root.render(React.createElement(LessonLayerView, props));
}
_render() {
if (this.div) {
ReactDOM.unmountComponentAtNode(this.div);
if (this.root) {
this.root.unmount();
this.root = null;
}
this.div = document.createElement('div');
@@ -320,7 +325,10 @@ export class LessonLayer {
this.model.off(this);
EventDispatcher.instance.off(this);
ReactDOM.unmountComponentAtNode(this.div);
if (this.root) {
this.root.unmount();
this.root = null;
}
}
resize() {}

View File

@@ -1,5 +1,5 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { createRoot } from 'react-dom/client';
import { IconName } from '@noodl-core-ui/components/common/Icon';
@@ -230,7 +230,8 @@ class PageComponentTemplate extends ComponentTemplate {
}
};
const div = document.createElement('div');
ReactDOM.render(React.createElement(PageComponentTemplatePopup, props), div);
const root = createRoot(div);
root.render(React.createElement(PageComponentTemplatePopup, props));
return { el: $(div) };
}

View File

@@ -27,8 +27,8 @@ export interface CodeEditorProps {
}
export function CodeEditor({ model, initialSize, onSave, outEditor }: CodeEditorProps) {
const rootRef = useRef<HTMLDivElement>();
const editorRef = useRef<HTMLDivElement>();
const rootRef = useRef<HTMLDivElement>(null);
const editorRef = useRef<HTMLDivElement>(null);
const [editor, setEditor] = useState<monaco.editor.IStandaloneCodeEditor>(null);
const [size, setSize] = useState<{ width: number; height: number }>({
width: initialSize?.x ?? 700,

View File

@@ -1,6 +1,6 @@
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
import React from 'react';
import ReactDOM from 'react-dom';
import { createRoot, Root } from 'react-dom/client';
import { WarningsModel } from '@noodl-models/warningsmodel';
import { createModel } from '@noodl-utils/CodeEditor';
@@ -66,6 +66,9 @@ export class CodeEditorType extends TypeView {
isPrimary: boolean;
propertyRoot: Root | null = null;
popoutRoot: Root | null = null;
static fromPort(args): TSFixme {
const view = new CodeEditorType();
@@ -97,8 +100,11 @@ export class CodeEditorType extends TypeView {
this.model?.dispose();
this.model = null;
// ReactDOM.unmountComponentAtNode(this.propertyDiv);
ReactDOM.unmountComponentAtNode(this.popoutDiv);
// Unmount popout root
if (this.popoutRoot) {
this.popoutRoot.unmount();
this.popoutRoot = null;
}
WarningsModel.instance.off(this);
}
@@ -107,7 +113,7 @@ export class CodeEditorType extends TypeView {
this.el = this.bindView($(`<div></div>`), this);
super.render();
const _this = this;
const self = this;
const propertyProps: PropertyProps = {
isPrimary: this.isPrimary,
@@ -115,12 +121,13 @@ export class CodeEditorType extends TypeView {
tooltip: this.tooltip,
isDefault: this.isDefault,
onClick(event) {
_this.onLaunchClicked(_this, event.currentTarget, event);
self.onLaunchClicked(self, event.currentTarget, event);
}
};
this.propertyDiv = document.createElement('div');
ReactDOM.render(React.createElement(Property, propertyProps), this.propertyDiv);
this.propertyRoot = createRoot(this.propertyDiv);
this.propertyRoot.render(React.createElement(Property, propertyProps));
return this.propertyDiv;
}
@@ -261,7 +268,8 @@ export class CodeEditorType extends TypeView {
}
this.popoutDiv = document.createElement('div');
ReactDOM.render(React.createElement(CodeEditor, props), this.popoutDiv);
this.popoutRoot = createRoot(this.popoutDiv);
this.popoutRoot.render(React.createElement(CodeEditor, props));
const popoutDiv = this.popoutDiv;
this.parent.showPopout({

View File

@@ -1,5 +1,5 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { createRoot, Root } from 'react-dom/client';
import { ProjectModel } from '@noodl-models/projectmodel';
@@ -94,9 +94,9 @@ export class ColorType extends TypeView {
bindColorPickerToView(this);
}
let colorStylePickerDiv;
let colorStylePickerDiv: HTMLDivElement | undefined;
let colorStylePickerRoot: Root | null = null;
const props = {};
let isShowingColorStylePicker = false;
EventDispatcher.instance.on(
'Model.stylesChanged',
@@ -113,10 +113,10 @@ export class ColorType extends TypeView {
});
this.$('input').on('click', (e) => {
// @ts-expect-error
// @ts-expect-error - Dynamic props assignment for legacy component
delete props.filter; //delete filter in case the user opens/closes multiple times
// @ts-expect-error
// @ts-expect-error - Dynamic props assignment for legacy component
props.onItemSelected = (name) => {
this.parent.setParameter(this.name, name);
this.updateCurrentValue();
@@ -124,36 +124,39 @@ export class ColorType extends TypeView {
};
const current = this.getCurrentValue();
// @ts-expect-error
// @ts-expect-error - Dynamic props assignment for legacy component
props.inputValue = current.value;
colorStylePickerDiv = document.createElement('div');
ReactDOM.render(React.createElement(ColorStylePicker, props), colorStylePickerDiv);
colorStylePickerRoot = createRoot(colorStylePickerDiv);
colorStylePickerRoot.render(React.createElement(ColorStylePicker, props));
this.parent.showPopout({
content: { el: $(colorStylePickerDiv) },
attachTo: this.el,
position: 'right',
onClose: () => {
ReactDOM.unmountComponentAtNode(colorStylePickerDiv);
isShowingColorStylePicker = false;
if (colorStylePickerRoot) {
colorStylePickerRoot.unmount();
colorStylePickerRoot = null;
colorStylePickerDiv = undefined;
}
}
});
isShowingColorStylePicker = true;
e.stopPropagation(); // Stop propagation, otherwise the popup will close
});
this.$('input').on('keyup', (e) => {
if (!isShowingColorStylePicker) {
if (!colorStylePickerRoot) {
return;
}
if (e.key === 'Enter') {
this.parent.hidePopout();
} else {
// @ts-expect-error
// @ts-expect-error - Dynamic props assignment for legacy component
props.filter = e.target.value;
ReactDOM.render(React.createElement(ColorStylePicker, props), colorStylePickerDiv);
colorStylePickerRoot.render(React.createElement(ColorStylePicker, props));
}
});

View File

@@ -1,5 +1,5 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { createRoot } from 'react-dom/client';
import { TypeView } from '../../TypeView';
import { getEditType } from '../../utils';
@@ -53,7 +53,8 @@ export class CurveType extends TypeView {
}
};
const div = document.createElement('div');
ReactDOM.render(React.createElement(require('./curveeditor.jsx'), props), div);
const root = createRoot(div);
root.render(React.createElement(require('./curveeditor.jsx'), props));
const curveEditorView = {
el: $(div)

View File

@@ -1,5 +1,5 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { createRoot } from 'react-dom/client';
import { NodeLibrary } from '@noodl-models/nodelibrary';
@@ -77,7 +77,8 @@ export class IconType extends TypeView {
}
};
const div = document.createElement('div');
ReactDOM.render(React.createElement(IconPicker, props), div);
const root = createRoot(div);
root.render(React.createElement(IconPicker, props));
this.parent.showPopout({
content: {

View File

@@ -1,11 +1,13 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { createRoot, Root } from 'react-dom/client';
import QueryEditor from '../components/QueryEditor';
import { TypeView } from '../TypeView';
import { getEditType } from '../utils';
export class QueryFilterType extends TypeView {
private root: Root | null = null;
static fromPort(args) {
const view = new QueryFilterType();
@@ -33,6 +35,8 @@ export class QueryFilterType extends TypeView {
this.isDefault = false;
};
const div = document.createElement('div');
const renderFilters = () => {
const props = {
filter: this.value,
@@ -40,10 +44,12 @@ export class QueryFilterType extends TypeView {
onChange
};
ReactDOM.render(React.createElement(QueryEditor.Filter, props), div);
if (!this.root) {
this.root = createRoot(div);
}
this.root.render(React.createElement(QueryEditor.Filter, props));
};
const div = document.createElement('div');
renderFilters();
this.el = $(div);

View File

@@ -1,11 +1,13 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { createRoot, Root } from 'react-dom/client';
import QueryEditor from '../components/QueryEditor';
import { TypeView } from '../TypeView';
import { getEditType } from '../utils';
export class QuerySortingType extends TypeView {
private root: Root | null = null;
public static fromPort(args: TSFixme) {
const view = new QuerySortingType();
@@ -34,6 +36,8 @@ export class QuerySortingType extends TypeView {
this.isDefault = false;
};
const div = document.createElement('div');
const renderSorting = () => {
const props = {
sorting: this.value,
@@ -41,10 +45,12 @@ export class QuerySortingType extends TypeView {
onChange
};
ReactDOM.render(React.createElement(QueryEditor.Sorting, props), div);
if (!this.root) {
this.root = createRoot(div);
}
this.root.render(React.createElement(QueryEditor.Sorting, props));
};
const div = document.createElement('div');
renderSorting();
this.el = $(div);

View File

@@ -1,5 +1,5 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { createRoot, Root } from 'react-dom/client';
import { NodeLibrary } from '@noodl-models/nodelibrary';
import { StylesModel } from '@noodl-models/StylesModel';
@@ -54,7 +54,8 @@ export class TextStyleType extends TypeView {
this
);
let textStylePickerDiv;
let textStylePickerDiv: HTMLDivElement | undefined;
let textStylePickerRoot: Root | null = null;
const props = {};
@@ -158,19 +159,21 @@ export class TextStyleType extends TypeView {
});
};
// @ts-expect-error
// @ts-expect-error - Dynamic props assignment for legacy component
props.inputValue = this.$('input').val();
textStylePickerDiv = document.createElement('div');
ReactDOM.render(React.createElement(TextStylePicker, props), textStylePickerDiv);
textStylePickerRoot = createRoot(textStylePickerDiv);
textStylePickerRoot.render(React.createElement(TextStylePicker, props));
this.parent.showPopout({
content: { el: $(textStylePickerDiv) },
attachTo: this.el,
position: 'right',
onClose: () => {
if (textStylePickerDiv) {
ReactDOM.unmountComponentAtNode(textStylePickerDiv);
if (textStylePickerRoot) {
textStylePickerRoot.unmount();
textStylePickerRoot = null;
textStylePickerDiv = undefined;
}
}
@@ -180,16 +183,16 @@ export class TextStyleType extends TypeView {
});
this.$('input').on('keyup', (e) => {
if (!textStylePickerDiv) {
if (!textStylePickerRoot) {
return;
}
if (e.key === 'Enter') {
this.parent.hidePopout();
} else {
// @ts-expect-error
// @ts-expect-error - Dynamic props assignment for legacy component
props.filter = e.target.value;
ReactDOM.render(React.createElement(TextStylePicker, props), textStylePickerDiv);
textStylePickerRoot.render(React.createElement(TextStylePicker, props));
}
});

View File

@@ -1,6 +1,6 @@
import { NodeGraphContextTmp } from '@noodl-contexts/NodeGraphContext/NodeGraphContext';
import React, { useState, useRef } from 'react';
import ReactDOM from 'react-dom';
import { createRoot, Root } from 'react-dom/client';
import { IconName, IconSize } from '@noodl-core-ui/components/common/Icon';
import { IconButton, IconButtonVariant } from '@noodl-core-ui/components/inputs/IconButton';
@@ -234,14 +234,17 @@ export class Pages extends React.Component {
}
};
const div = document.createElement('div');
ReactDOM.render(React.createElement(AddNewPagePopup, props), div);
const root = createRoot(div);
root.render(React.createElement(AddNewPagePopup, props));
PopupLayer.instance.showPopup({
content: { el: $(div) },
// @ts-expect-error
// @ts-expect-error - Legacy class component without proper typing
attachTo: $(this.popupAnchor),
position: 'right',
onClose: function () {}
onClose: function () {
root.unmount();
}
});
}

View File

@@ -1,6 +1,6 @@
import { NodeGraphContextTmp } from '@noodl-contexts/NodeGraphContext/NodeGraphContext';
import React from 'react';
import ReactDOM from 'react-dom';
import { createRoot, Root } from 'react-dom/client';
import { ProjectModel } from '@noodl-models/projectmodel';
@@ -10,6 +10,7 @@ import { Pages } from './Pages';
export class PagesType extends TypeView {
el: TSFixme;
private root: Root | null = null;
static fromPort(args) {
const view = new PagesType();
@@ -50,10 +51,19 @@ export class PagesType extends TypeView {
};
const div = document.createElement('div');
ReactDOM.render(React.createElement(Pages, props), div);
this.root = createRoot(div);
this.root.render(React.createElement(Pages, props));
this.el = $(div);
return this.el;
}
dispose() {
if (this.root) {
this.root.unmount();
this.root = null;
}
TypeView.prototype.dispose.call(this);
}
}

View File

@@ -1,7 +1,7 @@
import { NodeGraphContextTmp } from '@noodl-contexts/NodeGraphContext/NodeGraphContext';
import _ from 'underscore';
import React from 'react';
import ReactDOM from 'react-dom';
import { createRoot, Root } from 'react-dom/client';
import { ComponentModel } from '@noodl-models/componentmodel';
import { getComponentIconType, ComponentIconType } from '@noodl-models/nodelibrary/ComponentIcon';
@@ -34,6 +34,7 @@ export class ComponentPicker {
private reactMount: HTMLElement;
private componentsToShow: ComponentPickerOptions['components'];
private ignoreSheetName: boolean;
private root: Root | null = null;
constructor(args: ComponentPickerOptions) {
this.onItemSelected = args.onItemSelected;
@@ -159,7 +160,10 @@ export class ComponentPicker {
width: MenuDialogWidth.Medium
};
ReactDOM.render(React.createElement(MenuDialog, props), this.reactMount);
if (!this.root) {
this.root = createRoot(this.reactMount);
}
this.root.render(React.createElement(MenuDialog, props));
}
render(target: HTMLElement) {
@@ -168,8 +172,9 @@ export class ComponentPicker {
}
dispose() {
if (this.reactMount) {
ReactDOM.unmountComponentAtNode(this.reactMount);
if (this.root) {
this.root.unmount();
this.root = null;
}
}
}

View File

@@ -137,7 +137,7 @@ export class QueryGroup extends React.Component<QueryGroupProps> {
className={'queryeditor-group' + (this.props.isTopLevel ? ' toplevel' : '')}
style={{ position: 'relative' }}
>
<div className="queryeditor-group-children" ref={(el) => (this.childContainer = el)}>
<div className="queryeditor-group-children" ref={(el) => { this.childContainer = el; }}>
{this.renderChildren()}
</div>
<div className="queryeditor-group-row">

View File

@@ -77,7 +77,7 @@ export class QuerySortingEditor extends React.Component<QuerySortingEditorProps>
{this.sorting !== undefined ? (
<div>
<div className="queryeditor-sorting-rules">
<div ref={(el) => (this.childContainer = el)}>
<div ref={(el) => { this.childContainer = el; }}>
{this.sorting.map((s, idx) => (
<div key={idx /* TODO: Invalid key */}>
<QuerySortingRule

View File

@@ -1,9 +1,11 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { createRoot, Root } from 'react-dom/client';
import PopupLayer from '../../../../popuplayer';
export function openPopup(args) {
let root: Root | null = null;
const onChange = () => {
args.onChange && args.onChange();
renderPopup();
@@ -21,7 +23,10 @@ export function openPopup(args) {
onDelete
};
ReactDOM.render(React.createElement(args.reactComponent, props), div);
if (!root) {
root = createRoot(div);
}
root.render(React.createElement(args.reactComponent, props));
};
const div = document.createElement('div');
@@ -33,7 +38,10 @@ export function openPopup(args) {
attachTo: $(args.attachTo),
position: 'right',
onClose() {
ReactDOM.unmountComponentAtNode(div);
if (root) {
root.unmount();
root = null;
}
}
});
}

View File

@@ -135,7 +135,7 @@ export class PickVariantPopup extends React.Component<PickVariantPopupProps, Sta
<div className="variants-input-container">
<input
className="variants-input"
ref={(ref) => ref && setTimeout(() => ref.focus(), 10)}
ref={(ref) => { if (ref) setTimeout(() => ref.focus(), 10); }}
autoFocus
onKeyUp={this.onKeyUp.bind(this)}
onChange={(e) => (this.newVariantName = e.target.value)}

View File

@@ -1,5 +1,5 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { createRoot, Root } from 'react-dom/client';
import { ProjectModel } from '@noodl-models/projectmodel';
@@ -26,6 +26,7 @@ export class VariantsEditor extends React.Component<VariantsEditorProps, State>
model: VariantsEditorProps['model'];
popout: any;
popupAnchor: HTMLDivElement;
private popupRoot: Root | null = null;
constructor(props: VariantsEditorProps) {
super(props);
@@ -128,7 +129,7 @@ export class VariantsEditor extends React.Component<VariantsEditorProps, State>
}
return (
<div className="variants-editor" ref={(el) => (this.popupAnchor = el)}>
<div className="variants-editor" ref={(el) => { this.popupAnchor = el; }}>
{content}
</div>
);
@@ -185,13 +186,18 @@ export class VariantsEditor extends React.Component<VariantsEditorProps, State>
PopupLayer.instance.hidePopout(this.popout);
}
};
ReactDOM.render(React.createElement(PickVariantPopup, props), div);
this.popupRoot = createRoot(div);
this.popupRoot.render(React.createElement(PickVariantPopup, props));
this.popout = PopupLayer.instance.showPopout({
content: { el: $(div) },
attachTo: $(this.popupAnchor),
position: 'right',
onClose: function () {
onClose: () => {
if (this.popupRoot) {
this.popupRoot.unmount();
this.popupRoot = null;
}
this.popout = undefined;
}
});

View File

@@ -1,5 +1,5 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { createRoot, Root } from 'react-dom/client';
import { TransitionEditor } from './TransitionEditor';
@@ -23,6 +23,7 @@ type State = {
export class VisualStates extends React.Component<VisualStatesProps, State> {
popupAnchor: TSFixme;
private popupRoot: Root | null = null;
constructor(props: VisualStatesProps) {
super(props);
@@ -83,13 +84,20 @@ export class VisualStates extends React.Component<VisualStatesProps, State> {
model: this.props.model,
visualState: this.state.selectedVisualState
};
ReactDOM.render(React.createElement(TransitionEditor, props), div);
this.popupRoot = createRoot(div);
this.popupRoot.render(React.createElement(TransitionEditor, props));
this.props.portsView.showPopout({
arrowColor: '#444444',
content: { el: $(div) },
attachTo: $(this.popupAnchor),
position: 'right'
position: 'right',
onClose: () => {
if (this.popupRoot) {
this.popupRoot.unmount();
this.popupRoot = null;
}
}
});
evt.stopPropagation();
@@ -100,7 +108,7 @@ export class VisualStates extends React.Component<VisualStatesProps, State> {
<div
className="variants-section property-editor-visual-states"
style={{ position: 'relative', display: 'flex', alignItems: 'center' }}
ref={(el) => (this.popupAnchor = el)}
ref={(el) => { this.popupAnchor = el; }}
>
<div className="variants-name-section" onClick={this.onCurrentStateClicked.bind(this)}>
<label>{this.state.selectedVisualState.label} state</label>

View File

@@ -1,7 +1,7 @@
import { NodeGraphContextTmp } from '@noodl-contexts/NodeGraphContext/NodeGraphContext';
import _ from 'underscore';
import React from 'react';
import ReactDOM from 'react-dom';
import { createRoot, Root } from 'react-dom/client';
import { NodeGraphNode } from '@noodl-models/nodegraphmodel';
import { UndoQueue, UndoActionGroup } from '@noodl-models/undo-queue-model';
@@ -27,6 +27,8 @@ export class PropertyEditor extends View {
allowAsRoot: TSFixme;
portsView: TSFixme;
renderPortsViewScheduled: TSFixme;
variantsRoot: Root | null = null;
visualStatesRoot: Root | null = null;
constructor(args) {
super();
@@ -76,7 +78,11 @@ export class PropertyEditor extends View {
this.$('.sidebar-property-editor').removeClass('variants-sidepanel-edit-mode');
}
};
ReactDOM.render(React.createElement(VariantsEditor, props), this.$('.variants')[0]);
const container = this.$('.variants')[0];
if (!this.variantsRoot) {
this.variantsRoot = createRoot(container);
}
this.variantsRoot.render(React.createElement(VariantsEditor, props));
}
}
renderVisualStates() {
@@ -86,7 +92,11 @@ export class PropertyEditor extends View {
onVisualStateChanged: this.onVisualStateChanged.bind(this),
portsView: this.portsView
};
ReactDOM.render(React.createElement(VisualStates, props), this.$('.visual-states')[0]);
const container = this.$('.visual-states')[0];
if (!this.visualStatesRoot) {
this.visualStatesRoot = createRoot(container);
}
this.visualStatesRoot.render(React.createElement(VisualStates, props));
}
}
onVisualStateChanged(state) {

View File

@@ -1,5 +1,5 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { createRoot, Root } from 'react-dom/client';
import View from './view';
export interface ReactViewDefaultProps {
@@ -8,6 +8,7 @@ export interface ReactViewDefaultProps {
export abstract class ReactView<TProps extends ReactViewDefaultProps> extends View {
private props: TProps;
private root: Root | null = null;
public el: any;
@@ -31,14 +32,20 @@ export abstract class ReactView<TProps extends ReactViewDefaultProps> extends Vi
});
}
ReactDOM.render(React.createElement(this.renderReact.bind(this), this.props), this.el[0]);
if (!this.root) {
this.root = createRoot(this.el[0]);
}
this.root.render(React.createElement(this.renderReact.bind(this), this.props));
return this.el;
}
public dispose() {
this.el && ReactDOM.unmountComponentAtNode(this.el[0]);
if (this.root) {
this.root.unmount();
this.root = null;
}
}
protected abstract renderReact(props: TProps): JSX.Element;
protected abstract renderReact(props: TProps): React.JSX.Element;
}

View File

@@ -14,7 +14,8 @@
"@noodl/runtime": "file:../noodl-runtime"
},
"devDependencies": {
"copy-webpack-plugin": "^4.6.0",
"clean-webpack-plugin": "^4.0.0",
"copy-webpack-plugin": "^12.0.2",
"generate-json-webpack-plugin": "^2.0.0",
"ts-loader": "^9.5.4",
"typescript": "^4.9.5"

View File

@@ -6,18 +6,11 @@ const { outPath, runtimeVersion } = require('./constants.js');
const common = require('./webpack.common.js');
const webpack = require('webpack');
const CleanWebpackPlugin = require('clean-webpack-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');
const GenerateJsonPlugin = require('generate-json-webpack-plugin');
const noodlEditorExternalViewerPath = path.join(outPath, 'cloudruntime');
function stripStartDirectories(targetPath, numDirs) {
const p = targetPath.split('/');
p.splice(0, numDirs);
return p.join(path.sep);
}
const prefix = `const { ipcRenderer } = require('electron'); const _noodl_cloud_runtime_version = "${runtimeVersion}";`;
module.exports = merge(common, {
@@ -26,22 +19,23 @@ module.exports = merge(common, {
},
output: {
filename: 'sandbox.viewer.bundle.js',
path: noodlEditorExternalViewerPath
path: noodlEditorExternalViewerPath,
clean: true
},
plugins: [
new webpack.BannerPlugin({
banner: prefix,
raw: true
}),
new CleanWebpackPlugin(noodlEditorExternalViewerPath, {
allowExternal: true
new CopyWebpackPlugin({
patterns: [
{
from: 'static/viewer',
to: '.',
noErrorOnMissing: true
}
]
}),
new CopyWebpackPlugin([
{
from: 'static/viewer/**/*',
transformPath: (targetPath) => stripStartDirectories(targetPath, 2)
}
]),
new GenerateJsonPlugin('manifest.json', {
version: runtimeVersion
})

View File

@@ -30,20 +30,20 @@
"@babel/plugin-proposal-object-rest-spread": "^7.20.7",
"@babel/preset-env": "^7.28.3",
"@babel/preset-react": "^7.27.1",
"@types/jest": "^27.5.2",
"@types/jest": "^29.5.14",
"babel-loader": "^8.4.1",
"clean-webpack-plugin": "^1.0.1",
"copy-webpack-plugin": "^4.6.0",
"css-loader": "^5.0.0",
"jest": "^28.1.0",
"style-loader": "^2.0.0",
"ts-jest": "^28.0.3",
"clean-webpack-plugin": "^4.0.0",
"copy-webpack-plugin": "^12.0.2",
"css-loader": "^6.11.0",
"jest": "^29.7.0",
"style-loader": "^3.3.4",
"ts-jest": "^29.4.1",
"ts-loader": "^9.5.4",
"typescript": "^5.1.3",
"typescript": "^4.9.5",
"webpack": "^5.101.3",
"webpack-bundle-analyzer": "^4.10.2",
"webpack-cli": "^4.10.0",
"webpack-dev-server": "^3.11.2",
"webpack-dev-server": "^4.15.2",
"webpack-merge": "^5.10.0"
}
}

View File

@@ -3,38 +3,35 @@ const { merge } = require('webpack-merge');
const { outPath } = require('./constants.js');
const common = require('./webpack.common.js');
const CleanWebpackPlugin = require('clean-webpack-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');
const noodlEditorExternalDeployPath = path.join(outPath, 'deploy');
function stripStartDirectories(targetPath, numDirs) {
const p = targetPath.split('/');
p.splice(0, numDirs);
return p.join(path.sep);
}
module.exports = merge(common, {
entry: {
deploy: './index.deploy.js'
},
output: {
filename: 'noodl.[name].js',
path: noodlEditorExternalDeployPath
path: noodlEditorExternalDeployPath,
clean: true
},
plugins: [
new CleanWebpackPlugin(noodlEditorExternalDeployPath, {
allowExternal: true
}),
new CopyWebpackPlugin([
{
from: 'static/shared/**/*',
transformPath: (targetPath) => stripStartDirectories(targetPath, 2)
},
{
from: 'static/deploy/**/*',
transformPath: (targetPath) => stripStartDirectories(targetPath, 2)
}
])
new CopyWebpackPlugin({
patterns: [
{
from: 'static/shared',
to: '.',
noErrorOnMissing: true,
info: { minimized: true }
},
{
from: 'static/deploy',
to: '.',
noErrorOnMissing: true,
info: { minimized: true }
}
]
})
]
});

View File

@@ -1,39 +1,30 @@
const path = require('path');
const { outPath } = require('./constants.js');
const CleanWebpackPlugin = require('clean-webpack-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');
const noodlEditorExternalDeployPath = path.join(outPath, 'ssr');
function stripStartDirectories(targetPath, numDirs) {
const p = targetPath.split('/');
p.splice(0, numDirs);
return p.join(path.sep);
}
module.exports = {
entry: {
deploy: './index.ssr.js'
},
output: {
filename: 'noodl.[name].js',
path: noodlEditorExternalDeployPath
path: noodlEditorExternalDeployPath,
clean: true
},
plugins: [
new CleanWebpackPlugin(noodlEditorExternalDeployPath, {
allowExternal: true
}),
new CopyWebpackPlugin([
// {
// from: 'static/shared/**/*',
// transformPath: (targetPath) => stripStartDirectories(targetPath, 2)
// },
{
from: 'static/ssr/**/*',
transformPath: (targetPath) => stripStartDirectories(targetPath, 2)
}
])
new CopyWebpackPlugin({
patterns: [
{
from: 'static/ssr',
to: '.',
noErrorOnMissing: true,
info: { minimized: true }
}
]
})
],
externals: {
react: 'React',

View File

@@ -5,38 +5,35 @@ const { merge } = require('webpack-merge');
const { outPath } = require('./constants.js');
const common = require('./webpack.common.js');
const CleanWebpackPlugin = require('clean-webpack-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');
const noodlEditorExternalViewerPath = path.join(outPath, 'viewer');
function stripStartDirectories(targetPath, numDirs) {
const p = targetPath.split('/');
p.splice(0, numDirs);
return p.join(path.sep);
}
module.exports = merge(common, {
entry: {
viewer: './index.viewer.js'
},
output: {
filename: 'noodl.[name].js',
path: noodlEditorExternalViewerPath
path: noodlEditorExternalViewerPath,
clean: true
},
plugins: [
new CleanWebpackPlugin(noodlEditorExternalViewerPath, {
allowExternal: true
}),
new CopyWebpackPlugin([
{
from: 'static/shared/**/*',
transformPath: (targetPath) => stripStartDirectories(targetPath, 2)
},
{
from: 'static/viewer/**/*',
transformPath: (targetPath) => stripStartDirectories(targetPath, 2)
}
])
new CopyWebpackPlugin({
patterns: [
{
from: 'static/shared',
to: '.',
noErrorOnMissing: true,
info: { minimized: true }
},
{
from: 'static/viewer',
to: '.',
noErrorOnMissing: true,
info: { minimized: true }
}
]
})
]
});