@@ -31,6 +31,58 @@ export interface ClipComponentOptions {
3131}
3232
3333/** Renders a single clip element */
34+ function fileName ( src : string ) : string {
35+ return ( src . split ( "/" ) . pop ( ) || src ) . split ( "?" ) [ 0 ] ;
36+ }
37+
38+ function truncateLabel ( text : string ) : string {
39+ return text . substring ( 0 , 20 ) + ( text . length > 20 ? "..." : "" ) ;
40+ }
41+
42+ const BLOCK_ELEMENTS =
43+ "address, article, aside, blockquote, dd, div, dl, dt, figcaption, figure, footer, h1, h2, h3, h4, h5, h6, header, li, main, nav, ol, p, pre, section, table, td, th, tr, ul" ;
44+
45+ /** Each block's text on an html5 page, in reading order, with inline pieces such as animated words joined. */
46+ function pageLines ( html : string ) : string [ ] {
47+ // Parsing runs none of the page's scripts.
48+ const doc = new DOMParser ( ) . parseFromString ( html , "text/html" ) ;
49+ doc . querySelectorAll ( "script, style, template, noscript" ) . forEach ( element => element . remove ( ) ) ;
50+ const lines : string [ ] = [ ] ;
51+ let line = "" ;
52+ const endLine = ( ) : void => {
53+ const text = line . replace ( / \s + / g, " " ) . trim ( ) ;
54+ if ( text ) lines . push ( text ) ;
55+ line = "" ;
56+ } ;
57+ const read = ( node : Node ) : void => {
58+ if ( node . nodeType === Node . TEXT_NODE ) {
59+ const text = node . textContent ?? "" ;
60+ // Line and word spans often sit flush against each other. Letters split one per span stay together.
61+ if ( text . trim ( ) . length > 1 && / \S $ / . test ( line ) && / ^ \S / . test ( text ) ) line += " " ;
62+ line += text ;
63+ return ;
64+ }
65+ if ( node . nodeType !== Node . ELEMENT_NODE ) return ;
66+ const block = ( node as Element ) . matches ( BLOCK_ELEMENTS ) || node . nodeName === "BR" ;
67+ if ( block ) endLine ( ) ;
68+ node . childNodes . forEach ( read ) ;
69+ if ( block ) endLine ( ) ;
70+ } ;
71+ read ( doc . body ) ;
72+ endLine ( ) ;
73+ return lines ;
74+ }
75+
76+ /**
77+ * The line that reads as the page's headline. Pages often open with counters, bullets and all-caps labels, and a
78+ * headline is the first line of two or more words in sentence case.
79+ */
80+ export function pageHeadline ( html : string ) : string | null {
81+ const lines = pageLines ( html ) ;
82+ const sentence = lines . find ( text => / \p{ Ll} / u. test ( text ) && ( text . match ( / \p{ L} { 2 , } / gu) ?. length ?? 0 ) >= 2 ) ;
83+ return sentence ?? lines . find ( text => / \p{ L} / u. test ( text ) ) ?? null ;
84+ }
85+
3486export class ClipComponent {
3587 public readonly element : HTMLElement ;
3688 private readonly options : ClipComponentOptions ;
@@ -44,6 +96,9 @@ export class ClipComponent {
4496 // Cached element references (avoid querySelector every frame)
4597 private iconEl : HTMLElement | null = null ;
4698 private labelEl : HTMLElement | null = null ;
99+ // Parsing a page for its label is too slow to repeat on every draw.
100+ private html5LabelSource : string | null = null ;
101+ private html5Label = "" ;
47102 private badgeEl : HTMLElement | null = null ;
48103
49104 constructor ( clip : ClipState , options : ClipComponentOptions ) {
@@ -368,6 +423,8 @@ export class ClipComponent {
368423 shape : "◇" ,
369424 caption : "≡" ,
370425 html : "<>" ,
426+ html5 : "<>" ,
427+ "rich-caption" : "≡" ,
371428 luma : "◐" ,
372429 svg : "◇"
373430 } ;
@@ -407,14 +464,27 @@ export class ClipComponent {
407464 return "Shape" ;
408465 }
409466
410- if ( "src" in asset && typeof asset . src === "string" ) {
467+ if ( assetType === "html5" ) {
468+ const html = "html" in asset && typeof asset . html === "string" ? asset . html : "" ;
469+ if ( html !== this . html5LabelSource ) {
470+ this . html5LabelSource = html ;
471+ this . html5Label = truncateLabel ( pageHeadline ( html ) ?? "HTML" ) ;
472+ }
473+ return this . html5Label ;
474+ }
475+
476+ // Its words come from parsing or transcribing the source at load, so the label names the source.
477+ if ( assetType === "rich-caption" && "src" in asset && typeof asset . src === "string" ) {
411478 const { src } = asset ;
412- const filename = src . split ( "/" ) . pop ( ) || src ;
413- return filename . split ( "?" ) [ 0 ] ;
479+ return `Captions · ${ src . startsWith ( "alias://" ) ? src . slice ( "alias://" . length ) : fileName ( src ) } ` ;
480+ }
481+
482+ if ( "src" in asset && typeof asset . src === "string" ) {
483+ return fileName ( asset . src ) ;
414484 }
415485
416486 if ( "text" in asset && typeof asset . text === "string" ) {
417- return asset . text . substring ( 0 , 20 ) + ( asset . text . length > 20 ? "..." : "" ) ;
487+ return truncateLabel ( asset . text ) ;
418488 }
419489
420490 return assetType || "Clip" ;
0 commit comments