Checkpoint current UI and ink integration state
This commit is contained in:
@@ -450,6 +450,7 @@ class SentenceQueueModule extends BaseModule {
|
||||
dropCap: Boolean(metadata.dropCap),
|
||||
addTopSpace: Boolean(metadata.addTopSpace),
|
||||
cueMarkers: metadata.cueMarkers || [],
|
||||
deferredTags: Array.isArray(metadata.deferredTags) ? metadata.deferredTags : [],
|
||||
status: 'ready',
|
||||
tts: {
|
||||
duration: ttsData.duration,
|
||||
@@ -513,9 +514,12 @@ class SentenceQueueModule extends BaseModule {
|
||||
// first-line indent on following paragraphs.
|
||||
const isHeading = metadata.type === 'heading' || metadata.role === 'chapter-heading' || metadata.role === 'section-heading';
|
||||
const dropCapLines = metadata.dropCap ? 2 : 0;
|
||||
const dropCapWidth = metadata.dropCap ? lineHeight * 1.45 : 0;
|
||||
const indentWidth = (isHeading || metadata.isFirstParagraphInChapter || metadata.addTopSpace) ? 0 : lineHeight * 1.5;
|
||||
const layoutText = metadata.layoutText || text;
|
||||
const dropCapText = metadata.dropCap ? this.getDropCapText(layoutText) : '';
|
||||
const dropCapWidth = metadata.dropCap
|
||||
? this.measureDropCapReservation(storyElement, dropCapText, lineHeight)
|
||||
: 0;
|
||||
const layoutPlainText = metadata.dropCap ? this.extractDropCapText(layoutText) : layoutText;
|
||||
const measures = Array.isArray(metadata.measures) && metadata.measures.length > 0
|
||||
? metadata.measures
|
||||
@@ -550,13 +554,16 @@ class SentenceQueueModule extends BaseModule {
|
||||
|
||||
console.log(`SentenceQueue: Layout calculation - indentWidth: ${indentWidth.toFixed(1)}px, measures: [${measures.map(m => m.toFixed(1)).join(', ')}], offsets: [${lineOffsets.map(m => m.toFixed(1)).join(', ')}]`);
|
||||
|
||||
const layout = paragraphLayout.calculateLayout(layoutPlainText, {
|
||||
const layoutOptions = {
|
||||
measures,
|
||||
fontSize: `${fontSize}px`,
|
||||
fontFamily,
|
||||
lineHeight: lineHeight / fontSize,
|
||||
lineHeightPx: lineHeight
|
||||
});
|
||||
};
|
||||
const layout = metadata.dropCap
|
||||
? this.calculateDropCapLayout(paragraphLayout, layoutPlainText, measures, lineOffsets, layoutOptions)
|
||||
: paragraphLayout.calculateLayout(layoutPlainText, layoutOptions);
|
||||
|
||||
if (!layout) {
|
||||
throw new Error('Paragraph layout calculation failed');
|
||||
@@ -565,6 +572,7 @@ class SentenceQueueModule extends BaseModule {
|
||||
return {
|
||||
breaks: layout.breaks,
|
||||
nodes: layout.nodes,
|
||||
lines: layout.lines || null,
|
||||
processedText: layout.processedText || text,
|
||||
sourceLayoutText: layoutText,
|
||||
measures,
|
||||
@@ -572,7 +580,8 @@ class SentenceQueueModule extends BaseModule {
|
||||
indentWidth,
|
||||
imageWrap: metadata.imageWrap || null,
|
||||
dropCap: Boolean(metadata.dropCap),
|
||||
dropCapText: metadata.dropCap ? this.getDropCapText(layoutText) : '',
|
||||
dropCapText,
|
||||
dropCapWidth,
|
||||
dropCapLines,
|
||||
addTopSpace: Boolean(metadata.addTopSpace),
|
||||
role: metadata.role || (isHeading ? 'chapter-heading' : 'body'),
|
||||
@@ -826,6 +835,196 @@ class SentenceQueueModule extends BaseModule {
|
||||
return String(text).replace(dropCap, '').trimStart();
|
||||
}
|
||||
|
||||
measureDropCapReservation(container, dropCapText, lineHeight) {
|
||||
if (!container || !dropCapText) {
|
||||
return lineHeight * 1.34;
|
||||
}
|
||||
|
||||
const probeParagraph = document.createElement('p');
|
||||
const probe = document.createElement('span');
|
||||
Object.assign(probeParagraph.style, {
|
||||
position: 'absolute',
|
||||
visibility: 'hidden',
|
||||
left: '-8000px',
|
||||
top: '-8000px',
|
||||
margin: '0',
|
||||
padding: '0',
|
||||
lineHeight: `${lineHeight}px`
|
||||
});
|
||||
probe.className = 'drop-cap story-drop-cap';
|
||||
probe.textContent = dropCapText;
|
||||
probe.style.position = 'static';
|
||||
probe.style.display = 'inline-block';
|
||||
probeParagraph.appendChild(probe);
|
||||
container.appendChild(probeParagraph);
|
||||
|
||||
const rect = probe.getBoundingClientRect();
|
||||
const computed = window.getComputedStyle(probe);
|
||||
let inkRight = 0;
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
if (context) {
|
||||
context.font = [
|
||||
computed.fontStyle,
|
||||
computed.fontVariant,
|
||||
computed.fontWeight,
|
||||
computed.fontSize,
|
||||
computed.fontFamily
|
||||
].filter(Boolean).join(' ');
|
||||
const metrics = context.measureText(dropCapText);
|
||||
inkRight = Math.max(
|
||||
metrics.width || 0,
|
||||
metrics.actualBoundingBoxRight || 0
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('SentenceQueue: Could not measure drop-cap canvas ink bounds', error);
|
||||
}
|
||||
probeParagraph.remove();
|
||||
|
||||
const measuredAdvance = Math.max(
|
||||
Number.isFinite(rect.width) && rect.width > 0 ? rect.width : 0,
|
||||
Number.isFinite(probe.offsetWidth) && probe.offsetWidth > 0 ? probe.offsetWidth : 0,
|
||||
Number.isFinite(probe.scrollWidth) && probe.scrollWidth > 0 ? probe.scrollWidth : 0,
|
||||
inkRight
|
||||
);
|
||||
const glyphAdvance = measuredAdvance > 0 ? measuredAdvance : lineHeight * 1.34;
|
||||
return glyphAdvance + this.measureNormalTextGap(container, lineHeight);
|
||||
}
|
||||
|
||||
measureNormalTextGap(container, lineHeight) {
|
||||
const story = container?.closest?.('#story') || document.getElementById('story') || container;
|
||||
const computed = window.getComputedStyle(story);
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
if (context) {
|
||||
context.font = [
|
||||
computed.fontStyle,
|
||||
computed.fontVariant,
|
||||
computed.fontWeight,
|
||||
computed.fontSize,
|
||||
computed.fontFamily
|
||||
].filter(Boolean).join(' ');
|
||||
const gap = context.measureText('\u2002').width;
|
||||
if (Number.isFinite(gap) && gap > 0) {
|
||||
return gap;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('SentenceQueue: Could not measure normal text gap', error);
|
||||
}
|
||||
return lineHeight / 2;
|
||||
}
|
||||
|
||||
calculateDropCapLayout(paragraphLayout, text, measures, lineOffsets, layoutOptions) {
|
||||
const firstLineOptions = {
|
||||
...layoutOptions,
|
||||
measures: [measures[0], Math.max(measures[0] * 20, 10000)],
|
||||
fontVariantCaps: 'all-small-caps',
|
||||
fontFeatureSettings: '"smcp" on, "c2sc" on, "kern" on, "liga" on, "onum" on, "pnum" on'
|
||||
};
|
||||
const firstLayout = paragraphLayout.calculateLayout(text, firstLineOptions);
|
||||
if (!firstLayout?.breaks || firstLayout.breaks.length < 2) {
|
||||
return paragraphLayout.calculateLayout(text, layoutOptions);
|
||||
}
|
||||
|
||||
const firstLine = this.extractLayoutLine(firstLayout, 0, {
|
||||
measure: measures[0],
|
||||
offset: lineOffsets[0],
|
||||
styleClass: 'story-dropcap-first-line'
|
||||
});
|
||||
const remainingText = this.extractRemainingLayoutText(firstLayout, firstLayout.breaks[1].position);
|
||||
const remainingLayout = paragraphLayout.calculateLayout(remainingText, {
|
||||
...layoutOptions,
|
||||
measures: [measures[1], ...measures.slice(2)]
|
||||
});
|
||||
|
||||
const remainingLines = [];
|
||||
if (remainingLayout?.breaks?.length > 1) {
|
||||
for (let lineIndex = 0; lineIndex < remainingLayout.breaks.length - 1; lineIndex += 1) {
|
||||
remainingLines.push(this.extractLayoutLine(remainingLayout, lineIndex, {
|
||||
measure: measures[Math.min(lineIndex + 1, measures.length - 1)],
|
||||
offset: lineOffsets[Math.min(lineIndex + 1, lineOffsets.length - 1)] || 0,
|
||||
styleClass: ''
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
const lines = [firstLine, ...remainingLines].filter(Boolean);
|
||||
return {
|
||||
breaks: this.breaksFromLines(lines),
|
||||
nodes: lines.flatMap(line => line.nodes),
|
||||
lines,
|
||||
originalText: text,
|
||||
processedText: text,
|
||||
width: layoutOptions.width,
|
||||
lineHeight: layoutOptions.lineHeight,
|
||||
lineHeightPx: layoutOptions.lineHeightPx,
|
||||
fontSize: layoutOptions.fontSize,
|
||||
fontFamily: layoutOptions.fontFamily
|
||||
};
|
||||
}
|
||||
|
||||
extractLayoutLine(layout, lineIndex, metadata = {}) {
|
||||
const startBreak = layout.breaks[lineIndex];
|
||||
const endBreak = layout.breaks[lineIndex + 1];
|
||||
if (!startBreak || !endBreak || !Array.isArray(layout.nodes)) {
|
||||
return null;
|
||||
}
|
||||
const nodes = [];
|
||||
for (let index = startBreak.position; index <= endBreak.position; index += 1) {
|
||||
const node = layout.nodes[index];
|
||||
if (!node) continue;
|
||||
if (node.type === 'glue' && (index === startBreak.position || index === endBreak.position)) {
|
||||
continue;
|
||||
}
|
||||
const forcedBreak = window.linebreak?.infinity ? -window.linebreak.infinity : -100000;
|
||||
if (node.type === 'penalty' && node.penalty <= forcedBreak) {
|
||||
continue;
|
||||
}
|
||||
nodes.push({ ...node });
|
||||
}
|
||||
const endNode = layout.nodes[endBreak.position];
|
||||
return {
|
||||
nodes,
|
||||
ratio: endBreak.ratio || 0,
|
||||
measure: metadata.measure,
|
||||
offset: metadata.offset || 0,
|
||||
styleClass: metadata.styleClass || '',
|
||||
hyphenated: endNode?.type === 'penalty' && endNode.penalty === 100
|
||||
};
|
||||
}
|
||||
|
||||
extractRemainingLayoutText(layout, breakPosition) {
|
||||
if (!Array.isArray(layout.nodes)) return '';
|
||||
const fragments = [];
|
||||
for (let index = breakPosition + 1; index < layout.nodes.length; index += 1) {
|
||||
const node = layout.nodes[index];
|
||||
if (!node) continue;
|
||||
if (node.type === 'box' || node.type === 'tag') {
|
||||
fragments.push(node.value || '');
|
||||
} else if (node.type === 'glue' && node.width > 0) {
|
||||
fragments.push(' ');
|
||||
} else if (node.type === 'penalty' && node.penalty === 100) {
|
||||
fragments.push('|');
|
||||
}
|
||||
}
|
||||
return fragments.join('').replace(/\s+/g, ' ').trimStart();
|
||||
}
|
||||
|
||||
breaksFromLines(lines) {
|
||||
const breaks = [{ position: 0, ratio: 0 }];
|
||||
let position = 0;
|
||||
for (const line of lines) {
|
||||
position += Math.max(0, line.nodes.length - 1);
|
||||
breaks.push({ position, ratio: line.ratio || 0 });
|
||||
position += 1;
|
||||
}
|
||||
return breaks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate animation timing based on TTS duration
|
||||
* @param {Array<string>} words - Array of words to animate
|
||||
|
||||
Reference in New Issue
Block a user