Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions deno.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 2 additions & 6 deletions packages/patterns/chatbot-note.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export const Note = recipe<NoteInput>(
<ct-code-editor
$value={content}
$mentionable={allCharms}
oncharm-link-click={handleCharmLinkClick({})}
onbacklink-click={handleCharmLinkClick({})}
language="text/markdown"
style="min-height: 400px;"
/>
Expand Down Expand Up @@ -170,11 +170,7 @@ export default recipe<LLMTestInput, LLMTestResult>(
/>
</div>

<ct-vscroll flex showScrollbar fadeEdges snapToBottom>
<ct-vstack data-label="Tools">
<Note content={content} allCharms={allCharms} />
</ct-vstack>
</ct-vscroll>
<Note content={content} allCharms={allCharms} />
</ct-screen>

{ifElse(
Expand Down
3 changes: 2 additions & 1 deletion packages/ui/deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"@codemirror/lang-css": "npm:@codemirror/lang-css@^6.3.1",
"@codemirror/lang-html": "npm:@codemirror/lang-html@^6.4.9",
"@codemirror/lang-json": "npm:@codemirror/lang-json@^6.0.1",
"@codemirror/theme-one-dark": "npm:@codemirror/theme-one-dark@^6.1.2"
"@codemirror/theme-one-dark": "npm:@codemirror/theme-one-dark@^6.1.2",
"@codemirror/autocomplete": "npm:@codemirror/autocomplete@^6.15.0"
}
}
244 changes: 242 additions & 2 deletions packages/ui/src/v2/components/ct-code-editor/ct-code-editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { html, PropertyValues } from "lit";
import { BaseElement } from "../../core/base-element.ts";
import { styles } from "./styles.ts";
import { basicSetup } from "codemirror";
import { EditorView, placeholder } from "@codemirror/view";
import { EditorView, keymap, placeholder } from "@codemirror/view";
import { Compartment, EditorState, Extension } from "@codemirror/state";
import { LanguageSupport } from "@codemirror/language";
import { javascript as createJavaScript } from "@codemirror/lang-javascript";
Expand All @@ -11,9 +11,23 @@ import { css as createCss } from "@codemirror/lang-css";
import { html as createHtml } from "@codemirror/lang-html";
import { json as createJson } from "@codemirror/lang-json";
import { oneDark } from "@codemirror/theme-one-dark";
import { type Cell } from "@commontools/runner";
import {
autocompletion,
Completion,
CompletionContext,
CompletionResult,
} from "@codemirror/autocomplete";
import {
Decoration,
DecorationSet,
ViewPlugin,
ViewUpdate,
WidgetType,
} from "@codemirror/view";
import { type Cell, getEntityId, NAME } from "@commontools/runner";
import { type InputTimingOptions } from "../../core/input-timing-controller.ts";
import { createStringCellController } from "../../core/cell-controller.ts";
import { Charm } from "@commontools/charm";

/**
* Supported MIME types for syntax highlighting
Expand Down Expand Up @@ -66,10 +80,12 @@ const getLangExtFromMimeType = (mime: MimeType) => {
* @attr {string} placeholder - Placeholder text when empty
* @attr {string} timingStrategy - Input timing strategy: "immediate" | "debounce" | "throttle" | "blur"
* @attr {number} timingDelay - Delay in milliseconds for debounce/throttle (default: 500)
* @attr {Array} mentionable - Array of mentionable items with Charm structure for backlink autocomplete
*
* @fires ct-change - Fired when content changes with detail: { value, oldValue, language }
* @fires ct-focus - Fired on focus
* @fires ct-blur - Fired on blur
* @fires backlink-click - Fired when a backlink is clicked with Cmd/Ctrl+Enter with detail: { text, charm }
*
* @example
* <ct-code-editor language="text/javascript" placeholder="Enter code..."></ct-code-editor>
Expand All @@ -85,6 +101,7 @@ export class CTCodeEditor extends BaseElement {
placeholder: { type: String },
timingStrategy: { type: String },
timingDelay: { type: Number },
mentionable: { type: Array },
};

declare value: Cell<string> | string;
Expand All @@ -94,6 +111,7 @@ export class CTCodeEditor extends BaseElement {
declare placeholder: string;
declare timingStrategy: InputTimingOptions["strategy"];
declare timingDelay: number;
declare mentionable: Cell<Charm[]>;

private _editorView: EditorView | undefined;
private _lang = new Compartment();
Expand Down Expand Up @@ -124,6 +142,218 @@ export class CTCodeEditor extends BaseElement {
this.timingDelay = 500;
}

/**
* Create a backlink completion source for [[backlinks]]
*/
private createBacklinkCompletionSource() {
return (context: CompletionContext): CompletionResult | null => {
// Look for incomplete backlinks: [[ followed by optional text
const backlink = context.matchBefore(/\[\[([^\]]*)?/);

if (!backlink) {
return null;
}

// Check what comes after the cursor
const afterCursor = context.state.doc.sliceString(
context.pos,
context.pos + 2,
);

// Allow completion inside existing backlinks - we'll replace the content between [[ and ]]
const query = backlink.text.slice(2); // Remove [[ prefix

const mentionable = this.getFilteredMentionable(query);

if (mentionable.length === 0) return null;

const options: Completion[] = mentionable.map((charm) => {
const charmIdObj = getEntityId(charm);
const charmId = charmIdObj?.["/"] || "";
const charmName = charm[NAME] || "";
const insertText = `${charmName} (${charmId})`;
return {
label: charmName,
apply: afterCursor === "]]" ? insertText : insertText + "]]",
type: "text",
info: "Backlink to " + charmName,
};
});

return {
from: backlink.from + 2, // Start after [[
to: afterCursor === "]]" ? context.pos : undefined,
options,
validFor: /^[^\]]*$/,
};
};
}

/**
* Get filtered mentionable items based on query
*/
private getFilteredMentionable(query: string): Charm[] {
if (!this.mentionable) {
return [];
}

const mentionableData = this.mentionable.getAsQueryResult();

if (!mentionableData || mentionableData.length === 0) {
return [];
}

const queryLower = query.toLowerCase();
const matches: Charm[] = [];

for (let i = 0; i < mentionableData.length; i++) {
const mention = this.mentionable.key(i).getAsQueryResult();
if (mention && mention[NAME]?.toLowerCase()?.includes(queryLower)) {
matches.push(mention);
}
}

return matches;
}

/**
* Handle backlink clicks with Cmd/Ctrl+Click
*/
private createBacklinkClickHandler() {
return EditorView.domEventHandlers({
click: (event, view) => {
if (event.ctrlKey || event.metaKey) {
return this.handleBacklinkActivation(view, event);
}
return false;
},
});
}

/**
* Handle backlink activation (Cmd/Ctrl+Click on a backlink)
*/
private handleBacklinkActivation(
view: EditorView,
event?: MouseEvent,
): boolean {
const state = view.state;
const pos = state.selection.main.head;
const doc = state.doc;

// Find backlinks around cursor position
const lineStart = doc.lineAt(pos).from;
const lineEnd = doc.lineAt(pos).to;
const lineText = doc.sliceString(lineStart, lineEnd);

// Find all [[...]] patterns in the line
const backlinkRegex = /\[\[([^\]]+)\]\]/g;
let match;

while ((match = backlinkRegex.exec(lineText)) !== null) {
const matchStart = lineStart + match.index;
const matchEnd = matchStart + match[0].length;

// Check if cursor is within this backlink
if (pos >= matchStart && pos <= matchEnd) {
const backlinkText = match[1]; // This is "Name (id)" format
// Extract ID from "Name (id)" format
const idMatch = backlinkText.match(/\(([^)]+)\)$/);
const backlinkId = idMatch ? idMatch[1] : backlinkText;
const charm = this.findCharmById(backlinkId);

if (charm) {
this.emit("backlink-click", {
id: backlinkId,
text: backlinkText,
charm: charm,
});
return true;
}
}
}

return false;
}

/**
* Find a charm by ID in the mentionable list
*/
private findCharmById(id: string): Charm | null {
if (!this.mentionable) return null;

const mentionableData = this.mentionable.getAsQueryResult();
if (!mentionableData) return null;

for (let i = 0; i < mentionableData.length; i++) {
const charm = this.mentionable.key(i).getAsQueryResult();
if (charm) {
const charmIdObj = getEntityId(charm);
const charmId = charmIdObj?.["/"] || "";
if (charmId === id) {
return charm;
}
}
}

return null;
}

/**
* Create a plugin to decorate backlinks with special styling
*/
private createBacklinkDecorationPlugin() {
const backlinkMark = Decoration.mark({ class: "cm-backlink" });

return ViewPlugin.fromClass(
class {
decorations: DecorationSet;

constructor(view: EditorView) {
this.decorations = this.getBacklinkDecorations(view);
}

update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged) {
this.decorations = this.getBacklinkDecorations(update.view);
}
}

getBacklinkDecorations(view: EditorView) {
const decorations = [];
const doc = view.state.doc;
const backlinkRegex = /\[\[([^\]]+)\]\]/g;

for (const { from, to } of view.visibleRanges) {
for (let pos = from; pos <= to;) {
const line = doc.lineAt(pos);
const text = line.text;
let match;

backlinkRegex.lastIndex = 0; // Reset regex
while ((match = backlinkRegex.exec(text)) !== null) {
const start = line.from + match.index;
const end = start + match[0].length;

// Only decorate if within visible range
if (start >= from && end <= to) {
decorations.push(backlinkMark.range(start, end));
}
}

pos = line.to + 1;
}
}

return Decoration.set(decorations);
}
},
{
decorations: (v) => v.decorations,
},
);
}

private getValue(): string {
return this._cellController.getValue();
}
Expand Down Expand Up @@ -279,6 +509,16 @@ export class CTCodeEditor extends BaseElement {
return false;
},
}),
// Add backlink click handler for Cmd/Ctrl+Click
this.createBacklinkClickHandler(),
// Add backlink decoration plugin to visually style [[backlinks]]
this.createBacklinkDecorationPlugin(),
// Add autocompletion with backlink support
autocompletion({
override: [this.createBacklinkCompletionSource()],
activateOnTyping: true,
closeOnBlur: true,
}),
];

// Add placeholder extension if specified
Expand Down
18 changes: 18 additions & 0 deletions packages/ui/src/v2/components/ct-code-editor/styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,22 @@ export const styles = css`
.cm-editor {
border-radius: var(--radius, 0.375rem);
}

/* Backlink styling - make [[backlinks]] visually distinct */
.cm-content .cm-line {
position: relative;
}

/* Style for backlinks - we'll use a highlight mark */
.cm-backlink {
background-color: var(--ring-alpha, hsla(212, 100%, 47%, 0.1));
border-radius: 0.25rem;
padding: 0.125rem 0.25rem;
cursor: pointer;
transition: background-color 0.2s;
}

.cm-backlink:hover {
background-color: var(--ring-alpha, hsla(212, 100%, 47%, 0.2));
}
`;