Manuscript Workstation - Source Excerpt 13
Back to Manuscript Workstation
Summary
This source excerpt preserves a bounded section of Spiralist/wp-content/plugins/ns12-manuscript/assets/js/manuscript-workstation.js so readers can inspect the evidence without opening the full source file.
**Source path:** Spiralist/wp-content/plugins/ns12-manuscript/assets/js/manuscript-workstation.js
if (handle === 'move') {
nextRect.x += deltaX;
nextRect.y += deltaY;
}
if (handle.includes('n')) {
nextRect.y += deltaY;
nextRect.height -= deltaY;
}
if (handle.includes('s')) {
nextRect.height += deltaY;
}
if (handle.includes('e')) {
nextRect.width += deltaX;
}
if (handle.includes('w')) {
nextRect.x += deltaX;
nextRect.width -= deltaX;
}
const normalized = normalizeCropRect(nextRect);
if (JSON.stringify(normalized) === JSON.stringify(normalizeCropRect(rect))) {
return false;
}
Object.assign(rect, normalized);
return true;
}
bindKeyboard() {
document.addEventListener('keydown', (event) => {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') {
if (this.canEdit && !isEditableTarget(event.target)) {
event.preventDefault();
this.saveDirtyFolios();
}
return;
}
if (event.target instanceof HTMLElement && event.target.closest('[data-workstation-crop-handle]')) {
return;
}
if (isEditableTarget(event.target) || event.altKey || event.ctrlKey || event.metaKey) {
return;
}
if (this.isAnyModalOpen()) {
return;
}
if (event.key === 'ArrowLeft' && this.prevLink && !this.prevLink.hidden) {
event.preventDefault();
this.turnPage(this.getNavigationHref(this.prevLink), 'prev');
}
if (event.key === 'ArrowRight' && this.nextLink && !this.nextLink.hidden) {
event.preventDefault();
this.turnPage(this.getNavigationHref(this.nextLink), 'next');
}
if (event.key === 'PageUp' && this.prevLink && !this.prevLink.hidden) {
event.preventDefault();
this.turnPage(this.getNavigationHref(this.prevLink), 'prev');
}
if (event.key === 'PageDown' && this.nextLink && !this.nextLink.hidden) {
event.preventDefault();
this.turnPage(this.getNavigationHref(this.nextLink), 'next');
}
if (event.key === '0') {
event.preventDefault();
this.fitTo(this.getResetFitMode());
}
if (event.key === '+' || event.key === '=') {
event.preventDefault();
this.zoomTo(this.state.scale + 0.2);
}
if (event.key === '-') {
event.preventDefault();
this.zoomTo(this.state.scale - 0.2);
}
if (event.key.toLowerCase() === 'f' && this.fullscreenButton && this.isFullscreenSupported()) {
event.preventDefault();
this.fullscreenButton.click();
}
});
}
renderAllCrops() {
this.folios.forEach((folio) => {
this.renderCrop(folio);
});
}
renderCrop(folio) {
if (!folio.cropLayer) {
return;
}
const crop = normalizeCropRect(folio.crop);
folio.crop = crop;
folio.cropLayer.style.setProperty('--crop-x', `${crop.x * 100}%`);
folio.cropLayer.style.setProperty('--crop-y', `${crop.y * 100}%`);
folio.cropLayer.style.setProperty('--crop-width', `${crop.width * 100}%`);
folio.cropLayer.style.setProperty('--crop-height', `${crop.height * 100}%`);
}
setCrop(folio, rect, options = {}) {
const { dirty = true, autoDetected = false } = options;
folio.crop = normalizeCropRect(rect);
this.renderCrop(folio);
if (dirty) {
this.dirtyFolios.add(folio.slug);
}
if (autoDetected) {
this.autoDetected.add(folio.slug);
} else {
this.autoDetected.delete(folio.slug);
}
this.updateDirtyState();
}
getFitScale(mode) {
const viewportWidth = Math.max(1, this.viewport.clientWidth - 48);
const viewportHeight = Math.max(1, this.viewport.clientHeight - 48);
const contentWidth = Math.max(1, this.content.offsetWidth);
const contentHeight = Math.max(1, this.content.offsetHeight);
if (mode === 'height') {
return viewportHeight / contentHeight;
}
if (mode === 'actual') {
return 1;
}
return viewportWidth / contentWidth;
}
fitTo(mode = 'width', announce = true) {
const nextMode = mode === 'height' || mode === 'actual' ? mode : 'width';
this.state.fitMode = nextMode;
this.baseFitMode = nextMode;
this.state.scale = this.getFitScale(nextMode);
this.state.panX = 0;
this.state.panY = 0;
this.syncButtons();
this.renderSurface();
if (!announce) {
return;
}
this.updateStatus(
mode === 'height'
? 'Viewer fit to height.'
: mode === 'actual'
? 'Viewer set to 100%.'
: 'Viewer fit to width.'
);
}
clampPan() {
const { maxX, maxY } = this.getPanExtents();
this.state.panX = clamp(this.state.panX, -maxX, maxX);
this.state.panY = clamp(this.state.panY, -maxY, maxY);
}
renderSurface() {
this.clampPan();
this.surface.style.transform = `translate(-50%, -50%) translate(${this.state.panX}px, ${this.state.panY}px) scale(${this.state.scale})`;
this.requestViewerStateSync();
}
zoomTo(nextScale, clientX = null, clientY = null) {
const bounds = this.getScaleBounds();
const clampedScale = clamp(nextScale, bounds.min, bounds.max);
if (Math.abs(clampedScale - this.state.scale) < 0.001) {
return;
}
if (typeof clientX === 'number' && typeof clientY === 'number') {
const viewportRect = this.viewport.getBoundingClientRect();
const viewportX = clientX - viewportRect.left - viewportRect.width / 2;
const viewportY = clientY - viewportRect.top - viewportRect.height / 2;
const contentCenterX = this.content.offsetWidth / 2;
const contentCenterY = this.content.offsetHeight / 2;
const contentX = (viewportX - this.state.panX) / this.state.scale + contentCenterX;
const contentY = (viewportY - this.state.panY) / this.state.scale + contentCenterY;
this.state.panX = viewportX - (contentX - contentCenterX) * clampedScale;
this.state.panY = viewportY - (contentY - contentCenterY) * clampedScale;
}
this.state.fitMode = 'custom';
this.state.scale = clampedScale;
this.syncButtons();
this.renderSurface();
this.updateStatus(`Viewer zoom ${Math.round(this.state.scale * 100)}%.`);
}
ensureActiveImagesReady(callback) {
const activeFolios = this.getVisibleFolios();
if (!activeFolios.length) {
callback();
return;
}
let remaining = 0;
const done = () => {
remaining -= 1;
if (remaining <= 0) {
callback();
}
};
activeFolios.forEach((folio) => {
if (!folio.image) {
return;
}
remaining += 1;
if (folio.image.complete && folio.image.naturalWidth > 0) {
done();
return;
}
folio.image.addEventListener('load', done, { once: true });
folio.image.addEventListener('error', done, { once: true });
});
if (!remaining) {
callback();
}
}
swapImageSources() {
const visibleFolios = this.getVisibleFolios();
const activeFolios = visibleFolios.length ? visibleFolios : [this.getCurrentFolio()].filter(Boolean);
const activeSlugs = new Set(activeFolios.map((folio) => folio.slug));
const targetLevel = this.state.rawMode ? 'full' : 'standard';
return Promise.all(
this.folios.map((folio) => {
const nextLevel = activeSlugs.has(folio.slug) ? targetLevel : 'preview';
return this.applyFolioImageSource(folio, nextLevel);
})
);
}
async calibrateVisibleFolios() {
const activeFolios = this.getVisibleFolios();
for (const folio of activeFolios) {
if (!folio.image) {
continue;
}
const profile = await this.sampleBorderProfile(folio.image);
if (profile) {
this.calibrationProfiles.set(folio.slug, profile);
}
}
this.updateStatus('Calibration complete. Visible folio borders were sampled for auto-crop detection.');
}
async autoCropVisibleFolios() {
const activeFolios = this.getVisibleFolios();
if (!activeFolios.length) {
return;
}
await this.calibrateVisibleFolios();
for (const folio of activeFolios) {
if (!folio.image) {
continue;
}