File size: 7,043 Bytes
5e0e4e8 638c2d6 5e0e4e8 638c2d6 5e0e4e8 0d61133 5e0e4e8 638c2d6 5e0e4e8 638c2d6 16221a7 5e0e4e8 638c2d6 45a40b2 638c2d6 5e0e4e8 638c2d6 5e0e4e8 638c2d6 0d61133 638c2d6 5e0e4e8 0d61133 638c2d6 0d61133 638c2d6 0d61133 638c2d6 45a40b2 5e0e4e8 0d61133 638c2d6 0d61133 638c2d6 45a40b2 638c2d6 0d61133 638c2d6 5e0e4e8 45a40b2 638c2d6 5e0e4e8 45a40b2 5e0e4e8 638c2d6 5e0e4e8 0d61133 5e0e4e8 0d61133 638c2d6 5e0e4e8 45a40b2 638c2d6 5e0e4e8 16221a7 5e0e4e8 16221a7 5e0e4e8 16221a7 5e0e4e8 638c2d6 5e0e4e8 16221a7 5e0e4e8 f20c69c 5e0e4e8 0d61133 16221a7 0d61133 638c2d6 16221a7 638c2d6 16221a7 638c2d6 0d61133 16221a7 638c2d6 5e0e4e8 638c2d6 5e0e4e8 16221a7 638c2d6 5e0e4e8 |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 |
// @ts-check
/**
* Model Slash Plugin for Milkdown Crepe
*
* This module implements a custom slash command interface for model selection
* using Milkdown's slash plugin following the proper factory pattern.
*
* Features:
* - Custom slash menu UI with model icons and metadata
* - Support for auth-required models with visual indicators
* - Dynamic model list updates via getter function
* - Async command execution with error handling
*/
import { slashFactory, SlashProvider } from '@milkdown/plugin-slash';
import './model-slash.css';
/**
* @typedef {{
* id: string,
* name: string,
* size?: string,
* requiresAuth?: boolean
* }} ModelInfo
*/
// Create the slash plugin factory
export const modelSlash = slashFactory('ModelCommands');
/**
* Creates and configures the model slash plugin
* @param {{
* getModels: () => ModelInfo[],
* onSlashCommand?: (modelId: string) => void | boolean | Promise<void | boolean>
* }} options
*/
export function createModelSlashPlugin({ getModels, onSlashCommand }) {
// Create the menu DOM element
const menu = document.createElement('div');
menu.className = "slash-menu";
// Start hidden; provider may only control positioning. We'll manage visibility.
menu.style.display = 'none';
// Function to rebuild menu content
function rebuildMenu() {
menu.innerHTML = '';
const availableModels = getModels();
if (availableModels.length === 0) {
const noModels = document.createElement('div');
noModels.className = "px-3 py-4 text-sm text-gray-500 text-center";
noModels.textContent = "No models available";
menu.appendChild(noModels);
return;
}
// Create model list
const modelList = document.createElement('ul');
modelList.className = 'model-list';
availableModels.forEach((model, index) => {
const item = document.createElement('li');
item.className = 'model-entry';
item.dataset.modelId = model.id;
// Create icon
const icon = document.createElement('span');
icon.className = 'model-icon';
icon.textContent = model.requiresAuth ? 'π' : 'π€';
// Create text container
const textContainer = document.createElement('div');
textContainer.className = 'model-text-container';
const name = document.createElement('div');
name.className = 'name';
name.textContent = model.name;
textContainer.appendChild(name);
if (model.size) {
const subtitle = document.createElement('div');
subtitle.className = 'size';
subtitle.textContent = `(${model.size})`;
textContainer.appendChild(subtitle);
}
item.appendChild(icon);
item.appendChild(textContainer);
// Add auth indicator if needed
if (model.requiresAuth) {
const authSpan = document.createElement('span');
authSpan.className = 'auth';
authSpan.textContent = "Auth Required";
item.appendChild(authSpan);
}
modelList.appendChild(item);
});
menu.appendChild(modelList);
}
// We'll attach click handler after potential wrapping defined later
// Track current editor view for mutation operations (removing the slash)
let currentView = null;
// Helper: check if cursor is directly after a solitary '/'
function hasTriggerSlash(view) {
if (!view) return false;
const { state } = view;
const { from } = state.selection;
if (from === 0) return false;
const $pos = state.doc.resolve(from);
// Get char before cursor
const prevChar = state.doc.textBetween(from - 1, from, '\n', '\n');
if (prevChar !== '/') return false;
// Optional: ensure it's start of line or preceded by space (avoid paths / urls)
const beforePrev = from - 2 >= 0 ? state.doc.textBetween(from - 2, from - 1, '\n', '\n') : '';
if (beforePrev && /[\w/]/.test(beforePrev)) return false; // part of word or // sequence
return true;
}
// Helper: remove the trigger slash silently
function removeTriggerSlash(view) {
try {
if (!view) return;
const { state } = view;
const { from } = state.selection;
if (from === 0) return;
const prevChar = state.doc.textBetween(from - 1, from, '\n', '\n');
if (prevChar === '/') {
const tr = state.tr.delete(from - 1, from);
view.dispatch(tr);
}
} catch (e) {
// ignore
}
}
// Create the slash provider
const provider = new SlashProvider({
content: menu,
shouldShow(view) {
return hasTriggerSlash(view);
},
offset: 15,
});
// Hide on Escape key β attach a document listener and remove it on destroy
function onKeyDown(e) {
if (!e) return;
const key = e.key || e.keyCode;
if (key === 'Escape' || key === 'Esc' || key === 27) {
try {
provider.hide();
removeTriggerSlash(currentView);
} catch (err) {
// ignore
}
}
}
document.addEventListener('keydown', onKeyDown);
// Configuration function for the slash plugin
const slashConfig = (ctx) => {
ctx.set(modelSlash.key, {
view: () => ({
update: (view, prevState) => {
currentView = view;
// Rebuild menu content on each update to reflect current models
rebuildMenu();
provider.update(view, prevState);
if (hasTriggerSlash(view)) {
menu.style.display = '';
} else {
menu.style.display = 'none';
}
},
destroy: () => {
provider.destroy();
// cleanup the document key listener
document.removeEventListener('keydown', onKeyDown);
document.removeEventListener('mousedown', onOutsideMouseDown, true);
},
}),
});
};
// Expose a public helper so external code (onSlashCommand) can explicitly close & clean
function finalize() {
provider.hide();
removeTriggerSlash(currentView);
menu.style.display = 'none';
}
// Wrapped handler: no finalize here so UI hides immediately on click
const wrapped = onSlashCommand ? async (modelId) => {
try {
await onSlashCommand(modelId);
} catch (error) {
console.error('Error executing slash command:', error);
}
} : null;
// Attach click handler now
menu.addEventListener('click', async (e) => {
if (!e.target || !(e.target instanceof Element)) return;
const target = e.target.closest('li[data-model-id]');
if (!target || !(target instanceof HTMLElement)) return;
const modelId = target.dataset.modelId;
if (!modelId) return;
// Hide immediately
finalize();
if (wrapped) await wrapped(modelId);
});
// Outside click handler to dismiss menu
function onOutsideMouseDown(e) {
if (menu.style.display === 'none') return;
if (e.target instanceof Node && !menu.contains(e.target)) {
finalize();
}
}
document.addEventListener('mousedown', onOutsideMouseDown, true);
return {
plugin: modelSlash,
config: slashConfig
};
}
|