import { css, html, PropertyValues } from "lit";
import { property, state } from "lit/decorators.js";
import { consume } from "@lit/context";
import { BaseElement } from "../../core/base-element.ts";
import "../cf-chip/index.ts";
import {
type CellHandle,
CellRef,
cellRefToKey,
NAME,
parseLLMFriendlyLink,
type RuntimeClient,
} from "@commonfabric/runtime-client";
import type { DID } from "@commonfabric/identity";
import { runtimeContext, spaceContext } from "../../runtime-context.ts";
import {
appViewToUrlPath,
navigate,
preserveAppViewMode,
urlToAppView,
} from "@commonfabric/shell/shared";
import {
createDragPreview,
endDrag,
startDrag,
updateDragPointer,
} from "../../core/drag-state.ts";
/**
* CFCellLink - Renders a link or cell as a clickable, draggable pill
*
* Every cell link is a drag source by default. Set `static` to suppress
* drag behavior (used in drag previews to avoid recursion).
*
* @element cf-cell-link
*
* @property {string} link - The serialized path to a cell (e.g. /of:fid1:abc.../path)
* @property {CellHandle} cell - The live Cell reference
* @property {boolean} static - Suppress drag behavior
*
* @example
*
*
*
*/
export class CFCellLink extends BaseElement {
static override styles = [
BaseElement.baseStyles,
css`
:host {
display: inline-block;
vertical-align: middle;
}
cf-chip {
cursor: pointer;
max-width: 100%;
}
:host(.dragging) cf-chip {
cursor: grabbing;
opacity: 0.5;
}
`,
];
@property({ type: String })
accessor link: string | undefined = undefined;
@property({ type: String })
accessor label: string | undefined = undefined;
@property({ type: String })
accessor spaceName: string | undefined = undefined;
@property({ attribute: false })
accessor cell: CellHandle | undefined = undefined;
@property({ type: Boolean, reflect: true, attribute: "static" })
accessor isStatic: boolean | undefined = undefined;
@consume({ context: runtimeContext, subscribe: true })
@property({ attribute: false })
accessor runtime: RuntimeClient | undefined = undefined;
@consume({ context: spaceContext, subscribe: true })
@property({ attribute: false })
accessor space: DID | undefined = undefined;
@state()
private accessor _resolvedCell: CellHandle | undefined = undefined;
@state()
private accessor _name: string | undefined = undefined;
@state()
private accessor _handle: string | undefined = undefined;
private _unsubscribe?: () => void;
private _resolvedCellKey: string | undefined = undefined;
private _subscribedCell: CellHandle | undefined = undefined;
private _subscribedCellKey: string | undefined = undefined;
private _resolveCellGeneration = 0;
// Drag state
private _isDragging = false;
private _isTracking = false;
private _dragStartX = 0;
private _dragStartY = 0;
private _pointerId?: number;
private _preview?: HTMLElement;
private _boundPointerMove = this._onPointerMove.bind(this);
private _boundPointerUp = this._onPointerUp.bind(this);
override connectedCallback() {
super.connectedCallback();
this._updateSubscription();
}
override disconnectedCallback() {
super.disconnectedCallback();
this._cleanupSubscription();
this._endDrag();
}
private _cleanupSubscription() {
if (this._unsubscribe) {
this._unsubscribe();
this._unsubscribe = undefined;
}
this._subscribedCell = undefined;
this._subscribedCellKey = undefined;
}
private _endDrag() {
document.removeEventListener("pointermove", this._boundPointerMove);
document.removeEventListener("pointerup", this._boundPointerUp);
document.removeEventListener("pointercancel", this._boundPointerUp);
if (this._isDragging) {
endDrag();
this.classList.remove("dragging");
}
this._isDragging = false;
this._isTracking = false;
this._pointerId = undefined;
this._preview = undefined;
}
protected override willUpdate(changedProperties: PropertyValues) {
super.willUpdate(changedProperties);
if (
changedProperties.has("cell") || changedProperties.has("link") ||
changedProperties.has("runtime") || changedProperties.has("space")
) {
this._resolveCell();
}
}
protected override updated(changedProperties: PropertyValues) {
super.updated(changedProperties);
if (changedProperties.has("_resolvedCell")) {
this._updateSubscription();
this._updateDisplayInfo();
}
// Also update display info when link changes without resolving to a new cell
if (
changedProperties.has("link") && !changedProperties.has("_resolvedCell")
) {
this._updateDisplayInfo();
}
}
private async _resolveCell() {
const generation = ++this._resolveCellGeneration;
const cell = this.cell;
const link = this.link;
const runtime = this.runtime;
const space = this.space;
if (cell) {
this._prepareSubscriptionTarget(this._cellKey(cell));
try {
const resolvedCell = await cell.resolveAsCell();
if (generation !== this._resolveCellGeneration) return;
this._setResolvedCell(resolvedCell);
} catch (e) {
if (generation !== this._resolveCellGeneration) return;
// A disposal race (logout, runtime swap) cancels the resolve; that is
// cancellation, not a failure to surface. Read the cell's own runtime,
// not the ambient `this.runtime` (cleared to undefined on logout).
if (cell.runtime().signal.aborted) return;
console.error("Failed to resolve cell:", e);
this._prepareSubscriptionTarget(undefined);
this._setResolvedCell(undefined);
}
return;
}
if (link && runtime) {
try {
// TODO(runtime-worker-refactor): Making some changes here, but
// `this.space` will be Shell's active space, not necessarily the
// space for `this.link`.
const parsedLink = parseLLMFriendlyLink(link, space);
if (!parsedLink.space) {
throw new Error("Link missing space.");
}
const linkedCell = runtime.getCellFromRef(parsedLink as CellRef);
this._prepareSubscriptionTarget(this._cellKey(linkedCell));
const resolvedCell = await linkedCell.resolveAsCell();
if (generation !== this._resolveCellGeneration) return;
this._setResolvedCell(resolvedCell);
} catch (e) {
if (generation !== this._resolveCellGeneration) return;
// A disposal race (logout, runtime swap) cancels the resolve; that is
// cancellation, not a failure to surface. Read the runtime the linked
// cell was built from, not the ambient `this.runtime` (cleared on logout).
if (runtime.signal.aborted) return;
console.error("Failed to resolve link:", e);
this._prepareSubscriptionTarget(undefined);
this._setResolvedCell(undefined);
}
} else {
this._prepareSubscriptionTarget(undefined);
this._setResolvedCell(undefined);
}
}
private _updateSubscription() {
if (!this.isConnected) {
this._cleanupSubscription();
return;
}
const cell = this._resolvedCell;
const nextCellKey = this._cellKey(cell);
if (
this._unsubscribe && nextCellKey &&
nextCellKey === this._subscribedCellKey &&
cell === this._subscribedCell
) {
return;
}
this._cleanupSubscription();
if (cell) {
// Subscribe with a minimal schema that only resolves $NAME.
// Without this, cells from $cell bindings arrive with schema: {}
// (stripped from the VDOM prop's asCell wrapper), causing
// handleCellSubscribe to walk the entire piece output graph.
const namedCell = cell.asSchema<{ [NAME]?: string }>({
type: "object",
properties: { [NAME]: { type: "string" } },
});
this._subscribedCell = cell;
this._subscribedCellKey = nextCellKey;
this._unsubscribe = namedCell.subscribe((val) => {
this._updateNameFromValue(val);
});
}
}
private _setResolvedCell(cell: CellHandle | undefined) {
const nextCellKey = this._cellKey(cell);
if (cell === this._resolvedCell && nextCellKey === this._resolvedCellKey) {
return;
}
this._resolvedCell = cell;
this._resolvedCellKey = nextCellKey;
}
private _cellKey(cell: CellHandle | undefined): string | undefined {
return cell
? cellRefToKey({ ...cell.ref(), schema: undefined })
: undefined;
}
private _prepareSubscriptionTarget(nextCellKey: string | undefined) {
if (this._unsubscribe && nextCellKey !== this._subscribedCellKey) {
this._cleanupSubscription();
}
}
private _updateNameFromValue(val: unknown) {
if (val && typeof val === "object" && NAME in val) {
this._name = (val as any)[NAME];
} else {
this._name = undefined;
}
this.requestUpdate();
}
private _updateDisplayInfo() {
if (this._resolvedCell) {
const shortId = this._resolvedCell.id().slice(-6);
this._handle = `#${shortId}`;
} else if (this.link) {
// Fallback if we can't resolve the cell but have a link string
try {
const parsed = parseLLMFriendlyLink(this.link);
const id = parsed.id;
const shortId = id ? id.split(":").pop()?.slice(0, 6) ?? "???" : "???";
this._handle = `#${shortId}`;
} catch {
this._handle = this.link;
}
} else {
this._handle = undefined;
}
}
private _onPointerDown(e: PointerEvent) {
if (this.isStatic || !this._resolvedCell) return;
// Prevent parent cf-drag-source elements from also starting a drag
e.stopPropagation();
this._dragStartX = e.clientX;
this._dragStartY = e.clientY;
this._pointerId = e.pointerId;
this._isTracking = true;
document.addEventListener("pointermove", this._boundPointerMove);
document.addEventListener("pointerup", this._boundPointerUp);
document.addEventListener("pointercancel", this._boundPointerUp);
}
private _onPointerMove(e: PointerEvent) {
if (!this._isTracking || e.pointerId !== this._pointerId) return;
const dx = e.clientX - this._dragStartX;
const dy = e.clientY - this._dragStartY;
if (!this._isDragging && Math.sqrt(dx * dx + dy * dy) > 5) {
this._isDragging = true;
this._beginDrag(e);
}
if (this._isDragging && this._preview) {
this._preview.style.left = `${e.clientX + 10}px`;
this._preview.style.top = `${e.clientY + 10}px`;
updateDragPointer(e.clientX, e.clientY);
}
}
private _onPointerUp(e: PointerEvent) {
if (e.pointerId !== this._pointerId) return;
this._endDrag();
}
private _beginDrag(e: PointerEvent) {
if (!this._resolvedCell) return;
this.classList.add("dragging");
const preview = createDragPreview(this._resolvedCell);
document.body.appendChild(preview);
preview.style.left = `${e.clientX + 10}px`;
preview.style.top = `${e.clientY + 10}px`;
this._preview = preview;
startDrag({
cell: this._resolvedCell,
type: "cell-link",
sourceElement: this,
preview,
pointerX: e.clientX,
pointerY: e.clientY,
});
}
private _handleClick(e: MouseEvent) {
if (this._isDragging) return;
e.stopPropagation();
if (this._resolvedCell) {
if (this._resolvedCell.ref().path.length > 0) {
throw new Error(
"Attempted to navigate to a cell that isn't a root cell",
);
}
// TODO(runtime-worker-refactor):
const view = this.spaceName
? { spaceName: this.spaceName, pieceId: this._resolvedCell.id() }
: {
spaceDid: this._resolvedCell.space(),
pieceId: this._resolvedCell.id(),
};
// Cmd (Mac) or Ctrl (Windows/Linux) opens in new tab
if (e.metaKey || e.ctrlKey) {
const url = appViewToUrlPath(
preserveAppViewMode(
urlToAppView(new URL(globalThis.location.href)),
view,
),
);
globalThis.open(url, "_blank");
} else {
navigate(view);
}
}
}
override render() {
// Priority: label (from markdown) > [NAME] field > handle > "Unknown Link"
const displayText = this.label
? this.label
: this._name
? `${this._name} ${this._handle}`
: (this._handle || "Unknown Link");
return html`
${displayText}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"cf-cell-link": CFCellLink;
}
}