Skip to content
Draft
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
14 changes: 14 additions & 0 deletions src/__tests__/canvas/canvasFormControls.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLInputElement>('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
Expand Down
21 changes: 21 additions & 0 deletions src/__tests__/htmlImport/authoredAffordances.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('<input type="range" min="2" max="20" step="2" value="6">', '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('<video poster="poster.jpg" preload="none" aria-label="Datacenter" autoplay loop muted playsinline><source src="intro.mp4"></video>', '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)
Expand Down
22 changes: 22 additions & 0 deletions src/__tests__/siteImport/applyAssetRewrites.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '<html><body><video poster="../images/poster.jpg" preload="none"><source src="/media/intro.mp4"></video></body></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
// ---------------------------------------------------------------------------
Expand Down
9 changes: 9 additions & 0 deletions src/core/htmlImport/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ const TEXT_INPUT_TYPES = [
'tel',
'url',
'number',
'range',
'date',
'time',
'datetime-local',
Expand Down Expand Up @@ -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'),
Expand Down Expand Up @@ -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'),
Expand Down
9 changes: 2 additions & 7 deletions src/core/siteImport/applyAssetRewrites.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<string> = new Set(['src', 'href', 'srcset'])
import { URL_BEARING_PROPS } from './assetProps'

// ---------------------------------------------------------------------------
// Public function
Expand Down
10 changes: 2 additions & 8 deletions src/core/siteImport/assetPlan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string> = new Set(['src', 'href', 'srcset'])
import { URL_BEARING_PROPS } from './assetProps'

// ---------------------------------------------------------------------------
// Public types
Expand Down Expand Up @@ -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}')`)
}

2 changes: 2 additions & 0 deletions src/core/siteImport/assetProps.ts
Original file line number Diff line number Diff line change
@@ -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
19 changes: 7 additions & 12 deletions src/modules/base/forms/FormControls.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { InputProps } from './inputProps'
import type { ModuleComponentProps } from '@core/module-engine'
import { normalizeIdentifierValue } from '@core/utils/identifier'

Expand All @@ -12,18 +13,6 @@ type LabelProps = Record<string, unknown> & {
targetId: string
}

type InputProps = Record<string, unknown> & {
inputType: string
name: string
id: string
placeholder: string
value: string
required: boolean
disabled: boolean
readOnly: boolean
autocomplete: string
}

type TextareaProps = Record<string, unknown> & {
name: string
id: string
Expand Down Expand Up @@ -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}
/>
)
}
Expand Down
35 changes: 4 additions & 31 deletions src/modules/base/forms/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -58,38 +59,7 @@ const LabelPropsSchema = Type.Object({

type LabelProps = Static<typeof LabelPropsSchema>

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<typeof InputPropsSchema>

const TextareaPropsSchema = Type.Object({
fieldId: Type.String({ default: '' }),
Expand Down Expand Up @@ -278,6 +248,7 @@ export const InputModule: ModuleDefinition<InputProps> = {
['autocomplete', props.autocomplete],
['min', props.min],
['max', props.max],
['step', props.step],
['minlength', positiveNumber(props.minLength)],
['maxlength', positiveNumber(props.maxLength)],
['pattern', props.pattern],
Expand Down Expand Up @@ -474,6 +445,7 @@ function inputLikeSchema(typeLabel: string): ModuleDefinition<InputProps>['schem
'tel',
'url',
'number',
'range',
'date',
'time',
'datetime-local',
Expand All @@ -491,6 +463,7 @@ function inputLikeSchema(typeLabel: string): ModuleDefinition<InputProps>['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' },
Expand Down
36 changes: 36 additions & 0 deletions src/modules/base/forms/inputProps.ts
Original file line number Diff line number Diff line change
@@ -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<typeof InputPropsSchema>