From 2123371034d904adcbebec236227e5c4243f6f1d Mon Sep 17 00:00:00 2001 From: mdantonio Date: Wed, 30 Sep 2026 16:35:23 +0200 Subject: [PATCH] fix(import): preserve native video assets and range controls --- .../canvas/canvasFormControls.test.tsx | 14 ++++++++ .../htmlImport/authoredAffordances.test.ts | 21 +++++++++++ .../siteImport/applyAssetRewrites.test.ts | 22 ++++++++++++ src/core/htmlImport/rules.ts | 9 +++++ src/core/siteImport/applyAssetRewrites.ts | 9 ++--- src/core/siteImport/assetPlan.ts | 10 ++---- src/core/siteImport/assetProps.ts | 2 ++ src/modules/base/forms/FormControls.tsx | 19 ++++------ src/modules/base/forms/index.ts | 35 +++--------------- src/modules/base/forms/inputProps.ts | 36 +++++++++++++++++++ 10 files changed, 119 insertions(+), 58 deletions(-) create mode 100644 src/core/siteImport/assetProps.ts create mode 100644 src/modules/base/forms/inputProps.ts diff --git a/src/__tests__/canvas/canvasFormControls.test.tsx b/src/__tests__/canvas/canvasFormControls.test.tsx index 13086d4be..d10239cee 100644 --- a/src/__tests__/canvas/canvasFormControls.test.tsx +++ b/src/__tests__/canvas/canvasFormControls.test.tsx @@ -20,6 +20,20 @@ beforeEach(() => { }) describe('canvas form controls', () => { + it('renders range bounds and step in the editable canvas', async () => { + const site = useEditorStore.getState().createSite('Range controls') + const id = useEditorStore.getState().insertNode('base.input', { + inputType: 'range', min: '2', max: '20', step: '2', value: '6', + }, site.pages[0]!.rootNodeId) + renderCanvas() + const input = await waitForCanvasNodeInFrame('desktop', id) + expect(input.type).toBe('range') + expect(input.min).toBe('2') + expect(input.max).toBe('20') + expect(input.step).toBe('2') + expect(input.value).toBe('6') + }) + // Reproduces the leak that made the next test fail on CI and pass locally. // `it` bodies run in declaration order, so this guarantees the real test // always runs against a store a previous test has already dirtied. In live diff --git a/src/__tests__/htmlImport/authoredAffordances.test.ts b/src/__tests__/htmlImport/authoredAffordances.test.ts index 95f56dcd0..5ac874bc6 100644 --- a/src/__tests__/htmlImport/authoredAffordances.test.ts +++ b/src/__tests__/htmlImport/authoredAffordances.test.ts @@ -14,6 +14,27 @@ import { registry } from '@core/module-engine' import type { PageNode } from '@core/page-tree' import { importHtml } from '@core/htmlImport' +describe('native media and range controls', () => { + it('keeps a range input and its numeric bounds through public rendering', () => { + const node = firstNodeOf('', 'base.input') + expect(node.props).toMatchObject({ inputType: 'range', min: '2', max: '20', step: '2', value: '6' }) + const html = renderNode(node) + expect(html).toContain('type="range"') + expect(html).toContain('min="2"') + expect(html).toContain('max="20"') + expect(html).toContain('step="2"') + }) + + it('keeps video poster, preload, label and playback affordances', () => { + const node = firstNodeOf('', 'base.video') + expect(node.props).toMatchObject({ videoUrl: 'intro.mp4', poster: 'poster.jpg', preload: 'none', title: 'Datacenter', autoplay: true, loop: true, muted: true, playsinline: true }) + const html = renderNode(node) + expect(html).toContain('poster="poster.jpg"') + expect(html).toContain('preload="none"') + expect(html).toContain('src="intro.mp4"') + }) +}) + /** Find the first imported node produced by `moduleId`. */ function firstNodeOf(html: string, moduleId: string): PageNode { const result = importHtml(html) diff --git a/src/__tests__/siteImport/applyAssetRewrites.test.ts b/src/__tests__/siteImport/applyAssetRewrites.test.ts index 6c0ada246..a1fe6f8de 100644 --- a/src/__tests__/siteImport/applyAssetRewrites.test.ts +++ b/src/__tests__/siteImport/applyAssetRewrites.test.ts @@ -9,6 +9,28 @@ import { applyAssetRewrites, buildImportPlan } from '@core/siteImport' import type { ImportPlan } from '@core/siteImport' import { makeSampleFileMap, makeMockSiteDocument } from './mockSite' +describe('native video assets', () => { + it('normalises and rewrites both the video source and poster to uploaded media', () => { + const encoder = new TextEncoder() + const html = '' + const plan = buildImportPlan({ + currentSite: makeMockSiteDocument(), + fileMap: { files: { + 'pages/index.html': { bytes: encoder.encode(html) }, + 'media/intro.mp4': { bytes: new Uint8Array([0, 0, 0, 0]), mimeType: 'video/mp4' }, + 'images/poster.jpg': { bytes: new Uint8Array([255, 216, 255]), mimeType: 'image/jpeg' }, + } }, + }) + const video = Object.values(plan.pages[0]!.nodeFragment.nodes).find(node => node.moduleId === 'base.video')! + expect(video.props).toMatchObject({ videoUrl: 'media/intro.mp4', poster: 'images/poster.jpg', preload: 'none' }) + expect(plan.assets.map(asset => asset.sourcePath).sort()).toEqual(['images/poster.jpg', 'media/intro.mp4']) + const rewritten = applyAssetRewrites(plan, { 'media/intro.mp4': '/uploads/video.mp4', 'images/poster.jpg': '/uploads/poster.jpg' }) + expect(rewritten.pages[0]!.nodeFragment.nodes[video.id]!.props).toMatchObject({ videoUrl: '/uploads/video.mp4', poster: '/uploads/poster.jpg' }) + expect(video.props.videoUrl).toBe('media/intro.mp4') + expect(applyAssetRewrites(rewritten, { 'media/intro.mp4': '/uploads/video.mp4' })).toEqual(rewritten) + }) +}) + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- diff --git a/src/core/htmlImport/rules.ts b/src/core/htmlImport/rules.ts index fd0f13ddd..7931b292d 100644 --- a/src/core/htmlImport/rules.ts +++ b/src/core/htmlImport/rules.ts @@ -62,6 +62,7 @@ const TEXT_INPUT_TYPES = [ 'tel', 'url', 'number', + 'range', 'date', 'time', 'datetime-local', @@ -373,6 +374,7 @@ export const HTML_TO_MODULE_RULES: ImportRule[] = [ autocomplete: attr(el, 'autocomplete'), min: attr(el, 'min'), max: attr(el, 'max'), + step: attr(el, 'step'), minLength: numberAttr(el, 'minlength'), maxLength: numberAttr(el, 'maxlength'), pattern: attr(el, 'pattern'), @@ -608,11 +610,18 @@ export const HTML_TO_MODULE_RULES: ImportRule[] = [ attr(el, 'src') || el.querySelector('source')?.getAttribute('src') || '' + const authoredPreload = normalizedAttr(el, 'preload') + const preload = authoredPreload === 'none' || authoredPreload === 'metadata' || authoredPreload === 'auto' + ? authoredPreload + : el.hasAttribute('preload') ? 'auto' : 'metadata' return { moduleId: 'base.video', props: { videoUrl, + poster: attr(el, 'poster'), + preload, + title: attr(el, 'title') || attr(el, 'aria-label') || 'Video', autoplay: el.hasAttribute('autoplay'), loop: el.hasAttribute('loop'), muted: el.hasAttribute('muted'), diff --git a/src/core/siteImport/applyAssetRewrites.ts b/src/core/siteImport/applyAssetRewrites.ts index 7044c5245..66a2c5c8f 100644 --- a/src/core/siteImport/applyAssetRewrites.ts +++ b/src/core/siteImport/applyAssetRewrites.ts @@ -7,7 +7,7 @@ * newly-uploaded `newUrl`. * * Two surfaces to rewrite: - * 1. Page node props — string values for `src`, `href`, `srcset` that equal + * 1. Page node props — source, link, srcset, video and poster URLs that equal * a FileMap key after normalisation by `assetPlan`, plus imported * `htmlAttributes` bags such as `data-bg-src`. * 2. CSS rule `styles` and `contextStyles` — `url('key')` expressions where @@ -21,12 +21,7 @@ import type { PageNode } from '@core/page-tree' import type { ImportFragment } from '@core/htmlImport' import type { ImportPlan, ImportStylesheet, NewStyleRule, ImportFontFamily } from './types' - -// --------------------------------------------------------------------------- -// Props that may carry normalised FileMap keys in page nodes -// --------------------------------------------------------------------------- - -const URL_BEARING_PROPS: ReadonlySet = new Set(['src', 'href', 'srcset']) +import { URL_BEARING_PROPS } from './assetProps' // --------------------------------------------------------------------------- // Public function diff --git a/src/core/siteImport/assetPlan.ts b/src/core/siteImport/assetPlan.ts index b130389f1..0862e0747 100644 --- a/src/core/siteImport/assetPlan.ts +++ b/src/core/siteImport/assetPlan.ts @@ -4,7 +4,7 @@ * `applyAssetRewrites` can do exact-string replacement. * * Two sources of asset references: - * 1. PageNode props — `src`, `href`, `srcset` values set by the HTML + * 1. PageNode props — source, link, srcset, video and poster URLs set by the HTML * importer from element attributes, plus imported `htmlAttributes` bags * such as `data-bg-src`. * 2. CSS rule styles — `url(...)` payloads recorded by Phase 1's @@ -39,12 +39,7 @@ import type { ImportAsset, } from './types' import { guessMimeType, isImportUploadableMimeType } from './mimeTypes' - -// --------------------------------------------------------------------------- -// Props that may contain relative asset URLs in page nodes -// --------------------------------------------------------------------------- - -const URL_BEARING_PROPS: ReadonlySet = new Set(['src', 'href', 'srcset']) +import { URL_BEARING_PROPS } from './assetProps' // --------------------------------------------------------------------------- // Public types @@ -674,4 +669,3 @@ function replaceRawUrlInValue(value: string, rawUrl: string, fileMapKey: string) const re = new RegExp(`url\\(\\s*(['"]?)${escaped}\\1\\s*\\)`, 'g') return value.replace(re, `url('${fileMapKey}')`) } - diff --git a/src/core/siteImport/assetProps.ts b/src/core/siteImport/assetProps.ts new file mode 100644 index 000000000..012a9ff3b --- /dev/null +++ b/src/core/siteImport/assetProps.ts @@ -0,0 +1,2 @@ +/** Props emitted by HTML import that carry an asset URL or a srcset list. */ +export const URL_BEARING_PROPS = ['src', 'href', 'srcset', 'videoUrl', 'poster'] as const diff --git a/src/modules/base/forms/FormControls.tsx b/src/modules/base/forms/FormControls.tsx index c8d6f25cd..e9cbac28e 100644 --- a/src/modules/base/forms/FormControls.tsx +++ b/src/modules/base/forms/FormControls.tsx @@ -1,3 +1,4 @@ +import type { InputProps } from './inputProps' import type { ModuleComponentProps } from '@core/module-engine' import { normalizeIdentifierValue } from '@core/utils/identifier' @@ -12,18 +13,6 @@ type LabelProps = Record & { targetId: string } -type InputProps = Record & { - inputType: string - name: string - id: string - placeholder: string - value: string - required: boolean - disabled: boolean - readOnly: boolean - autocomplete: string -} - type TextareaProps = Record & { name: string id: string @@ -121,6 +110,12 @@ export function InputEditor({ mcClassName, nodeWrapperProps, props }: ModuleComp disabled={props.disabled} readOnly={props.readOnly} autoComplete={props.autocomplete || undefined} + min={props.min || undefined} + max={props.max || undefined} + step={props.step || undefined} + minLength={props.minLength > 0 ? props.minLength : undefined} + maxLength={props.maxLength > 0 ? props.maxLength : undefined} + pattern={props.pattern || undefined} /> ) } diff --git a/src/modules/base/forms/index.ts b/src/modules/base/forms/index.ts index 053f8c92a..5b12bb0aa 100644 --- a/src/modules/base/forms/index.ts +++ b/src/modules/base/forms/index.ts @@ -6,6 +6,7 @@ */ import type { ModuleDefinition } from '@core/module-engine' import { registry } from '@core/module-engine' +import { InputPropsSchema, type InputProps } from './inputProps' import { Type, Value, type Static } from '@core/utils/typeboxHelpers' import { normalizeIdentifierValue } from '@core/utils/identifier' import { safeUrl } from '@modules/base/utils/escape' @@ -58,38 +59,7 @@ const LabelPropsSchema = Type.Object({ type LabelProps = Static -const InputPropsSchema = Type.Object({ - inputType: Type.Union([ - Type.Literal('text'), - Type.Literal('email'), - Type.Literal('password'), - Type.Literal('search'), - Type.Literal('tel'), - Type.Literal('url'), - Type.Literal('number'), - Type.Literal('date'), - Type.Literal('time'), - Type.Literal('datetime-local'), - Type.Literal('file'), - Type.Literal('hidden'), - ], { default: 'text' }), - fieldId: Type.String({ default: '' }), - name: Type.String({ default: '' }), - id: Type.String({ default: '' }), - placeholder: Type.String({ default: '' }), - value: Type.String({ default: '' }), - required: Type.Boolean({ default: false }), - disabled: Type.Boolean({ default: false }), - readOnly: Type.Boolean({ default: false }), - autocomplete: Type.String({ default: '' }), - min: Type.String({ default: '' }), - max: Type.String({ default: '' }), - minLength: Type.Number({ default: 0 }), - maxLength: Type.Number({ default: 0 }), - pattern: Type.String({ default: '' }), -}) -type InputProps = Static const TextareaPropsSchema = Type.Object({ fieldId: Type.String({ default: '' }), @@ -278,6 +248,7 @@ export const InputModule: ModuleDefinition = { ['autocomplete', props.autocomplete], ['min', props.min], ['max', props.max], + ['step', props.step], ['minlength', positiveNumber(props.minLength)], ['maxlength', positiveNumber(props.maxLength)], ['pattern', props.pattern], @@ -474,6 +445,7 @@ function inputLikeSchema(typeLabel: string): ModuleDefinition['schem 'tel', 'url', 'number', + 'range', 'date', 'time', 'datetime-local', @@ -491,6 +463,7 @@ function inputLikeSchema(typeLabel: string): ModuleDefinition['schem autocomplete: { type: 'text', label: 'Autocomplete' }, min: { type: 'text', label: 'Min' }, max: { type: 'text', label: 'Max' }, + step: { type: 'text', label: 'Step' }, minLength: { type: 'number', label: 'Minimum length' }, maxLength: { type: 'number', label: 'Maximum length' }, pattern: { type: 'text', label: 'Pattern' }, diff --git a/src/modules/base/forms/inputProps.ts b/src/modules/base/forms/inputProps.ts new file mode 100644 index 000000000..8987743f6 --- /dev/null +++ b/src/modules/base/forms/inputProps.ts @@ -0,0 +1,36 @@ +import { Type, type Static } from '@core/utils/typeboxHelpers' + +export const InputPropsSchema = Type.Object({ + inputType: Type.Union([ + Type.Literal('text'), + Type.Literal('email'), + Type.Literal('password'), + Type.Literal('search'), + Type.Literal('tel'), + Type.Literal('url'), + Type.Literal('number'), + Type.Literal('range'), + Type.Literal('date'), + Type.Literal('time'), + Type.Literal('datetime-local'), + Type.Literal('file'), + Type.Literal('hidden'), + ], { default: 'text' }), + fieldId: Type.String({ default: '' }), + name: Type.String({ default: '' }), + id: Type.String({ default: '' }), + placeholder: Type.String({ default: '' }), + value: Type.String({ default: '' }), + required: Type.Boolean({ default: false }), + disabled: Type.Boolean({ default: false }), + readOnly: Type.Boolean({ default: false }), + autocomplete: Type.String({ default: '' }), + min: Type.String({ default: '' }), + max: Type.String({ default: '' }), + step: Type.String({ default: '' }), + minLength: Type.Number({ default: 0 }), + maxLength: Type.Number({ default: 0 }), + pattern: Type.String({ default: '' }), +}) + +export type InputProps = Static