546 lines
21 KiB
JavaScript
546 lines
21 KiB
JavaScript
// CodeMirror extension: highlight recognized chip natlangs in the doc
|
|
// with a subtle status-colored underline + on-hover tooltip showing the
|
|
// chip's display name, bucket, tag, natlang, and current QA status.
|
|
//
|
|
// Read-only annotation: nothing about the text changes — just a styled
|
|
// mark span underneath, so the editor stays a normal text editor and
|
|
// selection / cursor behavior is unaffected.
|
|
//
|
|
// Architecture: decorations live in a StateField (canonical CM6 pattern
|
|
// for async-computed decorations). The ViewPlugin watches doc changes,
|
|
// debounces a rescan, and dispatches a setDecorations effect that the
|
|
// field consumes. tr.changes mapping keeps decorations anchored as the
|
|
// user types between rescans.
|
|
|
|
import { loadChipIndex, getChipIndex, onChipIndexReady } from "./chip-index.js";
|
|
|
|
const BOUNDARY_RE = /[.,;\s]/;
|
|
const BASE_CLASS = "pcr-chip-recognized";
|
|
const DEBOUNCE_MS = 150;
|
|
|
|
function statusClass(status) {
|
|
if (status === "normalized") return "pcr-chip-recognized-ready";
|
|
if (status === "broken") return "pcr-chip-recognized-broken";
|
|
return "pcr-chip-recognized-unprocessed";
|
|
}
|
|
|
|
// Reverse the server's hair-length-onto-style weld. compose_character_
|
|
// natlang_v2 prefixes a hair_length adjective onto the primary hair_style
|
|
// phrase ("long" + "twin braids" -> "long twin braids"). Given that
|
|
// welded phrase, recover the two member chips so the span shows both on
|
|
// hover. Longest style suffix wins (drop leading words until the
|
|
// remainder is a known hair_style and the prefix is a known length
|
|
// adjective). Returns [lengthInfo, styleInfo] or null.
|
|
function decomposeHairWeld(phrase, index) {
|
|
if (!index || !index.hairStyleByPhrase || !index.hairLengthAdj) return null;
|
|
const words = phrase.toLowerCase().split(/\s+/);
|
|
if (words.length < 2) return null;
|
|
for (let i = 1; i < words.length; i++) {
|
|
const prefix = words.slice(0, i).join(" ");
|
|
const suffix = words.slice(i).join(" ");
|
|
const styleInfo = index.hairStyleByPhrase.get(suffix);
|
|
const lengthInfo = index.hairLengthAdj.get(prefix);
|
|
if (styleInfo && lengthInfo) return [lengthInfo, styleInfo];
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Walks the doc text, emits {from, to, info} for every recognized chip
|
|
// natlang. Two-pass: multi-comma natlangs first (longest-first, claim
|
|
// spans), then single-phrase comma-chunked lookup over the leftovers.
|
|
function scanChips(text, index) {
|
|
if (!text || !index) return [];
|
|
const matches = [];
|
|
const taken = []; // [start, end] intervals claimed by multi-comma pass
|
|
|
|
// Helper: longest-first phrase pass against the full text, claiming
|
|
// non-overlapping spans into `taken` so a later pass doesn't reclaim
|
|
// already-matched ranges.
|
|
const claimPass = (entries) => {
|
|
for (const entry of entries) {
|
|
let from = 0;
|
|
while (from <= text.length) {
|
|
const at = text.indexOf(entry.natlang, from);
|
|
if (at < 0) break;
|
|
const end = at + entry.natlang.length;
|
|
const before = at === 0 ? "" : text.charAt(at - 1);
|
|
const after = end >= text.length ? "" : text.charAt(end);
|
|
const okBefore = at === 0 || BOUNDARY_RE.test(before);
|
|
const okAfter = end >= text.length || BOUNDARY_RE.test(after);
|
|
let overlap = false;
|
|
for (const [s, e] of taken) {
|
|
if (at < e && end > s) { overlap = true; break; }
|
|
}
|
|
if (okBefore && okAfter && !overlap) {
|
|
matches.push({ from: at, to: end, info: entry.info });
|
|
taken.push([at, end]);
|
|
from = end;
|
|
} else {
|
|
from = at + 1;
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
// Characters first — a character base_natlang ("Cammy White from
|
|
// Street Fighter") is a strong identity match; if it appears anywhere
|
|
// the user means the character, not coincidental words.
|
|
claimPass(index.characterList || []);
|
|
// Override (enhancer) phrasings next — a character-specific enhancer
|
|
// like Cammy's verbose scar description is more specific than the
|
|
// generic chip phrase, so it should claim its span first.
|
|
claimPass(index.overrideList || []);
|
|
// Then multi-comma chips so a long armor description claims its full
|
|
// span before single-phrase pieces light up individually.
|
|
claimPass(index.multiCommaList);
|
|
|
|
// Weighted tokens — "(close-up:1.2)". The chunk splitter below excludes
|
|
// periods, so the decimal weight shreds the token and it never forms a
|
|
// clean chunk. Match these explicitly and look up the inner tag, then
|
|
// claim the inner span so the chunk pass doesn't reprocess the pieces.
|
|
const WEIGHT_RE = /\(\s*([^():]+?)\s*:\s*[\d.]+\s*\)/g;
|
|
let wm;
|
|
while ((wm = WEIGHT_RE.exec(text)) !== null) {
|
|
const inner = wm[1].trim();
|
|
if (!inner) continue;
|
|
const innerStart = wm.index + wm[0].indexOf(inner);
|
|
const innerEnd = innerStart + inner.length;
|
|
let overlap = false;
|
|
for (const [s, e] of taken) {
|
|
if (innerStart < e && innerEnd > s) { overlap = true; break; }
|
|
}
|
|
if (overlap) continue;
|
|
const info = index.phraseToChip.get(inner) || index.tagToChip?.get(inner.toLowerCase());
|
|
if (info) {
|
|
matches.push({ from: innerStart, to: innerEnd, info });
|
|
taken.push([innerStart, innerEnd]);
|
|
}
|
|
}
|
|
|
|
// Walk every comma/period/newline-delimited chunk and look the
|
|
// trimmed phrase up in the single-phrase map. Skip chunks fully
|
|
// inside a multi-comma claim so a "metal helmet" inside an armor
|
|
// span doesn't double-light.
|
|
const chunkRe = /([^,.\n]+)(?=,|\.\s|\.$|\n|$)/g;
|
|
let m;
|
|
while ((m = chunkRe.exec(text)) !== null) {
|
|
const raw = m[0];
|
|
const rawStart = m.index;
|
|
const phrase = raw.trim();
|
|
if (!phrase) {
|
|
if (chunkRe.lastIndex === m.index) chunkRe.lastIndex++;
|
|
continue;
|
|
}
|
|
const phraseStart = rawStart + raw.indexOf(phrase);
|
|
const phraseEnd = phraseStart + phrase.length;
|
|
|
|
let inside = false;
|
|
for (const [s, e] of taken) {
|
|
if (phraseStart >= s && phraseEnd <= e) { inside = true; break; }
|
|
}
|
|
if (inside) continue;
|
|
|
|
// Natlang phrase first, then tag form (so tag-mode tokens like
|
|
// "double bun" or "heart-shaped pupils" resolve even when their
|
|
// base_natlang differs from the tag).
|
|
const info = index.phraseToChip.get(phrase) || index.tagToChip?.get(phrase.toLowerCase());
|
|
if (info) {
|
|
matches.push({ from: phraseStart, to: phraseEnd, info });
|
|
continue;
|
|
}
|
|
// No whole-phrase chip: a welded hair feature ("long twin braids")
|
|
// reads as one chunk but maps to two canonical tags. Decompose it so
|
|
// both chips stay hoverable from the single span.
|
|
const members = decomposeHairWeld(phrase, index);
|
|
if (members) {
|
|
matches.push({ from: phraseStart, to: phraseEnd, members });
|
|
}
|
|
}
|
|
|
|
matches.sort((a, b) => a.from - b.from);
|
|
return matches;
|
|
}
|
|
|
|
function buildDecorations(CM, matches) {
|
|
const decs = [];
|
|
for (const { from, to, info, members } of matches) {
|
|
// Welded multi-chip span (e.g. "long twin braids" → long_hair +
|
|
// twin_braids). One decoration, both tags in a CSV attribute; the
|
|
// hover handler fans them back out into a stacked tooltip. Color
|
|
// follows the style chip's status since that's the visible feature.
|
|
if (members && members.length) {
|
|
const style = members[members.length - 1];
|
|
decs.push(
|
|
CM.Decoration.mark({
|
|
class: `${BASE_CLASS} ${statusClass(style.natlang_status)} pcr-chip-bucket-appearance`,
|
|
attributes: {
|
|
"data-pcr-chip-tags": members.map((m) => m.item_tag).join(","),
|
|
"data-pcr-chip-bucket": "appearance",
|
|
},
|
|
}).range(from, to)
|
|
);
|
|
continue;
|
|
}
|
|
// Pill color follows override_status when the match came from an
|
|
// override phrasing — the user's intent is to A/B the enhancer
|
|
// independently of the base chip natlang's status.
|
|
const statusForColor = info.override_scope
|
|
? info.override_status
|
|
: info.natlang_status;
|
|
const attrs = {
|
|
"data-pcr-chip-tag": info.item_tag,
|
|
"data-pcr-chip-bucket": info.bucket,
|
|
};
|
|
if (info.override_scope) {
|
|
attrs["data-pcr-chip-override-scope"] = info.override_scope;
|
|
attrs["data-pcr-chip-override-id"] = String(info.override_scope_id);
|
|
}
|
|
decs.push(
|
|
CM.Decoration.mark({
|
|
class: `${BASE_CLASS} ${statusClass(statusForColor)} pcr-chip-bucket-${info.bucket}`,
|
|
attributes: attrs,
|
|
}).range(from, to)
|
|
);
|
|
}
|
|
return CM.Decoration.set(decs, true);
|
|
}
|
|
|
|
// Shared floating tooltip DOM. Reused across editors. Anchored to
|
|
// document.body so it escapes overflow:hidden ancestors on node UI.
|
|
let tooltipEl = null;
|
|
function ensureTooltip() {
|
|
if (tooltipEl) return tooltipEl;
|
|
tooltipEl = document.createElement("div");
|
|
tooltipEl.className = "pcr-chip-tooltip";
|
|
tooltipEl.style.display = "none";
|
|
document.body.appendChild(tooltipEl);
|
|
return tooltipEl;
|
|
}
|
|
function hideTooltip() {
|
|
if (tooltipEl) tooltipEl.style.display = "none";
|
|
tooltipKey = null;
|
|
}
|
|
// Tracks which chip the tooltip currently represents so we can skip
|
|
// the expensive DOM rebuild + image load on every mousemove pixel and
|
|
// just reposition. Cleared in hideTooltip / when the cursor leaves a
|
|
// chip.
|
|
let tooltipKey = null;
|
|
|
|
function positionTooltip(x, y) {
|
|
const el = ensureTooltip();
|
|
el.style.left = `${x + 12}px`;
|
|
el.style.top = `${y + 18}px`;
|
|
el.style.display = "block";
|
|
const rect = el.getBoundingClientRect();
|
|
if (rect.right > window.innerWidth) {
|
|
el.style.left = `${Math.max(8, window.innerWidth - rect.width - 8)}px`;
|
|
}
|
|
if (rect.bottom > window.innerHeight) {
|
|
el.style.top = `${Math.max(8, y - rect.height - 12)}px`;
|
|
}
|
|
}
|
|
|
|
function renderTooltip(info, x, y) {
|
|
const el = ensureTooltip();
|
|
el.replaceChildren();
|
|
|
|
// Thumbnail at the top. Cast thumbs are served under the cast group
|
|
// (item_group), not the "cast" bucket name; everything else uses the
|
|
// bucket. Missing thumbs 404 silently via onerror.
|
|
const thumbSeg = info.bucket === "cast" ? info.item_group : info.bucket;
|
|
if (thumbSeg) {
|
|
const thumb = document.createElement("img");
|
|
thumb.className = "pcr-chip-tooltip-thumb";
|
|
thumb.src = `/promptchain/tag-builder/thumb/${encodeURIComponent(thumbSeg)}/${encodeURIComponent(info.item_tag)}`;
|
|
thumb.onerror = () => thumb.remove();
|
|
el.appendChild(thumb);
|
|
}
|
|
|
|
const title = document.createElement("div");
|
|
title.className = "pcr-chip-tooltip-title";
|
|
title.textContent = info.display_name || info.item_tag;
|
|
el.appendChild(title);
|
|
|
|
const sub = document.createElement("div");
|
|
sub.className = "pcr-chip-tooltip-sub";
|
|
sub.textContent = `${info.bucket}${info.item_group ? " · " + info.item_group : ""}`;
|
|
el.appendChild(sub);
|
|
|
|
// Every chip gets the standard Tag/Natlang label-value rows. Migrated
|
|
// character chips append a Base Appearance pill grid below so the user
|
|
// sees the bound appearance chips at a glance alongside the canonical
|
|
// tag/natlang display.
|
|
renderTagRow(el, info);
|
|
renderNatlangRow(el, info);
|
|
|
|
if (info.override_scope) {
|
|
renderEnhancerRow(el, info);
|
|
}
|
|
|
|
if (info.bucket === "characters" && info.migrated && info.appearance_chips?.length) {
|
|
renderBaseAppearanceSection(el, info);
|
|
}
|
|
|
|
// Status pill at the bottom. Override matches show the override's
|
|
// own status pill since that's what's authoritative for the matched
|
|
// text; non-override matches show the base chip's natlang_status.
|
|
const status = document.createElement("div");
|
|
if (info.override_scope) {
|
|
status.className = `pcr-chip-tooltip-status pcr-chip-status-${info.override_status || "unprocessed"}`;
|
|
status.textContent = `enhancer · ${info.override_status || "unprocessed"}`;
|
|
} else {
|
|
status.className = `pcr-chip-tooltip-status pcr-chip-status-${info.natlang_status || "unprocessed"}`;
|
|
status.textContent = info.natlang_status || "unprocessed";
|
|
}
|
|
el.appendChild(status);
|
|
|
|
positionTooltip(x, y);
|
|
}
|
|
|
|
// Tooltip for a welded multi-chip span. One phrase ("long twin braids")
|
|
// stands in for several canonical tags; show each member so the user sees
|
|
// both the prose feature and the tags it expands back into.
|
|
function renderCompositeTooltip(infos, x, y) {
|
|
const el = ensureTooltip();
|
|
el.replaceChildren();
|
|
|
|
const title = document.createElement("div");
|
|
title.className = "pcr-chip-tooltip-title";
|
|
title.textContent = infos.map((i) => i.display_name || i.item_tag).join(" + ");
|
|
el.appendChild(title);
|
|
|
|
const sub = document.createElement("div");
|
|
sub.className = "pcr-chip-tooltip-sub";
|
|
sub.textContent = "appearance · combined";
|
|
el.appendChild(sub);
|
|
|
|
// Canonical tags this single phrase expands back into on tag-mode.
|
|
const tagRow = document.createElement("div");
|
|
tagRow.className = "pcr-chip-tooltip-row";
|
|
const tagLabel = document.createElement("span");
|
|
tagLabel.className = "pcr-chip-tooltip-label";
|
|
tagLabel.textContent = "Tags";
|
|
const tagValue = document.createElement("span");
|
|
tagValue.className = "pcr-chip-tooltip-value pcr-chip-tooltip-mono";
|
|
tagValue.textContent = infos.map((i) => i.base_tags || i.item_tag).join(", ");
|
|
tagRow.appendChild(tagLabel);
|
|
tagRow.appendChild(tagValue);
|
|
el.appendChild(tagRow);
|
|
|
|
for (const info of infos) {
|
|
const header = document.createElement("div");
|
|
header.className = "pcr-chip-tooltip-section-header";
|
|
header.textContent = `${info.display_name || info.item_tag}${info.item_group ? " · " + info.item_group : ""}`;
|
|
el.appendChild(header);
|
|
renderNatlangRow(el, info);
|
|
const status = document.createElement("div");
|
|
status.className = `pcr-chip-tooltip-status pcr-chip-status-${info.natlang_status || "unprocessed"}`;
|
|
status.textContent = info.natlang_status || "unprocessed";
|
|
el.appendChild(status);
|
|
}
|
|
|
|
positionTooltip(x, y);
|
|
}
|
|
|
|
function renderEnhancerRow(el, info) {
|
|
// Scope label reads "Cammy White" for character overrides and
|
|
// "Delta Red (Cammy White)" for outfit/pose overrides — the
|
|
// character context disambiguates same-named outfits across roster.
|
|
const scopeLabel = info.override_scope_character_display
|
|
? `${info.override_scope_display} (${info.override_scope_character_display})`
|
|
: info.override_scope_display;
|
|
|
|
const header = document.createElement("div");
|
|
header.className = "pcr-chip-tooltip-section-header";
|
|
header.textContent = `Enhancer · ${info.override_scope} · ${scopeLabel}`;
|
|
el.appendChild(header);
|
|
|
|
const row = document.createElement("div");
|
|
row.className = "pcr-chip-tooltip-enhancer";
|
|
row.textContent = info.override_natlang;
|
|
el.appendChild(row);
|
|
}
|
|
|
|
function renderTagRow(el, info) {
|
|
const tagRow = document.createElement("div");
|
|
tagRow.className = "pcr-chip-tooltip-row";
|
|
const tagLabel = document.createElement("span");
|
|
tagLabel.className = "pcr-chip-tooltip-label";
|
|
tagLabel.textContent = "Tag";
|
|
const tagValue = document.createElement("span");
|
|
tagValue.className = "pcr-chip-tooltip-value pcr-chip-tooltip-mono";
|
|
tagValue.textContent = info.base_tags || info.item_tag;
|
|
tagRow.appendChild(tagLabel);
|
|
tagRow.appendChild(tagValue);
|
|
el.appendChild(tagRow);
|
|
}
|
|
|
|
function renderNatlangRow(el, info) {
|
|
const nlRow = document.createElement("div");
|
|
nlRow.className = "pcr-chip-tooltip-row";
|
|
const nlLabel = document.createElement("span");
|
|
nlLabel.className = "pcr-chip-tooltip-label";
|
|
nlLabel.textContent = "Natlang";
|
|
const nlValue = document.createElement("span");
|
|
nlValue.className = "pcr-chip-tooltip-value";
|
|
nlValue.textContent = info.base_natlang || "(empty)";
|
|
nlRow.appendChild(nlLabel);
|
|
nlRow.appendChild(nlValue);
|
|
el.appendChild(nlRow);
|
|
}
|
|
|
|
function renderBaseAppearanceSection(el, info) {
|
|
if (!info.appearance_chips?.length) return;
|
|
const header = document.createElement("div");
|
|
header.className = "pcr-chip-tooltip-section-header";
|
|
header.textContent = "Base appearance";
|
|
el.appendChild(header);
|
|
|
|
const grid = document.createElement("div");
|
|
grid.className = "pcr-chip-tooltip-pill-grid";
|
|
for (const chip of info.appearance_chips) {
|
|
const pill = document.createElement("span");
|
|
pill.className = `pcr-chip-tooltip-pill pcr-chip-tooltip-pill-${chip.natlang_status || "unprocessed"}`;
|
|
pill.textContent = chip.display_name || chip.item_tag;
|
|
pill.title = `${chip.item_tag} — ${chip.base_natlang || "(no natlang)"}`;
|
|
grid.appendChild(pill);
|
|
}
|
|
el.appendChild(grid);
|
|
}
|
|
|
|
// Quick reverse-lookup helper. We have the tag+bucket on the DOM via
|
|
// data attributes; the index is keyed by natlang phrase, so we scan
|
|
// the values to find the chip. 5k items is cheap enough on a single
|
|
// hover that a separate reverse index isn't worth the memory.
|
|
//
|
|
// `overrideFor` (optional) selects the override entry for the given
|
|
// character_tag instead of the generic chip — the tooltip needs the
|
|
// override metadata (enhancer text, status) to render its Enhancer
|
|
// section.
|
|
function findChipByTag(index, tag, bucket, overrideScope, overrideId) {
|
|
if (!index || !tag) return null;
|
|
if (overrideScope && overrideId !== null && overrideId !== undefined) {
|
|
for (const entry of index.overrideList || []) {
|
|
if (entry.info.item_tag === tag
|
|
&& entry.info.override_scope === overrideScope
|
|
&& String(entry.info.override_scope_id) === String(overrideId)) {
|
|
return entry.info;
|
|
}
|
|
}
|
|
// Fall through to generic lookup if the override row got deleted
|
|
// between scan and hover (rare race).
|
|
}
|
|
if (bucket === "characters") {
|
|
for (const entry of index.characterList || []) {
|
|
if (entry.info.item_tag === tag) return entry.info;
|
|
}
|
|
return null;
|
|
}
|
|
for (const v of index.phraseToChip.values()) {
|
|
if (v.item_tag === tag && v.bucket === bucket) return v;
|
|
}
|
|
for (const entry of index.multiCommaList) {
|
|
if (entry.info.item_tag === tag && entry.info.bucket === bucket) return entry.info;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Builds the extension. Returns a CM extension array suitable to spread
|
|
// into editor.js's createEditor extensions list.
|
|
export function chipRecognizerExtension(CM) {
|
|
loadChipIndex();
|
|
|
|
// Effect dispatched by the plugin when a fresh scan completes.
|
|
const setDecorationsEffect = CM.StateEffect.define();
|
|
|
|
// Decorations live in a StateField so they survive between plugin
|
|
// ticks and get re-mapped through doc changes (so a mark at position
|
|
// 50 stays on the same text after a 5-char insert at position 10).
|
|
const decorationsField = CM.StateField.define({
|
|
create: () => CM.Decoration.none,
|
|
update(value, tr) {
|
|
let v = value.map(tr.changes);
|
|
for (const ef of tr.effects) {
|
|
if (ef.is(setDecorationsEffect)) v = ef.value;
|
|
}
|
|
return v;
|
|
},
|
|
provide: f => CM.EditorView.decorations.from(f),
|
|
});
|
|
|
|
const plugin = CM.ViewPlugin.fromClass(class {
|
|
constructor(view) {
|
|
this.view = view;
|
|
this.timer = null;
|
|
this.schedule();
|
|
this.unsubscribe = onChipIndexReady(() => this.schedule());
|
|
}
|
|
update(update) {
|
|
if (update.docChanged) this.schedule();
|
|
}
|
|
schedule() {
|
|
if (this.timer) clearTimeout(this.timer);
|
|
this.timer = setTimeout(() => {
|
|
this.timer = null;
|
|
const index = getChipIndex();
|
|
const text = this.view.state.doc.toString();
|
|
const matches = index ? scanChips(text, index) : [];
|
|
const decs = buildDecorations(CM, matches);
|
|
this.view.dispatch({ effects: setDecorationsEffect.of(decs) });
|
|
}, DEBOUNCE_MS);
|
|
}
|
|
destroy() {
|
|
if (this.timer) clearTimeout(this.timer);
|
|
this.unsubscribe?.();
|
|
}
|
|
});
|
|
|
|
// Mousemove fires per pixel; rebuilding the tooltip on every event
|
|
// (which includes a new img element and image load) is what caused
|
|
// the flicker. Track which chip the tooltip currently represents and
|
|
// skip the rebuild when cursor stays inside the same chip span —
|
|
// just reposition.
|
|
const tooltipHandlers = CM.EditorView.domEventHandlers({
|
|
mousemove: (event) => {
|
|
const target = event.target.closest(`.${BASE_CLASS}`);
|
|
if (!target) { hideTooltip(); return false; }
|
|
// Welded multi-chip span: render every member stacked so both
|
|
// canonical tags behind the one phrase are visible.
|
|
const multiTags = target.getAttribute("data-pcr-chip-tags");
|
|
if (multiTags) {
|
|
const bucket = target.getAttribute("data-pcr-chip-bucket");
|
|
const key = `multi:${multiTags}`;
|
|
if (key === tooltipKey && tooltipEl && tooltipEl.style.display !== "none") {
|
|
positionTooltip(event.clientX, event.clientY);
|
|
return false;
|
|
}
|
|
const idx = getChipIndex();
|
|
const infos = multiTags.split(",")
|
|
.map((t) => findChipByTag(idx, t, bucket))
|
|
.filter(Boolean);
|
|
if (!infos.length) { hideTooltip(); return false; }
|
|
renderCompositeTooltip(infos, event.clientX, event.clientY);
|
|
tooltipKey = key;
|
|
return false;
|
|
}
|
|
const tag = target.getAttribute("data-pcr-chip-tag");
|
|
const bucket = target.getAttribute("data-pcr-chip-bucket");
|
|
const overrideScope = target.getAttribute("data-pcr-chip-override-scope");
|
|
const overrideId = target.getAttribute("data-pcr-chip-override-id");
|
|
const key = `${bucket}:${tag}:${overrideScope || ""}:${overrideId || ""}`;
|
|
if (key === tooltipKey && tooltipEl && tooltipEl.style.display !== "none") {
|
|
positionTooltip(event.clientX, event.clientY);
|
|
return false;
|
|
}
|
|
const info = findChipByTag(getChipIndex(), tag, bucket, overrideScope, overrideId);
|
|
if (!info) { hideTooltip(); return false; }
|
|
renderTooltip(info, event.clientX, event.clientY);
|
|
tooltipKey = key;
|
|
return false;
|
|
},
|
|
mouseleave: () => { hideTooltip(); return false; },
|
|
});
|
|
|
|
return [decorationsField, plugin, tooltipHandlers];
|
|
}
|