Add project files
Verificar despliegue Docker / build (push) Has been cancelled

This commit is contained in:
Andres Santana
2026-10-02 11:12:37 -04:00
parent 10d76c094f
commit 47cf941ba7
177 changed files with 31728 additions and 0 deletions
@@ -0,0 +1,108 @@
import { PARENT_TOOLBAR_HEIGHT } from '../../../constants/layout.js';
import { isInFixedContext } from '../../../utils/dom-utils.js';
let _toolbarElement = null;
let _toolbarTarget = null;
let dragOffset = { x: 0, y: 0 };
let isDragging = false;
let dragStart = { x: 0, y: 0 };
let toolbarSide = 'below';
/** @param {HTMLElement|null} element */
export function setPositioningEl(element) { _toolbarElement = element; }
/** @param {HTMLElement|null} element */
export function setPositioningTarget(element) { _toolbarTarget = element; }
/** Resets user drag offset to zero. */
export function resetDragOffset() { dragOffset = { x: 0, y: 0 }; }
/**
* Chooses above vs below placement based on viewport space.
* @returns {'above'|'below'}
*/
export function lockToolbarSide() {
const targetBounds = _toolbarTarget.getBoundingClientRect();
const toolbarHeight = _toolbarElement.offsetHeight;
const GAP = 10;
const fitsBelow = window.innerHeight - PARENT_TOOLBAR_HEIGHT - targetBounds.bottom >= 88;
const fitsAbove = targetBounds.top - toolbarHeight - GAP >= 0;
toolbarSide = (!fitsBelow && fitsAbove) ? 'above' : 'below';
return toolbarSide;
}
/**
* @returns {boolean} True when the toolbar is anchored to a viewport-fixed
* element (fixed/sticky ancestor) and must therefore use `position: fixed`.
* Document-flow elements use `position: absolute` so the toolbar scrolls with
* the page natively (no per-scroll repositioning, no jitter).
*/
export function isToolbarFixedToViewport() {
return !!_toolbarTarget && isInFixedContext(_toolbarTarget);
}
/** Applies locked side and drag offset to position the toolbar. */
export function positionToolbar() {
if (!_toolbarTarget || !_toolbarElement) return;
const targetBounds = _toolbarTarget.getBoundingClientRect();
const toolbarWidth = _toolbarElement.offsetWidth;
const toolbarHeight = _toolbarElement.offsetHeight;
const GAP = 10;
const MARGIN = 8;
// Anchor in document coordinates (position:absolute) so the toolbar scrolls
// together with the element natively. Elements inside fixed/sticky ancestors
// stay in the viewport while the page scrolls, so they keep position:fixed.
const fixed = isInFixedContext(_toolbarTarget);
_toolbarElement.style.position = fixed ? 'fixed' : 'absolute';
const scrollX = fixed ? 0 : window.scrollX;
const scrollY = fixed ? 0 : window.scrollY;
// Use the side locked when the toolbar was shown — don't re-evaluate on scroll.
const baseTop = (toolbarSide === 'above'
? targetBounds.top - toolbarHeight - GAP
: targetBounds.bottom + GAP) + scrollY;
const baseLeft = Math.min(targetBounds.left - 4, window.innerWidth - toolbarWidth - MARGIN) + scrollX;
let left = baseLeft + dragOffset.x;
let top = baseTop + dragOffset.y;
if (isDragging) {
left = Math.max(MARGIN + scrollX, Math.min(left, scrollX + window.innerWidth - toolbarWidth - MARGIN));
top = Math.max(MARGIN + scrollY, Math.min(top, scrollY + window.innerHeight - PARENT_TOOLBAR_HEIGHT - toolbarHeight - MARGIN));
}
_toolbarElement.style.left = `${left}px`;
_toolbarElement.style.top = `${top}px`;
}
function onDragMove(event) {
if (!isDragging) return;
dragOffset.x = event.clientX - dragStart.x;
dragOffset.y = event.clientY - dragStart.y;
positionToolbar();
}
function onDragEnd() {
isDragging = false;
_toolbarElement?.classList.remove('tft-dragging');
document.removeEventListener('mousemove', onDragMove);
document.removeEventListener('mouseup', onDragEnd);
}
/**
* Begins toolbar drag tracking.
* @param {MouseEvent} event
*/
export function startDrag(event) {
isDragging = true;
dragStart = { x: event.clientX - dragOffset.x, y: event.clientY - dragOffset.y };
_toolbarElement.classList.add('tft-dragging');
document.addEventListener('mousemove', onDragMove);
document.addEventListener('mouseup', onDragEnd);
}
/** Repositions the toolbar on scroll and closes the more-formatting panel. */
export function onToolbarScroll() {
_toolbarElement.querySelector('.tft-more-panel')?.classList.remove('tft-open');
positionToolbar();
}
@@ -0,0 +1,211 @@
import { TOOLBAR_ID } from './template.js';
import { PANEL_BG, BORDER_COLOR, BORDER_COLOR_TRANSLUCENT, HOVER_BG, ACTIVE_BG, BOX_SHADOW_TOOLBAR, COLOR_WHITE, Z_INDEX_EDITOR_PANEL } from '../../../constants/theme.js';
/** Injected CSS for the floating text-format toolbar. */
export const TOOLBAR_STYLES = `
#${TOOLBAR_ID} {
position: fixed;
display: none;
align-items: center;
padding: 4px;
background: ${PANEL_BG};
border: 1px solid ${BORDER_COLOR};
border-radius: 12px;
box-shadow: ${BOX_SHADOW_TOOLBAR};
user-select: none;
color: ${COLOR_WHITE};
font-family: DM Sans, sans-serif;
white-space: nowrap;
cursor: grab;
z-index: ${Z_INDEX_EDITOR_PANEL};
/* Never exceed the viewport — the inner row scrolls horizontally instead. */
max-width: calc(100vw - 16px);
}
#${TOOLBAR_ID}.tft-visible { display: flex; }
#${TOOLBAR_ID}.tft-dragging { cursor: grabbing; }
/* Horizontally scrollable control row. The more-panel is a sibling (not inside
this container) so it is never clipped by the scroll overflow. */
.tft-scroll {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
max-width: 100%;
overflow-x: auto;
white-space: nowrap;
scrollbar-width: none;
-ms-overflow-style: none;
-webkit-overflow-scrolling: touch;
}
.tft-scroll::-webkit-scrollbar { display: none; }
.tft-text-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 6px 12px;
background: transparent;
border: none;
border-radius: 8px;
color: ${COLOR_WHITE};
font-family: DM Sans, sans-serif;
font-size: 14px;
font-weight: 600;
line-height: 20px;
cursor: pointer;
flex-shrink: 0;
}
.tft-text-btn:hover { background: ${HOVER_BG}; }
.tft-text-btn svg { flex-shrink: 0; }
.tft-swatch {
display: flex;
align-items: center;
justify-content: center;
padding: 6px;
background: transparent;
border: none;
border-radius: 8px;
cursor: pointer;
flex-shrink: 0;
}
.tft-swatch:hover { background: ${HOVER_BG}; }
.tft-swatch-preview {
width: 20px;
height: 20px;
border-radius: 6px;
background: ${COLOR_WHITE};
border: 1px solid ${BORDER_COLOR_TRANSLUCENT};
pointer-events: none;
}
.tft-divider {
width: 1px;
height: 24px;
background: ${COLOR_WHITE}26;
flex-shrink: 0;
}
.tft-select {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 8px;
background: transparent;
border: none;
border-radius: 8px;
color: ${COLOR_WHITE};
font-size: 14px;
font-weight: 400;
line-height: 20px;
min-width: 32px;
max-width: 120px;
cursor: pointer;
white-space: nowrap;
}
.tft-select:hover { background: ${HOVER_BG}; }
.tft-select--size { width: 64px; }
/* Fills the button so long font names ellipsis-truncate instead of widening it. */
.tft-label {
flex: 1 0 0;
min-width: 0;
overflow: hidden;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
}
.tft-font-size {
color: ${COLOR_WHITE};
font-size: 14px;
font-weight: 400;
line-height: 20px;
font-family: DM Sans, sans-serif;
cursor: text;
}
.tft-font-size--editing::after {
content: '';
display: inline-block;
width: 1px;
height: 0.9em;
background: ${COLOR_WHITE};
margin-left: 1px;
vertical-align: text-bottom;
animation: tft-cursor-blink 1s step-end infinite;
}
@keyframes tft-cursor-blink {
50% { opacity: 0; }
}
.tft-btn-group {
display: flex;
gap: 2px;
}
.tft-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
background: transparent;
border: none;
border-radius: 8px;
color: #9ca3af;
font-size: 14px;
font-weight: 700;
cursor: pointer;
transition: background 0.15s, color 0.15s;
flex-shrink: 0;
}
.tft-btn:hover {
background: ${ACTIVE_BG};
color: ${COLOR_WHITE};
}
.tft-btn.tft-active {
background: ${ACTIVE_BG};
color: ${COLOR_WHITE};
}
.tft-btn--italic {
font-style: italic;
font-weight: 400;
font-size: 15px;
}
.tft-btn--more { display: none; }
@media (pointer: coarse) {
.tft-btn-group .tft-btn[data-action="bold"],
.tft-btn-group .tft-btn[data-action="italic"],
.tft-btn-group .tft-btn[data-action="underline"],
.tft-btn-group .tft-btn[data-action="align"] { display: none; }
.tft-btn--more { display: flex; }
}
.tft-more-panel {
display: none;
position: absolute;
top: calc(100% + 8px);
right: 0;
align-items: center;
gap: 2px;
padding: 4px;
background: ${PANEL_BG};
border: 1px solid ${BORDER_COLOR};
border-radius: 12px;
box-shadow: ${BOX_SHADOW_TOOLBAR};
}
.tft-more-panel.tft-open {
display: flex;
}
`;
@@ -0,0 +1,52 @@
import { ICON_CHEVRON_DOWN, ICON_BOLD, ICON_ITALIC, ICON_UNDERLINE, ICON_ALIGN_LEFT, ICON_MORE, ICON_LINK, ICON_SPARKLES } from "../../../constants/icons.js";
/** DOM id for the floating text-format toolbar root element. */
export const TOOLBAR_ID = 'text-format-toolbar';
/** HTML scaffold for the inline text-format toolbar buttons. */
export const TOOLBAR_HTML = `
<div id="${TOOLBAR_ID}">
<div class="tft-scroll">
<button class="tft-text-btn" data-action="edit-with-ai" title="Edit with AI">
${ICON_SPARKLES}
<span class="tft-text-btn-label">Edit with AI</span>
</button>
<span class="tft-divider"></span>
<button class="tft-swatch" data-action="color" title="Text color">
<span class="tft-swatch-preview"></span>
</button>
<span class="tft-divider"></span>
<button class="tft-select" data-action="font-family" title="Font family">
<span class="tft-label tft-font-family">Inter</span>
${ICON_CHEVRON_DOWN}
</button>
<span class="tft-divider"></span>
<button class="tft-select tft-select--size" data-action="font-size" title="Font size">
<span class="tft-label tft-font-size">16</span>
${ICON_CHEVRON_DOWN}
</button>
<span class="tft-divider"></span>
<div class="tft-btn-group" role="group" aria-label="Text formatting options">
<button class="tft-btn" data-action="bold" title="Bold">${ICON_BOLD}</button>
<button class="tft-btn tft-btn--italic" data-action="italic" title="Italic">${ICON_ITALIC}</button>
<button class="tft-btn" data-action="underline" title="Underline">${ICON_UNDERLINE}</button>
<button class="tft-btn" data-action="align" title="Text alignment">${ICON_ALIGN_LEFT}</button>
<button class="tft-btn tft-btn--more" data-action="more" title="More formatting">${ICON_MORE}</button>
<button class="tft-btn" data-action="link" title="Link">${ICON_LINK}</button>
</div>
</div>
<div class="tft-more-panel">
<button class="tft-btn" data-action="bold" title="Bold">${ICON_BOLD}</button>
<button class="tft-btn tft-btn--italic" data-action="italic" title="Italic">${ICON_ITALIC}</button>
<button class="tft-btn" data-action="underline" title="Underline">${ICON_UNDERLINE}</button>
<button class="tft-btn" data-action="align" title="Text alignment">${ICON_ALIGN_LEFT}</button>
</div>
</div>
`.trim();
/** SVG icons for left, center, and right text alignment toolbar states. */
export const ALIGN_ICONS = {
left: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M10.5771 11.2539C10.9551 11.2925 11.25 11.6118 11.25 12C11.25 12.3882 10.9551 12.7075 10.5771 12.7461L10.5 12.75H3.5C3.08579 12.75 2.75 12.4142 2.75 12C2.75 11.5858 3.08579 11.25 3.5 11.25H10.5L10.5771 11.2539Z" fill="white"/><path d="M7.5 7.25C7.91421 7.25 8.25 7.58579 8.25 8C8.25 8.41421 7.91421 8.75 7.5 8.75H3.5C3.08579 8.75 2.75 8.41421 2.75 8C2.75 7.58579 3.08579 7.25 3.5 7.25H7.5Z" fill="white"/><path d="M13.5 3.25C13.9142 3.25007 14.25 3.58583 14.25 4C14.25 4.41417 13.9142 4.74993 13.5 4.75H3.5C3.08579 4.75 2.75 4.41421 2.75 4C2.75 3.58579 3.08579 3.25 3.5 3.25H13.5Z" fill="white"/></svg>`,
center: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M11.5771 11.2539C11.9551 11.2925 12.25 11.6118 12.25 12C12.25 12.3882 11.9551 12.7075 11.5771 12.7461L11.5 12.75H4.5C4.08579 12.75 3.75 12.4142 3.75 12C3.75 11.5858 4.08579 11.25 4.5 11.25H11.5L11.5771 11.2539Z" fill="white"/><path d="M10.0771 7.25391C10.4551 7.29253 10.75 7.61183 10.75 8C10.75 8.38817 10.4551 8.70747 10.0771 8.74609L10 8.75H6C5.58579 8.75 5.25 8.41421 5.25 8C5.25 7.58579 5.58579 7.25 6 7.25H10L10.0771 7.25391Z" fill="white"/><path d="M13 3.25C13.4142 3.25 13.75 3.58579 13.75 4C13.75 4.41421 13.4142 4.75 13 4.75H3C2.58579 4.75 2.25 4.41421 2.25 4C2.25 3.58579 2.58579 3.25 3 3.25H13Z" fill="white"/></svg>`,
right: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M12.5 11.25C12.9142 11.25 13.25 11.5858 13.25 12C13.25 12.4142 12.9142 12.75 12.5 12.75H5.5L5.42285 12.7461C5.04488 12.7075 4.75 12.3882 4.75 12C4.75 11.6118 5.04488 11.2925 5.42285 11.2539L5.5 11.25H12.5Z" fill="white"/><path d="M12.5 7.25C12.9142 7.25 13.25 7.58579 13.25 8C13.25 8.41421 12.9142 8.75 12.5 8.75H8.5C8.08579 8.75 7.75 8.41421 7.75 8C7.75 7.58579 8.08579 7.25 8.5 7.25H12.5Z" fill="white"/><path d="M12.5 3.25C12.9142 3.25 13.25 3.58579 13.25 4C13.25 4.41421 12.9142 4.75 12.5 4.75H2.5C2.08579 4.75 1.75 4.41421 1.75 4C1.75 3.58579 2.08579 3.25 2.5 3.25H12.5Z" fill="white"/></svg>`,
};
@@ -0,0 +1,357 @@
import { isolateEditorUiEvents } from '../../../utils/dom-utils.js';
import { hideLinkAction, toggleLinkAction, positionLinkAction } from '../link-action/link-action.js';
import { hideFontAction, toggleFontAction, positionFontAction } from '../font-family-action/font-family-action.js';
import { isPanelVisible, PanelId } from '../../../state/panel-state.js';
import { hideSizeAction, showSizeAction, toggleSizeAction, saveSizeRange, applySizeAction, positionSizeAction } from '../size-action/size-action.js';
import { hideColorAction, toggleColorAction, positionColorAction } from '../color-action/color-action.js';
import { applyBold, applyItalic, applyUnderline, applyAlign } from '../style-actions.js';
import { onSelectionChange } from '../../inline-edit/cursor-styles.js';
import { placeCursorAtEnd } from '../../inline-edit/edit-action.js';
import { TOOLBAR_HTML, ALIGN_ICONS } from './template.js';
import { TOOLBAR_STYLES } from './styles.js';
import {
setPositioningEl, setPositioningTarget, resetDragOffset,
lockToolbarSide, positionToolbar, startDrag, isToolbarFixedToViewport,
} from './positioning.js';
import { openOrCreateAnnotation, getAnnotationPanelEl, hideAnnotationPanel } from '../../annotation-panel/panel.js';
import { getEditorTranslations } from '../../../state/annotation-state.js';
import { PANEL_GAP, PANEL_MARGIN, PARENT_TOOLBAR_HEIGHT, ANNOTATION_PANEL_WIDTH, ANNOTATION_PANEL_ESTIMATED_HEIGHT } from '../../../constants/layout.js';
let toolbarElement = null;
let toolbarTarget = null;
let toolbarResizeObserver = null;
let toolbarMutationObserver = null;
let windowResizeListener = null;
let repositionAnimationFrameId = null;
let fontSizeEditing = false;
let fontSizeOriginal = '';
function onFontSizeKeydown(event) {
const span = toolbarElement?.querySelector('.tft-font-size');
if (!span) return;
if (/^\d$/.test(event.key)) {
event.preventDefault(); event.stopPropagation();
if (span.textContent.length < 3) span.textContent += event.key;
} else if (event.key === 'Backspace') {
event.preventDefault(); event.stopPropagation();
span.textContent = span.textContent.slice(0, -1);
} else if (event.key === 'Enter') {
event.preventDefault(); event.stopPropagation();
applySizeAction(parseInt(span.textContent, 10));
stopFontSizeEdit();
hideSizeAction();
} else if (event.key === 'Escape') {
event.preventDefault(); event.stopPropagation();
span.textContent = fontSizeOriginal;
stopFontSizeEdit();
hideSizeAction();
}
}
function startFontSizeEdit() {
if (fontSizeEditing) return;
const span = toolbarElement?.querySelector('.tft-font-size');
if (!span) return;
fontSizeEditing = true;
fontSizeOriginal = span.textContent;
span.textContent = '';
span.classList.add('tft-font-size--editing');
saveSizeRange();
document.addEventListener('keydown', onFontSizeKeydown, true);
}
/** Ends inline font-size keyboard editing on the toolbar label. */
export function stopFontSizeEdit() {
if (!fontSizeEditing) return;
fontSizeEditing = false;
const span = toolbarElement?.querySelector('.tft-font-size');
if (span) {
span.classList.remove('tft-font-size--editing');
if (!span.textContent) span.textContent = fontSizeOriginal;
}
document.removeEventListener('keydown', onFontSizeKeydown, true);
}
/** @returns {HTMLElement|null} */
export function getToolbarEl() { return toolbarElement; }
/** @returns {HTMLElement|null} The element currently being formatted. */
export function getToolbarTarget() { return toolbarTarget; }
function restoreSelectionAndCloseLinkPanel() {
if (!isPanelVisible(PanelId.LINK)) return;
hideLinkAction();
}
function repositionAttachedPanels() {
positionColorAction();
positionFontAction();
positionSizeAction();
positionLinkAction();
repositionAnnotationToToolbar();
}
function repositionAnnotationToToolbar() {
const panel = getAnnotationPanelEl();
if (!panel?.classList.contains('active') || !toolbarElement) return;
if (panel.style.position === 'absolute') return;
const rect = toolbarElement.getBoundingClientRect();
let left = rect.left;
let top = rect.bottom + PANEL_GAP;
if (left + ANNOTATION_PANEL_WIDTH > window.innerWidth - PANEL_MARGIN) {
left = window.innerWidth - ANNOTATION_PANEL_WIDTH - PANEL_MARGIN;
}
if (left < PANEL_MARGIN) left = PANEL_MARGIN;
if (top + ANNOTATION_PANEL_ESTIMATED_HEIGHT > window.innerHeight - PARENT_TOOLBAR_HEIGHT - PANEL_MARGIN) {
top = rect.top - ANNOTATION_PANEL_ESTIMATED_HEIGHT - PANEL_GAP;
}
if (top < PANEL_MARGIN) top = PANEL_MARGIN;
panel.style.left = `${left}px`;
panel.style.top = `${top}px`;
}
function repositionEditOverlays() {
if (!toolbarTarget || !toolbarElement?.classList.contains('tft-visible')) return;
positionToolbar();
repositionAttachedPanels();
}
/** Repositions toolbar and open sub-panels after the edit target changes size. */
export function scheduleRepositionEditOverlays() {
if (repositionAnimationFrameId) cancelAnimationFrame(repositionAnimationFrameId);
repositionAnimationFrameId = requestAnimationFrame(() => {
repositionAnimationFrameId = null;
repositionEditOverlays();
});
}
function observeToolbarTarget() {
disconnectToolbarTargetObserver();
if (!toolbarTarget) return;
if (typeof ResizeObserver !== 'undefined') {
toolbarResizeObserver = new ResizeObserver(scheduleRepositionEditOverlays);
toolbarResizeObserver.observe(toolbarTarget);
}
if (typeof MutationObserver !== 'undefined') {
toolbarMutationObserver = new MutationObserver(scheduleRepositionEditOverlays);
toolbarMutationObserver.observe(toolbarTarget, {
childList: true,
subtree: true,
characterData: true,
});
}
windowResizeListener = () => scheduleRepositionEditOverlays();
window.addEventListener('resize', windowResizeListener, { passive: true });
}
function disconnectToolbarTargetObserver() {
toolbarResizeObserver?.disconnect();
toolbarResizeObserver = null;
toolbarMutationObserver?.disconnect();
toolbarMutationObserver = null;
if (windowResizeListener) {
window.removeEventListener('resize', windowResizeListener);
windowResizeListener = null;
}
if (repositionAnimationFrameId) {
cancelAnimationFrame(repositionAnimationFrameId);
repositionAnimationFrameId = null;
}
}
function onToolbarScroll() {
toolbarElement?.querySelector('.tft-more-panel')?.classList.remove('tft-open');
if (isToolbarFixedToViewport()) {
// The element lives in a fixed/sticky ancestor, so it stays in the
// viewport while the page scrolls; keep the viewport-anchored toolbar
// and its panels aligned to it.
repositionEditOverlays();
return;
}
// Document-flow element: the absolutely-positioned toolbar scrolls with the
// page natively (no reposition needed, so no bounce). Only realign open
// sub-panels, which are viewport-fixed and anchored to the toolbar.
repositionAttachedPanels();
}
function initToolbar() {
if (toolbarElement) return;
const style = document.createElement('style');
style.id = 'text-format-toolbar-styles';
style.textContent = TOOLBAR_STYLES;
document.head.appendChild(style);
const wrapper = document.createElement('div');
wrapper.innerHTML = TOOLBAR_HTML;
toolbarElement = wrapper.firstElementChild;
document.body.appendChild(toolbarElement);
isolateEditorUiEvents(toolbarElement);
const editWithAiLabel = getEditorTranslations().toolbarEditWithAi;
if (editWithAiLabel) {
const labelSpan = toolbarElement.querySelector('[data-action="edit-with-ai"] .tft-text-btn-label');
if (labelSpan) labelSpan.textContent = editWithAiLabel;
}
setPositioningEl(toolbarElement);
toolbarElement.addEventListener('mousedown', (event) => {
event.preventDefault();
if (event.target.closest('[data-action]')) return;
hideFontAction();
hideLinkAction();
hideSizeAction();
hideColorAction(true);
startDrag(event);
});
toolbarElement.addEventListener('click', (event) => {
const button = event.target.closest('[data-action]');
if (!button || !toolbarTarget) return;
const action = button.dataset.action;
const opensDropdownPanel = action === 'color' || action === 'font-family' || action === 'font-size' || action === 'link';
if (action !== 'font-family') hideFontAction();
if (action !== 'font-size') { hideSizeAction(); stopFontSizeEdit(); }
if (action !== 'color') hideColorAction();
if (action !== 'link') restoreSelectionAndCloseLinkPanel();
if (action !== 'edit-with-ai') {
const annotationWasOpen = getAnnotationPanelEl()?.classList.contains('active');
// Skip the focus/caret restore when opening another dropdown panel —
// it fires a selectionchange event that would immediately close the
// panel this same click is about to open.
hideAnnotationPanel({ restoreFocus: !opensDropdownPanel });
if (annotationWasOpen && toolbarTarget && !opensDropdownPanel) {
toolbarTarget.focus();
placeCursorAtEnd(toolbarTarget);
}
}
switch (action) {
case 'color': toggleColorAction(); break;
case 'font-family': toggleFontAction(); break;
case 'more':
toolbarElement.querySelector('.tft-more-panel')?.classList.toggle('tft-open');
break;
case 'font-size':
if (event.target.closest('.tft-font-size')) {
showSizeAction();
startFontSizeEdit();
} else {
stopFontSizeEdit();
toggleSizeAction();
}
break;
case 'bold': applyBold(toolbarTarget); onSelectionChange(true); break;
case 'italic': applyItalic(toolbarTarget); onSelectionChange(true); break;
case 'underline': applyUnderline(toolbarTarget); onSelectionChange(true); break;
case 'align': {
const current = getComputedStyle(toolbarTarget).textAlign;
const next = current === 'left' || current === 'start' ? 'center'
: current === 'center' ? 'right'
: 'left';
applyAlign(toolbarTarget, next);
onSelectionChange(true);
break;
}
case 'link': toggleLinkAction(); break;
case 'edit-with-ai': {
const target = toolbarTarget;
if (!target) break;
if (getAnnotationPanelEl()?.classList.contains('active')) {
hideAnnotationPanel();
break;
}
const toolbarRect = toolbarElement.getBoundingClientRect();
openOrCreateAnnotation(target, toolbarRect.left, toolbarRect.bottom, toolbarRect.top);
break;
}
}
});
}
/**
* Shows and positions the toolbar for the given edit target.
* @param {HTMLElement} targetElement
*/
export function showTextFormatToolbar(targetElement) {
if (!toolbarElement) initToolbar();
else if (!toolbarElement.isConnected) document.body.appendChild(toolbarElement);
toolbarTarget = targetElement;
setPositioningTarget(targetElement);
resetDragOffset();
toolbarElement.classList.add('tft-visible');
lockToolbarSide();
positionToolbar();
observeToolbarTarget();
document.addEventListener('scroll', onToolbarScroll, true);
}
/** Hides the toolbar and all sub-panels. */
export function hideTextFormatToolbar() {
if (!toolbarElement) return;
toolbarElement.classList.remove('tft-visible');
toolbarElement.querySelector('.tft-more-panel')?.classList.remove('tft-open');
hideFontAction();
hideLinkAction();
hideSizeAction();
hideColorAction(true);
stopFontSizeEdit();
toolbarTarget = null;
setPositioningTarget(null);
disconnectToolbarTargetObserver();
document.removeEventListener('scroll', onToolbarScroll, true);
}
/**
* Syncs toolbar controls to styles reported at the caret.
* @param {object} styles - From getStylesAtCursor().
*/
export function updateTextFormatToolbar(styles) {
if (!toolbarElement?.classList.contains('tft-visible')) return;
const swatch = toolbarElement.querySelector('.tft-swatch-preview');
if (swatch) swatch.style.background = styles.color;
const fontFamilyLabel = toolbarElement.querySelector('.tft-font-family');
if (fontFamilyLabel) {
fontFamilyLabel.textContent = styles.fontFamily
.split(',')[0]
.replace(/['"]/g, '')
.trim();
}
const fontSizeLabel = toolbarElement.querySelector('.tft-font-size');
if (fontSizeLabel && !fontSizeEditing) {
fontSizeLabel.textContent = String(Math.round(parseFloat(styles.fontSize)));
}
const isBold = !!styles.isBold;
const isItalic = !!styles.isItalic;
const isUnderline = !!styles.isUnderline;
const align = styles.textAlign === 'center' ? 'center'
: styles.textAlign === 'right' ? 'right'
: 'left';
toolbarElement.querySelectorAll('[data-action="bold"]').forEach(button =>
button.classList.toggle('tft-active', isBold));
toolbarElement.querySelectorAll('[data-action="italic"]').forEach(button =>
button.classList.toggle('tft-active', isItalic));
toolbarElement.querySelectorAll('[data-action="underline"]').forEach(button =>
button.classList.toggle('tft-active', isUnderline));
toolbarElement.querySelectorAll('[data-action="align"]').forEach(button => {
button.innerHTML = ALIGN_ICONS[align];
});
}