Initial commit for Coolify deploy
This commit is contained in:
@@ -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];
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user