11652 lines
449 KiB
JavaScript
11652 lines
449 KiB
JavaScript
/**
|
||||
|
|
* Impeccable Live Variant Mode - Browser Script
|
|||
|
|
*
|
|||
|
|
* Injected into the user's page via <script src="http://localhost:PORT/live.js">.
|
|||
|
|
* The server prepends window.__IMPECCABLE_TOKEN__ and window.__IMPECCABLE_PORT__
|
|||
|
|
* before this code.
|
|||
|
|
*
|
|||
|
|
* UI: a single floating bar that morphs between three states -
|
|||
|
|
* configure (pick action + go), generating (progressive dots), and cycling
|
|||
|
|
* (prev/next + accept/discard). Feels like Spotlight, not a modal.
|
|||
|
|
*/
|
|||
|
|
(function () {
|
|||
|
|
'use strict';
|
|||
|
|
if (typeof window === 'undefined') return;
|
|||
|
|
|
|||
|
|
// Guard against double-init. Bun's HTML loader may process the <script> tag
|
|||
|
|
// and create a bundled copy alongside the external load, or HMR may re-execute.
|
|||
|
|
// Check BEFORE reading token/port to catch all cases.
|
|||
|
|
if (window.__IMPECCABLE_LIVE_INIT__) return;
|
|||
|
|
window.__IMPECCABLE_LIVE_INIT__ = true;
|
|||
|
|
|
|||
|
|
const TOKEN = window.__IMPECCABLE_TOKEN__;
|
|||
|
|
const PORT = window.__IMPECCABLE_PORT__;
|
|||
|
|
if (!TOKEN || !PORT) {
|
|||
|
|
window.__IMPECCABLE_LIVE_INIT__ = false; // reset so the real load can init
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
//
|
|||
|
|
// Design tokens
|
|||
|
|
//
|
|||
|
|
|
|||
|
|
// Brand kinpaku (gold) is pinned to the site's neo-kinpaku tokens
|
|||
|
|
// (see site/styles/kinpaku-tokens.css) so Accept / knobs / cycle-dots /
|
|||
|
|
// the selection outline / the comment tag all match the site's accent,
|
|||
|
|
// not a washed theme-adjusted one. These mirror the kit's picker
|
|||
|
|
// colors in site/styles/kinpaku-kit.css; keep them in sync by hand.
|
|||
|
|
const C = {
|
|||
|
|
brand: 'oklch(84% 0.19 80.46)', // kinpaku gold
|
|||
|
|
brandHov: 'oklch(86% 0.07 84)', // kinpaku-pale (hover lift)
|
|||
|
|
brandSoft: 'oklch(84% 0.19 80.46 / 0.18)', // kinpaku-dim
|
|||
|
|
ink: 'oklch(4% 0.004 95)', // lacquer-deep
|
|||
|
|
ash: 'oklch(55% 0.018 82)', // warm muted text
|
|||
|
|
paper: 'oklch(98% 0.005 95 / 0.92)', // light overlay on user pages
|
|||
|
|
paperSolid:'oklch(98% 0.005 95)',
|
|||
|
|
mist: 'oklch(90% 0.008 82 / 0.6)', // light hairline
|
|||
|
|
white: 'oklch(99% 0 0)',
|
|||
|
|
};
|
|||
|
|
// Picker bar chrome - mirrors .live-demo-gbar / .live-demo-ctx in kinpaku-kit.css.
|
|||
|
|
// Quiet neutral elevation: no gold halo ring (gold is reserved for the brand
|
|||
|
|
// mark and the active control, not the container outline).
|
|||
|
|
const PICKER_SHADOW =
|
|||
|
|
'0 16px 36px -12px oklch(0% 0 0 / 0.6)';
|
|||
|
|
const FONT = 'system-ui, -apple-system, sans-serif';
|
|||
|
|
const MONO = 'ui-monospace, SFMono-Regular, Menlo, monospace';
|
|||
|
|
// z-index: detect overlays use 99999, so our UI must be above them
|
|||
|
|
const Z = { highlight: 100001, bar: 100005, picker: 100007, toast: 100010 };
|
|||
|
|
const EASE = 'cubic-bezier(0.22, 1, 0.36, 1)'; // ease-out-quint
|
|||
|
|
const PREFIX = 'impeccable-live';
|
|||
|
|
const IMPECCABLE_COMMAND = (window.__IMPECCABLE_COMMAND_PREFIX__ || '/') + 'impeccable';
|
|||
|
|
const PICK_CURSOR_STYLE_ID = PREFIX + '-pick-cursor-style';
|
|||
|
|
const MANUAL_APPLY_STATE_TTL_MS = 15 * 60 * 1000;
|
|||
|
|
const sessionState = window.__IMPECCABLE_LIVE_SESSION__?.createLiveBrowserSessionState({
|
|||
|
|
prefix: PREFIX,
|
|||
|
|
storage: localStorage,
|
|||
|
|
idFactory: () => crypto.randomUUID().replace(/-/g, '').slice(0, 8),
|
|||
|
|
});
|
|||
|
|
if (!sessionState) {
|
|||
|
|
console.error('[impeccable] live-browser-session.js was not loaded. Live mode cannot start safely.');
|
|||
|
|
window.__IMPECCABLE_LIVE_INIT__ = false;
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
const HIGHLIGHT_TRANSITION =
|
|||
|
|
'top 140ms ' + EASE +
|
|||
|
|
', left 140ms ' + EASE +
|
|||
|
|
', width 140ms ' + EASE +
|
|||
|
|
', height 140ms ' + EASE +
|
|||
|
|
', opacity 150ms ease';
|
|||
|
|
const TOOLTIP_TRANSITION =
|
|||
|
|
'top 140ms ' + EASE + ', left 140ms ' + EASE + ', opacity 150ms ease';
|
|||
|
|
|
|||
|
|
const SKIP_TAGS = new Set([
|
|||
|
|
'html', 'head', 'body', 'script', 'style', 'link', 'meta', 'noscript', 'br', 'wbr',
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
// Command vocabulary (values + labels + icons) comes from the canonical source,
|
|||
|
|
// skill/scripts/live/vocabulary.mjs, which live-server.mjs serializes into
|
|||
|
|
// window.__IMPECCABLE_VOCAB__ when it serves /live.js (same injection path as
|
|||
|
|
// the token/port above, so it is always present here). The icons stack above
|
|||
|
|
// each chip label and recolor to C.brand when selected (strokes use
|
|||
|
|
// currentColor). ACTIONS drives the picker grid; ICONS maps value -> svg.
|
|||
|
|
const VOCAB = Array.isArray(window.__IMPECCABLE_VOCAB__) ? window.__IMPECCABLE_VOCAB__ : [];
|
|||
|
|
const ICONS = {};
|
|||
|
|
const ACTIONS = VOCAB.map((c) => {
|
|||
|
|
ICONS[c.value] = c.icon;
|
|||
|
|
return { value: c.value, label: c.label };
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const LIVE_CHROME_MOUNT_CONTRACT = ['root', 'transport', 'state', 'actions'];
|
|||
|
|
const LIVE_UI_SURFACES = [
|
|||
|
|
{ key: 'global-bottom-bar', ids: [PREFIX + '-global-bar', PREFIX + '-global-bar-brand', PREFIX + '-pick-toggle', PREFIX + '-insert-toggle', PREFIX + '-detect-toggle', PREFIX + '-detect-badge', PREFIX + '-design-toggle', PREFIX + '-page-chat', PREFIX + '-page-chat-input', PREFIX + '-page-chat-voice'] },
|
|||
|
|
{ key: 'pending-copy-edit-dock', ids: [PREFIX + '-pending-dock'] },
|
|||
|
|
{ key: 'element-selection-chrome', ids: [PREFIX + '-highlight', PREFIX + '-tooltip', PREFIX + '-bar', PREFIX + '-selection-pill', PREFIX + '-input', PREFIX + '-configure-voice', PREFIX + '-configure-bar-tooltip'] },
|
|||
|
|
{ key: 'action-picker', ids: [PREFIX + '-picker'] },
|
|||
|
|
{ key: 'edit-chrome', ids: [PREFIX + '-edit-badge'] },
|
|||
|
|
{ key: 'generating-row', ids: [PREFIX + '-bar', PREFIX + '-shader'] },
|
|||
|
|
{ key: 'variant-cycling-row', ids: [PREFIX + '-bar', PREFIX + '-params-panel'] },
|
|||
|
|
{ key: 'variant-params-panel', ids: [PREFIX + '-params-panel'] },
|
|||
|
|
{ key: 'saving-confirmed-rows', ids: [PREFIX + '-bar'] },
|
|||
|
|
{ key: 'insert-mode-chrome', ids: [PREFIX + '-insert-line', PREFIX + '-insert-placeholder', PREFIX + '-placeholder-resize', PREFIX + '-insert-input', PREFIX + '-insert-voice', PREFIX + '-insert-create', PREFIX + '-insert-create-tooltip'] },
|
|||
|
|
{ key: 'annotation-chrome', ids: [PREFIX + '-annot', PREFIX + '-annot-svg', PREFIX + '-annot-pins', PREFIX + '-annot-clear'] },
|
|||
|
|
{ key: 'design-system-panel', ids: [PREFIX + '-design-host'] },
|
|||
|
|
{ key: 'toasts-and-errors', ids: [PREFIX + '-toast'] },
|
|||
|
|
{ key: 'css-isolation-boundary', ids: [PREFIX + '-root'] },
|
|||
|
|
];
|
|||
|
|
const LIVE_UI_COMPONENT_IDS = [...new Set(LIVE_UI_SURFACES.flatMap((surface) => surface.ids))];
|
|||
|
|
|
|||
|
|
//
|
|||
|
|
// State
|
|||
|
|
//
|
|||
|
|
|
|||
|
|
let state = 'IDLE';
|
|||
|
|
let hoveredElement = null;
|
|||
|
|
let selectedElement = null;
|
|||
|
|
let currentSessionId = null;
|
|||
|
|
let expectedVariants = 0;
|
|||
|
|
let arrivedVariants = 0;
|
|||
|
|
let visibleVariant = 0;
|
|||
|
|
let generationPhase = null;
|
|||
|
|
// Ascending order of the agent-generation lifecycle. The visible progress bar
|
|||
|
|
// must never regress: a `browser_resumed`/behind checkpoint re-broadcasts an
|
|||
|
|
// earlier phase (the server regresses the snapshot phase to `generating` on a
|
|||
|
|
// behind checkpoint), and without this the bar jumps backward mid-generation.
|
|||
|
|
// Unranked phases (params sidecar flow, unknown values) always pass so we
|
|||
|
|
// never block a phase we do not model.
|
|||
|
|
const PHASE_RANK = {
|
|||
|
|
queued: 0,
|
|||
|
|
picked_up: 1,
|
|||
|
|
scaffolding: 2,
|
|||
|
|
scaffold_fallback: 3,
|
|||
|
|
source_ready: 4,
|
|||
|
|
generation_ready: 5,
|
|||
|
|
generating: 5,
|
|||
|
|
variants_progress: 5,
|
|||
|
|
first_variant_generating: 6,
|
|||
|
|
first_variant_validating: 7,
|
|||
|
|
first_reviewable: 8,
|
|||
|
|
remaining_variants_generating: 9,
|
|||
|
|
remaining_variants_validating: 10,
|
|||
|
|
second_reviewable: 11,
|
|||
|
|
all_variants_ready: 12,
|
|||
|
|
variants_ready: 12,
|
|||
|
|
variant_parameters_generating: 13,
|
|||
|
|
variant_parameters_validating: 14,
|
|||
|
|
parameters_ready: 15,
|
|||
|
|
};
|
|||
|
|
function shouldAdvancePhase(current, next) {
|
|||
|
|
if (!next || next === current) return false;
|
|||
|
|
const nextRank = PHASE_RANK[next];
|
|||
|
|
const currentRank = PHASE_RANK[current];
|
|||
|
|
// Only block a known-lower phase from overwriting a known-higher one.
|
|||
|
|
if (nextRank === undefined || currentRank === undefined) return true;
|
|||
|
|
return nextRank >= currentRank;
|
|||
|
|
}
|
|||
|
|
let parameterGenerationState = 'idle';
|
|||
|
|
let parameterReadyAnnouncedSession = null;
|
|||
|
|
let svelteComponentSession = null;
|
|||
|
|
let svelteRuntimePromise = null;
|
|||
|
|
let pendingSvelteComponentRetryObserver = null;
|
|||
|
|
let currentSourceFile = null;
|
|||
|
|
let currentPreviewFile = null;
|
|||
|
|
let currentPreviewMode = null;
|
|||
|
|
let recoveryWaitingForAnchor = false;
|
|||
|
|
let pickedAnchorSnapshot = null;
|
|||
|
|
let pickedAnchorViewportTop = null;
|
|||
|
|
let pendingVariantAnchorRetryObserver = null;
|
|||
|
|
let pendingAcceptedSession = null;
|
|||
|
|
let variantObserver = null;
|
|||
|
|
let variantSelectionInFlight = false;
|
|||
|
|
let variantSelectionPromise = null;
|
|||
|
|
let recoveringEmptyCycling = false;
|
|||
|
|
let hasProjectContext = false;
|
|||
|
|
let selectedAction = 'impeccable';
|
|||
|
|
let selectedCount = 3;
|
|||
|
|
const browserOwner = sessionState.owner;
|
|||
|
|
let checkpointTimer = null;
|
|||
|
|
|
|||
|
|
// Scroll lock - holds window.scrollY at a fixed value while the session is
|
|||
|
|
// active, so HMR DOM patches and variant swaps can't drift the page. See
|
|||
|
|
// startScrollLock / stopScrollLock below.
|
|||
|
|
let scrollLockObserver = null;
|
|||
|
|
let scrollLockTargetY = null;
|
|||
|
|
let scrollLockAnchorTop = null;
|
|||
|
|
let scrollLockRaf = null;
|
|||
|
|
let scrollLockAbort = null;
|
|||
|
|
const SCROLL_ANCHOR_LOCK_ID = 'impeccable-scroll-anchor-lock';
|
|||
|
|
const VARIANT_STATE_STYLE_ID = 'impeccable-variant-state';
|
|||
|
|
const DISCARD_STATE_STYLE_ID = 'impeccable-discard-state';
|
|||
|
|
|
|||
|
|
// Dedicated key for scroll position - SEPARATE from LS_KEY so that
|
|||
|
|
// saveSession's state updates don't clobber a carefully-captured scrollY.
|
|||
|
|
// (Previously: saveSession wrote scrollY alongside state, so every call
|
|||
|
|
// during resume overwrote the pre-reload value with whatever the browser
|
|||
|
|
// had landed on, typically 0.)
|
|||
|
|
function writeScrollY(y) { sessionState.writeScrollY(y); }
|
|||
|
|
function readScrollY() { return sessionState.readScrollY(); }
|
|||
|
|
function clearScrollY() { sessionState.clearScrollY(); }
|
|||
|
|
|
|||
|
|
// Pre-empt the browser: apply manual scroll restoration and jump to the
|
|||
|
|
// saved scrollY at script-parse time. Retries on fonts.ready and load
|
|||
|
|
// are essential: scrollTo(y) clamps to the current document.scrollHeight,
|
|||
|
|
// which is often hundreds of pixels short of the final value until
|
|||
|
|
// async-loaded fonts swap in and reflow.
|
|||
|
|
try {
|
|||
|
|
history.scrollRestoration = 'manual';
|
|||
|
|
const savedY = readScrollY();
|
|||
|
|
if (savedY != null) {
|
|||
|
|
const apply = () => {
|
|||
|
|
if (Math.abs(window.scrollY - savedY) > 0.5) {
|
|||
|
|
window.scrollTo(0, savedY);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
apply();
|
|||
|
|
if (document.fonts?.ready) document.fonts.ready.then(apply).catch(() => {});
|
|||
|
|
window.addEventListener('load', apply, { once: true });
|
|||
|
|
}
|
|||
|
|
} catch {}
|
|||
|
|
|
|||
|
|
// UI refs
|
|||
|
|
let highlightEl = null;
|
|||
|
|
let tooltipEl = null;
|
|||
|
|
let barEl = null;
|
|||
|
|
let barHideSeq = 0;
|
|||
|
|
let pickerEl = null;
|
|||
|
|
let toastEl = null;
|
|||
|
|
let scrollRaf = null;
|
|||
|
|
let editBadgeEl = null;
|
|||
|
|
let editBadgeProxyRoot = null;
|
|||
|
|
let editBadgeProxyByTarget = new Map();
|
|||
|
|
|
|||
|
|
//
|
|||
|
|
// Helpers
|
|||
|
|
//
|
|||
|
|
|
|||
|
|
const domHelpers = window.__IMPECCABLE_LIVE_DOM__?.createLiveBrowserDomHelpers({
|
|||
|
|
prefix: PREFIX,
|
|||
|
|
skipTags: SKIP_TAGS,
|
|||
|
|
document,
|
|||
|
|
});
|
|||
|
|
if (!domHelpers) {
|
|||
|
|
console.error('[impeccable] live-browser-dom.js was not loaded. Live mode cannot start safely.');
|
|||
|
|
window.__IMPECCABLE_LIVE_INIT__ = false;
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
const {
|
|||
|
|
own,
|
|||
|
|
pickable,
|
|||
|
|
desc,
|
|||
|
|
rectIsUsableAnchor,
|
|||
|
|
makeFrozenAnchor,
|
|||
|
|
id8,
|
|||
|
|
cssId,
|
|||
|
|
liveUiRoot,
|
|||
|
|
uiAppend,
|
|||
|
|
uiAppendStyle,
|
|||
|
|
uiGetById,
|
|||
|
|
activeElementDeep,
|
|||
|
|
defangOutsideHandlers,
|
|||
|
|
} = domHelpers;
|
|||
|
|
|
|||
|
|
window.__IMPECCABLE_LIVE_CHROME_CORE__ = {
|
|||
|
|
version: 1,
|
|||
|
|
adapter: window.__IMPECCABLE_LIVE_ADAPTER__ || 'dom',
|
|||
|
|
mountContract: LIVE_CHROME_MOUNT_CONTRACT,
|
|||
|
|
surfaces: LIVE_UI_SURFACES,
|
|||
|
|
componentIds: LIVE_UI_COMPONENT_IDS,
|
|||
|
|
root: liveUiRoot,
|
|||
|
|
append: uiAppend,
|
|||
|
|
appendStyle: uiAppendStyle,
|
|||
|
|
getById: uiGetById,
|
|||
|
|
activeElementDeep,
|
|||
|
|
debugState: () => ({
|
|||
|
|
state,
|
|||
|
|
currentSessionId,
|
|||
|
|
expectedVariants,
|
|||
|
|
arrivedVariants,
|
|||
|
|
visibleVariant,
|
|||
|
|
savedSession: loadSession(),
|
|||
|
|
sourceFile: currentSourceFile,
|
|||
|
|
previewFile: currentPreviewFile,
|
|||
|
|
previewMode: currentPreviewMode,
|
|||
|
|
barText: barEl?.textContent || null,
|
|||
|
|
barConnected: !!barEl?.isConnected,
|
|||
|
|
hasSvelteComponentSession: !!svelteComponentSession,
|
|||
|
|
mountedSvelteVariant: svelteComponentSession?.mountedVariant || 0,
|
|||
|
|
pickActive,
|
|||
|
|
pendingApplyInFlight,
|
|||
|
|
hoveredElement: hoveredElement ? {
|
|||
|
|
tag: hoveredElement.tagName,
|
|||
|
|
classes: hoveredElement.className,
|
|||
|
|
pickable: pickable(hoveredElement),
|
|||
|
|
} : null,
|
|||
|
|
pendingSvelteComponentRetry: !!pendingSvelteComponentRetryObserver,
|
|||
|
|
recoveryWaitingForAnchor,
|
|||
|
|
evtSourceReadyState: evtSource ? evtSource.readyState : null,
|
|||
|
|
}),
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
//
|
|||
|
|
// Highlight overlay
|
|||
|
|
//
|
|||
|
|
|
|||
|
|
function initHighlight() {
|
|||
|
|
highlightEl = document.createElement('div');
|
|||
|
|
highlightEl.id = PREFIX + '-highlight';
|
|||
|
|
Object.assign(highlightEl.style, {
|
|||
|
|
position: 'fixed', top: '0', left: '0', width: '0', height: '0',
|
|||
|
|
border: '2px solid ' + C.brand, borderRadius: '3px',
|
|||
|
|
pointerEvents: 'none', zIndex: Z.highlight, boxSizing: 'border-box',
|
|||
|
|
transition: HIGHLIGHT_TRANSITION,
|
|||
|
|
display: 'none', opacity: '0',
|
|||
|
|
});
|
|||
|
|
uiAppend(highlightEl);
|
|||
|
|
|
|||
|
|
tooltipEl = document.createElement('div');
|
|||
|
|
tooltipEl.id = PREFIX + '-tooltip';
|
|||
|
|
Object.assign(tooltipEl.style, {
|
|||
|
|
position: 'fixed',
|
|||
|
|
background: C.ink, color: C.white,
|
|||
|
|
fontFamily: MONO, fontSize: '10px', fontWeight: '500',
|
|||
|
|
padding: '2px 6px', borderRadius: '3px',
|
|||
|
|
zIndex: Z.highlight + 1, pointerEvents: 'none',
|
|||
|
|
whiteSpace: 'nowrap', display: 'none',
|
|||
|
|
letterSpacing: '0.02em',
|
|||
|
|
transition: TOOLTIP_TRANSITION,
|
|||
|
|
});
|
|||
|
|
uiAppend(tooltipEl);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function shouldShowHighlightTagTooltip() {
|
|||
|
|
// Configure/edit carry the tag in the bar selection pill, so keep only the outline.
|
|||
|
|
return state !== 'CONFIGURING' && state !== 'EDITING';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function hideHighlightTagTooltip() {
|
|||
|
|
if (!tooltipEl) return;
|
|||
|
|
tooltipEl.style.opacity = '0';
|
|||
|
|
tooltipEl.style.display = 'none';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function showHighlight(el) {
|
|||
|
|
if (!el || !highlightEl) return;
|
|||
|
|
if (el.hasAttribute?.('data-impeccable-insert-placeholder')) return;
|
|||
|
|
const r = el.getBoundingClientRect();
|
|||
|
|
const top = (r.top - 2) + 'px', left = (r.left - 2) + 'px';
|
|||
|
|
const width = (r.width + 4) + 'px', height = (r.height + 4) + 'px';
|
|||
|
|
const showTagTooltip = shouldShowHighlightTagTooltip();
|
|||
|
|
|
|||
|
|
const hiWasHidden = highlightEl.style.display === 'none' || highlightEl.style.opacity === '0';
|
|||
|
|
if (hiWasHidden) {
|
|||
|
|
// Snap to first target without animating from (0,0), then fade in.
|
|||
|
|
highlightEl.style.transition = 'none';
|
|||
|
|
Object.assign(highlightEl.style, { top, left, width, height, display: 'block' });
|
|||
|
|
void highlightEl.offsetWidth;
|
|||
|
|
highlightEl.style.transition = HIGHLIGHT_TRANSITION;
|
|||
|
|
highlightEl.style.opacity = '1';
|
|||
|
|
} else {
|
|||
|
|
Object.assign(highlightEl.style, { top, left, width, height, display: 'block', opacity: '1' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!showTagTooltip) {
|
|||
|
|
hideHighlightTagTooltip();
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const tipTop = r.top - 20;
|
|||
|
|
const tipY = (tipTop < 4 ? r.bottom + 4 : tipTop) + 'px';
|
|||
|
|
const tipX = Math.max(4, r.left) + 'px';
|
|||
|
|
tooltipEl.textContent = desc(el);
|
|||
|
|
if (hiWasHidden) {
|
|||
|
|
tooltipEl.style.transition = 'none';
|
|||
|
|
Object.assign(tooltipEl.style, { top: tipY, left: tipX, display: 'block' });
|
|||
|
|
void tooltipEl.offsetWidth;
|
|||
|
|
tooltipEl.style.transition = TOOLTIP_TRANSITION;
|
|||
|
|
tooltipEl.style.opacity = '1';
|
|||
|
|
} else {
|
|||
|
|
Object.assign(tooltipEl.style, { top: tipY, left: tipX, display: 'block', opacity: '1' });
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function hideHighlight() {
|
|||
|
|
if (highlightEl) { highlightEl.style.opacity = '0'; highlightEl.style.display = 'none'; }
|
|||
|
|
if (tooltipEl) { tooltipEl.style.opacity = '0'; tooltipEl.style.display = 'none'; }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
//
|
|||
|
|
// Annotation overlay (comment pins + kinpaku strokes)
|
|||
|
|
//
|
|||
|
|
// Active while state === 'CONFIGURING'. The overlay is a fixed-positioned
|
|||
|
|
// sibling of <body> mirroring selectedElement's bounding rect. Click (no
|
|||
|
|
// drag) drops a comment pin; drag paints a kinpaku SVG stroke. All coords
|
|||
|
|
// are stored in element-local CSS px so they survive scroll / resize and
|
|||
|
|
// correlate directly with the captured PNG.
|
|||
|
|
//
|
|||
|
|
|
|||
|
|
const DRAG_THRESHOLD = 5; // px - below this, treat pointerup as a click
|
|||
|
|
const PIN_DBL_CLICK_MS = 300; // two clicks on the same pin within this delete it
|
|||
|
|
let annotOverlayEl = null;
|
|||
|
|
let annotSvgEl = null;
|
|||
|
|
let annotPinsEl = null;
|
|||
|
|
let annotClearChipEl = null;
|
|||
|
|
let annotState = { comments: [], strokes: [] };
|
|||
|
|
let annotActive = false;
|
|||
|
|
// `annotPointer` is either:
|
|||
|
|
// { kind: 'new', x0, y0, moved, strokeEl, strokePoints } creating a stroke/pin
|
|||
|
|
// { kind: 'pin', idx, startPointer, startPin, moved } dragging an existing pin
|
|||
|
|
let annotPointer = null;
|
|||
|
|
let annotEditing = null; // { idx, input, wrapEl }
|
|||
|
|
let annotLastPinClick = { idx: -1, time: 0 }; // for click-click-to-delete
|
|||
|
|
let placeholderResizeLayerEl = null;
|
|||
|
|
let placeholderResizeDrag = null;
|
|||
|
|
|
|||
|
|
function initAnnotOverlay() {
|
|||
|
|
annotOverlayEl = document.createElement('div');
|
|||
|
|
annotOverlayEl.id = PREFIX + '-annot';
|
|||
|
|
Object.assign(annotOverlayEl.style, {
|
|||
|
|
position: 'fixed', top: '0', left: '0', width: '0', height: '0',
|
|||
|
|
pointerEvents: 'auto', zIndex: Z.highlight + 2,
|
|||
|
|
display: 'none', overflow: 'visible',
|
|||
|
|
cursor: 'crosshair', touchAction: 'none',
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
annotSvgEl = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|||
|
|
annotSvgEl.id = PREFIX + '-annot-svg';
|
|||
|
|
Object.assign(annotSvgEl.style, {
|
|||
|
|
position: 'absolute', top: '0', left: '0',
|
|||
|
|
width: '100%', height: '100%',
|
|||
|
|
// The SVG itself doesn't absorb clicks; individual hit-paths opt-in via
|
|||
|
|
// pointer-events=stroke so gaps still fall through to the overlay.
|
|||
|
|
pointerEvents: 'none', overflow: 'visible',
|
|||
|
|
});
|
|||
|
|
annotOverlayEl.appendChild(annotSvgEl);
|
|||
|
|
|
|||
|
|
annotPinsEl = document.createElement('div');
|
|||
|
|
annotPinsEl.id = PREFIX + '-annot-pins';
|
|||
|
|
Object.assign(annotPinsEl.style, {
|
|||
|
|
position: 'absolute', inset: '0',
|
|||
|
|
pointerEvents: 'none',
|
|||
|
|
});
|
|||
|
|
annotOverlayEl.appendChild(annotPinsEl);
|
|||
|
|
|
|||
|
|
annotClearChipEl = document.createElement('div');
|
|||
|
|
annotClearChipEl.id = PREFIX + '-annot-clear';
|
|||
|
|
annotClearChipEl.dataset.annotClear = 'true';
|
|||
|
|
annotClearChipEl.textContent = 'Clear';
|
|||
|
|
Object.assign(annotClearChipEl.style, {
|
|||
|
|
position: 'absolute', top: '8px', right: '8px',
|
|||
|
|
background: C.ink, color: C.white,
|
|||
|
|
fontFamily: FONT, fontSize: '10px', fontWeight: '500',
|
|||
|
|
letterSpacing: '0.08em', textTransform: 'uppercase',
|
|||
|
|
padding: '5px 12px', borderRadius: '999px',
|
|||
|
|
cursor: 'pointer', pointerEvents: 'auto',
|
|||
|
|
display: 'none', userSelect: 'none',
|
|||
|
|
boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
|
|||
|
|
});
|
|||
|
|
annotOverlayEl.appendChild(annotClearChipEl);
|
|||
|
|
|
|||
|
|
placeholderResizeLayerEl = document.createElement('div');
|
|||
|
|
placeholderResizeLayerEl.id = PREFIX + '-placeholder-resize';
|
|||
|
|
Object.assign(placeholderResizeLayerEl.style, {
|
|||
|
|
position: 'absolute',
|
|||
|
|
inset: '0',
|
|||
|
|
pointerEvents: 'none',
|
|||
|
|
display: 'none',
|
|||
|
|
zIndex: '2',
|
|||
|
|
});
|
|||