import type {
InlineTool,
InlineToolConstructorOptions,
SanitizerConfig
} from '../../../types';
import type { BlokConfig } from '../../../types/configs/blok-config';
import type { Notifier, Toolbar, I18n, InlineToolbar } from '../../../types/api';
import type { MenuConfig } from '../../../types/tools';
import { DATA_ATTR, createSelector, INLINE_TOOLBAR_INTERFACE_VALUE } from '../constants';
import { IconLink, IconGlobe, IconMail, IconHash, IconTrash, IconReturn, IconWarning } from '../icons';
import { SelectionUtils } from '../selection/index';
import { isMobileScreen, log } from '../utils';
import { PopoverItemType } from '../utils/popover';
import { setFieldValidity } from '../utils/field-validity';
import { applyResolvedLinkAttributes, resolveLinkAttributes } from '../utils/resolve-link-attributes';
import { hasUnsafeScheme } from '../utils/sanitize-url';
import { twMerge } from '../utils/tw';
/**
* Content-driven input width: the field rests at the narrow default and
* stretches with the typed link, capped at the old fixed width. The chrome
* constant covers the input's own horizontal padding, borders and caret slack
* on top of the measured text width.
*/
const INPUT_MIN_WIDTH = 220;
const INPUT_MAX_WIDTH = 320;
const INPUT_CHROME_WIDTH = 28;
const SUGGESTION_ROW_BASE = 'flex items-center gap-2.5 w-full mt-0.5 px-1.5 py-1.5 rounded-[10px] text-left appearance-none border-0 bg-transparent font-[inherit] outline-hidden';
const SUGGESTION_ROW_VALID = `${SUGGESTION_ROW_BASE} cursor-pointer can-hover:hover:bg-item-hover-bg focus-visible:bg-item-hover-bg transition-colors`;
const SUGGESTION_ROW_INVALID = `${SUGGESTION_ROW_BASE} pointer-events-none`;
/**
* The suggestion's leading icon sits in the same fixed box the popover menus
* use for their item icons, so the link card reads as part of one system.
*/
const SUGGESTION_ICON_CLASSES = 'flex items-center justify-center size-6 shrink-0 text-gray-text transition-opacity duration-150 [&_svg]:size-4';
const SUGGESTION_URL_TEXT = 'block text-[13px] leading-[18px] font-medium truncate';
const SUGGESTION_TYPE_TEXT = 'block text-[11px] leading-[14px] text-gray-text mt-px';
/**
* ⏎ affordance on the confirmable suggestion row. Shown only when pressing
* Enter would actually insert the link.
*/
const ENTER_HINT_CLASSES = 'items-center justify-center size-5 shrink-0 text-gray-text [&_svg]:size-3.5';
/**
* Link Tool
*
* Inline Toolbar Tool
*
* Wrap selected text with tag
*/
export class LinkInlineTool implements InlineTool {
/**
* Specifies Tool as Inline Toolbar Tool
* @returns {boolean}
*/
public static isInline = true;
/**
* Title for the Inline Tool
*/
public static title = 'Link';
/**
* Translation key for i18n
*/
public static titleKey = 'link';
/**
* Sanitizer Rule
* Leave tags. target/rel pass through (instead of being forced to the
* defaults) so consumer-configured BlokConfig.link.target / .rel values
* survive save-time sanitization. Created anchors still default to
* '_blank'/'nofollow' via insertLink().
* @returns {object}
*/
public static get sanitize(): SanitizerConfig {
return {
a: {
href: true,
target: true,
rel: true,
},
};
}
/**
* Tailwind classes for input
*/
private readonly INPUT_BASE_CLASSES = 'hidden w-full min-w-[220px] m-0 px-2.5 py-1.5 text-sm leading-[22px] font-medium text-text-primary bg-item-hover-bg border border-transparent rounded-[10px]! outline-hidden box-border appearance-none font-[inherit] placeholder:text-gray-text transition-[background-color,border-color,box-shadow] duration-150 ease-out focus:bg-popover-bg focus:border-search-input-focus-border aria-invalid:border-[var(--blok-color-danger)] focus:aria-invalid:border-[var(--blok-color-danger)] mobile:text-[15px] mobile:font-medium';
/**
* Data attributes for e2e selectors
*/
private readonly DATA_ATTRIBUTES = {
buttonActive: 'data-blok-link-tool-active',
buttonUnlink: 'data-blok-link-tool-unlink',
inputOpened: 'data-blok-link-tool-input-opened',
} as const;
/**
* Elements
*/
private nodes: {
input: HTMLInputElement | null;
urlLabel: HTMLElement | null;
titleInput: HTMLInputElement | null;
titleLabel: HTMLElement | null;
inputWrapper: HTMLElement | null;
suggestion: HTMLElement | null;
error: HTMLElement | null;
errorMessage: HTMLElement | null;
divider: HTMLElement | null;
removeButton: HTMLButtonElement | null;
button: HTMLButtonElement | null;
} = {
input: null,
urlLabel: null,
titleInput: null,
titleLabel: null,
inputWrapper: null,
suggestion: null,
error: null,
errorMessage: null,
divider: null,
removeButton: null,
button: null,
};
/**
* Whether the popover is editing an existing link (vs. creating one). In edit
* mode the labeled Page/Title fields replace the create-mode suggestion chip.
*/
private editing = false;
/**
* Reused canvas for measuring the input's text width (see measureTextWidth).
*/
private measureCanvas: HTMLCanvasElement | null = null;
/**
* Stable id linking the input to its inline error via aria-describedby.
*/
private readonly errorId = `blok-link-tool-error-${Math.random().toString(36).slice(2, 9)}`;
/**
* SelectionUtils instance
*/
private selection: SelectionUtils;
/**
* Input opening state
*/
private inputOpened = false;
/**
* Tracks whether unlink action is available via toolbar button toggle
*/
private unlinkAvailable = false;
/**
* Available Toolbar methods (open/close)
*/
private toolbar: Toolbar;
/**
* Available inline toolbar methods (open/close)
*/
private inlineToolbar: InlineToolbar;
/**
* Notifier API methods
*/
private notifier: Notifier;
/**
* I18n API
*/
private i18n: I18n;
/**
* Global anchor-building config (target / rel / transformHref)
*/
private linkConfig: NonNullable;
/**
* @param api - Blok API
*/
constructor({ api }: InlineToolConstructorOptions) {
this.toolbar = api.toolbar;
this.inlineToolbar = api.inlineToolbar;
this.notifier = api.notifier;
this.i18n = api.i18n;
this.linkConfig = api.config.link ?? {};
this.selection = new SelectionUtils();
this.nodes.urlLabel = this.createFieldLabel(this.i18n.t('tools.link.pageOrUrl'), 'inline-tool-url-label', false);
this.nodes.input = this.createInput();
this.nodes.suggestion = this.createSuggestion();
this.nodes.error = this.createError();
this.nodes.titleLabel = this.createFieldLabel(this.i18n.t('tools.link.linkTitle'), 'inline-tool-title-label', true);
this.nodes.titleInput = this.createTitleInput();
this.nodes.divider = this.createDivider();
this.nodes.removeButton = this.createRemoveButton();
this.nodes.inputWrapper = document.createElement('div');
// Horizontal padding keeps the URL input's 2px focus ring from being clipped
// by the popover's overflow-y-auto items box, which sits flush to the wrapper.
this.nodes.inputWrapper.className = 'px-1';
this.nodes.inputWrapper.append(
this.nodes.urlLabel,
this.nodes.input,
this.nodes.error,
this.nodes.suggestion,
this.nodes.titleLabel,
this.nodes.titleInput,
this.nodes.divider,
this.nodes.removeButton
);
}
/**
* Create button for Inline Toolbar
*/
public render(): MenuConfig {
return {
icon: IconLink,
name: 'link',
isActive: () => !!this.selection.findParentTag('A'),
children: {
hideChevron: true,
// The link field reads as a continuation of the toolbar, so it opens
// under the toolbar card instead of beside it.
placement: 'below',
items: [
{
type: PopoverItemType.Html,
// Wrapper contains the input and suggestion chip
element: this.nodes.inputWrapper as HTMLElement,
},
],
onOpen: () => {
this.openActions(true);
},
onClose: () => {
this.closeActions();
},
},
};
}
/**
* Input for the link
*/
private createInput(): HTMLInputElement {
const input = document.createElement('input');
input.placeholder = this.i18n.t('tools.link.addLink');
input.enterKeyHint = 'done';
input.className = this.INPUT_BASE_CLASSES;
input.setAttribute('data-blok-testid', 'inline-tool-input');
this.setBooleanStateAttribute(input, this.DATA_ATTRIBUTES.inputOpened, false);
input.addEventListener('keydown', (event: KeyboardEvent) => {
if (event.key === 'Enter') {
this.enterPressed(event);
}
});
input.addEventListener('paste', () => {
requestAnimationFrame(() => {
// A paste replaces the rejected value, so the stale error must not
// linger next to the freshly revealed suggestion row.
this.clearValidationError();
this.updateSuggestion(input.value);
this.resizeInput();
});
});
input.addEventListener('input', () => {
this.clearValidationError();
this.updateSuggestion(input.value);
this.resizeInput();
});
return input;
}
/**
* Measure how wide `text` renders in the URL input's own font. Returns null
* when measurement is unavailable (no canvas 2d context, e.g. jsdom) — the
* class-based fallback width applies then.
* @param text - the string to measure
*/
private measureTextWidth(text: string): number | null {
if (!this.nodes.input) {
return null;
}
this.measureCanvas ??= document.createElement('canvas');
const context = this.measureCanvas.getContext('2d');
if (!context) {
return null;
}
const style = window.getComputedStyle(this.nodes.input);
const font = `${style.fontWeight} ${style.fontSize} ${style.fontFamily}`.trim();
if (font !== '') {
context.font = font;
}
return context.measureText(text).width;
}
/**
* Size the URL input to its content: rest at the narrow default and stretch
* with the typed link, capped at the old fixed width. Mobile keeps the
* fluid class-based width — the popover manages its own sizing there.
*/
private resizeInput(): void {
const input = this.nodes.input;
if (!input) {
return;
}
if (isMobileScreen()) {
input.style.width = '';
return;
}
const measured = this.measureTextWidth(input.value || input.placeholder || '');
if (measured === null) {
return;
}
const width = Math.ceil(Math.min(Math.max(measured + INPUT_CHROME_WIDTH, INPUT_MIN_WIDTH), INPUT_MAX_WIDTH));
input.style.width = `${width}px`;
}
/**
* Title (link text) input, shown only when editing an existing link so the
* user can change the anchor's visible text alongside its href. Hidden when
* creating a new link (the selected text is the link text in that case).
*/
private createTitleInput(): HTMLInputElement {
const input = document.createElement('input');
input.placeholder = this.i18n.t('tools.link.linkText');
input.enterKeyHint = 'done';
input.className = this.INPUT_BASE_CLASSES;
input.setAttribute('data-blok-testid', 'inline-tool-title-input');
input.addEventListener('keydown', (event: KeyboardEvent) => {
if (event.key === 'Enter') {
this.enterPressed(event);
}
});
return input;
}
/**
* Small field caption shown above a field in edit mode (e.g. "Page or URL",
* "Link title"). Hidden by default; revealed alongside its field.
* @param text - the caption text
* @param testid - e2e selector hook
* @param topGap - whether to add top spacing (for the second, stacked label)
*/
private createFieldLabel(text: string, testid: string, topGap: boolean): HTMLElement {
const label = document.createElement('div');
label.className = twMerge(
// px-2.5 keeps the caption on the same optical column as the input text.
'hidden px-2.5 mb-1.5 text-xs font-normal tracking-[0.01em] text-gray-text',
topGap ? 'mt-3.5' : ''
);
label.textContent = text;
label.setAttribute('data-blok-testid', testid);
return label;
}
/**
* Hairline separator above the "Remove link" action in edit mode.
*/
private createDivider(): HTMLElement {
const divider = document.createElement('div');
divider.className = 'hidden mt-2.5 mb-1.5 -mx-1 h-px bg-link-input-border';
divider.setAttribute('data-blok-testid', 'inline-tool-link-divider');
return divider;
}
/**
* "Remove link" action, shown only when editing an existing link. Unlinks the
* anchor (keeping its text) and closes the toolbar. A destructive menu row:
* neutral at rest, tinting red on hover (text + icon) to signal its intent.
*/
private createRemoveButton(): HTMLButtonElement {
const button = document.createElement('button');
button.type = 'button';
button.className = 'hidden group/remove w-full flex items-center gap-2.5 px-2 py-1.5 rounded-[10px] text-left text-sm font-medium text-text-primary cursor-pointer can-hover:hover:bg-red-500/10 can-hover:hover:text-red-500 focus-visible:bg-red-500/10 focus-visible:text-red-500 outline-hidden transition-colors appearance-none border-0 bg-transparent font-[inherit]';
button.setAttribute('data-blok-testid', 'inline-tool-remove-link');
const iconEl = document.createElement('span');
iconEl.className = 'shrink-0 flex text-gray-text transition-colors can-hover:group-hover/remove:text-red-500 group-focus-visible/remove:text-red-500 [&>svg]:size-5';
iconEl.innerHTML = IconTrash;
const label = document.createElement('span');
label.textContent = this.i18n.t('tools.link.removeLink');
button.append(iconEl, label);
// Keep the selection intact when the button takes focus, then remove.
button.addEventListener('mousedown', (event) => event.preventDefault());
button.addEventListener('click', () => this.removeLinkAndClose());
return button;
}
/**
* Inline error region for URL validation failures. Linked to the input via
* aria-describedby (see {@link setFieldValidity}) so the message is exposed
* to assistive tech alongside the existing toast.
*/
private createError(): HTMLElement {
const error = document.createElement('div');
// No display class on the root so the `hidden` attribute keeps working;
// the flex layout lives on the inner row instead.
error.id = this.errorId;
// w-0 + min-w-full for the same reason as the suggestion wrapper: a long
// localized message must not widen the content-driven card.
error.className = 'w-0 min-w-full mt-1.5 px-1.5';
error.setAttribute('data-blok-link-tool-error', '');
error.setAttribute('role', 'alert');
error.hidden = true;
const row = document.createElement('span');
row.className = 'flex items-center gap-1.5 text-xs font-medium text-[var(--blok-color-danger)]';
const iconEl = document.createElement('span');
iconEl.className = 'shrink-0 flex [&_svg]:size-3.5';
iconEl.setAttribute('aria-hidden', 'true');
iconEl.innerHTML = IconWarning;
const message = document.createElement('span');
this.nodes.errorMessage = message;
row.append(iconEl, message);
error.append(row);
return error;
}
/**
* Surface a URL validation failure: fill and reveal the inline error and
* mark the input aria-invalid, describing it by the error region.
*/
private showValidationError(): void {
if (!this.nodes.input || !this.nodes.error || !this.nodes.errorMessage) {
return;
}
this.nodes.errorMessage.textContent = this.i18n.t('tools.link.invalidLink');
this.nodes.error.hidden = false;
// The error and the suggestion row contradict each other — while the URL
// is rejected, the row must not keep offering that same URL as confirmable.
this.nodes.suggestion?.classList.add('hidden');
setFieldValidity(this.nodes.input, false, this.errorId);
}
/**
* Reset the inline error and the input's invalid state.
*/
private clearValidationError(): void {
if (!this.nodes.input || !this.nodes.error) {
return;
}
if (this.nodes.errorMessage) {
this.nodes.errorMessage.textContent = '';
}
this.nodes.error.hidden = true;
setFieldValidity(this.nodes.input, true, this.errorId);
}
/**
* Create the suggestion chip shown below the input when a URL is present
*/
private createSuggestion(): HTMLElement {
const wrapper = document.createElement('div');
// w-0 + min-w-full: the URL input alone drives the card's content-driven
// width — the row contributes nothing to the intrinsic measurement (its
// long untruncated URL text would otherwise inflate the card) yet still
// renders at the card's resolved width, which is what lets it truncate.
wrapper.className = 'hidden w-0 min-w-full';
wrapper.setAttribute('data-link-suggestion', '');
const divider = document.createElement('div');
divider.className = 'mt-1 mb-0.5 h-px bg-link-input-border';
const row = document.createElement('button');
row.type = 'button';
row.className = SUGGESTION_ROW_VALID;
row.setAttribute('data-link-suggestion-row', '');
const iconEl = document.createElement('span');
iconEl.className = SUGGESTION_ICON_CLASSES;
iconEl.setAttribute('data-link-suggestion-icon', '');
const textEl = document.createElement('span');
textEl.className = 'flex-1 min-w-0';
const urlEl = document.createElement('span');
urlEl.className = `${SUGGESTION_URL_TEXT} text-text-primary`;
urlEl.setAttribute('data-link-suggestion-url', '');
const typeEl = document.createElement('span');
typeEl.className = SUGGESTION_TYPE_TEXT;
typeEl.setAttribute('data-link-suggestion-type', '');
const enterHint = document.createElement('span');
enterHint.className = `hidden ${ENTER_HINT_CLASSES}`;
enterHint.setAttribute('data-link-suggestion-enter-hint', '');
enterHint.setAttribute('aria-hidden', 'true');
enterHint.innerHTML = IconReturn;
textEl.append(urlEl, typeEl);
row.append(iconEl, textEl, enterHint);
wrapper.append(divider, row);
row.addEventListener('mousedown', (e) => e.preventDefault());
row.addEventListener('click', () => this.confirmLink());
return wrapper;
}
/**
* Update the suggestion chip content and visibility based on current input value
*/
private updateSuggestion(value: string): void {
if (!this.nodes.suggestion) {
return;
}
const trimmed = value.trim();
// The suggestion chip belongs to create mode only; in edit mode the labeled
// Page/Title fields stand in for it.
if (!trimmed || this.editing) {
this.nodes.suggestion.classList.add('hidden');
return;
}
const isComplete = this.isLinkComplete(trimmed);
const { icon, label } = this.getLinkTypeInfo(trimmed);
const iconEl = this.nodes.suggestion.querySelector('[data-link-suggestion-icon]');
const urlEl = this.nodes.suggestion.querySelector('[data-link-suggestion-url]');
const typeEl = this.nodes.suggestion.querySelector('[data-link-suggestion-type]');
const row = this.nodes.suggestion.querySelector('[data-link-suggestion-row]');
const enterHint = this.nodes.suggestion.querySelector('[data-link-suggestion-enter-hint]');
if (iconEl) {
iconEl.innerHTML = icon;
iconEl.className = `${SUGGESTION_ICON_CLASSES} ${isComplete ? '' : 'opacity-50'}`.trim();
}
if (urlEl) {
urlEl.textContent = trimmed;
urlEl.className = `${SUGGESTION_URL_TEXT} ${isComplete ? 'text-text-primary' : 'text-gray-text'}`;
}
if (typeEl) {
typeEl.textContent = isComplete ? label : this.i18n.t('tools.link.keepTyping');
}
if (row instanceof HTMLButtonElement) {
row.className = isComplete ? SUGGESTION_ROW_VALID : SUGGESTION_ROW_INVALID;
// pointer-events-none does not stop keyboard focus — keep the inert row
// out of the tab order too.
row.tabIndex = isComplete ? 0 : -1;
}
if (enterHint) {
enterHint.className = `${isComplete ? 'flex' : 'hidden'} ${ENTER_HINT_CLASSES}`;
}
this.nodes.suggestion.classList.remove('hidden');
}
/**
* Return true if the URL is complete enough to confirm as a link.
*
* Rules by category:
* - http/https → must have at least one character after "://"
* - other :// → same (ftp, ws, etc.)
* - mailto/tel/… → must have something after the colon
* - //host → must have at least one character after "//"
* - #anchor → must have at least one character after "#"
* - /path → always valid (internal link)
* - plain text → must look like a domain (dot + 2+ letter TLD) or IP address
*/
private isLinkComplete(url: string): boolean {
// http / https — require a non-empty host after "://"
if (/^https?:\/\//i.test(url)) {
return url.replace(/^https?:\/\//i, '').length > 0;
}
// Other double-slash protocols (ftp://, ws://, etc.)
if (/^\w+:\/\//.test(url)) {
return url.replace(/^\w+:\/\//, '').length > 0;
}
// Single-colon schemes: mailto:, tel:, sms:, etc. — require something after ":"
if (/^\w+:/.test(url)) {
return url.slice(url.indexOf(':') + 1).length > 0;
}
// Protocol-relative — require a non-empty host after "//"
if (url.startsWith('//')) {
return url.slice(2).length > 0;
}
// Anchor — require at least one character after "#"
if (url.startsWith('#')) {
return url.length > 1;
}
// Absolute internal path — always valid
if (url.startsWith('/')) {
return true;
}
// Plain text — must look like a domain or IP address
return /\.[a-zA-Z]{2,}/.test(url) || /^\d{1,3}(\.\d{1,3}){3}/.test(url);
}
/**
* Return the icon SVG and human-readable label for a given URL
*/
private getLinkTypeInfo(url: string): { icon: string; label: string } {
if (url.startsWith('mailto:')) {
return { icon: IconMail, label: this.i18n.t('tools.link.emailAddress') };
}
if (url.startsWith('#')) {
return { icon: IconHash, label: this.i18n.t('tools.link.jumpToSection') };
}
return { icon: IconGlobe, label: this.i18n.t('tools.link.webLink') };
}
/**
* Insert the link from the input — called by the suggestion chip click
*/
private confirmLink(): void {
if (!this.nodes.input) {
return;
}
const value = this.nodes.input.value || '';
if (!value.trim() || !this.isLinkComplete(value.trim())) {
return;
}
if (!this.validateURL(value)) {
// Surface the failure once, via the accessible inline field error
// (aria-invalid + error region). A duplicate notifier toast would render
// the same "Invalid link" text a second time.
this.showValidationError();
return;
}
this.clearValidationError();
const preparedValue = this.prepareLink(value);
this.selection.removeFakeBackground();
this.selection.restore();
this.insertLink(preparedValue);
this.selection.collapseToEnd();
this.inlineToolbar.close();
}
/**
* Shortcut for the link tool
*/
public static shortcut = 'CMD+K';
/**
* @param {boolean} needFocus - on link creation we need to focus input. On editing - nope.
*/
private openActions(needFocus = false): void {
if (!this.nodes.input) {
return;
}
const anchorTag = this.selection.findParentTag('A');
const hasAnchor = Boolean(anchorTag);
this.updateButtonStateAttributes(hasAnchor);
this.unlinkAvailable = hasAnchor;
if (anchorTag) {
/**
* Fill input value with link href
*/
const hrefAttr = anchorTag.getAttribute('href');
this.nodes.input.value = hrefAttr !== null ? hrefAttr : '';
} else {
this.nodes.input.value = '';
}
/**
* Editing an existing link surfaces the title field (prefilled with the
* anchor's text) and the remove-link action. Creating a new link keeps them
* hidden — the selected text already serves as the link text.
*/
this.setEditAffordancesVisible(hasAnchor, anchorTag?.textContent ?? '');
// Edit mode uses the labeled Page/Title fields, not the create-mode
// suggestion chip — keep it hidden even though the URL is prefilled.
this.updateSuggestion(this.nodes.input.value);
this.resizeInput();
this.nodes.input.className = twMerge(this.INPUT_BASE_CLASSES, 'block');
this.setBooleanStateAttribute(this.nodes.input, this.DATA_ATTRIBUTES.inputOpened, true);
/**
* Set fake background to visually indicate selection when focus moves to input
*/
this.selection.setFakeBackground();
this.selection.save();
if (needFocus) {
this.focusInputWithRetry();
}
this.inputOpened = true;
}
/**
* Toggle the edit-only affordances (title field + remove button). When shown,
* the title field is prefilled with the link's current text.
* @param visible - whether the affordances should be shown
* @param titleValue - text to prefill the title field with when shown
*/
private setEditAffordancesVisible(visible: boolean, titleValue = ''): void {
this.editing = visible;
if (this.nodes.titleInput) {
this.nodes.titleInput.value = visible ? titleValue : '';
this.nodes.titleInput.className = visible
? twMerge(this.INPUT_BASE_CLASSES, 'block')
: this.INPUT_BASE_CLASSES;
}
this.nodes.urlLabel?.classList.toggle('hidden', !visible);
this.nodes.urlLabel?.classList.toggle('block', visible);
this.nodes.titleLabel?.classList.toggle('hidden', !visible);
this.nodes.titleLabel?.classList.toggle('block', visible);
this.nodes.divider?.classList.toggle('hidden', !visible);
this.nodes.divider?.classList.toggle('block', visible);
this.nodes.removeButton?.classList.toggle('hidden', !visible);
this.nodes.removeButton?.classList.toggle('flex', visible);
}
/**
* Restore the selection, unlink the anchor (keeping its text) and close the
* toolbar. Backs the explicit "Remove link" button in edit mode.
*/
private removeLinkAndClose(): void {
this.restoreSelection();
this.unlink();
this.inlineToolbar.close();
}
/**
* Ensures the link input receives focus even if other listeners steal it
*/
private focusInputWithRetry(): void {
if (!this.nodes.input) {
return;
}
this.nodes.input.focus();
if (typeof window === 'undefined' || typeof document === 'undefined') {
return;
}
window.setTimeout(() => {
if (document.activeElement !== this.nodes.input) {
this.nodes.input?.focus();
}
}, 0);
}
/**
* Resolve the current inline toolbar button element
*/
private getButtonElement(): HTMLButtonElement | null {
// Always query fresh to ensure we have the latest DOM element
const button = document.querySelector(
`${createSelector(DATA_ATTR.interface, INLINE_TOOLBAR_INTERFACE_VALUE)} [data-blok-item-name="link"]`
);
// Only add click listener if this is a new button element
if (button && button !== this.nodes.button) {
button.addEventListener('click', this.handleButtonClick, true);
this.nodes.button = button;
}
return button;
}
/**
* Update button state attributes for e2e hooks
* @param hasAnchor - Optional override for anchor presence
*/
private updateButtonStateAttributes(hasAnchor?: boolean): void {
const button = this.getButtonElement();
if (!button) {
return;
}
const anchorPresent = typeof hasAnchor === 'boolean' ? hasAnchor : Boolean(this.selection.findParentTag('A'));
this.setBooleanStateAttribute(button, this.DATA_ATTRIBUTES.buttonActive, anchorPresent);
this.setBooleanStateAttribute(button, this.DATA_ATTRIBUTES.buttonUnlink, anchorPresent);
}
/**
* Handles toggling the inline tool button while actions menu is open
* @param event - Click event emitted by the inline tool button
*/
private handleButtonClick = (event: MouseEvent): void => {
if (!this.inputOpened || !this.unlinkAvailable) {
return;
}
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
this.restoreSelection();
this.unlink();
this.inlineToolbar.close();
};
/**
* Close input
* @param {boolean} clearSavedSelection — we don't need to clear saved selection
* on toggle-clicks on the icon of opened Toolbar
*/
private closeActions(clearSavedSelection = true): void {
const shouldRestoreSelection = this.selection.isFakeBackgroundEnabled ||
(clearSavedSelection && !!this.selection.savedSelectionRange);
if (shouldRestoreSelection) {
this.restoreSelection();
}
if (!this.nodes.input) {
return;
}
this.nodes.input.className = this.INPUT_BASE_CLASSES;
this.setBooleanStateAttribute(this.nodes.input, this.DATA_ATTRIBUTES.inputOpened, false);
this.nodes.input.value = '';
this.nodes.input.style.width = '';
this.setEditAffordancesVisible(false);
this.clearValidationError();
this.nodes.suggestion?.classList.add('hidden');
this.updateButtonStateAttributes(false);
this.unlinkAvailable = false;
if (clearSavedSelection) {
this.selection.clearSaved();
}
this.inputOpened = false;
}
/**
* Restore selection after closing actions
*/
private restoreSelection(): void {
// if actions is broken by other selection We need to save new selection
const currentSelection = new SelectionUtils();
const isSelectionInBlok = SelectionUtils.isAtBlok;
if (isSelectionInBlok) {
currentSelection.save();
}
this.selection.removeFakeBackground();
this.selection.restore();
// and recover new selection after removing fake background
if (!isSelectionInBlok && this.selection.savedSelectionRange) {
const range = this.selection.savedSelectionRange;
const container = range.commonAncestorContainer;
const element = container.nodeType === Node.ELEMENT_NODE ? container as HTMLElement : container.parentElement;
element?.focus();
}
if (!isSelectionInBlok) {
return;
}
currentSelection.restore();
const range = currentSelection.savedSelectionRange;
if (range) {
const container = range.commonAncestorContainer;
const element = container.nodeType === Node.ELEMENT_NODE ? container as HTMLElement : container.parentElement;
element?.focus();
}
}
/**
* Enter pressed on input
* @param {KeyboardEvent} event - enter keydown event
*/
private enterPressed(event: KeyboardEvent): void {
if (!this.nodes.input) {
return;
}
const value = this.nodes.input.value || '';
if (!value.trim()) {
this.selection.restore();
this.unlink();
event.preventDefault();
this.closeActions();
// Explicitly close inline toolbar as well, similar to legacy behavior
this.inlineToolbar.close();
return;
}
if (!this.validateURL(value)) {
// Surface the failure once, via the accessible inline field error
// (aria-invalid + error region). A duplicate notifier toast would render
// the same "Invalid link" text a second time.
this.showValidationError();
log('Incorrect Link pasted', 'warn', value);
return;
}
this.clearValidationError();
const preparedValue = this.prepareLink(value);
this.selection.removeFakeBackground();
this.selection.restore();
this.insertLink(preparedValue);
/**
* Preventing events that will be able to happen
*/
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
this.selection.collapseToEnd();
this.inlineToolbar.close();
}
/**
* Detects if passed string is URL
* @param {string} str - string to validate
* @returns {boolean}
*/
private validateURL(str: string): boolean {
/**
* Don't allow spaces
*/
if (/\s/.test(str)) {
return false;
}
/**
* Reject URL schemes that execute script when the anchor is clicked.
* The anchor is inserted into the live document before any save-time
* sanitization runs, so an unfiltered `javascript:`/`data:` href is
* clickable XSS. An allowlist of safe schemes is used because denylists
* keep losing to scheme smuggling (e.g. `data:image/svg+xml`).
*/
if (hasUnsafeScheme(str)) {
return false;
}
return true;
}
/**
* Process link before injection
* - sanitize
* - add protocol for links like 'google.com'
* @param {string} link - raw user input
*/
private prepareLink(link: string): string {
return this.addProtocol(link.trim());
}
/**
* Add 'http' protocol to the links like 'vc.ru', 'google.com'
* @param {string} link - string to process
*/
private addProtocol(link: string): string {
/**
* If protocol already exists, do nothing
*/
if (/^(\w+):(\/\/)?/.test(link)) {
return link;
}
/**
* We need to add missed HTTP protocol to the link, but skip 2 cases:
* 1) Internal links like "/general"
* 2) Anchors looks like "#results"
* 3) Protocol-relative URLs like "//google.com"
*/
const isInternal = /^\/[^/\s]/.test(link);
const isAnchor = link.substring(0, 1) === '#';
const isProtocolRelative = /^\/\/[^/\s]/.test(link);
if (!isInternal && !isAnchor && !isProtocolRelative) {
return 'http://' + link;
}
return link;
}
/**
* Inserts tag with "href"
* @param {string} link - "href" value
*/
private insertLink(link: string): void {
/**
* Edit all link, not selected part
*/
const anchorTag = this.selection.findParentTag('A');
if (anchorTag instanceof HTMLAnchorElement) {
/**
* Apply an edited link text before re-selecting: only when it actually
* changed, so an untouched anchor keeps its inner formatting instead of
* being flattened to plain text. Done before resolving so the `link`
* config's `transform` sees the final anchor text.
*/
const newTitle = this.nodes.titleInput?.value ?? '';
if (newTitle.trim() !== '' && newTitle !== anchorTag.textContent) {
anchorTag.textContent = newTitle;
}
this.selection.expandToTag(anchorTag);
// Same-page targeting, transformHref/transform and any extra attributes are
// resolved centrally so hand-created links match render/paste exactly.
applyResolvedLinkAttributes(anchorTag, resolveLinkAttributes(link, anchorTag, this.linkConfig));
return;
}
const range = SelectionUtils.range;
if (!range) {
return;
}
const anchor = document.createElement('a');
// Populate the anchor text before resolving so the `transform` sees it.
anchor.appendChild(range.extractContents());
applyResolvedLinkAttributes(anchor, resolveLinkAttributes(link, anchor, this.linkConfig));
range.insertNode(anchor);
this.selection.expandToTag(anchor);
}
/**
* Removes tag
*/
private unlink(): void {
const anchorTag = this.selection.findParentTag('A');
if (anchorTag) {
this.unwrap(anchorTag);
this.updateButtonStateAttributes(false);
this.unlinkAvailable = false;
}
}
/**
* Unwrap passed node
* @param term - node to unwrap
*/
private unwrap(term: HTMLElement): void {
const docFrag = document.createDocumentFragment();
while (term.firstChild) {
docFrag.appendChild(term.firstChild);
}
term.parentNode?.replaceChild(docFrag, term);
}
/**
* Persist state as data attributes for testing hooks
* @param element - The HTML element to set the attribute on, or null
* @param attributeName - The name of the attribute to set
* @param state - The boolean state value to persist
*/
private setBooleanStateAttribute(element: HTMLElement | null, attributeName: string, state: boolean): void {
if (!element) {
return;
}
element.setAttribute(attributeName, state ? 'true' : 'false');
}
};