Clean deploy: nueva versión

This commit is contained in:
Andres Santana
2026-10-01 12:28:54 -04:00
commit 030734d521
111 changed files with 23159 additions and 0 deletions
@@ -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();