first commit

This commit is contained in:
2026-09-29 10:27:27 -04:00
commit a863b400fb
92 changed files with 23514 additions and 0 deletions
@@ -0,0 +1,766 @@
import { registerPanel, PanelId, setPanelVisible, isPanelVisible } from '../../../state/panel-state.js';
import { getToolbarEl, getToolbarTarget } from '../toolbar/toolbar.js';
import { createPanelElement, positionDropdownPanel, createOutsideDismiss, captureSelectionRange } from '../dropdown-panel.js';
import { getActiveRange, wrapRange, syncUnderlineColorFromElement } from '../style-actions.js';
import { getStylesAtCursor, onSelectionChange } from '../../inline-edit/cursor-styles.js';
import { COLOR_ACTION_HTML, SOLID_COLORS, GRADIENTS } from './template.js';
import { COLOR_ACTION_STYLES } from './styles.js';
import { hsvToHex, hsvToColor, hexToHsv, parseHexInput, parseColor, extractAlpha } from './color-conversions.js';
import { parseGradientStops, buildGradient } from './gradient.js';
import {
initSwatches, syncSolidSwatchActive, syncGradientSwatchActive,
ensureCustomSwatch, ensureCustomGradientSwatch,
appendCustomSwatch, appendCustomGradientSwatch, restorePersistedSwatches,
recolorSolidSwatch, recolorGradientSwatch, setActiveSwatch,
} from './swatches.js';
import { initHexInput, setHexFromPicker, setHexInputActive, hexPointerDown, hexPointerMove, handleHexKeydown, handleHexPaste, handleHexCopy, selectAllHex } from './hex-input.js';
import { initPickerSquare } from './picker-square.js';
let colorActionElement = null;
let savedColorRange = null;
let pickerHue = 0, pickerSaturation = 1, pickerValue = 1, pickerAlpha = 1;
let pickerOnChange = null;
export let pickerDragging = false;
let hexDragging = false;
let activeSwatchOption = null;
let lastColorWrapper = null;
let _suppressHide = false;
let _colorEdited = false;
let _pickerSquare = null, _pickerIndicator = null;
let _pickerHueInput = null, _pickerAlphaInput = null;
let _eyedropperPickAbort = null;
let gradientColors = ['#FF6B6B', '#FFE66D'];
let gradientAngle = 90;
let activeGradientStop = 0;
/**
* Runs `handler` with pickerDragging forced on (so live previews skip expensive
* input events), restoring the previous flag afterwards.
* @param {() => void} handler
*/
function withPickerDragging(handler) {
const wasDragging = pickerDragging;
pickerDragging = true;
try {
handler();
} finally {
pickerDragging = wasDragging;
}
}
/** Applies a solid color to a wrapper element, clearing gradient props. */
function setWrapperSolid(element, color) {
element.style.color = color;
element.style.removeProperty('background-image');
element.style.removeProperty('background-clip');
element.style.removeProperty('-webkit-background-clip');
}
/** Applies a gradient text fill to a wrapper element. */
function setWrapperGradient(element, gradient) {
element.style.backgroundImage = gradient;
element.style.backgroundClip = 'text';
element.style.webkitBackgroundClip = 'text';
element.style.color = 'transparent';
}
/** Updates the toolbar's color swatch preview. */
function setToolbarPreview(value) {
getToolbarEl()?.querySelector('.tft-swatch-preview')?.style.setProperty('background', value);
}
/**
* Commits a solid color: restyles the existing wrapper when connected,
* otherwise wraps the saved selection silently (no input event).
* @param {string} color
*/
function applySolidFull(color) {
if (lastColorWrapper?.isConnected) {
setWrapperSolid(lastColorWrapper, color);
} else {
withPickerDragging(() => applyColorAction(color, false));
}
}
/**
* Commits a gradient like {@link applySolidFull}, re-resolving the wrapper
* after a fresh wrap so subsequent live updates can target it.
* @param {string} gradient
*/
function applyGradientFull(gradient) {
recoverGradientWrapper();
if (lastColorWrapper?.isConnected) {
setWrapperGradient(lastColorWrapper, gradient);
} else {
withPickerDragging(() => applyColorAction(gradient, true));
recoverGradientWrapper();
}
}
function isGradientTabActive() {
return !!colorActionElement?.querySelector('.tft-color-tab[data-tab="gradient"]')?.classList.contains('tca-tab-active');
}
function refreshGradientStopSwatches() {
if (!colorActionElement) return;
[0, 1].forEach(index => {
const element = colorActionElement.querySelector(`.tft-gradient-stop-swatch[data-stop="${index}"]`);
if (!element) return;
element.querySelector('.tft-color-swatch').style.background = gradientColors[index] || '#000000';
element.classList.toggle('tca-active', index === activeGradientStop);
});
}
function selectGradientStop(stopIndex) {
activeGradientStop = stopIndex;
refreshGradientStopSwatches();
const hex = parseColor(gradientColors[stopIndex]) || '#000000';
seedPicker(hex);
}
function updatePickerUI(skipHex = false) {
const hex = hsvToHex(pickerHue, pickerSaturation, pickerValue);
if (isGradientTabActive()) {
gradientColors[activeGradientStop] = hex;
refreshGradientStopSwatches();
const gradient = buildGradient(gradientAngle, gradientColors[0], gradientColors[1]);
if (canApplyColor()) {
recoverGradientWrapper();
if (lastColorWrapper?.isConnected) {
lastColorWrapper.style.backgroundImage = gradient;
}
}
setToolbarPreview(gradient);
// Edit the selected gradient swatch in place (keeps it highlighted); otherwise just highlight a match.
if (activeSwatchOption?.isConnected && activeSwatchOption.hasAttribute('data-gradient')) {
recolorGradientSwatch(activeSwatchOption, gradient);
setActiveSwatch(activeSwatchOption);
} else {
activeSwatchOption = syncGradientSwatchActive(gradient);
}
} else if (activeSwatchOption?.isConnected && activeSwatchOption.hasAttribute('data-color')) {
recolorSolidSwatch(activeSwatchOption, hex);
setActiveSwatch(activeSwatchOption);
} else {
activeSwatchOption = syncSolidSwatchActive(hex);
}
if (_pickerSquare) _pickerSquare.style.background = `hsl(${pickerHue}, 100%, 50%)`;
if (_pickerIndicator) {
const width = _pickerSquare.offsetWidth;
const height = _pickerSquare.offsetHeight;
_pickerIndicator.style.left = `${pickerSaturation * width}px`;
_pickerIndicator.style.top = `${(1 - pickerValue) * height}px`;
_pickerIndicator.style.background = hex;
}
if (_pickerHueInput) _pickerHueInput.value = pickerHue;
if (_pickerAlphaInput) {
const hslColor = `hsl(${pickerHue}, 100%, 50%)`;
_pickerAlphaInput.style.background = [
`linear-gradient(to right, transparent, ${hslColor})`,
`linear-gradient(45deg, #555 25%, transparent 25%, transparent 75%, #555 75%) 0 0 / 8px 8px`,
`linear-gradient(45deg, #555 25%, transparent 25%, transparent 75%, #555 75%) 4px 4px / 8px 8px`,
`#2a2b2f`,
].join(', ');
}
if (!skipHex) setHexFromPicker(hex);
}
function seedPicker(hex) {
const hsv = hexToHsv(hex) ?? { h: 0, s: 0, v: 0 };
pickerHue = hsv.h;
pickerSaturation = hsv.s;
pickerValue = hsv.v;
updatePickerUI();
}
function findGradientWrapper() {
if (!getToolbarTarget()) return null;
const selection = window.getSelection();
let node = selection?.rangeCount > 0 ? selection.getRangeAt(0).commonAncestorContainer : null;
if (!node) node = savedColorRange?.commonAncestorContainer;
if (node?.nodeType === Node.TEXT_NODE) node = node.parentElement;
while (node && node !== getToolbarTarget().parentElement) {
if (node === getToolbarTarget()) { node = node.parentElement; continue; }
if (node.nodeType === Node.ELEMENT_NODE) {
if (node.style?.backgroundClip === 'text' || node.style?.webkitBackgroundClip === 'text') {
return node;
}
try {
const computedStyle = getComputedStyle(node);
const backgroundClip = computedStyle.backgroundClip || computedStyle.webkitBackgroundClip;
if (computedStyle.backgroundImage?.includes('gradient') && backgroundClip === 'text') {
return node;
}
} catch { /* ignore */ }
}
node = node.parentElement;
}
return null;
}
function recoverGradientWrapper() {
if (lastColorWrapper?.isConnected) return;
lastColorWrapper = findGradientWrapper();
if (lastColorWrapper) {
try {
const range = document.createRange();
range.selectNodeContents(lastColorWrapper);
savedColorRange = range.cloneRange();
} catch { /* ignore */ }
}
}
function applyLive(color) {
if (isGradientTabActive()) {
gradientColors[activeGradientStop] = color;
refreshGradientStopSwatches();
const gradient = buildGradient(gradientAngle, gradientColors[0], gradientColors[1]);
if (canApplyColor()) {
recoverGradientWrapper();
if (lastColorWrapper?.isConnected) {
lastColorWrapper.style.backgroundImage = gradient;
} else {
withPickerDragging(() => applyColorAction(gradient, true));
}
}
setToolbarPreview(gradient);
} else {
if (canApplyColor()) {
if (lastColorWrapper?.isConnected) {
lastColorWrapper.style.color = color;
} else {
withPickerDragging(() => applyColorAction(color, false));
}
}
setToolbarPreview(color);
}
}
function notifyChange() {
if (!getToolbarTarget()) return;
// Swatches are only created via the "+" button now, never auto-added on commit.
getToolbarTarget().dispatchEvent(new CustomEvent('tft-color-commit', { bubbles: true }));
}
function applyColorAction(value, isGradient) {
if (!savedColorRange || !getToolbarTarget() || !value) return;
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(savedColorRange);
applyColor(getToolbarTarget(), value, isGradient);
if (selection.rangeCount > 0) savedColorRange = selection.getRangeAt(0).cloneRange();
if (!pickerDragging) onSelectionChange();
}
function stripColorProps(element) {
element.style.removeProperty('color');
element.style.removeProperty('background-image');
element.style.removeProperty('background-clip');
element.style.removeProperty('-webkit-background-clip');
}
function hasColorProps(element) {
return !!(element.style.color || element.style.backgroundImage || element.style.backgroundClip || element.style.webkitBackgroundClip);
}
function findReusableAncestor(range, editTarget) {
let element = range.commonAncestorContainer;
if (element.nodeType === Node.TEXT_NODE) element = element.parentElement;
while (element && element !== editTarget) {
if (element.nodeType === Node.ELEMENT_NODE) {
try {
const elementRange = document.createRange();
elementRange.selectNodeContents(element);
const selectionText = range.toString();
const elementText = elementRange.toString();
if (selectionText && elementText && selectionText === elementText) return element;
} catch { /* ignore */ }
}
element = element.parentElement;
}
return null;
}
function splitParentAroundWrapper(wrapper, parent, styleProperties, isGradient) {
const parentParent = parent.parentNode;
if (!parentParent) return;
const before = parent.cloneNode(false);
const after = parent.cloneNode(false);
let node = parent.firstChild;
while (node && node !== wrapper) {
const next = node.nextSibling;
before.appendChild(node);
node = next;
}
node = wrapper.nextSibling;
while (node) {
const next = node.nextSibling;
after.appendChild(node);
node = next;
}
parent.removeChild(wrapper);
// Inherit parent styles then apply new color
wrapper.style.cssText = parent.style.cssText;
Object.assign(wrapper.style, styleProperties);
if (!isGradient) {
wrapper.style.removeProperty('background-image');
wrapper.style.removeProperty('background-clip');
wrapper.style.removeProperty('-webkit-background-clip');
}
if (before.hasChildNodes()) parentParent.insertBefore(before, parent);
parentParent.insertBefore(wrapper, parent);
if (after.hasChildNodes()) parentParent.insertBefore(after, parent);
parentParent.removeChild(parent);
}
function applyColor(editTarget, value, isGradient) {
const range = getActiveRange(editTarget);
if (!range) return;
const styleProperties = isGradient
? { backgroundImage: value, backgroundClip: 'text', webkitBackgroundClip: 'text', color: 'transparent' }
: { color: value };
const reuse = findReusableAncestor(range, editTarget);
if (reuse) {
Object.assign(reuse.style, styleProperties);
if (!isGradient) {
reuse.style.removeProperty('background-image');
reuse.style.removeProperty('background-clip');
reuse.style.removeProperty('-webkit-background-clip');
}
lastColorWrapper = reuse;
reuse.querySelectorAll('*').forEach(element => {
if (hasColorProps(element)) stripColorProps(element);
});
syncUnderlineColorFromElement(reuse, editTarget);
if (!pickerDragging) editTarget.dispatchEvent(new Event('input', { bubbles: true }));
return;
}
const wrapper = wrapRange(range, 'span', styleProperties);
lastColorWrapper = wrapper ?? null;
if (wrapper) {
wrapper.querySelectorAll('*').forEach(element => {
if (hasColorProps(element)) stripColorProps(element);
});
const parent = wrapper.parentElement;
if (parent && parent !== editTarget && parent.tagName === 'SPAN' && hasColorProps(parent)) {
const parentRange = document.createRange();
parentRange.selectNodeContents(parent);
if (parentRange.toString() === wrapper.textContent) {
stripColorProps(parent);
} else {
splitParentAroundWrapper(wrapper, parent, styleProperties, isGradient);
}
}
syncUnderlineColorFromElement(wrapper, editTarget);
}
if (!pickerDragging) {
requestAnimationFrame(() => {
editTarget.dispatchEvent(new Event('input', { bubbles: true }));
});
}
}
export function positionColorAction() {
if (!isPanelVisible(PanelId.COLOR) || !colorActionElement) return;
positionDropdownPanel(colorActionElement, { buttonAction: 'color' });
}
const outsideDismiss = createOutsideDismiss({
getElement: () => colorActionElement,
dismiss: () => {
_suppressHide = false;
hideColorAction();
},
ignoreToolbar: false,
ignoreMouseDown: (event) => !!event.target.closest('[data-action="color"]'),
captureMouseDown: true,
});
/**
* @returns {boolean} Whether color can be applied to content. A highlighted
* range colors that selection; a collapsed caret inside the edit target colors
* the whole element (same as bold/italic via {@link getActiveRange}).
*/
function canApplyColor() {
if (!savedColorRange || !getToolbarTarget()) return false;
if (!savedColorRange.collapsed) return true;
return getToolbarTarget().contains(savedColorRange.commonAncestorContainer);
}
/** True only when the user has highlighted text (not a bare caret). */
function hasExpandedTextSelection() {
return !!savedColorRange && !savedColorRange.collapsed;
}
function onSelectionChangeForColor() {
if (!isPanelVisible(PanelId.COLOR)) return;
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return;
const range = selection.getRangeAt(0);
if (!getToolbarTarget()?.contains(range.commonAncestorContainer)) return;
savedColorRange = range.cloneRange();
}
function switchColorTab(tab) {
if (!colorActionElement) return;
colorActionElement.querySelectorAll('.tft-color-tab').forEach(tabElement =>
tabElement.classList.toggle('tca-tab-active', tabElement.dataset.tab === tab));
colorActionElement.querySelectorAll('[data-tab-panel]').forEach(panelElement =>
panelElement.classList.toggle('tca-hidden', panelElement.dataset.tabPanel !== tab));
if (tab === 'gradient' && isPanelVisible(PanelId.COLOR)) {
const firstPreset = parseGradientStops(GRADIENTS[0]);
if (firstPreset) {
gradientAngle = firstPreset.angle;
gradientColors = firstPreset.colors.map(color => parseColor(color) || color);
}
refreshGradientStopSwatches();
selectGradientStop(0);
activeSwatchOption = syncGradientSwatchActive(GRADIENTS[0]);
if (hasExpandedTextSelection() && getToolbarTarget()) {
applyGradientFull(GRADIENTS[0]);
notifyChange();
}
setToolbarPreview(GRADIENTS[0]);
} else if (tab === 'solid' && isPanelVisible(PanelId.COLOR)) {
const defaultSolid = SOLID_COLORS[0];
seedPicker(defaultSolid);
activeSwatchOption = syncSolidSwatchActive(defaultSolid);
syncGradientSwatchActive(null);
if (hasExpandedTextSelection() && getToolbarTarget()) {
recoverGradientWrapper();
applySolidFull(defaultSolid);
notifyChange();
}
setToolbarPreview(defaultSolid);
}
positionColorAction();
}
function initColorAction() {
if (colorActionElement) return;
colorActionElement = createPanelElement({
styleId: 'text-format-color-action-styles',
styles: COLOR_ACTION_STYLES,
html: COLOR_ACTION_HTML,
});
initSwatches(colorActionElement);
restorePersistedSwatches();
colorActionElement.addEventListener('mousedown', (event) => {
if (event.target.closest('input[type="range"]')) return;
event.preventDefault();
if (event.target.closest('.tft-picker-hex-input')) {
setHexInputActive(true);
hexPointerDown(event.clientX);
hexDragging = true;
}
});
document.addEventListener('mousemove', (event) => {
if (hexDragging) hexPointerMove(event.clientX);
});
document.addEventListener('mouseup', () => { hexDragging = false; });
_pickerSquare = colorActionElement.querySelector('.tft-picker-square');
_pickerIndicator = colorActionElement.querySelector('.tft-picker-indicator');
_pickerHueInput = colorActionElement.querySelector('.tft-picker-hue');
_pickerAlphaInput = colorActionElement.querySelector('.tft-picker-alpha');
const hexElement = colorActionElement.querySelector('.tft-picker-hex-input');
initHexInput(hexElement);
initPickerSquare(_pickerSquare, {
onDragStart: (saturation, value) => {
pickerDragging = true;
_colorEdited = true;
if (canApplyColor() && !lastColorWrapper?.isConnected) {
if (isGradientTabActive()) {
applyColorAction(buildGradient(gradientAngle, gradientColors[0], gradientColors[1]), true);
} else {
applyColorAction(hsvToColor(pickerHue, pickerSaturation, pickerValue, pickerAlpha), false);
}
}
pickerSaturation = saturation; pickerValue = value;
updatePickerUI();
},
onPickChange: (saturation, value) => {
if (!pickerDragging) return;
pickerSaturation = saturation; pickerValue = value;
updatePickerUI();
applyLive(hsvToColor(pickerHue, pickerSaturation, pickerValue, pickerAlpha));
},
onDragEnd: () => {
pickerDragging = false;
notifyChange();
},
});
_pickerHueInput.addEventListener('input', () => {
_colorEdited = true;
pickerHue = Number(_pickerHueInput.value);
updatePickerUI();
applyLive(hsvToColor(pickerHue, pickerSaturation, pickerValue, pickerAlpha));
});
_pickerHueInput.addEventListener('change', () => notifyChange());
_pickerAlphaInput.addEventListener('input', () => {
_colorEdited = true;
pickerAlpha = Number(_pickerAlphaInput.value) / 100;
updatePickerUI(true);
applyLive(hsvToColor(pickerHue, pickerSaturation, pickerValue, pickerAlpha));
});
_pickerAlphaInput.addEventListener('change', () => notifyChange());
const eyedropperButton = colorActionElement.querySelector('.tft-picker-hex-picker');
if (eyedropperButton) {
if (!('EyeDropper' in window)) {
eyedropperButton.style.display = 'none';
} else {
eyedropperButton.addEventListener('click', async (event) => {
event.stopPropagation();
_suppressHide = true;
_eyedropperPickAbort?.abort();
_eyedropperPickAbort = new AbortController();
try {
const { sRGBHex } = await new window.EyeDropper().open({ signal: _eyedropperPickAbort.signal });
const parsed = parseHexInput(sRGBHex);
if (!parsed) return;
_colorEdited = true;
seedPicker(parsed);
applyLive(hsvToColor(pickerHue, pickerSaturation, pickerValue, pickerAlpha));
notifyChange();
} catch (error) {
if (error?.name !== 'AbortError') console.warn('EyeDropper failed', error);
} finally {
_eyedropperPickAbort = null;
requestAnimationFrame(() => { _suppressHide = false; });
}
});
}
}
colorActionElement.addEventListener('dblclick', (event) => {
if (event.target.closest('.tft-picker-hex-input')) {
event.preventDefault();
selectAllHex();
}
});
colorActionElement.addEventListener('click', (event) => {
const tab = event.target.closest('.tft-color-tab');
if (tab) {
switchColorTab(tab.dataset.tab);
return;
}
if (event.target.closest('.tft-picker-hex-input')) return;
const stopSwatch = event.target.closest('.tft-gradient-stop-swatch');
if (stopSwatch) {
selectGradientStop(Number(stopSwatch.dataset.stop));
return;
}
const addButton = event.target.closest('.tft-color-add');
if (addButton) {
_colorEdited = true;
if (isGradientTabActive()) {
const gradient = activeSwatchOption?.dataset.gradient
|| buildGradient(gradientAngle, gradientColors[0], gradientColors[1]);
const newSwatch = appendCustomGradientSwatch(gradient);
if (newSwatch) { activeSwatchOption = newSwatch; setActiveSwatch(newSwatch); }
setToolbarPreview(gradient);
} else {
const hex = activeSwatchOption?.dataset.color
|| hsvToHex(pickerHue, pickerSaturation, pickerValue);
const newSwatch = appendCustomSwatch(hex);
if (newSwatch) { activeSwatchOption = newSwatch; setActiveSwatch(newSwatch); }
setToolbarPreview(hex);
}
return;
}
const option = event.target.closest('.tft-color-option');
if (!option) return;
if (option.dataset.gradient) {
const gradient = option.dataset.gradient;
activeSwatchOption = option;
setActiveSwatch(option);
const parsed = parseGradientStops(gradient);
if (parsed) {
gradientAngle = parsed.angle;
gradientColors = [...parsed.colors];
}
refreshGradientStopSwatches();
selectGradientStop(0);
if (canApplyColor()) applyGradientFull(gradient);
setToolbarPreview(gradient);
notifyChange();
} else {
const hex = option.dataset.color;
activeSwatchOption = option;
setActiveSwatch(option);
seedPicker(hex);
if (canApplyColor()) applySolidFull(hex);
setToolbarPreview(hex);
notifyChange();
}
});
}
function tryLiveFromHex(rawValue) {
const hex = parseHexInput(rawValue);
if (!hex) return;
_colorEdited = true;
const hsv = hexToHsv(hex);
if (!hsv) return;
pickerHue = hsv.h; pickerSaturation = hsv.s; pickerValue = hsv.v;
updatePickerUI(true);
applyLive(hsvToColor(pickerHue, pickerSaturation, pickerValue, pickerAlpha));
}
function onHexCommit(hex) {
seedPicker(hex);
_suppressHide = false;
if (isGradientTabActive()) {
applyLive(hsvToColor(pickerHue, pickerSaturation, pickerValue, pickerAlpha));
} else {
pickerOnChange?.(hsvToColor(pickerHue, pickerSaturation, pickerValue, pickerAlpha));
}
hideColorAction();
}
function onHexCancel() {
_suppressHide = false;
hideColorAction();
}
function onHexKeydownHandler(event) {
if (!isPanelVisible(PanelId.COLOR)) return;
handleHexKeydown(event, { onLiveChange: tryLiveFromHex, onCommit: onHexCommit, onCancel: onHexCancel });
}
function onHexPasteHandler(event) {
if (!isPanelVisible(PanelId.COLOR)) return;
const newValue = handleHexPaste(event);
if (newValue !== undefined) tryLiveFromHex(newValue);
}
function onHexCopyHandler(event) {
if (!isPanelVisible(PanelId.COLOR)) return;
const cut = handleHexCopy(event);
if (cut) tryLiveFromHex('');
}
/** Opens the color panel seeded from the current selection styles. */
export function showColorAction() {
if (!colorActionElement) initColorAction();
savedColorRange = captureSelectionRange();
const styles = getStylesAtCursor();
const currentValue = (styles?.color ?? '').toLowerCase();
const isGradientActive = !!styles?.isGradient;
_colorEdited = false;
if (isGradientActive) {
ensureCustomGradientSwatch(currentValue);
activeSwatchOption = syncGradientSwatchActive(currentValue);
syncSolidSwatchActive(null);
} else {
const parsedColor = parseColor(currentValue);
if (parsedColor) ensureCustomSwatch(parsedColor);
activeSwatchOption = syncSolidSwatchActive(parsedColor);
syncGradientSwatchActive(null);
}
switchColorTab(isGradientActive ? 'gradient' : 'solid');
colorActionElement.classList.add('tca-visible');
setPanelVisible(PanelId.COLOR, true);
_suppressHide = true;
positionColorAction();
pickerAlpha = isGradientActive ? 1 : extractAlpha(currentValue);
if (_pickerAlphaInput) _pickerAlphaInput.value = Math.round(pickerAlpha * 100);
pickerOnChange = (color) => {
if (canApplyColor()) applySolidFull(color);
setToolbarPreview(color);
notifyChange();
};
setHexInputActive(false);
if (isGradientActive) {
const parsed = parseGradientStops(currentValue);
if (parsed) {
gradientAngle = parsed.angle;
gradientColors = parsed.colors.map(color => parseColor(color) || color);
}
refreshGradientStopSwatches();
selectGradientStop(0);
} else {
activeGradientStop = 0;
seedPicker(parseColor(currentValue) || '#000000');
}
document.addEventListener('keydown', onHexKeydownHandler, true);
document.addEventListener('paste', onHexPasteHandler, true);
document.addEventListener('copy', onHexCopyHandler, true);
document.addEventListener('cut', onHexCopyHandler, true);
document.addEventListener('selectionchange', onSelectionChangeForColor);
outsideDismiss.attach();
}
/**
* Closes the color panel and resets picker state.
* @param {boolean} [force] - Close even when hide is suppressed (e.g. eyedropper).
*/
export function hideColorAction(force = false) {
if (!force && _suppressHide) return;
if (!colorActionElement) return;
_eyedropperPickAbort?.abort();
_eyedropperPickAbort = null;
// Swatches are added/edited explicitly via the grid now — nothing to persist on close.
_colorEdited = false;
_suppressHide = false;
colorActionElement.classList.remove('tca-visible');
setPanelVisible(PanelId.COLOR, false);
savedColorRange = null;
pickerOnChange = null;
activeSwatchOption = null;
lastColorWrapper = null;
setHexInputActive(false);
gradientColors = ['#FF6B6B', '#FFE66D'];
gradientAngle = 90;
activeGradientStop = 0;
document.removeEventListener('keydown', onHexKeydownHandler, true);
document.removeEventListener('paste', onHexPasteHandler, true);
document.removeEventListener('copy', onHexCopyHandler, true);
document.removeEventListener('cut', onHexCopyHandler, true);
document.removeEventListener('selectionchange', onSelectionChangeForColor);
outsideDismiss.detach();
}
/** Toggles the color panel visibility. */
export function toggleColorAction() {
if (isPanelVisible(PanelId.COLOR)) {
_suppressHide = false;
hideColorAction();
} else {
showColorAction();
}
}
registerPanel({ id: PanelId.COLOR, hide: hideColorAction });
@@ -0,0 +1,104 @@
/**
* @param {number} hue - Hue 0–360.
* @param {number} saturation - Saturation 0–1.
* @param {number} value - Value 0–1.
* @returns {string}
*/
export function hsvToHex(hue, saturation, value) {
const f = (n, k = (n + hue / 60) % 6) => value - value * saturation * Math.max(Math.min(k, 4 - k, 1), 0);
const toHex = x => Math.round(x * 255).toString(16).padStart(2, '0');
return `#${toHex(f(5))}${toHex(f(3))}${toHex(f(1))}`;
}
/**
* @param {number} hue
* @param {number} saturation
* @param {number} value
* @param {number} [alpha] - Alpha 0–1.
* @returns {string}
*/
export function hsvToColor(hue, saturation, value, alpha = 1) {
const hex = hsvToHex(hue, saturation, value);
if (alpha >= 1) return hex;
const red = parseInt(hex.slice(1, 3), 16);
const green = parseInt(hex.slice(3, 5), 16);
const blue = parseInt(hex.slice(5, 7), 16);
return `rgba(${red}, ${green}, ${blue}, ${parseFloat(alpha.toFixed(3))})`;
}
/**
* @param {string} hex
* @returns {{ h: number, s: number, v: number }|null}
*/
export function hexToHsv(hex) {
hex = hex.replace('#', '');
if (hex.length === 3) hex = hex.split('').map(character => character + character).join('');
if (hex.length !== 6) return null;
const red = parseInt(hex.slice(0, 2), 16) / 255;
const green = parseInt(hex.slice(2, 4), 16) / 255;
const blue = parseInt(hex.slice(4, 6), 16) / 255;
const max = Math.max(red, green, blue), min = Math.min(red, green, blue);
const value = max, delta = max - min;
const saturation = max === 0 ? 0 : delta / max;
let hue = 0;
if (delta !== 0) {
switch (max) {
case red: hue = ((green - blue) / delta + (green < blue ? 6 : 0)) / 6; break;
case green: hue = ((blue - red) / delta + 2) / 6; break;
default: hue = ((red - green) / delta + 4) / 6; break;
}
}
return { h: hue * 360, s: saturation, v: value };
}
/**
* @param {string} value
* @returns {string|null} Normalized #rrggbb or null.
*/
export function parseHexInput(value) {
value = value.trim().replace(/^#/, '');
if (value.length === 3) value = value.split('').map(character => character + character).join('');
if (!/^[0-9a-fA-F]{6}$/.test(value)) return null;
return `#${value.toLowerCase()}`;
}
/**
* @param {string} value - Any CSS color string.
* @returns {string|null}
*/
export function cssColorToHex(value) {
const cleaned = value.replace(/\s+[\d.]+(%|px|em|rem)\s*$/, '').trim();
try {
const context = document.createElement('canvas').getContext('2d');
context.fillStyle = '#010203';
context.fillStyle = cleaned;
const result = context.fillStyle;
if (result === '#010203' && cleaned !== '#010203') return null;
return parseHexInput(result);
} catch { return null; }
}
/**
* @param {string} value
* @returns {string|null} Hex color when parseable.
*/
export function parseColor(value) {
if (!value) return null;
const hex = parseHexInput(value);
if (hex) return hex;
const rgbMatch = value.match(/rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)/);
if (rgbMatch) return `#${[rgbMatch[1], rgbMatch[2], rgbMatch[3]].map(component => Number(component).toString(16).padStart(2, '0')).join('')}`;
const rgbaMatch = value.match(/rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*([\d.]+)\s*\)/);
if (rgbaMatch) return `#${[rgbaMatch[1], rgbaMatch[2], rgbaMatch[3]].map(component => Number(component).toString(16).padStart(2, '00')).join('')}`;
return cssColorToHex(value);
}
/**
* @param {string} value
* @returns {number} Alpha 0–1.
*/
export function extractAlpha(value) {
if (!value) return 1;
const match = value.match(/rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*([\d.]+)\s*\)/);
return match ? Math.min(1, Math.max(0, parseFloat(match[1]))) : 1;
}
@@ -0,0 +1,59 @@
import { parseColor } from './color-conversions.js';
/**
* @param {string} value - CSS linear-gradient value.
* @returns {{ angle: number, colors: string[] }|null}
*/
export function parseGradientStops(value) {
if (!value) return null;
const match = value.match(/linear-gradient\(\s*([\s\S]+)\s*\)/);
if (!match) return null;
const segments = [];
let depth = 0, current = '';
for (const character of match[1] + ',') {
if (character === '(') { depth++; current += character; }
else if (character === ')') { depth--; current += character; }
else if (character === ',' && depth === 0) { segments.push(current.trim()); current = ''; }
else { current += character; }
}
if (segments.length < 2) return null;
let angle = 90;
let colorStart = 0;
const first = segments[0];
if (/^\d+(\.\d+)?deg$/.test(first)) {
angle = parseFloat(first);
colorStart = 1;
} else if (/^to\s+/i.test(first)) {
const map = { 'to right': 90, 'to left': 270, 'to top': 0, 'to bottom': 180,
'to top right': 45, 'to top left': 315, 'to bottom right': 135, 'to bottom left': 225 };
angle = map[first.toLowerCase()] ?? 90;
colorStart = 1;
}
const colors = segments.slice(colorStart);
if (colors.length < 2) return null;
return { angle, colors: [colors[0], colors[1]] };
}
/**
* @param {number} angle - Degrees.
* @param {string} firstColor
* @param {string} secondColor
* @returns {string}
*/
export function buildGradient(angle, firstColor, secondColor) {
return `linear-gradient(${angle}deg, ${firstColor}, ${secondColor})`;
}
/**
* Normalized key for comparing gradient presets and custom swatches.
* @param {string} value
* @returns {string}
*/
export function canonicalGradientKey(value) {
if (!value) return '';
const parsed = parseGradientStops(value);
if (!parsed) return value.replace(/\s/g, '').toLowerCase();
const firstColor = (parseColor(parsed.colors[0]) || parsed.colors[0]).toLowerCase();
const secondColor = (parseColor(parsed.colors[1]) || parsed.colors[1]).toLowerCase();
return `${parsed.angle}|${firstColor}|${secondColor}`;
}
@@ -0,0 +1,252 @@
import { parseHexInput } from './color-conversions.js';
let _element = null;
let hexVirtualValue = '';
let hexVirtualCursor = 0;
let hexAnchor = 0;
let hexInputActive = false;
/** @param {HTMLElement} element */
export function initHexInput(element) { _element = element; }
/** @returns {boolean} */
export function isHexInputActive() { return hexInputActive; }
/** @returns {string} */
export function getHexValue() { return hexVirtualValue; }
/** @returns {number} Lower selection bound. */
function selectionStart() { return Math.min(hexAnchor, hexVirtualCursor); }
/** @returns {number} Upper selection bound. */
function selectionEnd() { return Math.max(hexAnchor, hexVirtualCursor); }
/** @returns {boolean} Whether a (non-collapsed) selection exists. */
function hasSelection() { return hexAnchor !== hexVirtualCursor; }
/** Collapses the selection onto the caret. */
function collapseSelection() { hexAnchor = hexVirtualCursor; }
/**
* @param {string} hex
*/
export function setHexFromPicker(hex) {
hexVirtualValue = hex;
hexVirtualCursor = hex.length;
hexAnchor = hex.length;
renderHexInput();
}
/** @param {boolean} active */
export function setHexInputActive(active) {
hexInputActive = active;
if (!active) {
_element?.classList.remove('tca-focused');
collapseSelection();
}
}
/** Selects the whole hex value (e.g. on double-click) so the next input/paste replaces it. */
export function selectAllHex() {
hexInputActive = true;
_element?.classList.add('tca-focused');
hexAnchor = 0;
hexVirtualCursor = hexVirtualValue.length;
renderHexInput();
}
/** Renders the virtual hex field as per-character spans (so selection can be highlighted). */
export function renderHexInput() {
if (!_element) return;
if (!hexInputActive) {
_element.textContent = hexVirtualValue;
return;
}
const start = selectionStart();
const end = selectionEnd();
const caret = '<span class="tft-picker-hex-cursor"></span>';
let html = '';
for (let index = 0; index < hexVirtualValue.length; index++) {
if (index === hexVirtualCursor) html += caret;
const selectionClass = index >= start && index < end ? ' tft-picker-hex-csel' : '';
html += `<span class="tft-picker-hex-char${selectionClass}" data-i="${index}">${hexVirtualValue[index]}</span>`;
}
if (hexVirtualCursor === hexVirtualValue.length) html += caret;
_element.innerHTML = html;
}
/**
* Maps a viewport x coordinate to a caret index between characters.
* @param {number} pointerX
* @returns {number}
*/
function caretIndexFromPointerX(pointerX) {
if (!_element) return 0;
const characters = _element.querySelectorAll('.tft-picker-hex-char');
for (let index = 0; index < characters.length; index++) {
const characterBounds = characters[index].getBoundingClientRect();
if (pointerX < characterBounds.left + characterBounds.width / 2) return index;
}
return characters.length;
}
/**
* Begins a click/drag in the field: activates it and drops a collapsed caret.
* @param {number} pointerX
*/
export function hexPointerDown(pointerX) {
hexInputActive = true;
_element?.classList.add('tca-focused');
renderHexInput();
const caretIndex = caretIndexFromPointerX(pointerX);
hexAnchor = caretIndex;
hexVirtualCursor = caretIndex;
renderHexInput();
}
/**
* Extends the selection to the dragged-to position.
* @param {number} pointerX
*/
export function hexPointerMove(pointerX) {
if (!hexInputActive) return;
hexVirtualCursor = caretIndexFromPointerX(pointerX);
renderHexInput();
}
/**
* @param {KeyboardEvent} event
* @param {{ onLiveChange: (hex: string) => void, onCommit: (hex: string) => void, onCancel: () => void }} callbacks
*/
export function handleHexKeydown(event, { onLiveChange, onCommit, onCancel }) {
if (event.key === 'Enter') {
event.preventDefault(); event.stopPropagation();
const hex = parseHexInput(hexVirtualValue);
if (hex) onCommit(hex);
return;
}
if (event.key === 'Escape') {
event.preventDefault(); event.stopPropagation();
onCancel();
return;
}
if (!hexInputActive) return;
if (event.key === 'a' && (event.metaKey || event.ctrlKey)) {
event.preventDefault(); event.stopPropagation();
hexAnchor = 0;
hexVirtualCursor = hexVirtualValue.length;
renderHexInput();
return;
}
if (hasSelection()) {
const start = selectionStart();
const end = selectionEnd();
if (event.key.length === 1 && !event.metaKey && !event.ctrlKey && /[#0-9a-fA-F]/.test(event.key)) {
event.preventDefault(); event.stopPropagation();
hexVirtualValue = (hexVirtualValue.slice(0, start) + event.key + hexVirtualValue.slice(end)).slice(0, 7);
hexVirtualCursor = start + 1;
collapseSelection();
renderHexInput();
onLiveChange(hexVirtualValue);
return;
}
if (event.key === 'Backspace' || event.key === 'Delete') {
event.preventDefault(); event.stopPropagation();
hexVirtualValue = hexVirtualValue.slice(0, start) + hexVirtualValue.slice(end);
hexVirtualCursor = start;
collapseSelection();
renderHexInput();
onLiveChange(hexVirtualValue);
return;
}
collapseSelection();
}
if (event.key === 'Backspace') {
event.preventDefault(); event.stopPropagation();
if (hexVirtualCursor > 0) {
hexVirtualValue = hexVirtualValue.slice(0, hexVirtualCursor - 1) + hexVirtualValue.slice(hexVirtualCursor);
hexVirtualCursor--;
collapseSelection();
renderHexInput();
onLiveChange(hexVirtualValue);
}
return;
}
if (event.key === 'Delete') {
event.preventDefault(); event.stopPropagation();
if (hexVirtualCursor < hexVirtualValue.length) {
hexVirtualValue = hexVirtualValue.slice(0, hexVirtualCursor) + hexVirtualValue.slice(hexVirtualCursor + 1);
collapseSelection();
renderHexInput();
onLiveChange(hexVirtualValue);
}
return;
}
if ((event.key === 'ArrowLeft' && (event.metaKey || event.ctrlKey)) || event.key === 'Home') {
event.preventDefault(); event.stopPropagation();
hexVirtualCursor = 0; collapseSelection(); renderHexInput(); return;
}
if ((event.key === 'ArrowRight' && (event.metaKey || event.ctrlKey)) || event.key === 'End') {
event.preventDefault(); event.stopPropagation();
hexVirtualCursor = hexVirtualValue.length; collapseSelection(); renderHexInput(); return;
}
if (event.key === 'ArrowLeft') {
event.preventDefault(); event.stopPropagation();
hexVirtualCursor = Math.max(0, hexVirtualCursor - 1); collapseSelection(); renderHexInput(); return;
}
if (event.key === 'ArrowRight') {
event.preventDefault(); event.stopPropagation();
hexVirtualCursor = Math.min(hexVirtualValue.length, hexVirtualCursor + 1); collapseSelection(); renderHexInput(); return;
}
if (event.key.length === 1 && !event.metaKey && !event.ctrlKey) {
event.preventDefault(); event.stopPropagation();
if (hexVirtualValue.length < 7 && /[#0-9a-fA-F]/.test(event.key)) {
hexVirtualValue = hexVirtualValue.slice(0, hexVirtualCursor) + event.key + hexVirtualValue.slice(hexVirtualCursor);
hexVirtualCursor++;
collapseSelection();
renderHexInput();
onLiveChange(hexVirtualValue);
}
}
}
/**
* Handles copy/cut for the virtual hex field: writes the selected text to the
* clipboard ourselves (the field isn't a real input, so the browser can't).
* @param {ClipboardEvent} event
* @returns {boolean} Whether the value changed (cut).
*/
export function handleHexCopy(event) {
if (!hexInputActive || !hasSelection()) return false;
const text = hexVirtualValue.slice(selectionStart(), selectionEnd());
if (!text) return false;
event.preventDefault(); event.stopPropagation();
event.clipboardData?.setData('text/plain', text);
if (event.type === 'cut') {
const start = selectionStart();
hexVirtualValue = hexVirtualValue.slice(0, start) + hexVirtualValue.slice(selectionEnd());
hexVirtualCursor = start;
collapseSelection();
renderHexInput();
return true;
}
return false;
}
/**
* @param {ClipboardEvent} event
* @returns {string|undefined} Updated hex value after paste.
*/
export function handleHexPaste(event) {
event.preventDefault(); event.stopPropagation();
const inserted = (event.clipboardData?.getData('text/plain') || '').replace(/[^#0-9a-fA-F]/g, '');
// Replace the current selection (if any), otherwise insert at the caret.
const start = selectionStart();
const end = selectionEnd();
const next = (hexVirtualValue.slice(0, start) + inserted + hexVirtualValue.slice(end)).slice(0, 7);
hexVirtualCursor = Math.min(next.length, start + inserted.length);
hexVirtualValue = next;
collapseSelection();
renderHexInput();
return hexVirtualValue;
}
@@ -0,0 +1,35 @@
/**
* Wires mousedown/drag on the SV square to saturation/value callbacks.
* @param {HTMLElement} squareElement
* @param {{ onDragStart: (s: number, v: number) => void, onPickChange: (s: number, v: number) => void, onDragEnd: () => void }} callbacks
*/
export function initPickerSquare(squareElement, callbacks) {
const { onDragStart, onPickChange, onDragEnd } = callbacks;
squareElement.addEventListener('mousedown', (event) => {
const { s: saturation, v: value } = getSaturationValueFromPointer(event, squareElement);
onDragStart(saturation, value);
function onMove(event) {
event.preventDefault();
const { s: saturation, v: value } = getSaturationValueFromPointer(event, squareElement);
onPickChange(saturation, value);
}
function onUp(event) {
event.preventDefault();
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
onDragEnd();
}
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
}
function getSaturationValueFromPointer(event, squareElement) {
const bounds = squareElement.getBoundingClientRect();
return {
s: Math.max(0, Math.min(1, (event.clientX - bounds.left) / bounds.width)),
v: Math.max(0, Math.min(1, 1 - (event.clientY - bounds.top) / bounds.height)),
};
}
@@ -0,0 +1,305 @@
import { COLOR_ACTION_ID } from './template.js';
import { PANEL_BG, BORDER_COLOR, BORDER_COLOR_FOCUS, HOVER_BG, BOX_SHADOW_DROPDOWN, COLOR_WHITE, Z_INDEX_EDITOR_PANEL } from '../../../constants/theme.js';
/** Injected CSS for the text color and gradient action panel. */
export const COLOR_ACTION_STYLES = `
#${COLOR_ACTION_ID} {
position: fixed;
display: none;
flex-direction: column;
width: 216px;
background: ${PANEL_BG};
border: 1px solid ${BORDER_COLOR};
border-radius: 20px;
padding: 12px;
box-shadow: ${BOX_SHADOW_DROPDOWN};
font-family: DM Sans, sans-serif;
user-select: none;
z-index: ${Z_INDEX_EDITOR_PANEL};
}
#${COLOR_ACTION_ID}.tca-visible {
display: flex;
}
.tft-color-tabs {
display: flex;
gap: 1px;
padding: 1px;
border: 1px solid ${BORDER_COLOR};
border-radius: 8px;
}
.tft-color-tab {
flex: 1;
background: transparent;
border: none;
color: ${COLOR_WHITE};
padding: 4px 8px;
font-size: 12px;
font-weight: 500;
font-family: inherit;
border-radius: 6px;
cursor: pointer;
font-size: 12px;
font-style: normal;
font-weight: 600;
line-height: 20px;
}
.tft-color-tab:hover {
background: ${HOVER_BG};
}
.tft-color-tab.tca-tab-active {
background: ${BORDER_COLOR};
}
.tft-color-grid {
display: flex;
flex-wrap: wrap;
margin: 10px 0px;
}
.tft-color-grid.tca-hidden {
display: none;
}
.tft-color-option {
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
cursor: pointer;
border: 1px solid transparent;
padding: 2px;
}
.tft-color-swatch {
display: block;
width: 20px;
height: 20px;
border-radius: 5px;
border: 1px solid #262831;
pointer-events: none;
transition: border-color 0.15s;
}
/* Indicate hover/active by recoloring the swatch's own border (no extra ring). */
.tft-color-option:hover .tft-color-swatch,
.tft-color-option.tca-active .tft-color-swatch {
border-color: ${COLOR_WHITE};
}
.tft-color-add {
display: flex;
align-items: center;
justify-content: center;
border: 1px solid transparent;
padding: 2px;
cursor: pointer;
}
.tft-color-add-box {
box-sizing: border-box;
position: relative;
width: 22px;
height: 22px;
border-radius: 5px;
border: 1px solid ${COLOR_WHITE}4d;
transition: border-color 0.15s;
}
/* Plus drawn with two bars so it is always geometrically centered. */
.tft-color-add-box::before,
.tft-color-add-box::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: ${COLOR_WHITE}99;
transition: background 0.15s;
}
.tft-color-add-box::before { width: 9px; height: 1px; }
.tft-color-add-box::after { width: 1px; height: 9px; }
.tft-color-add:hover .tft-color-add-box {
border-color: ${COLOR_WHITE};
}
.tft-color-add:hover .tft-color-add-box::before,
.tft-color-add:hover .tft-color-add-box::after {
background: ${COLOR_WHITE};
}
.tft-picker {
display: flex;
flex-direction: column;
gap: 8px;
}
.tft-picker-square {
position: relative;
width: 100%;
height: 116px;
border-radius: 6px;
overflow: hidden;
cursor: crosshair;
}
.tft-picker-sq-white {
position: absolute;
inset: 0;
background: linear-gradient(to right, ${COLOR_WHITE}, transparent);
pointer-events: none;
}
.tft-picker-sq-black {
position: absolute;
inset: 0;
background: linear-gradient(to top, #000, transparent);
pointer-events: none;
}
.tft-picker-indicator {
position: absolute;
width: 12px;
height: 12px;
border-radius: 50%;
border: 2px solid ${COLOR_WHITE};
box-shadow: 0 0 0 1px rgba(0,0,0,0.4);
transform: translate(-50%, -50%);
pointer-events: none;
}
.tft-picker-hue,
.tft-picker-alpha {
width: 100%;
height: 10px;
-webkit-appearance: none;
appearance: none;
border-radius: 5px;
outline: none;
cursor: pointer;
border: none;
}
.tft-picker-hue {
background: linear-gradient(to right, #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00);
}
.tft-picker-hue::-webkit-slider-thumb,
.tft-picker-alpha::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: ${COLOR_WHITE};
border: 1.5px solid rgba(255,255,255,0.4);
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
cursor: pointer;
}
.tft-picker-hue::-moz-range-thumb,
.tft-picker-alpha::-moz-range-thumb {
width: 14px;
height: 14px;
border-radius: 50%;
background: ${COLOR_WHITE};
border: 1.5px solid rgba(255,255,255,0.4);
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
cursor: pointer;
}
.tft-picker-hex-row {
display: flex;
align-items: center;
gap: 6px;
}
.tft-picker-hex-input {
flex: 1;
background: transparent;
border: 1px solid ${BORDER_COLOR};
border-radius: 8px;
color: ${COLOR_WHITE};
font-family: inherit;
font-size: 12px;
line-height: 20px;
padding: 6px 8px;
height: 28px;
box-sizing: border-box;
cursor: text;
position: relative;
white-space: nowrap;
overflow: hidden;
display: flex;
align-items: center;
transition: border-color 0.15s;
}
.tft-picker-hex-input.tca-focused {
border-color: ${BORDER_COLOR_FOCUS};
}
.tft-picker-hex-cursor {
display: inline-block;
width: 1px;
height: 1em;
background: ${COLOR_WHITE};
margin: 0 -1px;
vertical-align: text-bottom;
animation: tft-cursor-blink 1s step-end infinite;
}
.tft-picker-hex-char {
white-space: pre;
}
.tft-picker-hex-csel {
background: #357DF966;
border-radius: 1px;
}
@keyframes tft-cursor-blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
.tft-picker-hex-picker {
background: transparent;
border: 1px solid ${BORDER_COLOR};
padding: 0;
width: 28px;
height: 28px;
border-radius: 8px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.tft-picker-hex-picker:hover { background: rgba(255, 255, 255, 0.05); }
.tft-gradient-stops {
display: flex;
gap: 4px;
margin: 0 0 8px;
}
.tft-gradient-stops.tca-hidden { display: none; }
.tft-gradient-stop-swatch {
flex: 1;
height: 28px;
border-radius: 10px;
}
.tft-gradient-stop-swatch .tft-color-swatch {
width: 100%;
height: 100%;
border-radius: 7px;
}
`;
@@ -0,0 +1,295 @@
import { SOLID_COLORS, GRADIENTS } from './template.js';
import { canonicalGradientKey } from './gradient.js';
/** Maximum total swatches shown in a grid (presets + custom): 3 rows of 7. */
export const COLOR_GRID_MAX = 21;
/** Maximum custom solid/gradient swatches persisted in localStorage. */
export const CUSTOM_SWATCHES_MAX = 32;
/** localStorage key for user-added solid color swatches. */
const CUSTOM_SOLIDS_KEY = 'tft-color-custom-solids';
/** localStorage key for user-added gradient swatches. */
const CUSTOM_GRADIENTS_KEY = 'tft-color-custom-gradients';
/** Lowercase preset solid colors used to skip duplicate custom entries. */
const PRESET_SOLIDS_LOWERCASE = SOLID_COLORS.map(color => color.toLowerCase());
/** Canonical keys for preset gradients used to skip duplicate custom entries. */
const PRESET_GRADIENT_KEYS = GRADIENTS.map(gradient => canonicalGradientKey(gradient));
let _element = null;
/** @param {HTMLElement} colorActionElement */
export function initSwatches(colorActionElement) {
_element = colorActionElement;
}
/**
* @param {HTMLElement} grid
* @returns {boolean} Whether the grid is already at the 21-swatch cap.
*/
function isGridFull(grid) {
return grid.querySelectorAll('.tft-color-option').length >= COLOR_GRID_MAX;
}
/** @param {HTMLElement} option @returns {boolean} */
export function isCustomSwatchEl(option) {
return !!option && option.classList?.contains('tft-custom');
}
/** Highlights `option` as the active swatch within its grid (and clears siblings). */
export function setActiveSwatch(option) {
const grid = option?.parentElement;
if (!grid) return;
grid.querySelectorAll('.tft-color-option').forEach(swatchOption => swatchOption.classList.toggle('tca-active', swatchOption === option));
}
/** Shows/hides the grid's "+" add button based on the 21-swatch cap. */
export function updateAddButtonVisibility(grid) {
const addButton = grid?.querySelector('.tft-color-add');
if (!addButton) return;
addButton.style.display = isGridFull(grid) ? 'none' : '';
}
/** Inserts a swatch before the trailing "+" add button (or appends it). */
function appendSwatch(grid, element) {
const addButton = grid.querySelector('.tft-color-add');
if (addButton) grid.insertBefore(element, addButton);
else grid.appendChild(element);
updateAddButtonVisibility(grid);
}
/**
* @param {string} key - localStorage key.
* @returns {string[]}
*/
export function loadCustomList(key) {
try {
const raw = localStorage.getItem(key);
const array = raw ? JSON.parse(raw) : [];
return Array.isArray(array) ? array.filter(value => typeof value === 'string') : [];
} catch { return []; }
}
/**
* @param {string} key
* @param {string[]} array
*/
export function saveCustomList(key, array) {
try { localStorage.setItem(key, JSON.stringify(array.slice(-CUSTOM_SWATCHES_MAX))); } catch { /* quota / disabled */ }
}
/** Rewrites the persisted custom solids list from the swatches currently in the grid. */
function persistSolidsFromDom(grid) {
const customs = Array.from(grid.querySelectorAll('.tft-color-option.tft-custom'))
.map(option => (option.dataset.color || '').toLowerCase())
.filter(Boolean);
saveCustomList(CUSTOM_SOLIDS_KEY, customs);
}
/** Rewrites the persisted custom gradients list from the swatches currently in the grid. */
function persistGradientsFromDom(grid) {
const customs = Array.from(grid.querySelectorAll('.tft-color-option.tft-custom'))
.map(option => (option.dataset.gradient || '').toLowerCase())
.filter(Boolean);
saveCustomList(CUSTOM_GRADIENTS_KEY, customs);
}
/**
* Highlights the solid swatch matching the given hex.
* @param {string|null} hex
* @returns {HTMLElement|null} Matched option element.
*/
export function syncSolidSwatchActive(hex) {
if (!_element) return null;
const target = hex?.toLowerCase();
let found = null;
_element.querySelectorAll('.tft-color-grid[data-tab-panel="solid"] .tft-color-option').forEach(option => {
const match = !!target && (option.dataset.color || '').toLowerCase() === target;
option.classList.toggle('tca-active', match);
if (match) found = option;
});
return found;
}
/**
* Highlights the gradient swatch matching the given CSS gradient.
* @param {string|null} gradient
* @returns {HTMLElement|null} Matched option element.
*/
export function syncGradientSwatchActive(gradient) {
if (!_element) return null;
const targetKey = gradient ? canonicalGradientKey(gradient) : '';
let found = null;
_element.querySelectorAll('.tft-color-grid[data-tab-panel="gradient"] .tft-color-option').forEach(option => {
const optionKey = canonicalGradientKey(option.dataset.gradient || '');
const match = !!targetKey && optionKey === targetKey;
option.classList.toggle('tca-active', match);
if (match) found = option;
});
return found;
}
/** Recolors a swatch in place (presets too); re-persists when it's a custom one. */
export function recolorSolidSwatch(option, hex) {
if (!option || !hex) return;
hex = hex.toLowerCase();
option.dataset.color = hex;
const innerSwatch = option.querySelector('.tft-color-swatch');
if (innerSwatch) innerSwatch.style.background = hex;
if (isCustomSwatchEl(option)) persistSolidsFromDom(option.parentElement);
}
/** Recolors a gradient swatch in place (presets too); re-persists when custom. */
export function recolorGradientSwatch(option, gradient) {
if (!option || !gradient) return;
const normalizedGradient = gradient.toLowerCase();
option.dataset.gradient = normalizedGradient;
const innerSwatch = option.querySelector('.tft-color-swatch');
if (innerSwatch) innerSwatch.style.background = normalizedGradient;
if (isCustomSwatchEl(option)) persistGradientsFromDom(option.parentElement);
}
/**
* Returns the existing solid swatch matching `hex`, or appends a new custom one
* (up to the 21-swatch cap). Returns null only when the grid is full.
* @param {string} hex
* @returns {HTMLElement|null}
*/
export function ensureCustomSwatch(hex) {
if (!_element || !hex) return null;
hex = hex.toLowerCase();
const grid = _element.querySelector('.tft-color-grid[data-tab-panel="solid"]');
if (!grid) return null;
const existing = Array.from(grid.querySelectorAll('.tft-color-option'))
.find(candidateOption => (candidateOption.dataset.color || '').toLowerCase() === hex);
if (existing) return existing;
if (isGridFull(grid)) return null;
const option = document.createElement('div');
option.className = 'tft-color-option tft-custom';
option.dataset.color = hex;
option.innerHTML = `<span class="tft-color-swatch" style="background:${hex}"></span>`;
appendSwatch(grid, option);
persistSolidsFromDom(grid);
return option;
}
/**
* Returns the existing gradient swatch matching `gradient`, or appends a new
* custom one (up to the cap). Returns null only when the grid is full.
* @param {string} gradient
* @returns {HTMLElement|null}
*/
export function ensureCustomGradientSwatch(gradient) {
if (!_element || !gradient) return null;
const normalizedGradient = gradient.toLowerCase();
const key = canonicalGradientKey(normalizedGradient);
const grid = _element.querySelector('.tft-color-grid[data-tab-panel="gradient"]');
if (!grid) return null;
const existing = Array.from(grid.querySelectorAll('.tft-color-option'))
.find(candidateOption => canonicalGradientKey(candidateOption.dataset.gradient || '') === key);
if (existing) return existing;
if (isGridFull(grid)) return null;
const option = document.createElement('div');
option.className = 'tft-color-option tft-custom';
option.dataset.gradient = normalizedGradient;
option.innerHTML = `<span class="tft-color-swatch" style="background:${normalizedGradient}"></span>`;
appendSwatch(grid, option);
persistGradientsFromDom(grid);
return option;
}
/**
* Appends a new custom solid swatch even when the color already exists — used by
* the "+" button to duplicate the active swatch. Returns null when the grid is full.
* @param {string} hex
* @returns {HTMLElement|null}
*/
export function appendCustomSwatch(hex) {
if (!_element || !hex) return null;
hex = hex.toLowerCase();
const grid = _element.querySelector('.tft-color-grid[data-tab-panel="solid"]');
if (!grid || isGridFull(grid)) return null;
const option = document.createElement('div');
option.className = 'tft-color-option tft-custom';
option.dataset.color = hex;
option.innerHTML = `<span class="tft-color-swatch" style="background:${hex}"></span>`;
appendSwatch(grid, option);
persistSolidsFromDom(grid);
return option;
}
/**
* Appends a new custom gradient swatch even when it already exists — used by the
* "+" button to duplicate the active gradient. Returns null when the grid is full.
* @param {string} gradient
* @returns {HTMLElement|null}
*/
export function appendCustomGradientSwatch(gradient) {
if (!_element || !gradient) return null;
const normalizedGradient = gradient.toLowerCase();
const grid = _element.querySelector('.tft-color-grid[data-tab-panel="gradient"]');
if (!grid || isGridFull(grid)) return null;
const option = document.createElement('div');
option.className = 'tft-color-option tft-custom';
option.dataset.gradient = normalizedGradient;
option.innerHTML = `<span class="tft-color-swatch" style="background:${normalizedGradient}"></span>`;
appendSwatch(grid, option);
persistGradientsFromDom(grid);
return option;
}
/** Rebuilds custom solid and gradient swatches from localStorage. */
export function restorePersistedSwatches() {
if (!_element) return;
const solidGrid = _element.querySelector('.tft-color-grid[data-tab-panel="solid"]');
if (solidGrid) {
const stored = loadCustomList(CUSTOM_SOLIDS_KEY);
const cleaned = [];
const seen = new Set();
stored.forEach(hex => {
const lowercaseHex = (hex || '').toLowerCase();
if (!lowercaseHex || PRESET_SOLIDS_LOWERCASE.includes(lowercaseHex) || seen.has(lowercaseHex)) return;
seen.add(lowercaseHex);
cleaned.push(lowercaseHex);
const exists = Array.from(solidGrid.querySelectorAll('.tft-color-option'))
.some(option => (option.dataset.color || '').toLowerCase() === lowercaseHex);
if (exists || isGridFull(solidGrid)) return;
const div = document.createElement('div');
div.className = 'tft-color-option tft-custom';
div.dataset.color = lowercaseHex;
div.innerHTML = `<span class="tft-color-swatch" style="background:${lowercaseHex}"></span>`;
appendSwatch(solidGrid, div);
});
if (cleaned.length !== stored.length) saveCustomList(CUSTOM_SOLIDS_KEY, cleaned);
updateAddButtonVisibility(solidGrid);
}
const gradientGrid = _element.querySelector('.tft-color-grid[data-tab-panel="gradient"]');
if (gradientGrid) {
const stored = loadCustomList(CUSTOM_GRADIENTS_KEY);
const cleaned = [];
const seen = new Set();
stored.forEach(gradient => {
const lowercaseGradient = (gradient || '').toLowerCase();
if (!lowercaseGradient) return;
const key = canonicalGradientKey(lowercaseGradient);
if (PRESET_GRADIENT_KEYS.includes(key) || seen.has(key)) return;
seen.add(key);
cleaned.push(lowercaseGradient);
const exists = Array.from(gradientGrid.querySelectorAll('.tft-color-option'))
.some(option => canonicalGradientKey(option.dataset.gradient || '') === key);
if (exists || isGridFull(gradientGrid)) return;
const div = document.createElement('div');
div.className = 'tft-color-option tft-custom';
div.dataset.gradient = lowercaseGradient;
div.innerHTML = `<span class="tft-color-swatch" style="background:${lowercaseGradient}"></span>`;
appendSwatch(gradientGrid, div);
});
if (cleaned.length !== stored.length) saveCustomList(CUSTOM_GRADIENTS_KEY, cleaned);
updateAddButtonVisibility(gradientGrid);
}
}
@@ -0,0 +1,60 @@
/** DOM id for the text color/gradient action panel root element. */
export const COLOR_ACTION_ID = 'text-format-color-action';
/** Preset solid fill colors for the swatch grid. */
export const SOLID_COLORS = [
'#000000',
'#FF3B30',
'#FF9500',
'#34C759',
'#007AFF',
];
/** Preset CSS linear gradients for the swatch grid. */
export const GRADIENTS = [
'linear-gradient(90deg, #FF6B6B, #FFE66D)',
'linear-gradient(90deg, #4A90E2, #50C9C3)',
'linear-gradient(90deg, #F12711, #F5AF19)',
'linear-gradient(90deg, #8E2DE2, #4A00E0)',
'linear-gradient(90deg, #00B09B, #96C93D)',
];
/** HTML scaffold for the color action panel (tabs, swatches, picker). */
export const COLOR_ACTION_HTML = `
<div id="${COLOR_ACTION_ID}">
<div class="tft-color-tabs">
<button class="tft-color-tab tca-tab-active" data-tab="solid">Solid</button>
<button class="tft-color-tab" data-tab="gradient">Gradient</button>
</div>
<div class="tft-color-grid" data-tab-panel="solid">
${SOLID_COLORS.map(color => `<div class="tft-color-option" data-color="${color}"><span class="tft-color-swatch" style="background:${color}"></span></div>`).join('\n ')}
<div class="tft-color-add" role="button" aria-label="Add color" title="Add color"><span class="tft-color-add-box"></span></div>
</div>
<div class="tft-color-grid tca-hidden" data-tab-panel="gradient">
${GRADIENTS.map(gradient => `<div class="tft-color-option" data-gradient="${gradient}"><span class="tft-color-swatch" style="background:${gradient}"></span></div>`).join('\n ')}
<div class="tft-color-add" role="button" aria-label="Add gradient" title="Add gradient"><span class="tft-color-add-box"></span></div>
</div>
<div class="tft-gradient-stops tca-hidden" data-tab-panel="gradient">
<div class="tft-color-option tft-gradient-stop-swatch" data-stop="0"><span class="tft-color-swatch"></span></div>
<div class="tft-color-option tft-gradient-stop-swatch" data-stop="1"><span class="tft-color-swatch"></span></div>
</div>
<div class="tft-picker">
<div class="tft-picker-square">
<div class="tft-picker-sq-white"></div>
<div class="tft-picker-sq-black"></div>
<div class="tft-picker-indicator"></div>
</div>
<input type="range" class="tft-picker-hue" min="0" max="360" value="0">
<input type="range" class="tft-picker-alpha" min="0" max="100" value="100">
<div class="tft-picker-hex-row">
<div class="tft-picker-hex-input" role="textbox"></div>
<button class="tft-picker-hex-picker">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M8.22768 7.04306L4.90249 10.3683C4.84755 10.4232 4.8201 10.5012 4.83022 10.5782L4.90249 11.1212L5.39174 11.1661L5.44643 11.1651C5.50056 11.1581 5.55163 11.1331 5.59096 11.0938L8.93471 7.75009L9.99624 8.81064L6.65151 12.1544C6.33072 12.4751 5.89991 12.6576 5.45132 12.6671L5.25796 12.6602L4.52065 12.5938L4.27553 12.839C3.98275 13.1317 3.50791 13.1315 3.21499 12.839C2.94053 12.5645 2.92334 12.1306 3.16323 11.836L3.21499 11.7784L3.44643 11.547L3.34389 10.7735C3.273 10.2342 3.45731 9.69234 3.84194 9.30771L7.16714 5.98251L8.22768 7.04306Z" fill="#ffffff"/>
<path d="M10.3869 2.83798C11.1679 2.05694 12.4349 2.05694 13.216 2.83798C13.9966 3.61894 13.9966 4.88512 13.216 5.66611L11.6242 7.25693L11.9777 7.61044C12.2706 7.90334 12.2706 8.37907 11.9777 8.67197C11.6848 8.96443 11.2099 8.96461 10.9171 8.67197L7.38198 5.13584C7.08909 4.84294 7.08909 4.36818 7.38198 4.07529C7.67488 3.78249 8.14967 3.78243 8.44253 4.07529L8.79604 4.4288L10.3869 2.83798Z" fill="#ffffff"/>
</svg>
</button>
</div>
</div>
</div>
`.trim();
@@ -0,0 +1,92 @@
/**
* Shared lifecycle helpers for the toolbar dropdown action panels
* (size, font, link, color): DOM bootstrapping, positioning relative to the
* toolbar, and outside-interaction dismissal.
*/
import { PANEL_GAP, PANEL_MARGIN, PARENT_TOOLBAR_HEIGHT } from '../../constants/layout.js';
import { getToolbarEl } from './toolbar/toolbar.js';
/**
* Injects the panel stylesheet (once) and mounts the panel root element.
* @param {{ styleId: string, styles: string, html: string }} config
* @returns {HTMLElement} The mounted panel root.
*/
export function createPanelElement({ styleId, styles, html }) {
if (!document.getElementById(styleId)) {
const style = document.createElement('style');
style.id = styleId;
style.textContent = styles;
document.head.appendChild(style);
}
const wrapper = document.createElement('div');
wrapper.innerHTML = html;
const element = wrapper.firstElementChild;
document.body.appendChild(element);
return element;
}
/**
* Positions a dropdown panel below (or above) the toolbar, left-aligned to its
* trigger button and clamped to the viewport.
* @param {HTMLElement} element - Panel root.
* @param {{ buttonAction?: string|null, matchToolbarWidth?: boolean }} [options]
*/
export function positionDropdownPanel(element, { buttonAction = null, matchToolbarWidth = false } = {}) {
const toolbarElement = getToolbarEl();
if (!toolbarElement || !element) return;
const toolbarBounds = toolbarElement.getBoundingClientRect();
if (matchToolbarWidth) element.style.width = `${toolbarBounds.width}px`;
const button = buttonAction ? toolbarElement.querySelector(`[data-action="${buttonAction}"]`) : null;
const anchorBounds = button ? button.getBoundingClientRect() : toolbarBounds;
const panelWidth = matchToolbarWidth ? toolbarBounds.width : element.offsetWidth;
const panelHeight = element.offsetHeight;
const spaceBelow = window.innerHeight - PARENT_TOOLBAR_HEIGHT - toolbarBounds.bottom;
const top = Math.max(PANEL_MARGIN, spaceBelow >= panelHeight + PANEL_GAP
? toolbarBounds.bottom + PANEL_GAP
: toolbarBounds.top - panelHeight - PANEL_GAP);
const left = Math.max(PANEL_MARGIN, Math.min(anchorBounds.left, window.innerWidth - panelWidth - PANEL_MARGIN));
element.style.left = `${left}px`;
element.style.top = `${top}px`;
}
/**
* Builds attach/detach-able listeners that dismiss a panel on outside
* mousedown.
* @param {{
* getElement: () => HTMLElement|null,
* dismiss: () => void,
* ignoreToolbar?: boolean,
* ignoreMouseDown?: ((event: MouseEvent) => boolean)|null,
* captureMouseDown?: boolean,
* }} config
* @returns {{ attach: () => void, detach: () => void }}
*/
export function createOutsideDismiss({ getElement, dismiss, ignoreToolbar = true, ignoreMouseDown = null, captureMouseDown = false }) {
function onMouseDown(event) {
if (getElement()?.contains(event.target)) return;
if (ignoreToolbar && getToolbarEl()?.contains(event.target)) return;
if (ignoreMouseDown?.(event)) return;
dismiss();
}
return {
attach() {
document.addEventListener('mousedown', onMouseDown, captureMouseDown);
},
detach() {
document.removeEventListener('mousedown', onMouseDown, captureMouseDown);
},
};
}
/**
* @returns {Range|null} Clone of the current selection range, if any.
*/
export function captureSelectionRange() {
const selection = window.getSelection();
return selection && selection.rangeCount > 0 ? selection.getRangeAt(0).cloneRange() : null;
}
@@ -0,0 +1,113 @@
import { registerPanel, PanelId, setPanelVisible, isPanelVisible } from '../../../state/panel-state.js';
import { getToolbarTarget } from '../toolbar/toolbar.js';
import { wrapSelectionWithStyle } from '../style-actions.js';
import { getStylesAtCursor, onSelectionChange } from '../../inline-edit/cursor-styles.js';
import { createPanelElement, positionDropdownPanel, createOutsideDismiss, captureSelectionRange } from '../dropdown-panel.js';
import { SYSTEM_FONTS, FONT_ACTION_HTML } from './template.js';
import { FONT_ACTION_STYLES } from './styles.js';
let fontActionElement = null;
let savedFontRange = null;
const outsideDismiss = createOutsideDismiss({
getElement: () => fontActionElement,
dismiss: () => hideFontAction(),
});
function getAvailableFonts() {
const fonts = [...SYSTEM_FONTS];
const seen = new Set(SYSTEM_FONTS.map(font => font.name.toLowerCase()));
document.fonts?.forEach(face => {
if (face.status !== 'loaded') return;
const family = face.family.replace(/^['"]|['"]$/g, '');
const key = family.toLowerCase();
if (seen.has(key)) return;
seen.add(key);
const needsQuotes = /\s/.test(family);
const quoted = needsQuotes ? `"${family}"` : family;
fonts.push({ name: family, stack: `${quoted}, sans-serif` });
});
return fonts;
}
function renderFontOptions() {
if (!fontActionElement) return;
fontActionElement.innerHTML = getAvailableFonts()
.map(font => `<div class="tft-font-option" data-font='${font.stack}' style='font-family: ${font.stack}'>${font.name}</div>`)
.join('\n ');
}
function initFontAction() {
if (fontActionElement) return;
fontActionElement = createPanelElement({
styleId: 'text-format-font-action-styles',
styles: FONT_ACTION_STYLES,
html: FONT_ACTION_HTML,
});
fontActionElement.addEventListener('mousedown', (event) => event.preventDefault());
fontActionElement.addEventListener('click', (event) => {
const option = event.target.closest('.tft-font-option');
if (!option) return;
applyFontAction(option.dataset.font);
hideFontAction();
});
}
function applyFontAction(family) {
if (!savedFontRange || !getToolbarTarget() || !family) return;
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(savedFontRange);
wrapSelectionWithStyle(getToolbarTarget(), 'fontFamily', 'font-family', family);
onSelectionChange();
}
export function positionFontAction() {
if (!isPanelVisible(PanelId.FONT) || !fontActionElement) return;
positionDropdownPanel(fontActionElement, { buttonAction: 'font-family' });
}
/** Opens the font family panel with the current family highlighted. */
export function showFontAction() {
if (!fontActionElement) initFontAction();
renderFontOptions();
savedFontRange = captureSelectionRange();
const styles = getStylesAtCursor();
const currentFamily = styles?.fontFamily
?? (getToolbarTarget() ? getComputedStyle(getToolbarTarget()).fontFamily : '');
const normalizedCurrent = currentFamily.split(',')[0].replace(/['"]/g, '').trim().toLowerCase();
fontActionElement.querySelectorAll('.tft-font-option').forEach(option => {
const optionName = option.dataset.font.split(',')[0].replace(/['"]/g, '').trim().toLowerCase();
option.classList.toggle('tfa-active', optionName === normalizedCurrent);
});
fontActionElement.classList.add('tfa-visible');
setPanelVisible(PanelId.FONT, true);
positionFontAction();
outsideDismiss.attach();
}
/** Closes the font family panel. */
export function hideFontAction() {
if (!fontActionElement) return;
fontActionElement.classList.remove('tfa-visible');
setPanelVisible(PanelId.FONT, false);
savedFontRange = null;
outsideDismiss.detach();
}
/** Toggles the font family panel visibility. */
export function toggleFontAction() {
if (isPanelVisible(PanelId.FONT)) {
hideFontAction();
} else {
showFontAction();
}
}
registerPanel({ id: PanelId.FONT, hide: hideFontAction });
@@ -0,0 +1,41 @@
import { FONT_ACTION_ID } from './template.js';
import { PANEL_BG, BORDER_COLOR, HOVER_BG, ACTIVE_BG, BOX_SHADOW_DROPDOWN, COLOR_WHITE, Z_INDEX_EDITOR_PANEL } from '../../../constants/theme.js';
/** Injected CSS for the font family dropdown panel. */
export const FONT_ACTION_STYLES = `
#${FONT_ACTION_ID} {
position: fixed;
display: none;
flex-direction: column;
overflow-y: auto;
max-height: 280px;
background: ${PANEL_BG};
border: 1px solid ${BORDER_COLOR};
border-radius: 12px;
padding: 4px;
box-shadow: ${BOX_SHADOW_DROPDOWN};
user-select: none;
z-index: ${Z_INDEX_EDITOR_PANEL};
}
#${FONT_ACTION_ID}.tfa-visible {
display: flex;
}
.tft-font-option {
padding: 4px 10px;
color: ${COLOR_WHITE};
font-size: 14px;
border-radius: 8px;
cursor: pointer;
white-space: nowrap;
}
.tft-font-option:hover {
background: ${HOVER_BG};
}
.tft-font-option.tfa-active {
background: ${ACTIVE_BG};
}
`;
@@ -0,0 +1,16 @@
/** DOM id for the font family action panel root element. */
export const FONT_ACTION_ID = 'text-format-font-action';
/** System font presets shown in the font family dropdown. */
export const SYSTEM_FONTS = [
{ name: 'Arial', stack: 'Arial, sans-serif' },
{ name: 'Helvetica', stack: 'Helvetica, sans-serif' },
{ name: 'Verdana', stack: 'Verdana, sans-serif' },
{ name: 'Trebuchet MS', stack: '"Trebuchet MS", sans-serif' },
{ name: 'Georgia', stack: 'Georgia, serif' },
{ name: 'Times New Roman', stack: '"Times New Roman", serif' },
{ name: 'Courier New', stack: '"Courier New", monospace' },
];
/** HTML scaffold for the font family action panel. */
export const FONT_ACTION_HTML = `<div id="${FONT_ACTION_ID}"></div>`;
@@ -0,0 +1,427 @@
import { getEditing } from '../../../state/editing-state.js';
import { registerPanel, PanelId, setPanelVisible, isPanelVisible } from '../../../state/panel-state.js';
import { recordEdit } from '../../../state/history-state.js';
import { notifyDraftStateChanged } from '../../../api/draft-snapshot.js';
import { escapeHtml } from '../../../utils/html-utils.js';
import { createPanelElement, positionDropdownPanel, createOutsideDismiss, captureSelectionRange } from '../dropdown-panel.js';
import { captureElementMetadata } from '../../../utils/selection-mode-metadata.js';
import { LINK_ACTION_HTML } from './template.js';
import { LINK_ACTION_STYLES } from './styles.js';
let linkActionElement = null;
let savedLinkRange = null;
let linkVirtualValue = '';
let linkVirtualCursor = 0;
let linkAnchor = 0;
let linkDragging = false;
const outsideDismiss = createOutsideDismiss({
getElement: () => linkActionElement,
dismiss: () => saveLink(),
});
/** @returns {number} Lower selection bound. */
function selectionStart() { return Math.min(linkAnchor, linkVirtualCursor); }
/** @returns {number} Upper selection bound. */
function selectionEnd() { return Math.max(linkAnchor, linkVirtualCursor); }
/** @returns {boolean} Whether a (non-collapsed) selection exists. */
function hasSelection() { return linkAnchor !== linkVirtualCursor; }
/** Collapses the selection onto the caret. */
function collapseSelection() { linkAnchor = linkVirtualCursor; }
function renderLinkInput() {
const element = linkActionElement?.querySelector('#tft-link-url');
if (!element) return;
const caret = '<span class="tft-link-vcursor"></span>';
if (!linkVirtualValue) {
element.innerHTML = `<span class="tft-link-placeholder">Paste or type a link</span>${caret}`;
return;
}
const start = selectionStart();
const end = selectionEnd();
let html = '';
for (let index = 0; index < linkVirtualValue.length; index++) {
if (index === linkVirtualCursor) html += caret;
const selectionClass = index >= start && index < end ? ' tft-link-csel' : '';
html += `<span class="tft-link-char${selectionClass}">${escapeHtml(linkVirtualValue[index])}</span>`;
}
if (linkVirtualCursor === linkVirtualValue.length) html += caret;
element.innerHTML = html;
}
/** Selects the whole link value (double-click / Cmd+A) so the next input or paste replaces it. */
function selectAllLink() {
linkAnchor = 0;
linkVirtualCursor = linkVirtualValue.length;
renderLinkInput();
}
/**
* Maps a viewport x coordinate to a caret index between characters.
* @param {number} pointerX
* @returns {number}
*/
function caretIndexFromPointerX(pointerX) {
const element = linkActionElement?.querySelector('#tft-link-url');
if (!element) return 0;
const characters = element.querySelectorAll('.tft-link-char');
for (let index = 0; index < characters.length; index++) {
const characterBounds = characters[index].getBoundingClientRect();
// Caret goes before this character once the click lands left of its midpoint.
if (pointerX < characterBounds.left + characterBounds.width / 2) return index;
}
return characters.length;
}
/**
* Begins a click/drag in the field: drops a collapsed caret at the pointer.
* @param {number} pointerX
*/
function linkPointerDown(pointerX) {
renderLinkInput();
const caretIndex = caretIndexFromPointerX(pointerX);
linkAnchor = caretIndex;
linkVirtualCursor = caretIndex;
renderLinkInput();
}
/**
* Extends the selection to the dragged-to position.
* @param {number} pointerX
*/
function linkPointerMove(pointerX) {
linkVirtualCursor = caretIndexFromPointerX(pointerX);
renderLinkInput();
}
function onLinkKeydown(event) {
if (!isPanelVisible(PanelId.LINK)) return;
if (event.key === 'Enter') { event.preventDefault(); event.stopPropagation(); saveLink(); return; }
if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); hideLinkAction(); return; }
if (event.key === 'a' && (event.metaKey || event.ctrlKey)) {
event.preventDefault(); event.stopPropagation();
selectAllLink();
return;
}
if (hasSelection()) {
const start = selectionStart();
const end = selectionEnd();
if (event.key === 'Backspace' || event.key === 'Delete') {
event.preventDefault(); event.stopPropagation();
linkVirtualValue = linkVirtualValue.slice(0, start) + linkVirtualValue.slice(end);
linkVirtualCursor = start;
collapseSelection();
renderLinkInput();
return;
}
if (event.key.length === 1 && !event.metaKey && !event.ctrlKey) {
event.preventDefault(); event.stopPropagation();
linkVirtualValue = linkVirtualValue.slice(0, start) + event.key + linkVirtualValue.slice(end);
linkVirtualCursor = start + 1;
collapseSelection();
renderLinkInput();
return;
}
// Any other key collapses the selection before normal handling.
collapseSelection();
}
if (event.key === 'Backspace') {
event.preventDefault(); event.stopPropagation();
if (linkVirtualCursor > 0) {
linkVirtualValue = linkVirtualValue.slice(0, linkVirtualCursor - 1) + linkVirtualValue.slice(linkVirtualCursor);
linkVirtualCursor--;
collapseSelection();
renderLinkInput();
}
return;
}
if (event.key === 'Delete') {
event.preventDefault(); event.stopPropagation();
if (linkVirtualCursor < linkVirtualValue.length) {
linkVirtualValue = linkVirtualValue.slice(0, linkVirtualCursor) + linkVirtualValue.slice(linkVirtualCursor + 1);
collapseSelection();
renderLinkInput();
}
return;
}
if (event.key === 'Home' || (event.key === 'ArrowLeft' && (event.metaKey || event.ctrlKey))) {
event.preventDefault(); event.stopPropagation();
linkVirtualCursor = 0;
collapseSelection();
renderLinkInput();
return;
}
if (event.key === 'End' || (event.key === 'ArrowRight' && (event.metaKey || event.ctrlKey))) {
event.preventDefault(); event.stopPropagation();
linkVirtualCursor = linkVirtualValue.length;
collapseSelection();
renderLinkInput();
return;
}
if (event.key === 'ArrowLeft') {
event.preventDefault(); event.stopPropagation();
linkVirtualCursor = Math.max(0, linkVirtualCursor - 1);
collapseSelection();
renderLinkInput();
return;
}
if (event.key === 'ArrowRight') {
event.preventDefault(); event.stopPropagation();
linkVirtualCursor = Math.min(linkVirtualValue.length, linkVirtualCursor + 1);
collapseSelection();
renderLinkInput();
return;
}
if (event.key.length === 1 && !event.metaKey && !event.ctrlKey) {
event.preventDefault(); event.stopPropagation();
linkVirtualValue = linkVirtualValue.slice(0, linkVirtualCursor) + event.key + linkVirtualValue.slice(linkVirtualCursor);
linkVirtualCursor++;
collapseSelection();
renderLinkInput();
}
}
function onLinkCopy(event) {
if (!isPanelVisible(PanelId.LINK)) return;
if (!hasSelection()) return;
const text = linkVirtualValue.slice(selectionStart(), selectionEnd());
if (!text) return;
event.preventDefault(); event.stopPropagation();
event.clipboardData?.setData('text/plain', text);
if (event.type === 'cut') {
const start = selectionStart();
linkVirtualValue = linkVirtualValue.slice(0, start) + linkVirtualValue.slice(selectionEnd());
linkVirtualCursor = start;
collapseSelection();
renderLinkInput();
}
}
function onLinkPaste(event) {
if (!isPanelVisible(PanelId.LINK)) return;
event.preventDefault(); event.stopPropagation();
const text = (event.clipboardData?.getData('text/plain') || '').replace(/\s/g, '');
// Replace the current selection (if any), otherwise insert at the caret.
const start = selectionStart();
const end = selectionEnd();
linkVirtualValue = linkVirtualValue.slice(0, start) + text + linkVirtualValue.slice(end);
linkVirtualCursor = start + text.length;
collapseSelection();
renderLinkInput();
}
function initLinkAction() {
if (linkActionElement) return;
linkActionElement = createPanelElement({
styleId: 'text-format-link-action-styles',
styles: LINK_ACTION_STYLES,
html: LINK_ACTION_HTML,
});
linkActionElement.addEventListener('mousedown', (event) => {
event.preventDefault();
// Start a drag-selection inside the virtual link field (it isn't a real
// <input>, so we emulate caret/selection from pointer coordinates).
if (event.target.closest('#tft-link-url')) {
linkActionElement.querySelector('#tft-link-url')?.classList.add('tla-focused');
linkPointerDown(event.clientX);
linkDragging = true;
}
});
document.addEventListener('mousemove', (event) => {
if (linkDragging) linkPointerMove(event.clientX);
});
document.addEventListener('mouseup', () => { linkDragging = false; });
linkActionElement.addEventListener('dblclick', (event) => {
if (event.target.closest('#tft-link-url')) {
event.preventDefault();
selectAllLink();
}
});
linkActionElement.addEventListener('click', (event) => {
const labelElement = event.target.closest('.tft-link-newwindow');
if (labelElement) {
const checkbox = labelElement.querySelector('#tft-link-new-window');
if (checkbox) checkbox.checked = !checkbox.checked;
}
});
}
function findAnchorInRange(range) {
if (!range) return null;
for (const container of [range.startContainer, range.endContainer]) {
let node = container.nodeType === Node.TEXT_NODE ? container.parentNode : container;
while (node && node !== document.body) {
if (node.tagName === 'A') return node;
node = node.parentNode;
}
}
return null;
}
// A "source anchor" is an <a> rendered from JSX <a> or <Link> in the user's source
// (carries data-edit-url-attr). Its URL is editable in place by writing back to source.
// Distinct from toolbar-inserted anchors which only live inside the contenteditable.
function findSourceAnchorInRange(range) {
if (!range) return null;
for (const container of [range.startContainer, range.endContainer]) {
let node = container.nodeType === Node.TEXT_NODE ? container.parentNode : container;
while (node && node !== document.body) {
if (node.nodeType === Node.ELEMENT_NODE && node.hasAttribute('data-edit-url-attr')) return node;
node = node.parentNode;
}
}
return null;
}
function unwrapAnchor(anchor) {
const parent = anchor.parentNode;
while (anchor.firstChild) parent.insertBefore(anchor.firstChild, anchor);
parent.removeChild(anchor);
}
function applyLink(range, url, openNewTab) {
const anchor = document.createElement('a');
anchor.href = url;
anchor.style.textDecoration = 'underline';
if (openNewTab) {
anchor.target = '_blank';
anchor.rel = 'noopener noreferrer';
}
try {
range.surroundContents(anchor);
} catch {
anchor.appendChild(range.extractContents());
range.insertNode(anchor);
}
const selection = window.getSelection();
const newRange = document.createRange();
newRange.selectNodeContents(anchor);
selection.removeAllRanges();
selection.addRange(newRange);
}
function saveLink() {
const url = linkVirtualValue.trim();
const newTab = linkActionElement.querySelector('#tft-link-new-window').checked;
let range = savedLinkRange;
// Source-anchor path: rendered <a>/<Link> from JSX. Update href in place and
// persist the attribute edit. We must NOT unwrap — that destroys the contenteditable target.
const sourceAnchor = findSourceAnchorInRange(range);
if (sourceAnchor) {
// Empty URL on a source anchor would break navigation; no "remove link" semantic
// for source-attached anchors — leave as-is.
if (!url) { hideLinkAction(); return; }
const editId = sourceAnchor.getAttribute('data-edit-id');
const urlAttribute = sourceAnchor.getAttribute('data-edit-url-attr');
const oldUrl = sourceAnchor.getAttribute('href');
if (oldUrl !== url && editId && urlAttribute) {
const selectionMode = captureElementMetadata(sourceAnchor);
sourceAnchor.setAttribute('href', url);
recordEdit(editId, { beforeContent: oldUrl, afterContent: url }, { attribute: urlAttribute, element: sourceAnchor, selectionMode });
notifyDraftStateChanged();
}
hideLinkAction();
return;
}
// Inline anchor path: anchors that live only inside contenteditable HTML
// (created/edited by the toolbar itself). Wrap/unwrap the DOM and let the
// outer text edit's innerHTML diff carry the change to source on commit.
const existing = findAnchorInRange(range);
let changed = false;
if (existing) {
const children = Array.from(existing.childNodes);
const entirelyInside = children.length > 0
&& existing.contains(range.startContainer)
&& existing.contains(range.endContainer);
unwrapAnchor(existing);
changed = true;
if (entirelyInside) {
range = document.createRange();
range.setStartBefore(children[0]);
range.setEndAfter(children[children.length - 1]);
}
}
if (url && range) {
applyLink(range, url, newTab);
changed = true;
}
if (changed) {
getEditing()?.targetElement?.dispatchEvent(new Event('input', { bubbles: true }));
}
hideLinkAction();
}
export function positionLinkAction() {
if (!isPanelVisible(PanelId.LINK) || !linkActionElement) return;
positionDropdownPanel(linkActionElement, { matchToolbarWidth: true });
}
/** Opens the link panel prefilled from an existing anchor in the selection. */
export function showLinkAction() {
if (!linkActionElement) initLinkAction();
savedLinkRange = captureSelectionRange();
const existing = findAnchorInRange(savedLinkRange);
linkVirtualValue = existing ? (existing.getAttribute('href') || '') : '';
linkVirtualCursor = linkVirtualValue.length;
linkAnchor = linkVirtualValue.length;
// Source anchors don't roundtrip target/rel back to JSX yet, so hide the
// "open in new tab" checkbox to avoid a no-op control.
const sourceAnchor = findSourceAnchorInRange(savedLinkRange);
const newTabLabel = linkActionElement.querySelector('.tft-link-newwindow');
if (newTabLabel) newTabLabel.style.display = sourceAnchor ? 'none' : '';
linkActionElement.querySelector('#tft-link-new-window').checked = !sourceAnchor && existing ? existing.target === '_blank' : false;
linkActionElement.classList.add('tla-visible');
setPanelVisible(PanelId.LINK, true);
linkActionElement.querySelector('#tft-link-url')?.classList.add('tla-focused');
positionLinkAction();
renderLinkInput();
document.addEventListener('keydown', onLinkKeydown, true);
document.addEventListener('paste', onLinkPaste, true);
document.addEventListener('copy', onLinkCopy, true);
document.addEventListener('cut', onLinkCopy, true);
outsideDismiss.attach();
}
/** Closes the link panel and clears saved range. */
export function hideLinkAction() {
if (!linkActionElement) return;
linkActionElement.classList.remove('tla-visible');
linkActionElement.querySelector('#tft-link-url')?.classList.remove('tla-focused');
setPanelVisible(PanelId.LINK, false);
savedLinkRange = null;
linkAnchor = linkVirtualCursor;
linkDragging = false;
document.removeEventListener('keydown', onLinkKeydown, true);
document.removeEventListener('paste', onLinkPaste, true);
document.removeEventListener('copy', onLinkCopy, true);
document.removeEventListener('cut', onLinkCopy, true);
outsideDismiss.detach();
}
/** Toggles the link panel visibility. */
export function toggleLinkAction() {
if (isPanelVisible(PanelId.LINK)) {
hideLinkAction();
} else {
showLinkAction();
}
}
registerPanel({ id: PanelId.LINK, hide: hideLinkAction });
@@ -0,0 +1,120 @@
import { LINK_ACTION_ID } from './template.js';
import { PANEL_BG, BORDER_COLOR, BORDER_COLOR_FOCUS, BOX_SHADOW_DROPDOWN, COLOR_WHITE, PLACEHOLDER_COLOR, Z_INDEX_EDITOR_PANEL } from '../../../constants/theme.js';
/** Injected CSS for the link insertion panel. */
export const LINK_ACTION_STYLES = `
#${LINK_ACTION_ID} {
position: fixed;
display: none;
flex-direction: column;
gap: 8px;
background: ${PANEL_BG};
padding: 8px;
border-radius: 12px;
box-sizing: border-box;
box-shadow: ${BOX_SHADOW_DROPDOWN};
user-select: none;
font-family: DM Sans, sans-serif;
z-index: ${Z_INDEX_EDITOR_PANEL};
}
#${LINK_ACTION_ID}.tla-visible {
display: flex;
}
.tft-link-input {
background: transparent;
border: 1px solid ${BORDER_COLOR};
border-radius: 8px;
color: ${COLOR_WHITE};
font-family: DM Sans, sans-serif;
font-size: 12px;
line-height: 20px;
padding: 6px 8px;
width: 100%;
box-sizing: border-box;
height: 32px;
cursor: text;
position: relative;
white-space: nowrap;
overflow: hidden;
display: flex;
align-items: center;
}
.tft-link-input.tla-focused {
border-color: ${BORDER_COLOR_FOCUS};
}
.tft-link-placeholder {
position: absolute;
top: 50%;
transform: translateY(-50%);
left: 8px;
color: ${PLACEHOLDER_COLOR};
pointer-events: none;
}
.tft-link-char {
white-space: pre;
}
.tft-link-csel {
background: #357DF966;
border-radius: 1px;
}
.tft-link-vcursor {
display: inline-block;
width: 1px;
height: 1em;
background: ${COLOR_WHITE};
margin-right: -1px;
vertical-align: text-bottom;
animation: tft-cursor-blink 1s step-end infinite;
}
@keyframes tft-cursor-blink {
50% { opacity: 0; }
}
.tft-link-newwindow {
display: flex;
align-items: center;
gap: 8px;
padding: 0 4px;
cursor: pointer;
}
.tft-link-checkbox {
appearance: none;
-webkit-appearance: none;
width: 14px;
height: 14px;
border: 1.5px solid ${COLOR_WHITE}4d;
border-radius: 3px;
background: ${COLOR_WHITE}08;
cursor: pointer;
flex-shrink: 0;
position: relative;
}
.tft-link-checkbox:checked {
background: ${COLOR_WHITE};
border-color: ${COLOR_WHITE};
}
.tft-link-checkbox:checked::after {
content: '';
position: absolute;
left: 4px;
top: 2px;
width: 4px;
height: 7px;
border: 2px solid ${PANEL_BG};
border-top: none;
border-left: none;
transform: rotate(45deg);
}
.tft-link-newwindow-label {
font-size: 12px;
color: ${COLOR_WHITE};
}
`;
@@ -0,0 +1,13 @@
/** DOM id for the link action panel root element. */
export const LINK_ACTION_ID = 'text-format-link-action';
/** HTML scaffold for the link panel (URL input and new-tab checkbox). */
export const LINK_ACTION_HTML = `
<div id="${LINK_ACTION_ID}">
<div id="tft-link-url" class="tft-link-input" role="textbox" aria-label="URL"></div>
<label class="tft-link-newwindow">
<input class="tft-link-checkbox" type="checkbox" id="tft-link-new-window" />
<span class="tft-link-newwindow-label">New tab</span>
</label>
</div>
`.trim();
@@ -0,0 +1,102 @@
import { registerPanel, PanelId, setPanelVisible, isPanelVisible } from '../../../state/panel-state.js';
import { getToolbarTarget, stopFontSizeEdit } from '../toolbar/toolbar.js';
import { wrapSelectionWithStyle } from '../style-actions.js';
import { getStylesAtCursor, onSelectionChange } from '../../inline-edit/cursor-styles.js';
import { createPanelElement, positionDropdownPanel, createOutsideDismiss, captureSelectionRange } from '../dropdown-panel.js';
import { SIZE_ACTION_HTML } from './template.js';
import { SIZE_ACTION_STYLES } from './styles.js';
let sizeActionElement = null;
let savedSizeRange = null;
const outsideDismiss = createOutsideDismiss({
getElement: () => sizeActionElement,
dismiss: () => {
stopFontSizeEdit();
hideSizeAction();
},
});
function initSizeAction() {
if (sizeActionElement) return;
sizeActionElement = createPanelElement({
styleId: 'text-format-size-action-styles',
styles: SIZE_ACTION_STYLES,
html: SIZE_ACTION_HTML,
});
sizeActionElement.addEventListener('mousedown', (event) => event.preventDefault());
sizeActionElement.addEventListener('click', (event) => {
const option = event.target.closest('.tft-size-option');
if (!option) return;
applySizeAction(parseInt(option.dataset.size, 10));
stopFontSizeEdit();
hideSizeAction();
});
}
export function positionSizeAction() {
if (!isPanelVisible(PanelId.SIZE) || !sizeActionElement) return;
positionDropdownPanel(sizeActionElement, { buttonAction: 'font-size' });
}
/** Saves the current selection range for a subsequent size apply. */
export function saveSizeRange() {
const range = captureSelectionRange();
if (range) savedSizeRange = range;
}
/**
* Applies font size in pixels to the saved selection.
* @param {number} pixels
*/
export function applySizeAction(pixels) {
if (!savedSizeRange || !getToolbarTarget() || !(pixels > 0)) return;
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(savedSizeRange);
wrapSelectionWithStyle(getToolbarTarget(), 'fontSize', 'font-size', typeof pixels === 'number' ? `${pixels}px` : pixels);
onSelectionChange();
}
/** Opens the font size panel with the current size highlighted. */
export function showSizeAction() {
if (!sizeActionElement) initSizeAction();
savedSizeRange = captureSelectionRange();
const styles = getStylesAtCursor();
const currentSize = Math.round(parseFloat(
styles?.fontSize ?? getComputedStyle(getToolbarTarget()).fontSize
));
sizeActionElement.querySelectorAll('.tft-size-option').forEach(option => {
option.classList.toggle('tsa-active', Number(option.dataset.size) === currentSize);
});
sizeActionElement.classList.add('tsa-visible');
setPanelVisible(PanelId.SIZE, true);
positionSizeAction();
outsideDismiss.attach();
}
/** Closes the font size panel. */
export function hideSizeAction() {
if (!sizeActionElement) return;
sizeActionElement.classList.remove('tsa-visible');
setPanelVisible(PanelId.SIZE, false);
savedSizeRange = null;
outsideDismiss.detach();
}
/** Toggles the font size panel visibility. */
export function toggleSizeAction() {
if (isPanelVisible(PanelId.SIZE)) {
hideSizeAction();
} else {
showSizeAction();
}
}
registerPanel({ id: PanelId.SIZE, hide: hideSizeAction });
@@ -0,0 +1,42 @@
import { SIZE_ACTION_ID } from './template.js';
import { PANEL_BG, BORDER_COLOR, HOVER_BG, ACTIVE_BG, BOX_SHADOW_DROPDOWN, COLOR_WHITE, Z_INDEX_EDITOR_PANEL } from '../../../constants/theme.js';
/** Injected CSS for the font size dropdown panel. */
export const SIZE_ACTION_STYLES = `
#${SIZE_ACTION_ID} {
position: fixed;
display: none;
flex-direction: column;
overflow-y: auto;
max-height: 240px;
background: ${PANEL_BG};
border: 1px solid ${BORDER_COLOR};
border-radius: 12px;
padding: 4px;
box-shadow: ${BOX_SHADOW_DROPDOWN};
font-family: DM Sans, sans-serif;
user-select: none;
z-index: ${Z_INDEX_EDITOR_PANEL};
}
#${SIZE_ACTION_ID}.tsa-visible {
display: flex;
}
.tft-size-option {
padding: 6px 12px;
color: ${COLOR_WHITE};
font-size: 12px;
border-radius: 8px;
cursor: pointer;
white-space: nowrap;
}
.tft-size-option:hover {
background: ${HOVER_BG};
}
.tft-size-option.tsa-active {
background: ${ACTIVE_BG};
}
`;
@@ -0,0 +1,12 @@
/** DOM id for the font size action panel root element. */
export const SIZE_ACTION_ID = 'text-format-size-action';
/** Preset font sizes (px) listed in the size dropdown. */
export const TEXT_SIZES = [6, 8, 10, 12, 14, 16, 18, 21, 24, 28, 32, 36, 42, 48, 56, 64, 72, 80, 88, 96, 104, 120, 144];
/** HTML scaffold for the font size dropdown options. */
export const SIZE_ACTION_HTML = `
<div id="${SIZE_ACTION_ID}">
${TEXT_SIZES.map(size => `<div class="tft-size-option" data-size="${size}">${size}</div>`).join('')}
</div>
`.trim();
@@ -0,0 +1,502 @@
/**
* DOM range helpers for applying bold, italic, underline, and alignment inside
* the active contenteditable selection.
*/
import {
getTextNodesInRange,
isNodeBold,
isNodeItalic,
isNodeUnderlined,
} from '../../utils/inline-style-state.js';
function hasAncestorTag(textNode, tagNames, stopAt) {
let element = textNode.parentElement;
while (element && element !== stopAt) {
if (tagNames.includes(element.tagName.toLowerCase())) return true;
element = element.parentElement;
}
return false;
}
function isFullyWrapped(range, tagNames, editTarget) {
if (range.collapsed) return false;
const nodes = getTextNodesInRange(range);
return nodes.length > 0 && nodes.every(node => hasAncestorTag(node, tagNames, editTarget));
}
/**
* Wraps a range in an element, with optional inline styles.
* @param {Range} range
* @param {string} tagName
* @param {object} [styleProperties]
* @returns {HTMLElement}
*/
export function wrapRange(range, tagName, styleProperties) {
const wrapper = document.createElement(tagName);
if (styleProperties) Object.assign(wrapper.style, styleProperties);
try {
range.surroundContents(wrapper);
} catch {
wrapper.appendChild(range.extractContents());
range.insertNode(wrapper);
}
if (!styleProperties) {
wrapper.querySelectorAll(tagName).forEach(element => element.replaceWith(...element.childNodes));
wrapper.normalize();
}
return wrapper;
}
function restoreSelectionOver(firstNode, lastNode) {
if (!firstNode || !lastNode) return;
try {
const newRange = document.createRange();
if (firstNode.nodeType === Node.TEXT_NODE) {
newRange.setStart(firstNode, 0);
} else {
newRange.setStartBefore(firstNode);
}
if (lastNode.nodeType === Node.TEXT_NODE) {
newRange.setEnd(lastNode, lastNode.length);
} else {
newRange.setEndAfter(lastNode);
}
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(newRange);
} catch { /* ignore invalid range errors */ }
}
function ancestorDepthWithin(element, editTarget) {
let depth = 0;
let current = element;
while (current && current !== editTarget) {
depth += 1;
current = current.parentElement;
}
return depth;
}
/**
* Removes a single inline wrapper element over the portion covered by `range`,
* keeping the parts outside the range wrapped and preserving every descendant
* (e.g. a nested `<strong>`) of the in-range portion. Splitting the element when
* the selection only partially covers it.
* @param {HTMLElement} element
* @param {Range} range
*/
function unwrapElementOverRange(element, range) {
const elementContents = document.createRange();
elementContents.selectNodeContents(element);
const selectionStartsInside = range.compareBoundaryPoints(Range.START_TO_START, elementContents) > 0;
const selectionEndsInside = range.compareBoundaryPoints(Range.END_TO_END, elementContents) < 0;
if (selectionEndsInside) {
const afterRange = document.createRange();
afterRange.setStart(range.endContainer, range.endOffset);
afterRange.setEnd(elementContents.endContainer, elementContents.endOffset);
const afterContent = afterRange.extractContents();
if (afterContent.textContent !== '') {
const afterClone = element.cloneNode(false);
afterClone.appendChild(afterContent);
element.after(afterClone);
}
}
if (selectionStartsInside) {
const beforeRange = document.createRange();
beforeRange.setStart(elementContents.startContainer, elementContents.startOffset);
beforeRange.setEnd(range.startContainer, range.startOffset);
const beforeContent = beforeRange.extractContents();
if (beforeContent.textContent !== '') {
const beforeClone = element.cloneNode(false);
beforeClone.appendChild(beforeContent);
element.before(beforeClone);
}
}
element.replaceWith(...element.childNodes);
}
function unwrapTagsInRange(range, tagNames, editTarget) {
const tagSet = tagNames.map(tag => tag.toLowerCase());
const textNodes = getTextNodesInRange(range);
const firstTextNode = textNodes[0] ?? null;
const lastTextNode = textNodes[textNodes.length - 1] ?? null;
const targetElements = [];
for (const textNode of textNodes) {
let element = textNode.parentElement;
while (element && element !== editTarget) {
if (tagSet.includes(element.tagName?.toLowerCase()) && !targetElements.includes(element)) {
targetElements.push(element);
}
element = element.parentElement;
}
}
// Unwrap the deepest wrappers first so re-parenting never shifts the
// boundaries of a shallower wrapper still waiting to be processed.
targetElements.sort((a, b) => ancestorDepthWithin(b, editTarget) - ancestorDepthWithin(a, editTarget));
for (const element of targetElements) {
unwrapElementOverRange(element, range);
}
restoreSelectionOver(firstTextNode, lastTextNode);
editTarget.normalize();
}
/**
* Returns the user's active text selection inside the edit target.
* When the caret is collapsed (no highlighted text), the entire element's
* contents are selected so that style actions apply to all text.
* @param {HTMLElement} editTarget
* @returns {Range|null} Selection range inside the edit target.
*/
export function getActiveRange(editTarget) {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return null;
if (selection.isCollapsed) {
if (!editTarget.contains(selection.anchorNode)) return null;
const range = document.createRange();
range.selectNodeContents(editTarget);
selection.removeAllRanges();
selection.addRange(range);
return range;
}
const range = selection.getRangeAt(0);
if (!editTarget.contains(range.commonAncestorContainer)) return null;
return range;
}
/** Tailwind utility classes cleared when toggling bold off on an edit target. */
const TAILWIND_BOLD_CLASSES = ['font-bold', 'font-semibold', 'font-extrabold', 'font-black'];
/** Tailwind utility classes cleared when toggling italic off on an edit target. */
const TAILWIND_ITALIC_CLASSES = ['italic'];
function isFullyBold(range, editTarget) {
if (range.collapsed) return false;
const nodes = getTextNodesInRange(range);
return nodes.length > 0 && nodes.every((node) => isNodeBold(node, editTarget));
}
function isFullyItalic(range, editTarget) {
if (range.collapsed) return false;
const nodes = getTextNodesInRange(range);
return nodes.length > 0 && nodes.every((node) => isNodeItalic(node, editTarget));
}
/**
* Toggles bold on the current selection within the edit target.
* @param {HTMLElement} editTarget
*/
export function applyBold(editTarget) {
const range = getActiveRange(editTarget);
if (!range) return;
if (isFullyBold(range, editTarget)) {
unwrapTagsInRange(range, ['strong', 'b'], editTarget);
TAILWIND_BOLD_CLASSES.forEach(className => editTarget.classList.remove(className));
if (editTarget.style.fontWeight) editTarget.style.removeProperty('font-weight');
const selection = window.getSelection();
if (selection?.rangeCount > 0 && !selection.isCollapsed) {
const selectionRange = selection.getRangeAt(0);
if (isFullyBold(selectionRange, editTarget)) wrapRange(selectionRange, 'span', { fontWeight: 'normal' });
}
} else {
wrapRange(range, 'strong');
}
editTarget.dispatchEvent(new Event('input', { bubbles: true }));
}
/**
* Toggles italic on the current selection within the edit target.
* @param {HTMLElement} editTarget
*/
export function applyItalic(editTarget) {
const range = getActiveRange(editTarget);
if (!range) return;
if (isFullyItalic(range, editTarget)) {
unwrapTagsInRange(range, ['em', 'i'], editTarget);
TAILWIND_ITALIC_CLASSES.forEach(className => editTarget.classList.remove(className));
if (editTarget.style.fontStyle) editTarget.style.removeProperty('font-style');
const selection = window.getSelection();
if (selection?.rangeCount > 0 && !selection.isCollapsed) {
const selectionRange = selection.getRangeAt(0);
if (isFullyItalic(selectionRange, editTarget)) wrapRange(selectionRange, 'span', { fontStyle: 'normal' });
}
} else {
wrapRange(range, 'em');
}
editTarget.dispatchEvent(new Event('input', { bubbles: true }));
}
/** Tailwind utility classes cleared when toggling underline off on an edit target. */
const TAILWIND_UNDERLINE_CLASSES = ['underline'];
function isRangeFullyUnderlined(range, editTarget) {
if (range.collapsed) return false;
const nodes = getTextNodesInRange(range);
return nodes.length > 0 && nodes.every((node) => isNodeUnderlined(node, editTarget));
}
function removeExplicitUnderlineInRange(range, editTarget) {
getTextNodesInRange(range).forEach(node => {
let element = node.parentElement;
while (element && element !== editTarget) {
if (element.style?.textDecoration?.includes('underline') || element.style?.textDecorationLine?.includes('underline')) {
element.style.removeProperty('text-decoration');
element.style.removeProperty('text-decoration-line');
}
element = element.parentElement;
}
});
}
function getContextualDecorationStyle(range) {
// When text sits between CSS-class underline siblings (after a split), inherit
// their full decoration style so re-applied underline visually matches.
const node = range.startContainer;
if (node.nodeType !== Node.TEXT_NODE) return null;
for (const sibling of [node.previousSibling, node.nextSibling]) {
if (!sibling || sibling.nodeType !== Node.ELEMENT_NODE) continue;
const computedStyle = getComputedStyle(sibling);
const textDecorationLine = computedStyle.textDecorationLine || computedStyle.textDecoration || '';
if (!textDecorationLine.includes('underline')) continue;
const style = {};
if (computedStyle.textDecorationColor) {
style.textDecorationColor = computedStyle.textDecorationColor;
}
if (computedStyle.textDecorationThickness && computedStyle.textDecorationThickness !== 'auto') {
style.textDecorationThickness = computedStyle.textDecorationThickness;
}
if (computedStyle.textDecorationStyle && computedStyle.textDecorationStyle !== 'solid') {
style.textDecorationStyle = computedStyle.textDecorationStyle;
}
return Object.keys(style).length > 0 ? style : null;
}
return null;
}
function getEffectiveTextColor(range, editTarget) {
const nodes = getTextNodesInRange(range);
if (nodes.length === 0 && range.startContainer.nodeType === Node.TEXT_NODE) {
nodes.push(range.startContainer);
}
if (nodes.length === 0) return null;
const containerColor = getComputedStyle(editTarget).color;
for (const textNode of nodes) {
let element = textNode.parentElement;
while (element && element !== editTarget) {
const inlineColor = element.style?.color;
if (inlineColor && inlineColor !== 'transparent') return inlineColor;
element = element.parentElement;
}
const parent = textNode.parentElement;
if (!parent) continue;
const computed = getComputedStyle(parent).color;
if (computed && computed !== containerColor && computed !== 'rgba(0, 0, 0, 0)') {
return computed;
}
}
return null;
}
function buildUnderlineWrapStyle(range, editTarget) {
const style = getContextualDecorationStyle(range) ?? {};
const textColor = getEffectiveTextColor(range, editTarget);
if (textColor) {
style.textDecorationColor = textColor;
}
return Object.keys(style).length > 0 ? style : null;
}
/** Keeps `<u>` decoration color in sync when nested text color changes. */
export function syncUnderlineColorFromElement(element, editTarget) {
if (!element || !editTarget) return;
const inlineColor = element.style?.color;
const containerColor = getComputedStyle(editTarget).color;
const decorationColor = inlineColor
|| (() => {
const computed = getComputedStyle(element).color;
return computed !== containerColor && computed !== 'rgba(0, 0, 0, 0)' ? computed : null;
})();
if (!decorationColor) return;
let node = element.parentElement;
while (node && node !== editTarget) {
if (node.tagName?.toLowerCase() === 'u') {
node.style.textDecorationColor = decorationColor;
}
node = node.parentElement;
}
}
function findCssUnderlineAncestor(node, editTarget) {
// Find the closest ancestor that sources underline via a CSS class, not a <u> tag
// or inline style — those are handled by other steps.
let element = node.nodeType === Node.TEXT_NODE ? node.parentElement : node;
while (element && element !== editTarget) {
if (element.tagName?.toLowerCase() === 'u') { element = element.parentElement; continue; }
const hasInline = element.style?.textDecoration?.includes('underline') || element.style?.textDecorationLine?.includes('underline');
if (hasInline) { element = element.parentElement; continue; }
const textDecorationLine = getComputedStyle(element).textDecorationLine || getComputedStyle(element).textDecoration || '';
if (textDecorationLine.includes('underline')) return element;
element = element.parentElement;
}
return null;
}
function splitElementAroundRange(range, ancestor, editTarget) {
const fragment = range.extractContents();
const selectionFirst = fragment.firstChild;
const selectionLast = fragment.lastChild;
const parent = ancestor.parentNode;
const nextSibling = ancestor.nextSibling;
const afterRange = document.createRange();
afterRange.setStart(range.startContainer, range.startOffset);
if (ancestor.lastChild) afterRange.setEndAfter(ancestor.lastChild);
else afterRange.collapse(true);
const afterContent = ancestor.lastChild ? afterRange.extractContents() : null;
parent.insertBefore(fragment, nextSibling);
if (afterContent?.textContent) {
const afterClone = ancestor.cloneNode(false);
afterClone.appendChild(afterContent);
parent.insertBefore(afterClone, nextSibling);
}
if (!ancestor.textContent) ancestor.remove();
restoreSelectionOver(selectionFirst, selectionLast);
editTarget.normalize();
}
/**
* Toggles underline on the current selection within the edit target.
* @param {HTMLElement} editTarget
*/
export function applyUnderline(editTarget) {
const range = getActiveRange(editTarget);
if (!range) return;
if (isRangeFullyUnderlined(range, editTarget)) {
if (getTextNodesInRange(range).some(node => hasAncestorTag(node, ['u'], editTarget))) {
unwrapTagsInRange(range, ['u'], editTarget);
}
let selection = window.getSelection();
if (selection?.rangeCount > 0) removeExplicitUnderlineInRange(selection.getRangeAt(0), editTarget);
// Strip Tailwind underline classes from the editTarget itself
TAILWIND_UNDERLINE_CLASSES.forEach(className => editTarget.classList.remove(className));
selection = window.getSelection();
if (selection?.rangeCount > 0 && !selection.isCollapsed) {
const selectionRange = selection.getRangeAt(0);
if (isRangeFullyUnderlined(selectionRange, editTarget)) {
const textNodes = getTextNodesInRange(selectionRange);
const cssAncestor = textNodes.length > 0 ? findCssUnderlineAncestor(textNodes[0], editTarget) : null;
if (cssAncestor) splitElementAroundRange(selectionRange, cssAncestor, editTarget);
else wrapRange(selectionRange, 'span', { textDecoration: 'none' });
}
}
} else {
const decorationStyle = buildUnderlineWrapStyle(range, editTarget);
wrapRange(range, 'u', decorationStyle ?? undefined);
}
editTarget.dispatchEvent(new Event('input', { bubbles: true }));
}
/**
* Finds the nearest ancestor `<span>` that already carries `styleProperty` and whose
* text content exactly matches the selection. Reusing it (instead of nesting a
* new inner span) keeps in-between elements like `<u>` at the same font-size as
* the text, so e.g. underline thickness stays proportional.
* @param {Range} range
* @param {HTMLElement} editTarget
* @param {string} styleProperty
* @returns {HTMLElement|null}
*/
function findStyledAncestorWrappingRange(range, editTarget, styleProperty) {
let element = range.commonAncestorContainer;
if (element.nodeType === Node.TEXT_NODE) element = element.parentElement;
const selectionText = range.toString();
if (!selectionText) return null;
let match = null;
while (element && element !== editTarget) {
if (element.nodeType === Node.ELEMENT_NODE && element.tagName === 'SPAN' && element.style[styleProperty]) {
const elementRange = document.createRange();
elementRange.selectNodeContents(element);
if (elementRange.toString() === selectionText) match = element;
}
element = element.parentElement;
}
return match;
}
/**
* Wraps the active selection in a span carrying one inline style prop,
* stripping the same prop from descendants (and a redundant single-child
* parent span) so values never nest.
* @param {HTMLElement} editTarget
* @param {string} styleProperty - camelCase property (e.g. 'fontSize').
* @param {string} cssProperty - kebab-case property (e.g. 'font-size').
* @param {string} value
*/
export function wrapSelectionWithStyle(editTarget, styleProperty, cssProperty, value) {
const range = getActiveRange(editTarget);
if (!range) return;
const reuse = findStyledAncestorWrappingRange(range, editTarget, styleProperty);
if (reuse) {
reuse.style[styleProperty] = value;
if (styleProperty === 'fontSize') reuse.style.lineHeight = 'normal';
reuse.querySelectorAll('*').forEach(element => {
if (element.style[styleProperty]) {
element.style.removeProperty(cssProperty);
if (!element.style.cssText.trim()) element.replaceWith(...element.childNodes);
}
});
reuse.normalize();
editTarget.dispatchEvent(new Event('input', { bubbles: true }));
return;
}
const wrapperStyle = { [styleProperty]: value };
if (styleProperty === 'fontSize') wrapperStyle.lineHeight = 'normal';
const wrapper = wrapRange(range, 'span', wrapperStyle);
if (wrapper) {
wrapper.querySelectorAll('*').forEach(element => {
if (element.style[styleProperty]) {
element.style.removeProperty(cssProperty);
if (!element.style.cssText.trim()) element.replaceWith(...element.childNodes);
}
});
const parentElement = wrapper.parentElement;
if (parentElement && parentElement !== editTarget && parentElement.tagName === 'SPAN' && parentElement.style[styleProperty] && parentElement.childNodes.length === 1) {
parentElement.style.removeProperty(cssProperty);
if (!parentElement.style.cssText.trim()) parentElement.replaceWith(...parentElement.childNodes);
}
wrapper.normalize();
}
editTarget.dispatchEvent(new Event('input', { bubbles: true }));
}
/**
* Sets text-align on the edit target element.
* @param {HTMLElement} editTarget
* @param {'left'|'center'|'right'} alignment
*/
export function applyAlign(editTarget, alignment) {
const current = getComputedStyle(editTarget).textAlign;
if (current === alignment || (alignment === 'left' && (current === 'start' || current === 'left'))) return;
editTarget.style.textAlign = alignment;
editTarget.dispatchEvent(new Event('input', { bubbles: true }));
}
@@ -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,355 @@
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);
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];
});
}