Files
AFFiNE-Mirror/blocksuite/affine/inlines/link/src/link-node/affine-link.ts
2025-03-20 13:47:35 +00:00

186 lines
5.2 KiB
TypeScript

import { whenHover } from '@blocksuite/affine-components/hover';
import { RefNodeSlotsProvider } from '@blocksuite/affine-inline-reference';
import type { ReferenceInfo } from '@blocksuite/affine-model';
import {
ParseDocUrlProvider,
ToolbarRegistryIdentifier,
} from '@blocksuite/affine-shared/services';
import { affineTextStyles } from '@blocksuite/affine-shared/styles';
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import type { BlockComponent, BlockStdScope } from '@blocksuite/block-std';
import { BLOCK_ID_ATTR, ShadowlessElement } from '@blocksuite/block-std';
import {
INLINE_ROOT_ATTR,
type InlineRootElement,
ZERO_WIDTH_SPACE,
} from '@blocksuite/block-std/inline';
import { WithDisposable } from '@blocksuite/global/lit';
import type { DeltaInsert } from '@blocksuite/store';
import { css, html } from 'lit';
import { property } from 'lit/decorators.js';
import { type StyleInfo, styleMap } from 'lit/directives/style-map.js';
export class AffineLink extends WithDisposable(ShadowlessElement) {
static override styles = css`
affine-link a:hover [data-v-text='true'] {
text-decoration: underline;
}
`;
// The link has been identified.
private _identified: boolean = false;
// see https://github.com/toeverything/AFFiNE/issues/1540
private readonly _onMouseUp = () => {
const anchorElement = this.querySelector('a');
if (!anchorElement || !anchorElement.isContentEditable) return;
anchorElement.contentEditable = 'false';
setTimeout(() => {
anchorElement.removeAttribute('contenteditable');
}, 0);
};
private _referenceInfo: ReferenceInfo | null = null;
openLink = (e?: MouseEvent) => {
if (!this._identified) {
this._identified = true;
this._identify();
}
const referenceInfo = this._referenceInfo;
if (!referenceInfo) return;
const refNodeSlotsProvider = this.std.getOptional(RefNodeSlotsProvider);
if (!refNodeSlotsProvider) return;
e?.preventDefault();
refNodeSlotsProvider.docLinkClicked.next({
...referenceInfo,
host: this.std.host,
});
};
_whenHover = whenHover(
hovered => {
const message$ = this.std.get(ToolbarRegistryIdentifier).message$;
if (hovered) {
message$.value = {
flavour: 'affine:link',
element: this,
setFloating: this._whenHover.setFloating,
};
return;
}
// Clears previous bindings
message$.value = null;
this._whenHover.setFloating();
},
{ enterDelay: 500 }
);
override connectedCallback() {
super.connectedCallback();
this._whenHover.setReference(this);
const message$ = this.std.get(ToolbarRegistryIdentifier).message$;
this._disposables.add(() => {
if (message$?.value) {
message$.value = null;
}
this._whenHover.dispose();
});
}
// Workaround for links not working in contenteditable div
// see also https://stackoverflow.com/questions/12059211/how-to-make-clickable-anchor-in-contenteditable-div
//
// Note: We cannot use JS to directly open a new page as this may be blocked by the browser.
//
// Please also note that when readonly mode active,
// this workaround is not necessary and links work normally.
get block() {
if (!this.inlineEditor?.rootElement) return null;
const block = this.inlineEditor.rootElement.closest<BlockComponent>(
`[${BLOCK_ID_ATTR}]`
);
return block;
}
get inlineEditor() {
const inlineRoot = this.closest<InlineRootElement<AffineTextAttributes>>(
`[${INLINE_ROOT_ATTR}]`
);
return inlineRoot?.inlineEditor;
}
get link() {
return this.delta.attributes?.link ?? '';
}
get selfInlineRange() {
const selfInlineRange = this.inlineEditor?.getInlineRangeFromElement(this);
return selfInlineRange;
}
// Identify if url is an internal link
private _identify() {
const link = this.link;
if (!link) return;
const result = this.std.getOptional(ParseDocUrlProvider)?.parseDocUrl(link);
if (!result) return;
const { docId: pageId, ...params } = result;
this._referenceInfo = { pageId, params };
}
private _renderLink(style: StyleInfo) {
return html`<a
href=${this.link}
rel="noopener noreferrer"
target="_blank"
style=${styleMap(style)}
@click=${this.openLink}
@mouseup=${this._onMouseUp}
><v-text .str=${this.delta.insert}></v-text
></a>`;
}
override render() {
const linkStyle = {
color: 'var(--affine-link-color)',
fill: 'var(--affine-link-color)',
'text-decoration': 'none',
cursor: 'pointer',
};
if (this.delta.attributes && this.delta.attributes?.code) {
const codeStyle = affineTextStyles(this.delta.attributes);
return html`<code style=${styleMap(codeStyle)}>
${this._renderLink(linkStyle)}
</code>`;
}
const style = this.delta.attributes
? affineTextStyles(this.delta.attributes, linkStyle)
: {};
return this._renderLink(style);
}
@property({ type: Object })
accessor delta: DeltaInsert<AffineTextAttributes> = {
insert: ZERO_WIDTH_SPACE,
};
@property({ attribute: false })
accessor std!: BlockStdScope;
}