mirror of
https://github.com/LamGC/wp-asciiplayer.git
synced 2026-10-12 23:33:55 +00:00
Refresh player with native block editor preview and local assets
Preserve shortcode rendering, normalize options, bundle the player locally, and add reproducible packaging and WordPress integration tests.
This commit is contained in:
+85
@@ -0,0 +1,85 @@
|
||||
import { InspectorControls, useBlockProps } from '@wordpress/block-editor';
|
||||
import { Button, Notice, PanelBody, Placeholder, RangeControl, SelectControl, TextControl, ToggleControl } from '@wordpress/components';
|
||||
import { useEffect, useState } from '@wordpress/element';
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { finiteNumber, normalizeSource, normalizeWidth } from './options.js';
|
||||
import Preview from './preview.js';
|
||||
|
||||
export default function Edit( { attributes, setAttributes } ) {
|
||||
const { source, theme, speed, loop, controls, fit, startAt, idleTimeLimit, autoPlay, preload, width } = attributes;
|
||||
const [ draftSource, setDraftSource ] = useState( source );
|
||||
const [ approvedSource, setApprovedSource ] = useState( '' );
|
||||
const [ urlError, setUrlError ] = useState( false );
|
||||
const [ attempt, setAttempt ] = useState( 0 );
|
||||
useEffect( () => setDraftSource( source ), [ source ] );
|
||||
const blockProps = useBlockProps( { className: 'asciiplayer-editor' } );
|
||||
const applySource = ( event ) => {
|
||||
event.preventDefault();
|
||||
const next = normalizeSource( draftSource );
|
||||
setUrlError( ! next );
|
||||
if ( ! next ) return;
|
||||
setAttributes( { source: next } );
|
||||
setDraftSource( next );
|
||||
setApprovedSource( next );
|
||||
setAttempt( ( current ) => current + 1 );
|
||||
};
|
||||
const themes = [
|
||||
{ label: __( 'Asciinema', 'wp-asciiplayer' ), value: 'asciinema' },
|
||||
{ label: __( 'Recording theme (automatic)', 'wp-asciiplayer' ), value: 'auto/asciinema' },
|
||||
{ label: 'Dracula', value: 'dracula' }, { label: 'Monokai', value: 'monokai' },
|
||||
{ label: 'Nord', value: 'nord' }, { label: 'Solarized Dark', value: 'solarized-dark' },
|
||||
{ label: 'Solarized Light', value: 'solarized-light' }, { label: 'Tango', value: 'tango' },
|
||||
];
|
||||
if ( ! themes.some( ( option ) => option.value === theme ) ) themes.push( { label: theme, value: theme } );
|
||||
const widthOptions = [ '100%', '80%', '60%', '50%' ].map( ( value ) => ( { label: value, value } ) );
|
||||
if ( ! widthOptions.some( ( option ) => option.value === width ) ) widthOptions.push( { label: width, value: width } );
|
||||
const fitOptions = [
|
||||
{ label: __( 'Width', 'wp-asciiplayer' ), value: 'width' },
|
||||
{ label: __( 'Original size', 'wp-asciiplayer' ), value: 'none' },
|
||||
];
|
||||
if ( fit === 'height' || fit === 'both' ) fitOptions.push( { label: fit === 'height' ? __( 'Height (legacy)', 'wp-asciiplayer' ) : __( 'Width and height (legacy)', 'wp-asciiplayer' ), value: fit } );
|
||||
return (
|
||||
<>
|
||||
<InspectorControls>
|
||||
<PanelBody title={ __( 'Playback', 'wp-asciiplayer' ) }>
|
||||
<RangeControl label={ __( 'Playback speed', 'wp-asciiplayer' ) } value={ speed } min={ 0.25 } max={ Math.max( 8, speed ) } step={ 0.25 } onChange={ ( value ) => setAttributes( { speed: finiteNumber( value, 1, 0.01, 100 ) } ) } />
|
||||
<ToggleControl label={ __( 'Loop playback', 'wp-asciiplayer' ) } checked={ loop } onChange={ ( value ) => setAttributes( { loop: value } ) } />
|
||||
<ToggleControl label={ __( 'Autoplay on published page', 'wp-asciiplayer' ) } help={ __( 'The editor preview always waits for you to press play.', 'wp-asciiplayer' ) } checked={ autoPlay } onChange={ ( value ) => setAttributes( { autoPlay: value } ) } />
|
||||
<ToggleControl label={ __( 'Preload on published page', 'wp-asciiplayer' ) } help={ __( 'Loads the recording before the visitor presses play. The editor always preloads after you apply the URL.', 'wp-asciiplayer' ) } checked={ preload } onChange={ ( value ) => setAttributes( { preload: value } ) } />
|
||||
<TextControl label={ __( 'Start at (seconds)', 'wp-asciiplayer' ) } type="number" min="0" step="0.1" value={ startAt } onChange={ ( value ) => setAttributes( { startAt: finiteNumber( value, 0 ) } ) } />
|
||||
<TextControl label={ __( 'Idle time limit (seconds)', 'wp-asciiplayer' ) } help={ __( 'Shorten long pauses. Use 0 to keep the recording’s default.', 'wp-asciiplayer' ) } type="number" min="0" step="0.1" value={ idleTimeLimit } onChange={ ( value ) => setAttributes( { idleTimeLimit: finiteNumber( value, 0 ) } ) } />
|
||||
</PanelBody>
|
||||
<PanelBody title={ __( 'Appearance', 'wp-asciiplayer' ) }>
|
||||
<SelectControl label={ __( 'Theme', 'wp-asciiplayer' ) } value={ theme } options={ themes } onChange={ ( value ) => setAttributes( { theme: value } ) } />
|
||||
<SelectControl label={ __( 'Player controls', 'wp-asciiplayer' ) } value={ controls } options={ [
|
||||
{ label: __( 'Show on interaction', 'wp-asciiplayer' ), value: 'auto' },
|
||||
{ label: __( 'Always show', 'wp-asciiplayer' ), value: 'always' },
|
||||
{ label: __( 'Hide', 'wp-asciiplayer' ), value: 'never' },
|
||||
] } onChange={ ( value ) => setAttributes( { controls: value } ) } />
|
||||
<SelectControl label={ __( 'Fit', 'wp-asciiplayer' ) } value={ fit } options={ fitOptions } help={ [ 'height', 'both' ].includes( fit ) ? __( 'Height-based fitting requires a fixed container height in your site CSS.', 'wp-asciiplayer' ) : undefined } onChange={ ( value ) => setAttributes( { fit: value } ) } />
|
||||
<SelectControl label={ __( 'Player width', 'wp-asciiplayer' ) } value={ width } options={ widthOptions } onChange={ ( value ) => setAttributes( { width: value } ) } />
|
||||
{ attributes.legacyStyle && <p>{ __( 'The custom container style from your shortcode is preserved on the published page.', 'wp-asciiplayer' ) }</p> }
|
||||
</PanelBody>
|
||||
</InspectorControls>
|
||||
<div { ...blockProps }>
|
||||
<div className="asciiplayer-editor__source">
|
||||
<form onSubmit={ applySource }>
|
||||
<TextControl label={ __( 'Recording URL', 'wp-asciiplayer' ) } placeholder="https://example.com/recording.cast" value={ draftSource } onChange={ ( value ) => { setDraftSource( value ); setUrlError( false ); } } autoComplete="off" />
|
||||
<Button type="submit" variant="primary">{ __( 'Apply URL', 'wp-asciiplayer' ) }</Button>
|
||||
</form>
|
||||
<p className="asciiplayer-editor__privacy">{ __( 'Applying a URL loads the recording directly from that server, which receives your browser’s request. Published players contact the same server. Use a direct .cast URL; external servers must allow CORS.', 'wp-asciiplayer' ) }</p>
|
||||
{ urlError && <Notice status="error" isDismissible={ false }>{ __( 'Enter an HTTP(S) recording URL or a path starting with a single /.', 'wp-asciiplayer' ) }</Notice> }
|
||||
</div>
|
||||
{ approvedSource && approvedSource === source ? (
|
||||
<div style={ { width: normalizeWidth( width ), maxWidth: '100%' } }>
|
||||
<Preview source={ source } attributes={ attributes } attempt={ attempt } onRetry={ () => setAttempt( ( current ) => current + 1 ) } />
|
||||
</div>
|
||||
) : (
|
||||
<Placeholder icon="editor-code" label={ __( 'Asciicast Player', 'wp-asciiplayer' ) } instructions={ source ? __( 'Load the saved recording to play and adjust it here.', 'wp-asciiplayer' ) : __( 'Paste a terminal recording URL above, then apply it to see an interactive preview.', 'wp-asciiplayer' ) }>
|
||||
{ source && <Button variant="secondary" onClick={ () => { setApprovedSource( source ); setAttempt( ( current ) => current + 1 ); } }>{ __( 'Load preview', 'wp-asciiplayer' ) }</Button> }
|
||||
</Placeholder>
|
||||
) }
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
.asciiplayer-editor {
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.asciiplayer-editor__source form {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.asciiplayer-editor__source .components-base-control {
|
||||
flex: 1;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.asciiplayer-editor__source .components-base-control__field {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.asciiplayer-editor__privacy,
|
||||
.asciiplayer-editor__hint {
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: #50575e;
|
||||
}
|
||||
|
||||
.asciiplayer-editor__preview {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.asciiplayer-editor__player {
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.asciiplayer-editor__status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.asciiplayer-editor__source form {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { createBlock, registerBlockType } from '@wordpress/blocks';
|
||||
import { next } from '@wordpress/shortcode';
|
||||
import { decodeEntities } from '@wordpress/html-entities';
|
||||
import metadata from '../block.json';
|
||||
import Edit from './edit.js';
|
||||
import { attributesFromShortcode, canTransformShortcode, normalizeSource } from './options.js';
|
||||
import './style.css';
|
||||
import './editor.css';
|
||||
|
||||
function matchShortcodeBlock( text = '' ) {
|
||||
const match = next( 'asciiplayer', text );
|
||||
return match && text.trim() === match.content.trim() && canTransformShortcode( match.shortcode.attrs ) && normalizeSource( decodeEntities( match.shortcode.content ) ) ? match : null;
|
||||
}
|
||||
|
||||
registerBlockType( metadata.name, {
|
||||
...metadata,
|
||||
edit: Edit,
|
||||
save: () => null,
|
||||
transforms: {
|
||||
from: [
|
||||
{
|
||||
type: 'shortcode',
|
||||
tag: 'asciiplayer',
|
||||
isMatch: canTransformShortcode,
|
||||
transform: ( { named }, match ) => {
|
||||
const source = decodeEntities( match.shortcode.content || '' );
|
||||
if ( ! normalizeSource( source ) ) return createBlock( 'core/shortcode', { text: match.content } );
|
||||
return createBlock( metadata.name, attributesFromShortcode( named, source ) );
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'block',
|
||||
blocks: [ 'core/shortcode' ],
|
||||
isMatch: ( { text } ) => Boolean( matchShortcodeBlock( text ) ),
|
||||
transform: ( { text } ) => {
|
||||
const match = matchShortcodeBlock( text );
|
||||
return createBlock( metadata.name, attributesFromShortcode( match.shortcode.attrs.named, decodeEntities( match.shortcode.content ) ) );
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} );
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
/** Shared, side-effect-free validation for the editor and front-end adapter. */
|
||||
export const DEFAULTS = Object.freeze( {
|
||||
theme: 'asciinema', speed: 1, loop: false, controls: 'auto', fit: 'width',
|
||||
startAt: 0, idleTimeLimit: 0, autoPlay: false, preload: true, width: '100%',
|
||||
} );
|
||||
|
||||
export function booleanValue( value, fallback = false ) {
|
||||
if ( typeof value === 'boolean' ) return value;
|
||||
if ( value === 1 || /^(true|1|yes|on)$/i.test( String( value ).trim() ) ) return true;
|
||||
if ( value === 0 || /^(false|0|no|off)$/i.test( String( value ).trim() ) ) return false;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function finiteNumber( value, fallback, min = 0, max = Number.MAX_SAFE_INTEGER ) {
|
||||
if ( typeof value !== 'number' && typeof value !== 'string' ) return fallback;
|
||||
if ( typeof value === 'string' && ! /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i.test( value.trim() ) ) return fallback;
|
||||
const number = Number( value );
|
||||
return Number.isFinite( number ) && number >= min && number <= max ? number : fallback;
|
||||
}
|
||||
|
||||
/** Accept explicit web URLs and site-root-relative URLs; never executable schemes. */
|
||||
export function normalizeSource( input ) {
|
||||
if ( typeof input !== 'string' ) return '';
|
||||
const source = input.trim();
|
||||
if ( ! source || /[\u0000-\u0020\u007f<>"\\]/.test( source ) ) return '';
|
||||
if ( /^\/(?!\/)/.test( source ) ) return source;
|
||||
// URL() repairs missing/extra authority slashes; PHP deliberately does not.
|
||||
if ( ! /^https?:\/\/[^/]/i.test( source ) ) return '';
|
||||
try {
|
||||
const url = new URL( source );
|
||||
return [ 'http:', 'https:' ].includes( url.protocol ) && ! url.username && ! url.password ? source : '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeWidth( value ) {
|
||||
return typeof value === 'string' && /^(?:0|(?:\d+(?:\.\d+)?|\.\d+)(?:px|%|em|rem|vw|vh|vmin|vmax|ch))$/.test( value.trim() )
|
||||
? value.trim() : DEFAULTS.width;
|
||||
}
|
||||
|
||||
/** A bounded editor frame for old height-fitting shortcodes; never persisted. */
|
||||
export function previewHeight( attributes ) {
|
||||
if ( ! [ 'height', 'both' ].includes( attributes.fit ) ) return undefined;
|
||||
let height = '320px';
|
||||
for ( const declaration of String( attributes.legacyStyle || '' ).split( ';' ) ) {
|
||||
const match = declaration.match( /^\s*height\s*:\s*((?:\d+(?:\.\d+)?|\.\d+)(?:px|em|rem|vh|vw|vmin|vmax|ch))\s*$/i );
|
||||
if ( match ) height = match[ 1 ];
|
||||
}
|
||||
return height;
|
||||
}
|
||||
|
||||
export function normalizeControls( value ) {
|
||||
if ( typeof value === 'string' ) value = value.trim().toLowerCase();
|
||||
if ( value === 'always' || booleanValue( value, null ) === true ) return true;
|
||||
if ( value === 'never' || booleanValue( value, null ) === false ) return false;
|
||||
return 'auto';
|
||||
}
|
||||
|
||||
export function secondsValue( value ) {
|
||||
if ( typeof value === 'string' && /^\d+(?::[0-5]?\d){1,2}(?:\.\d+)?$/.test( value.trim() ) ) {
|
||||
return value.trim().split( ':' ).reduce( ( total, part ) => total * 60 + Number( part ), 0 );
|
||||
}
|
||||
return finiteNumber( value, 0 );
|
||||
}
|
||||
|
||||
export function normalizePlayerOptions( attributes = {}, { preview = false } = {} ) {
|
||||
const fit = typeof attributes.fit === 'string' ? attributes.fit.trim().toLowerCase() : attributes.fit;
|
||||
const fontSize = typeof attributes.terminalFontSize === 'string' ? attributes.terminalFontSize.trim().toLowerCase() : '';
|
||||
const fontFamily = typeof attributes.terminalFontFamily === 'string' ? attributes.terminalFontFamily.trim() : '';
|
||||
const options = {
|
||||
theme: typeof attributes.theme === 'string' && /^(?:auto\/)?[\w-]{1,64}$/.test( attributes.theme ) ? attributes.theme : DEFAULTS.theme,
|
||||
speed: finiteNumber( attributes.speed, 1, 0.01, 100 ),
|
||||
loop: booleanValue( attributes.loop ),
|
||||
controls: normalizeControls( attributes.controls ),
|
||||
fit: [ 'width', 'height', 'both', 'none' ].includes( fit ) ? fit : booleanValue( fit, null ) === false ? 'none' : 'width',
|
||||
startAt: secondsValue( attributes.startAt ),
|
||||
autoPlay: preview ? false : booleanValue( attributes.autoPlay ?? attributes.autoplay ),
|
||||
preload: preview ? true : booleanValue( attributes.preload, true ),
|
||||
};
|
||||
// Shortcodes may intentionally request a finite number of loops.
|
||||
if ( typeof attributes.loop === 'number' && attributes.loop > 0 && Number.isInteger( attributes.loop ) ) options.loop = attributes.loop;
|
||||
const idle = finiteNumber( attributes.idleTimeLimit, 0 );
|
||||
if ( idle > 0 ) options.idleTimeLimit = idle;
|
||||
for ( const key of [ 'cols', 'rows' ] ) {
|
||||
const number = finiteNumber( attributes[ key ], 0, 1, 1000 );
|
||||
if ( Number.isInteger( number ) && number > 0 ) options[ key ] = number;
|
||||
}
|
||||
const lineHeight = finiteNumber( attributes.terminalLineHeight, 0, 0.1, 10 );
|
||||
if ( lineHeight ) options.terminalLineHeight = lineHeight;
|
||||
if ( fontFamily.length <= 300 && /^[a-zA-Z0-9 _.,'"-]+$/.test( fontFamily ) ) options.terminalFontFamily = fontFamily;
|
||||
if ( /^(small|medium|big|0|(?:\d+(?:\.\d+)?|\.\d+)(?:px|%|em|rem|vw|vh|vmin|vmax|ch))$/.test( fontSize ) ) options.terminalFontSize = fontSize;
|
||||
if ( typeof attributes.poster === 'string' && attributes.poster.length <= 32768 && /^(npt:\d+(?::[0-5]?\d){0,2}(?:\.\d+)?|data:text\/plain,[\s\S]*)$/.test( attributes.poster ) ) options.poster = attributes.poster;
|
||||
return options;
|
||||
}
|
||||
|
||||
const SHORTCODE_KEYS = {
|
||||
theme: 'theme', speed: 'speed', loop: 'loop', controls: 'controls', fit: 'fit',
|
||||
startat: 'startAt', idletimelimit: 'idleTimeLimit', autoplay: 'autoPlay', preload: 'preload',
|
||||
cols: 'cols', rows: 'rows', terminalfontfamily: 'terminalFontFamily',
|
||||
terminalfontsize: 'terminalFontSize', terminallineheight: 'terminalLineHeight', poster: 'poster', style: 'legacyStyle',
|
||||
fontsize: 'terminalFontSize', fontfamily: 'terminalFontFamily', lineheight: 'terminalLineHeight', width: 'width',
|
||||
};
|
||||
|
||||
/** Unknown legacy options stay in their original shortcode rather than being lost. */
|
||||
export function canTransformShortcode( { named = {}, numeric = [] } = {} ) {
|
||||
return numeric.length === 0 && Object.entries( named ).every( ( [ key, value ] ) => {
|
||||
const lower = key.toLowerCase();
|
||||
if ( ! Object.prototype.hasOwnProperty.call( SHORTCODE_KEYS, lower ) ) return false;
|
||||
if ( lower === 'loop' && booleanValue( value, null ) === null ) return false;
|
||||
if ( lower === 'style' ) {
|
||||
const widths = String( value ).matchAll( /(?:^|;)\s*width\s*:\s*([^;]+)/gi );
|
||||
for ( const match of widths ) if ( normalizeWidth( match[ 1 ] ) !== match[ 1 ].trim() ) return false;
|
||||
}
|
||||
return true;
|
||||
} );
|
||||
}
|
||||
|
||||
export function attributesFromShortcode( named = {}, content = '' ) {
|
||||
const values = {};
|
||||
for ( const [ key, value ] of Object.entries( named ) ) {
|
||||
const target = SHORTCODE_KEYS[ key.toLowerCase() ];
|
||||
if ( target ) values[ target ] = value;
|
||||
}
|
||||
if ( values.fit === 'false' || values.fit === false ) values.fit = 'none';
|
||||
const normalized = normalizePlayerOptions( values );
|
||||
const attributes = {
|
||||
...normalized,
|
||||
source: normalizeSource( content ),
|
||||
controls: normalized.controls === true ? 'always' : normalized.controls === false ? 'never' : 'auto',
|
||||
idleTimeLimit: normalized.idleTimeLimit ?? 0,
|
||||
};
|
||||
if ( values.legacyStyle ) {
|
||||
attributes.legacyStyle = values.legacyStyle;
|
||||
for ( const match of String( values.legacyStyle ).matchAll( /(?:^|;)\s*width\s*:\s*([^;]+)/gi ) ) attributes.width = normalizeWidth( match[ 1 ] );
|
||||
}
|
||||
if ( values.width ) attributes.width = normalizeWidth( values.width );
|
||||
return attributes;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* asciinema uses realm-global document/HTMLButtonElement internally. Execute its
|
||||
* bundled script in the canvas document, not in the parent React document.
|
||||
*/
|
||||
const pendingDocuments = new WeakMap();
|
||||
const pendingStyles = new WeakMap();
|
||||
|
||||
function findAsset( document, selector, property ) {
|
||||
return document.querySelector( selector )?.[ property ];
|
||||
}
|
||||
|
||||
export function playerAssets( hostWindow = window ) {
|
||||
const settings = hostWindow.wpAsciiplayerSettings || {};
|
||||
return {
|
||||
script: settings.playerScriptUrl || findAsset( hostWindow.document, '#lamgc-asciiplayer-player-js', 'src' ),
|
||||
style: settings.playerStyleUrl || findAsset( hostWindow.document, '#lamgc-asciiplayer-player-style-css', 'href' ),
|
||||
};
|
||||
}
|
||||
|
||||
function ensureStyle( document, url ) {
|
||||
if ( ! url ) return Promise.reject( new Error( 'The local player stylesheet is unavailable.' ) );
|
||||
let link = Array.from( document.querySelectorAll( 'link[rel="stylesheet"]' ) ).find( ( item ) => item.href === url && ( item.sheet || ! item.dataset.asciiplayerFailed ) );
|
||||
if ( link?.sheet ) return Promise.resolve();
|
||||
if ( pendingStyles.has( document ) ) return pendingStyles.get( document );
|
||||
const created = ! link;
|
||||
if ( created ) {
|
||||
link = document.createElement( 'link' );
|
||||
link.rel = 'stylesheet';
|
||||
link.href = url;
|
||||
link.dataset.asciiplayerAsset = 'style';
|
||||
}
|
||||
const promise = new Promise( ( resolve, reject ) => {
|
||||
let timer;
|
||||
const cleanup = () => {
|
||||
document.defaultView.clearTimeout( timer );
|
||||
link.removeEventListener( 'load', loaded );
|
||||
link.removeEventListener( 'error', failed );
|
||||
};
|
||||
const loaded = () => { cleanup(); resolve(); };
|
||||
const failed = () => {
|
||||
cleanup();
|
||||
link.dataset.asciiplayerFailed = 'true';
|
||||
if ( created ) link.remove();
|
||||
reject( new Error( 'The local player stylesheet could not be loaded.' ) );
|
||||
};
|
||||
link.addEventListener( 'load', loaded );
|
||||
link.addEventListener( 'error', failed );
|
||||
timer = document.defaultView.setTimeout( failed, 20000 );
|
||||
if ( created ) document.head.appendChild( link );
|
||||
if ( link.sheet ) loaded();
|
||||
} );
|
||||
pendingStyles.set( document, promise );
|
||||
promise.then( () => pendingStyles.delete( document ), () => pendingStyles.delete( document ) );
|
||||
return promise;
|
||||
}
|
||||
|
||||
export async function playerForDocument( document, assets ) {
|
||||
const canvasWindow = document.defaultView;
|
||||
if ( ! canvasWindow ) return Promise.reject( new Error( 'The editor canvas is unavailable.' ) );
|
||||
// Player metrics are measured at create(), so CSS must finish first.
|
||||
await ensureStyle( document, assets.style );
|
||||
if ( typeof canvasWindow.AsciinemaPlayer?.create === 'function' ) return Promise.resolve( canvasWindow.AsciinemaPlayer );
|
||||
if ( pendingDocuments.has( document ) ) return pendingDocuments.get( document );
|
||||
if ( ! assets.script ) return Promise.reject( new Error( 'The local player script is unavailable.' ) );
|
||||
|
||||
const pending = new Promise( ( resolve, reject ) => {
|
||||
let script = Array.from( document.scripts ).find( ( item ) => item.src === assets.script && ! item.dataset.asciiplayerFailed );
|
||||
const created = ! script;
|
||||
if ( created ) {
|
||||
script = document.createElement( 'script' );
|
||||
script.src = assets.script;
|
||||
script.async = true;
|
||||
script.dataset.asciiplayerAsset = 'script';
|
||||
}
|
||||
let timer;
|
||||
const cleanup = () => {
|
||||
canvasWindow.clearTimeout( timer );
|
||||
script.removeEventListener( 'load', loaded );
|
||||
script.removeEventListener( 'error', failed );
|
||||
};
|
||||
const failed = () => {
|
||||
cleanup();
|
||||
script.dataset.asciiplayerFailed = 'true';
|
||||
// Only remove a script we own. A retry can then load a fresh copy.
|
||||
if ( created ) script.remove();
|
||||
reject( new Error( 'The local player script could not be loaded.' ) );
|
||||
};
|
||||
const loaded = () => {
|
||||
if ( typeof canvasWindow.AsciinemaPlayer?.create !== 'function' ) return failed();
|
||||
cleanup();
|
||||
resolve( canvasWindow.AsciinemaPlayer );
|
||||
};
|
||||
script.addEventListener( 'load', loaded );
|
||||
script.addEventListener( 'error', failed );
|
||||
timer = canvasWindow.setTimeout( failed, 20000 );
|
||||
if ( created ) document.head.appendChild( script );
|
||||
// Covers a script that finished between the global check and listener setup.
|
||||
if ( typeof canvasWindow.AsciinemaPlayer?.create === 'function' ) loaded();
|
||||
} );
|
||||
pendingDocuments.set( document, pending );
|
||||
pending.catch( () => pendingDocuments.delete( document ) );
|
||||
return pending;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Button, Notice, Spinner } from '@wordpress/components';
|
||||
import { useRefEffect } from '@wordpress/compose';
|
||||
import { useState } from '@wordpress/element';
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { normalizePlayerOptions, previewHeight } from './options.js';
|
||||
import { playerAssets, playerForDocument } from './player-bridge.js';
|
||||
|
||||
export default function Preview( { source, attributes, attempt, onRetry } ) {
|
||||
const [ status, setStatus ] = useState( 'loading' );
|
||||
const [ problem, setProblem ] = useState( '' );
|
||||
const optionsJSON = JSON.stringify( normalizePlayerOptions( attributes, { preview: true } ) );
|
||||
const playerRef = useRefEffect( ( container ) => {
|
||||
let disposed = false;
|
||||
let player;
|
||||
let readinessTimer;
|
||||
let deadline;
|
||||
const canvasWindow = container.ownerDocument.defaultView;
|
||||
const request = new canvasWindow.AbortController();
|
||||
setStatus( 'loading' );
|
||||
setProblem( '' );
|
||||
const fail = ( message ) => {
|
||||
if ( disposed ) return;
|
||||
canvasWindow.clearInterval( readinessTimer );
|
||||
canvasWindow.clearTimeout( deadline );
|
||||
setProblem( message );
|
||||
setStatus( 'error' );
|
||||
};
|
||||
const ready = () => {
|
||||
if ( disposed ) return;
|
||||
canvasWindow.clearInterval( readinessTimer );
|
||||
canvasWindow.clearTimeout( deadline );
|
||||
setStatus( 'ready' );
|
||||
};
|
||||
playerForDocument( container.ownerDocument, playerAssets() ).then( ( api ) => {
|
||||
if ( disposed ) return;
|
||||
player = api.create( { url: source, fetchOpts: { signal: request.signal } }, container, JSON.parse( optionsJSON ) );
|
||||
player.addEventListener( 'error', () => fail( __( 'Unable to load the recording. Check the URL, file format, and the source server’s CORS permissions.', 'wp-asciiplayer' ) ) );
|
||||
player.addEventListener( 'playing', ready );
|
||||
// getDuration is public API; a mounted wrapper alone does not mean the
|
||||
// recording loaded successfully. Works across supported player versions.
|
||||
const checkReady = () => {
|
||||
const duration = player.getDuration();
|
||||
if ( typeof duration === 'number' && Number.isFinite( duration ) ) ready();
|
||||
};
|
||||
readinessTimer = canvasWindow.setInterval( checkReady, 150 );
|
||||
deadline = canvasWindow.setTimeout( () => fail( __( 'The recording is taking too long to load. Check your connection and try again.', 'wp-asciiplayer' ) ), 30000 );
|
||||
checkReady();
|
||||
} ).catch( () => fail( __( 'The local player could not start. Try again, or check that the plugin’s built assets are installed.', 'wp-asciiplayer' ) ) );
|
||||
return () => {
|
||||
disposed = true;
|
||||
request.abort();
|
||||
canvasWindow.clearInterval( readinessTimer );
|
||||
canvasWindow.clearTimeout( deadline );
|
||||
if ( player ) player.dispose();
|
||||
container.replaceChildren();
|
||||
};
|
||||
}, [ source, optionsJSON, attempt ] );
|
||||
|
||||
return (
|
||||
<div className="asciiplayer-editor__preview" data-preview-status={ status }>
|
||||
{ status === 'loading' && <div className="asciiplayer-editor__status" role="status"><Spinner />{ __( 'Loading recording…', 'wp-asciiplayer' ) }</div> }
|
||||
{ status === 'error' && <Notice status="error" isDismissible={ false }><p>{ problem }</p><Button variant="secondary" onClick={ onRetry }>{ __( 'Retry preview', 'wp-asciiplayer' ) }</Button></Notice> }
|
||||
<div className="asciiplayer-editor__player" style={ { height: previewHeight( attributes ) } } ref={ playerRef } aria-label={ __( 'Interactive terminal recording preview', 'wp-asciiplayer' ) } />
|
||||
{ [ 'height', 'both' ].includes( attributes.fit ) && <p className="asciiplayer-editor__hint">{ __( 'Height-based previews use the shortcode’s fixed height when available, or a 320px editor frame. Published height still comes from your container style.', 'wp-asciiplayer' ) }</p> }
|
||||
<p className="asciiplayer-editor__hint">{ __( 'Use the player controls to play, pause, and seek. Autoplay is disabled only in the editor.', 'wp-asciiplayer' ) }</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
.wp-block-lamgc-asciiplayer,
|
||||
.asciiplayer-container {
|
||||
box-sizing: border-box;
|
||||
max-width: 100%;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.asciiplayer-container .ap-wrapper {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.asciiplayer-container[data-ap-status="error"] .asciiplayer-error {
|
||||
padding: 1em;
|
||||
border: 1px solid #b32d2e;
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { normalizePlayerOptions, normalizeSource } from './options.js';
|
||||
|
||||
const instances = new WeakMap();
|
||||
|
||||
function showError( container ) {
|
||||
container.dataset.apStatus = 'error';
|
||||
if ( container.querySelector( '.asciiplayer-error' ) ) return;
|
||||
const notice = container.ownerDocument.createElement( 'p' );
|
||||
notice.className = 'asciiplayer-error';
|
||||
notice.setAttribute( 'role', 'status' );
|
||||
notice.textContent = __( 'Unable to load this terminal recording. The file may be unavailable, invalid, or blocked by the source server’s CORS policy.', 'wp-asciiplayer' );
|
||||
container.appendChild( notice );
|
||||
}
|
||||
|
||||
/** One explicit instance per front-end container; never scan an editor canvas. */
|
||||
export function mountPlayers( root = document ) {
|
||||
const document = root.ownerDocument || root;
|
||||
if ( document.body?.classList.contains( 'wp-admin' ) || document.querySelector( '.editor-styles-wrapper, .block-editor-block-list__layout' ) ) return;
|
||||
const api = document.defaultView?.AsciinemaPlayer;
|
||||
if ( typeof api?.create !== 'function' ) return;
|
||||
root.querySelectorAll( '.asciiplayer-container' ).forEach( ( container ) => {
|
||||
if ( instances.has( container ) ) return;
|
||||
const source = normalizeSource( container.dataset.apSrc );
|
||||
if ( ! source ) return showError( container );
|
||||
let options;
|
||||
try {
|
||||
// New markup uses escaped plain JSON. The legacy adapter used URL-encoded
|
||||
// JSON; accepting it also lets cached old pages use the new local player.
|
||||
const serialized = container.dataset.apOptions;
|
||||
options = serialized ? JSON.parse( serialized ) : JSON.parse( decodeURIComponent( container.dataset.apOpts || '{}' ) );
|
||||
if ( ! options || Array.isArray( options ) || typeof options !== 'object' ) throw new Error( 'Invalid player options.' );
|
||||
options = normalizePlayerOptions( options );
|
||||
} catch {
|
||||
showError( container );
|
||||
return;
|
||||
}
|
||||
try {
|
||||
container.dataset.apStatus = 'loading';
|
||||
const player = api.create( source, container, options );
|
||||
instances.set( container, player );
|
||||
player.addEventListener( 'error', () => showError( container ) );
|
||||
player.addEventListener( 'playing', () => { container.dataset.apStatus = 'ready'; } );
|
||||
// The upstream player owns loading/error/controls; readiness is confirmed
|
||||
// by its parsed duration rather than merely by a mounted DOM wrapper.
|
||||
if ( options.preload || options.autoPlay || options.poster?.startsWith( 'npt:' ) ) {
|
||||
let ticks = 0;
|
||||
const timer = document.defaultView.setInterval( () => {
|
||||
const duration = player.getDuration();
|
||||
if ( typeof duration === 'number' && Number.isFinite( duration ) ) container.dataset.apStatus = 'ready';
|
||||
if ( ! container.isConnected || container.dataset.apStatus !== 'loading' || ++ticks > 200 ) document.defaultView.clearInterval( timer );
|
||||
}, 150 );
|
||||
} else {
|
||||
container.dataset.apStatus = 'idle';
|
||||
}
|
||||
} catch {
|
||||
showError( container );
|
||||
}
|
||||
} );
|
||||
}
|
||||
|
||||
if ( document.readyState === 'loading' ) {
|
||||
document.addEventListener( 'DOMContentLoaded', () => mountPlayers(), { once: true } );
|
||||
} else {
|
||||
mountPlayers();
|
||||
}
|
||||
Reference in New Issue
Block a user