minecodex 2.0.21 → 2.0.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,6 +6,23 @@ import { installMarkdownToc } from './toc.mjs';
6
6
  import { loadNativeMarkdownTableActions, installMarkdownTableActions } from './table-actions.mjs';
7
7
  import { loadNativeMarkdownRichText, installMarkdownRichText } from './rich-text.mjs';
8
8
 
9
+ // 每个 Preview 共用换行状态,虚拟滚动重挂载的块也读取当前值。
10
+ export function createMarkdownWrapState() {
11
+ let enabled = false;
12
+ const listeners = new Set();
13
+ return {
14
+ get: () => enabled,
15
+ toggle() {
16
+ enabled = !enabled;
17
+ for (const listener of listeners) listener();
18
+ },
19
+ subscribe(listener) {
20
+ listeners.add(listener);
21
+ return () => listeners.delete(listener);
22
+ },
23
+ };
24
+ }
25
+
9
26
  // 原生能力加载与会话寿命归 Adapter;入口只持有检查结果和释放动作。
10
27
  export function createMarkdownReadingRuntime(
11
28
  codex,
@@ -37,6 +54,7 @@ export function createMarkdownReadingRuntime(
37
54
  }
38
55
  function attach({ editor, content, scroller }) {
39
56
  const view = content.cmTile?.view;
57
+ const wrapState = createMarkdownWrapState();
40
58
  const states = new Map([
41
59
  ['markdown-reading-layout', 'loading'],
42
60
  ['markdown-reading-links', 'loading'],
@@ -71,7 +89,7 @@ export function createMarkdownReadingRuntime(
71
89
  await stage('markdown-reading-rich-text', 'native-rich-text-unavailable', async () => {
72
90
  const rich = await load('loadNativeMarkdownRichText', actions);
73
91
  if (!current()) return;
74
- cleanups.push(capabilities.installMarkdownRichText(view, api, rich, codex));
92
+ cleanups.push(capabilities.installMarkdownRichText(view, api, rich, codex, wrapState));
75
93
  report('markdown-reading-rich-text', true);
76
94
  });
77
95
  }
@@ -102,7 +120,7 @@ export function createMarkdownReadingRuntime(
102
120
  const api = await capabilities.loadMarkdownListAPI(view);
103
121
  if (!current()) return;
104
122
  cleanups.push(capabilities.installMarkdownLists(view, api));
105
- const tables = capabilities.installMarkdownTables(view, api);
123
+ const tables = capabilities.installMarkdownTables(view, api, wrapState);
106
124
  cleanups.push(tables);
107
125
  report('markdown-reading-layout', true);
108
126
  await Promise.all([installLinks(api), installToc(api, tables)]);
@@ -38,7 +38,7 @@ export async function loadNativeMarkdownRichText(native) {
38
38
  return { ...native, CodeSnippet: sharedBinding(codeName), katex };
39
39
  }
40
40
 
41
- export function installMarkdownRichText(view, api, native, codex) {
41
+ export function installMarkdownRichText(view, api, native, codex, wrapState) {
42
42
  const { Decoration, StateField, StateEffect, Compartment, WidgetType, Prec } = api;
43
43
  const {
44
44
  React,
@@ -55,8 +55,7 @@ export function installMarkdownRichText(view, api, native, codex) {
55
55
  } = native;
56
56
  const h = React.createElement,
57
57
  roots = new Set(),
58
- footnoteReturns = new Map(),
59
- codeStates = new Map();
58
+ footnoteReturns = new Map();
60
59
  const withProviders = captureMarkdownProviders(view.dom, codex);
61
60
  const mount = (element, tree) => {
62
61
  const root = ReactDOM.createRoot(element);
@@ -64,11 +63,9 @@ export function installMarkdownRichText(view, api, native, codex) {
64
63
  root.render(withProviders(React, tree));
65
64
  return root;
66
65
  };
67
- function Code({ content, language, id }) {
68
- const [wrap, setWrap] = React.useState(
69
- () => (codeStates.get(id)?.source === content && codeStates.get(id).wrap) || false,
70
- ),
71
- [overflow, setOverflow] = React.useState(false),
66
+ function Code({ content, language }) {
67
+ const wrap = React.useSyncExternalStore(wrapState.subscribe, wrapState.get);
68
+ const [overflow, setOverflow] = React.useState(false),
72
69
  ref = React.useRef(null);
73
70
  React.useLayoutEffect(() => {
74
71
  const element = ref.current?.querySelector('code')?.parentElement;
@@ -84,7 +81,9 @@ export function installMarkdownRichText(view, api, native, codex) {
84
81
  check();
85
82
  return () => observer.disconnect();
86
83
  }, [wrap, content]);
87
- const label = wrap ? '关闭代码换行' : '开启代码换行';
84
+ const label = document.documentElement.lang.startsWith('zh')
85
+ ? wrap ? '关闭文档换行' : '开启文档换行'
86
+ : wrap ? 'Disable document word wrap' : 'Enable document word wrap';
88
87
  return h(
89
88
  'div',
90
89
  { ref, className: 'mcx-reading-code-block group/app-widget' },
@@ -112,10 +111,7 @@ export function installMarkdownRichText(view, api, native, codex) {
112
111
  color: 'blockAction',
113
112
  radius: 'full',
114
113
  size: 'inputIcon',
115
- onClick: () => {
116
- codeStates.set(id, { source: content, wrap: !wrap });
117
- setWrap(!wrap);
118
- },
114
+ onClick: () => wrapState.toggle(),
119
115
  },
120
116
  h(wrap ? wrapIcons.disable : wrapIcons.enable),
121
117
  ),
@@ -159,7 +155,7 @@ export function installMarkdownRichText(view, api, native, codex) {
159
155
  else if (this.kind === 'code')
160
156
  element._mcxRoot = mount(
161
157
  element,
162
- h(Code, { content: this.source, language: this.options.language, id: this.options.from }),
158
+ h(Code, { content: this.source, language: this.options.language }),
163
159
  );
164
160
  else if (this.kind === 'math')
165
161
  element.innerHTML = katex.renderToString(this.source, {
@@ -136,11 +136,11 @@ export function installMarkdownTableActions(view, native, codex, controller) {
136
136
  const tokens = React.useMemo(() => (open ? lexer(item.source, undefined) : []), [open, item.source]);
137
137
  const wrap = chinese
138
138
  ? item.fit
139
- ? '恢复自然列宽'
140
- : '适配宽度并换行'
139
+ ? '关闭文档换行'
140
+ : '开启文档换行'
141
141
  : item.fit
142
- ? 'Use natural table width'
143
- : 'Fit table width and wrap';
142
+ ? 'Disable document word wrap'
143
+ : 'Enable document word wrap';
144
144
  const copy = chinese ? '复制表格' : 'Copy table';
145
145
  const expand = chinese ? '展开表格' : 'Expand table';
146
146
  const preview = chinese ? '表格预览' : 'Table preview';
@@ -186,7 +186,7 @@ export function installMarkdownTableActions(view, native, codex, controller) {
186
186
  color: 'blockAction',
187
187
  radius: 'full',
188
188
  size: 'inputIcon',
189
- onClick: () => controller.toggle(item.id),
189
+ onClick: () => controller.toggle(),
190
190
  },
191
191
  h(item.fit ? wrapIcons.disable : wrapIcons.enable),
192
192
  ),
@@ -1,5 +1,5 @@
1
1
  // 保留原生逐行表格及选区;每张表的行共享横向位置,不移动正文节点。
2
- export function installMarkdownTables(view, api) {
2
+ export function installMarkdownTables(view, api, wrapState) {
3
3
  const { Decoration, ViewPlugin, StateEffect, Compartment, WidgetType } = api;
4
4
  class EmptyCell extends WidgetType {
5
5
  eq() {
@@ -82,7 +82,6 @@ export function installMarkdownTables(view, api) {
82
82
  view.dom.append(layoutStyle);
83
83
  let measuredDoc = null;
84
84
  const tables = new Map();
85
- const fitted = new Set();
86
85
  let hovered = null;
87
86
  function inline(state, node, target) {
88
87
  let cursor = node.from;
@@ -155,9 +154,9 @@ export function installMarkdownTables(view, api) {
155
154
  const inset = (scroller.clientWidth - available) / 2;
156
155
  const breakout = Math.max(0, parseFloat(padding.paddingLeft) - inset);
157
156
  for (const [id, table] of tables) {
158
- table.style.inlineSize = fitted.has(id) ? '100%' : 'max-content';
157
+ table.style.inlineSize = wrapState.get() ? '100%' : 'max-content';
159
158
  for (const cell of table.querySelectorAll('td,th'))
160
- cell.style.overflowWrap = fitted.has(id) ? 'anywhere' : 'break-word';
159
+ cell.style.overflowWrap = wrapState.get() ? 'anywhere' : 'break-word';
161
160
  }
162
161
  const widths = new Map(
163
162
  [...tables].map(([id, table]) => [
@@ -173,7 +172,7 @@ export function installMarkdownTables(view, api) {
173
172
  );
174
173
  columns.forEach((width, index) =>
175
174
  rules.push(
176
- `${selector} > .cm-markdown-table-cell:nth-child(${index + 1} of .cm-markdown-table-cell){width:${width}px;overflow-wrap:${fitted.has(id) ? 'anywhere' : 'break-word'}}`,
175
+ `${selector} > .cm-markdown-table-cell:nth-child(${index + 1} of .cm-markdown-table-cell){width:${width}px;overflow-wrap:${wrapState.get() ? 'anywhere' : 'break-word'}}`,
177
176
  ),
178
177
  );
179
178
  }
@@ -322,7 +321,7 @@ export function installMarkdownTables(view, api) {
322
321
  const headerInset = parseFloat(getComputedStyle(rows[0]).paddingTop);
323
322
  items.push({
324
323
  id,
325
- fit: fitted.has(id),
324
+ fit: wrapState.get(),
326
325
  overflow: (scrollLimits.get(id) ?? 0) > 0,
327
326
  visible: hovered === id,
328
327
  left: (first.left - frameRect.left) / scale,
@@ -435,7 +434,6 @@ export function installMarkdownTables(view, api) {
435
434
  update(update) {
436
435
  if (update.docChanged) {
437
436
  offsets.clear();
438
- fitted.clear();
439
437
  geometryRows = [];
440
438
  }
441
439
  if (update.docChanged || update.startState.tree !== update.state.tree) measuredDoc = null;
@@ -456,16 +454,16 @@ export function installMarkdownTables(view, api) {
456
454
  const controller = {
457
455
  schedule,
458
456
  renderActions: null,
459
- toggle(id) {
460
- if (fitted.has(id)) fitted.delete(id);
461
- else fitted.add(id);
462
- offsets.delete(id);
463
- schedule();
464
- view.requestMeasure();
465
- },
457
+ toggle: () => wrapState.toggle(),
466
458
  };
459
+ const unsubscribeWrap = wrapState.subscribe(() => {
460
+ offsets.clear();
461
+ schedule();
462
+ view.requestMeasure();
463
+ });
467
464
  schedule();
468
465
  const dispose = () => {
466
+ unsubscribeWrap();
469
467
  view.dom.removeEventListener('pointerover', onHover);
470
468
  view.dom.removeEventListener('pointerout', onHover);
471
469
  view.dom.removeEventListener('focusin', schedule);
@@ -3,6 +3,7 @@ import { collectMarkdownFootnotes } from './domain/footnotes.mjs';
3
3
  import { captureMarkdownProviders } from './adapters/codex/providers.mjs';
4
4
  import {
5
5
  createMarkdownReadingRuntime,
6
+ createMarkdownWrapState,
6
7
  installMarkdownMetadataScroll,
7
8
  } from './adapters/codex/reading-runtime.mjs';
8
9
  import { loadNativeMarkdownRichText, installMarkdownRichText } from './adapters/codex/rich-text.mjs';
@@ -23,6 +24,7 @@ export async function createPageScriptSource() {
23
24
  collectMarkdownFootnotes,
24
25
  captureMarkdownProviders,
25
26
  createMarkdownReadingRuntime,
27
+ createMarkdownWrapState,
26
28
  createMarkdownPreviewAdapter,
27
29
  installMarkdownMetadataScroll,
28
30
  loadMarkdownListAPI,
@@ -7,8 +7,8 @@
7
7
  "zh-CN": "模型窗口"
8
8
  },
9
9
  "description": {
10
- "en": "Switch models and thinking levels, and keep favorites close at hand.",
11
- "zh-CN": "快速切换模型和思考强度,收藏常用模型。"
10
+ "en": "Search and switch models and thinking levels, and keep favorites close at hand.",
11
+ "zh-CN": "搜索与切换模型和思考强度,收藏常用模型。"
12
12
  },
13
13
  "entry": {
14
14
  "kind": "composer-model-selector"
@@ -28,6 +28,14 @@
28
28
  "openai": "OpenAI"
29
29
  },
30
30
  "strings": {
31
+ "searchPlaceholder": {
32
+ "en": "Search model",
33
+ "zh-CN": "搜索模型"
34
+ },
35
+ "noResults": {
36
+ "en": "No models found",
37
+ "zh-CN": "未找到模型"
38
+ },
31
39
  "star": {
32
40
  "en": "Star {model}",
33
41
  "zh-CN": "收藏 {model}"
@@ -31,6 +31,18 @@ export function installModelSlider(signal, { feature, ui, createIcon, requestRef
31
31
  z-index: 10;
32
32
  padding: 6px;
33
33
  }
34
+ [data-codex-model-slider-search-header] {
35
+ position: sticky; top: 0; z-index: 1; flex-shrink: 0; padding: 2px 8px 8px;
36
+ background: var(--mcx-color-surface-elevated-secondary);
37
+ }
38
+ [data-codex-model-slider-search] {
39
+ width: 100%; height: 28px; border: 0; outline: none; padding: 0;
40
+ background: transparent; color: var(--mcx-color-text-primary);
41
+ font: inherit; font-size: var(--mcx-text-sm, 13px);
42
+ }
43
+ [data-codex-model-slider-search]::placeholder { color: var(--mcx-color-text-tertiary); }
44
+ [data-codex-model-slider-empty] { padding: 12px 8px; color: var(--mcx-color-text-secondary); font-size: 13px; }
45
+ [data-codex-model-slider-item][data-codex-model-slider-keyboard] { background: var(--mcx-color-background-primary-ghost-hover); }
34
46
  [data-codex-model-slider-menu][data-codex-model-slider-overflow] {
35
47
  max-height: var(--codex-model-slider-menu-max-height) !important;
36
48
  overflow-y: auto;
@@ -647,7 +659,12 @@ export function installModelSlider(signal, { feature, ui, createIcon, requestRef
647
659
  const items = modelMenuItems(menu).filter((item) => item.dataset.codexModelSliderRaw);
648
660
  if (!items.length) return;
649
661
  const favorites = loadModelFavorites(selector);
650
- items.forEach((item) => { item.style.display = ""; });
662
+ const query = menu.querySelector('[data-codex-model-slider-search]')?.value.trim().toLocaleLowerCase() ?? "";
663
+ const terms = query.split(/\s+/).filter(Boolean);
664
+ items.forEach((item) => {
665
+ const text = `${item.dataset.codexModelSliderRaw} ${item.querySelector('[data-codex-model-slider-copy]')?.textContent ?? ""}`.toLocaleLowerCase();
666
+ item.style.display = terms.every((term) => text.includes(term)) ? "" : "none";
667
+ });
651
668
  if (!menu.hasAttribute("data-codex-model-slider-controller-menu")) {
652
669
  if (!modelSelectorMenuSizeFrames.has(menu)) {
653
670
  const frame = requestAnimationFrame(() => {
@@ -658,8 +675,9 @@ export function installModelSlider(signal, { feature, ui, createIcon, requestRef
658
675
  }
659
676
  return;
660
677
  }
661
- const starred = items.filter((item) => favorites.has(item.dataset.codexModelSliderRaw));
662
- const ordinary = items.filter((item) => !favorites.has(item.dataset.codexModelSliderRaw));
678
+ const visible = items.filter((item) => item.style.display !== "none");
679
+ const starred = visible.filter((item) => favorites.has(item.dataset.codexModelSliderRaw));
680
+ const ordinary = visible.filter((item) => !favorites.has(item.dataset.codexModelSliderRaw));
663
681
  const desired = items.slice().sort((a, b) => compareEnhancedModelItems(a, b, favorites));
664
682
  const parent = items[0].parentElement;
665
683
  if (!parent || !desired.every((item) => item.parentElement === parent)) return;
@@ -673,13 +691,15 @@ export function installModelSlider(signal, { feature, ui, createIcon, requestRef
673
691
  divider.setAttribute("data-codex-model-slider-divider", "");
674
692
  divider.setAttribute("role", "separator");
675
693
  }
676
- const firstOrdinary = desired.find((item) => !favorites.has(item.dataset.codexModelSliderRaw));
694
+ const firstOrdinary = desired.find((item) => item.style.display !== "none" && !favorites.has(item.dataset.codexModelSliderRaw));
677
695
  if (firstOrdinary && divider.nextElementSibling !== firstOrdinary) {
678
696
  parent.insertBefore(divider, firstOrdinary);
679
697
  }
680
698
  } else {
681
699
  divider?.remove();
682
700
  }
701
+ const empty = menu.querySelector('[data-codex-model-slider-empty]');
702
+ if (empty) empty.hidden = visible.length > 0;
683
703
 
684
704
  if (!modelSelectorMenuSizeFrames.has(menu)) {
685
705
  const frame = requestAnimationFrame(() => {
@@ -693,7 +713,20 @@ export function installModelSlider(signal, { feature, ui, createIcon, requestRef
693
713
  function sizeModelMenuViewport(menu, maxVisibleItems) {
694
714
  if (!menu.isConnected) return;
695
715
  if (modelSelectorControllerMenu?.menu === menu && modelSelectorControllerMenu.animations?.length) return;
696
- const items = modelMenuItems(menu).filter((item) => item.dataset.codexModelSliderRaw);
716
+ const items = modelMenuItems(menu).filter((item) => item.dataset.codexModelSliderRaw && item.style.display !== "none");
717
+ if (menu.hasAttribute("data-codex-model-slider-controller-menu")) {
718
+ const content = menu.querySelector('[data-codex-model-slider-results]');
719
+ const header = menu.querySelector('[data-codex-model-slider-search-header]');
720
+ const style = getComputedStyle(menu);
721
+ const chrome = [style.paddingTop, style.paddingBottom, style.borderTopWidth, style.borderBottomWidth]
722
+ .reduce((sum, value) => sum + (Number.parseFloat(value) || 0), 0);
723
+ const last = items[Math.max(1, maxVisibleItems) - 1];
724
+ const resultsHeight = last ? last.offsetTop + last.offsetHeight - content.offsetTop : content.offsetHeight;
725
+ const available = modelSelectorControllerMenu.parentMenu.getBoundingClientRect().bottom - 8;
726
+ menu.style.height = `${Math.ceil(Math.min(chrome + header.offsetHeight + resultsHeight, available))}px`;
727
+ menu.style.overflowY = "auto";
728
+ return;
729
+ }
697
730
  if (items.length <= maxVisibleItems) {
698
731
  menu.removeAttribute("data-codex-model-slider-overflow");
699
732
  menu.style.removeProperty("--codex-model-slider-menu-max-height");
@@ -1055,7 +1088,20 @@ export function installModelSlider(signal, { feature, ui, createIcon, requestRef
1055
1088
  featureUI.surface(menu, "dropdown", { variant: "opaque" });
1056
1089
  // 切换同一个 picker 视图,不重复播放整个 Dropdown 的入场缩放。
1057
1090
  menu.style.animation = "none";
1091
+ menu.style.boxSizing = "border-box";
1092
+ const header = document.createElement("div");
1093
+ header.setAttribute("data-codex-model-slider-search-header", "");
1094
+ const search = document.createElement("input");
1095
+ search.type = "text";
1096
+ search.setAttribute("data-codex-model-slider-search", "");
1097
+ search.placeholder = selector.strings.searchPlaceholder;
1098
+ search.setAttribute("aria-label", search.placeholder);
1099
+ search.autocomplete = "off";
1100
+ search.spellcheck = false;
1101
+ header.append(search);
1102
+ menu.append(header);
1058
1103
  const content = document.createElement("div");
1104
+ content.setAttribute("data-codex-model-slider-results", "");
1059
1105
  content.style.flexShrink = "0";
1060
1106
  menu.append(content);
1061
1107
  models.forEach((model, index) => {
@@ -1063,6 +1109,7 @@ export function installModelSlider(signal, { feature, ui, createIcon, requestRef
1063
1109
  item.className = "no-drag outline-hidden rounded-lg px-[var(--padding-row-x)] py-[var(--padding-row-y)] text-sm text-default group hover:bg-primary-ghost-hover focus:bg-primary-ghost-hover cursor-interaction flex flex-col";
1064
1110
  item.setAttribute("role", "menuitem");
1065
1111
  item.setAttribute("tabindex", "-1");
1112
+ item.id = `mcx-model-result-${index}`;
1066
1113
  enhanceModelItem(item, menu, selector, index, {
1067
1114
  rawValue: String(model.model),
1068
1115
  selected: String(model.model) === String(controller.model),
@@ -1071,6 +1118,71 @@ export function installModelSlider(signal, { feature, ui, createIcon, requestRef
1071
1118
  });
1072
1119
  content.append(item);
1073
1120
  });
1121
+ const empty = document.createElement("div");
1122
+ empty.setAttribute("data-codex-model-slider-empty", "");
1123
+ empty.setAttribute("role", "status");
1124
+ empty.textContent = selector.strings.noResults;
1125
+ empty.hidden = true;
1126
+ content.append(empty);
1127
+ let keyboardItem = null;
1128
+ const resetKeyboard = () => {
1129
+ keyboardItem?.removeAttribute("data-codex-model-slider-keyboard");
1130
+ keyboardItem = null;
1131
+ search.removeAttribute("aria-activedescendant");
1132
+ };
1133
+ search.addEventListener("input", () => {
1134
+ resetKeyboard();
1135
+ const record = modelSelectorControllerMenu;
1136
+ if (!record || record.menu !== menu || record.closing) return;
1137
+ const previousHeight = menu.getBoundingClientRect().height;
1138
+ clearTimeout(record.motionTimer);
1139
+ record.animations?.forEach((animation) => animation.cancel());
1140
+ record.animations = [];
1141
+ menu.scrollTop = 0;
1142
+ renderEnhancedModelMenu(menu, selector);
1143
+ sizeModelMenuViewport(menu, selector.maxVisibleItems);
1144
+ record.listHeight = menu.getBoundingClientRect().height;
1145
+ if (matchMedia("(prefers-reduced-motion: reduce)").matches) return;
1146
+ const motion = menu.animate([{ height: `${previousHeight}px` }, { height: `${record.listHeight}px` }],
1147
+ { duration: 320, easing: "cubic-bezier(.19,1,.22,1)" });
1148
+ record.animations.push(motion);
1149
+ record.motionTimer = setTimeout(() => {
1150
+ if (modelSelectorControllerMenu !== record || record.closing) return;
1151
+ motion.cancel();
1152
+ record.animations = [];
1153
+ }, 320);
1154
+ });
1155
+ // 原生菜单的 typeahead 不应吞掉搜索输入;鼠标操作保留输入焦点。
1156
+ menu.addEventListener("pointerdown", (event) => {
1157
+ if (event.target.closest?.('[data-codex-model-slider-item]')) event.preventDefault();
1158
+ });
1159
+ search.addEventListener("keydown", (event) => {
1160
+ event.stopPropagation();
1161
+ if (event.isComposing) return;
1162
+ if (event.key === "Escape") {
1163
+ event.preventDefault();
1164
+ closeControllerModelMenu();
1165
+ modelButton.focus();
1166
+ } else if (event.key === "ArrowDown" || event.key === "ArrowUp") {
1167
+ event.preventDefault();
1168
+ const visible = modelMenuItems(menu).filter((item) => item.style.display !== "none");
1169
+ if (!visible.length) return;
1170
+ const current = visible.indexOf(keyboardItem);
1171
+ const index = current < 0 ? (event.key === "ArrowDown" ? 0 : visible.length - 1)
1172
+ : (current + (event.key === "ArrowDown" ? 1 : -1) + visible.length) % visible.length;
1173
+ resetKeyboard();
1174
+ keyboardItem = visible[index];
1175
+ keyboardItem.setAttribute("data-codex-model-slider-keyboard", "");
1176
+ search.setAttribute("aria-activedescendant", keyboardItem.id);
1177
+ const top = keyboardItem.offsetTop - header.offsetHeight;
1178
+ const bottom = keyboardItem.offsetTop + keyboardItem.offsetHeight - menu.clientHeight;
1179
+ if (top < menu.scrollTop) menu.scrollTop = top;
1180
+ else if (bottom > menu.scrollTop) menu.scrollTop = bottom;
1181
+ } else if (event.key === "Enter") {
1182
+ event.preventDefault();
1183
+ (keyboardItem ?? modelMenuItems(menu).find((item) => item.style.display !== "none"))?.click();
1184
+ }
1185
+ });
1074
1186
  menu.addEventListener("click", (event) => {
1075
1187
  const star = event.target.closest?.("[data-codex-model-slider-star]");
1076
1188
  const item = event.target.closest?.("[data-codex-model-slider-item]");
@@ -1132,8 +1244,7 @@ export function installModelSlider(signal, { feature, ui, createIcon, requestRef
1132
1244
  parentMenu.append(menu);
1133
1245
  sizeModelMenuViewport(menu, selector.maxVisibleItems);
1134
1246
  modelButton.setAttribute("aria-expanded", "true");
1135
- (menu.querySelector('[data-codex-model-slider-selected]')
1136
- ?? menu.querySelector('[role="menuitem"]'))?.focus();
1247
+ search.focus({ preventScroll: true });
1137
1248
  const record = modelSelectorControllerMenu;
1138
1249
  record.listHeight = menu.getBoundingClientRect().height;
1139
1250
  animateControllerModelMenu(record, true, () => {
@@ -1331,18 +1442,14 @@ export function installModelSlider(signal, { feature, ui, createIcon, requestRef
1331
1442
  particles.setAttribute("aria-hidden", "true");
1332
1443
  particles.setAttribute("data-animation-active", "true");
1333
1444
  const travelDuration = 1.9;
1334
- const stagger = travelDuration / 14;
1335
1445
  const valueMax = Number(rail.getAttribute("aria-valuemax") ?? 0);
1336
1446
  const valueNow = Number(rail.getAttribute("aria-valuenow") ?? 0);
1337
- const initialStartPercent = valueMax > 0 ? (valueNow / valueMax) * 100 : 0;
1338
- const remainingTravel = 1 - Math.min(Math.max(initialStartPercent, 0), 100) / 100;
1339
1447
  particles.append(...Array.from({ length: 14 }, (_, index) => {
1340
- const speed = 1 + (nativeParticleSeed(index, 21) - 0.5) * 0.4;
1341
- const duration = travelDuration / speed;
1342
1448
  const path = document.createElement("span");
1343
1449
  path.className = pathClass;
1344
- path.style.animationDelay = `${index * stagger - duration * remainingTravel}s`;
1345
- path.style.animationDuration = `${duration}s`;
1450
+ // 等速、等间距铺满完整路径;档位只裁剪可见范围,不压缩运动或聚集粒子。
1451
+ path.style.animationDelay = `${-((index + 0.5) / 14) * travelDuration}s`;
1452
+ path.style.animationDuration = `${travelDuration}s`;
1346
1453
  path.style.top = `${12 + nativeParticleSeed(index, 23) * 76}%`;
1347
1454
  const particle = document.createElement("span");
1348
1455
  particle.className = particleClass;
@@ -1354,6 +1461,8 @@ export function installModelSlider(signal, { feature, ui, createIcon, requestRef
1354
1461
  clip.append(particles);
1355
1462
  const tickRail = Array.from(track.children).find((element) => String(element.className).includes("_TickRail_"));
1356
1463
  track.insertBefore(clip, tickRail ?? null);
1464
+ // 重建粒子层后按当前档位恢复裁剪,避免重开面板时覆盖整条滑轨。
1465
+ setGenericSliderVisual(rail, valueNow, valueMax + 1);
1357
1466
  if (entering) {
1358
1467
  // 首帧沿用关闭 Fast 时的星点,再渐显流动粒子。
1359
1468
  clip.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 240, easing: "ease-out" });
@@ -1381,7 +1490,6 @@ export function installModelSlider(signal, { feature, ui, createIcon, requestRef
1381
1490
  rail.setAttribute("data-fast-mode", String(enabled));
1382
1491
  rail.setAttribute("data-fast-mode-dot-transition", animate ? (enabled ? "entering" : "exiting") : (enabled ? "active" : "inactive"));
1383
1492
  syncNativeFastParticles(rail, enabled, animate && enabled);
1384
- setGenericSliderVisual(rail, Number(rail.getAttribute("aria-valuenow") ?? 0), Number(rail.getAttribute("aria-valuemax") ?? 0) + 1);
1385
1493
  if (animate && enabled) {
1386
1494
  shell.setAttribute("data-codex-model-slider-fast-transitioning", "");
1387
1495
  setTimeout(() => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "minecodex",
3
- "version": "2.0.21",
3
+ "version": "2.0.23",
4
4
  "description": "Lightweight, local-first plugins for the Codex desktop app.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -152,7 +152,7 @@ async function normalizeModelSelector(modelSelector, projectDir) {
152
152
  throw new Error("modelSelector.icons requires fallback, star, and unstar");
153
153
  }
154
154
  const strings = {};
155
- for (const key of ["star", "unstar"]) {
155
+ for (const key of new Set(["star", "unstar", ...Object.keys(modelSelector.strings ?? {})])) {
156
156
  const localized = normalizeLocalizedText(modelSelector.strings?.[key], `modelSelector.strings.${key}`);
157
157
  strings[key] = { value: localized.value, locales: localized.locales };
158
158
  }