dsh-codinput 0.1.0
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.
- package/CHANGELOG.md +30 -0
- package/LICENSE +21 -0
- package/NOTICE +18 -0
- package/README.en.md +167 -0
- package/README.md +167 -0
- package/TESTING.md +95 -0
- package/assets/en/preview-ball-detail.png +0 -0
- package/assets/en/preview-float.png +0 -0
- package/assets/en/preview-normal.png +0 -0
- package/assets/en/preview-side.png +0 -0
- package/assets/en/preview-split.png +0 -0
- package/assets/icon.svg +1 -0
- package/assets/preview-ball-detail.png +0 -0
- package/assets/preview-float.png +0 -0
- package/assets/preview-normal.png +0 -0
- package/assets/preview-side.png +0 -0
- package/assets/preview-split.png +0 -0
- package/cordis.patch.yml +5 -0
- package/lib/client.js +544 -0
- package/lib/index.js +5 -0
- package/package.json +79 -0
- package/src/client/attachments.ts +43 -0
- package/src/client/cm.ts +207 -0
- package/src/client/command.ts +78 -0
- package/src/client/components/AttachmentRail.tsx +241 -0
- package/src/client/components/CodinputBar.tsx +357 -0
- package/src/client/components/CodinputSurface.tsx +375 -0
- package/src/client/components/EditorPane.tsx +214 -0
- package/src/client/components/ExpandBall.tsx +122 -0
- package/src/client/components/MenuSurface.tsx +72 -0
- package/src/client/components/ModelMenu.tsx +234 -0
- package/src/client/components/PermissionMenu.tsx +257 -0
- package/src/client/components/PopupSelectView.tsx +140 -0
- package/src/client/components/SlashMenu.tsx +255 -0
- package/src/client/components/StatsRow.tsx +188 -0
- package/src/client/components/Toolbar.tsx +239 -0
- package/src/client/components/popup-filter.ts +13 -0
- package/src/client/components/useSnapshot.ts +18 -0
- package/src/client/diag.ts +58 -0
- package/src/client/host-types.ts +328 -0
- package/src/client/hotkey.ts +82 -0
- package/src/client/i18n.ts +312 -0
- package/src/client/icons.tsx +296 -0
- package/src/client/index.tsx +178 -0
- package/src/client/modeldir.ts +42 -0
- package/src/client/permission.ts +123 -0
- package/src/client/prefs.ts +123 -0
- package/src/client/services.ts +78 -0
- package/src/client/settings.tsx +236 -0
- package/src/client/sidebar.tsx +114 -0
- package/src/client/sideinput.ts +171 -0
- package/src/client/stats.ts +166 -0
- package/src/client/styles.ts +489 -0
- package/src/client/triggers.ts +84 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/** 偏好存储:localStorage 持久化(技术决策:无宿主往返,升级宿主不影响)。 */
|
|
2
|
+
|
|
3
|
+
/** 快捷键组合:规范化串(如 "mod+enter"、"ctrl+shift+k"),见 hotkey.ts。 */
|
|
4
|
+
export type Hotkey = string;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 输入入口形态(三态,任一时刻只有一个实例):
|
|
8
|
+
* - `normal`:接管卡片在原输入位(主输入卡片);
|
|
9
|
+
* - `float`:卡片 portal 悬浮(可拖动位置、可拖边界/角落缩放);
|
|
10
|
+
* - `side`:卡片由右侧栏 Codinput 标签承载(侧栏收起时经右上角小球唤出)。
|
|
11
|
+
*
|
|
12
|
+
* 持久化只存 normal/float 两值——`side` 由「侧栏标签 body 是否挂载」派生
|
|
13
|
+
* (挂载即侧栏承载该会话输入),避免布局丢失后草稿入口凭空消失。
|
|
14
|
+
*/
|
|
15
|
+
export type CodinputMode = 'normal' | 'float' | 'side';
|
|
16
|
+
|
|
17
|
+
export interface CodinputPrefs {
|
|
18
|
+
/** 启用接管:关闭即恢复官方输入框(卸载遮蔽条目)。 */
|
|
19
|
+
enabled: boolean;
|
|
20
|
+
/** 显示行号(当前行与行号高亮常开)。 */
|
|
21
|
+
lineNumbers: boolean;
|
|
22
|
+
/** 编辑器字体(默认 JetBrains Mono)。 */
|
|
23
|
+
fontFamily: string;
|
|
24
|
+
/** 发送快捷键(任意组合,默认 Cmd/Ctrl+Enter)。 */
|
|
25
|
+
sendHotkey: Hotkey;
|
|
26
|
+
/** 换行快捷键(任意组合,默认 Enter;不得与发送快捷键相同)。 */
|
|
27
|
+
lineBreakHotkey: Hotkey;
|
|
28
|
+
/** 默认视图:编辑开关初值。 */
|
|
29
|
+
defaultEdit: boolean;
|
|
30
|
+
/** 默认视图:预览开关初值。 */
|
|
31
|
+
defaultPreview: boolean;
|
|
32
|
+
/** 编辑区高度 px(顶边拖把调节,固定高);null = 随内容、46vh 封顶。 */
|
|
33
|
+
composerBodyHeight: number | null;
|
|
34
|
+
/** 入口形态(持久化值只有 normal/float;side 由侧栏标签挂载派生)。 */
|
|
35
|
+
mode: CodinputMode;
|
|
36
|
+
/** 悬浮位置(视口坐标,拖动后记忆);null = 默认居中偏上。 */
|
|
37
|
+
floatX: number | null;
|
|
38
|
+
floatY: number | null;
|
|
39
|
+
/** 悬浮尺寸 px(拖边界/角落调节后记忆);null = 默认宽 720、高度随内容。 */
|
|
40
|
+
floatW: number | null;
|
|
41
|
+
floatH: number | null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const DEFAULT_PREFS: CodinputPrefs = {
|
|
45
|
+
enabled: true,
|
|
46
|
+
lineNumbers: true,
|
|
47
|
+
fontFamily: "'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
|
|
48
|
+
sendHotkey: 'mod+enter',
|
|
49
|
+
lineBreakHotkey: 'enter',
|
|
50
|
+
defaultEdit: true,
|
|
51
|
+
defaultPreview: false,
|
|
52
|
+
composerBodyHeight: null,
|
|
53
|
+
mode: 'normal',
|
|
54
|
+
floatX: null,
|
|
55
|
+
floatY: null,
|
|
56
|
+
floatW: null,
|
|
57
|
+
floatH: null,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// v2:旧版(已移除的 v0.1.0)遗留的 'dsh-codinput.prefs' 形状不同且可能
|
|
61
|
+
// enabled:false——换键避免被旧数据劫持接管开关。
|
|
62
|
+
const KEY = 'dsh-codinput.prefs.v2';
|
|
63
|
+
const PERM_KEY = 'dsh-codinput.permission-applied';
|
|
64
|
+
|
|
65
|
+
const listeners = new Set<() => void>();
|
|
66
|
+
|
|
67
|
+
export function loadPrefs(): CodinputPrefs {
|
|
68
|
+
try {
|
|
69
|
+
const raw = window.localStorage.getItem(KEY);
|
|
70
|
+
if (!raw) return { ...DEFAULT_PREFS };
|
|
71
|
+
const parsed = JSON.parse(raw);
|
|
72
|
+
const merged: CodinputPrefs = { ...DEFAULT_PREFS, ...(parsed && typeof parsed === 'object' ? parsed : null) };
|
|
73
|
+
const legacy = parsed as { sendKey?: string; lineBreakKey?: string; mode?: unknown; floating?: boolean };
|
|
74
|
+
// 旧字段迁移:sendKey('mod-enter'|'enter') → sendHotkey/lineBreakHotkey。
|
|
75
|
+
if (legacy.sendKey === 'enter') {
|
|
76
|
+
merged.sendHotkey = 'enter';
|
|
77
|
+
merged.lineBreakHotkey = 'shift-enter';
|
|
78
|
+
}
|
|
79
|
+
// 旧字段迁移:两态模型时代的 floating:true → mode:'float'。
|
|
80
|
+
merged.mode =
|
|
81
|
+
legacy.mode === 'float' || legacy.mode === 'normal'
|
|
82
|
+
? legacy.mode
|
|
83
|
+
: legacy.floating === true
|
|
84
|
+
? 'float'
|
|
85
|
+
: 'normal';
|
|
86
|
+
return merged;
|
|
87
|
+
} catch {
|
|
88
|
+
return { ...DEFAULT_PREFS };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function savePrefs(patch: Partial<CodinputPrefs>): CodinputPrefs {
|
|
93
|
+
const next = { ...loadPrefs(), ...patch };
|
|
94
|
+
try {
|
|
95
|
+
window.localStorage.setItem(KEY, JSON.stringify(next));
|
|
96
|
+
} catch {
|
|
97
|
+
/* 存储不可用时仅内存生效 */
|
|
98
|
+
}
|
|
99
|
+
for (const fn of listeners) fn();
|
|
100
|
+
return next;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function subscribePrefs(fn: () => void): () => void {
|
|
104
|
+
listeners.add(fn);
|
|
105
|
+
return () => listeners.delete(fn);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** 权限菜单最近一次实际应用的预设(仅本浏览器记忆,宿主无公开读取面)。 */
|
|
109
|
+
export function loadAppliedPermission(): string | null {
|
|
110
|
+
try {
|
|
111
|
+
return window.localStorage.getItem(PERM_KEY);
|
|
112
|
+
} catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function saveAppliedPermission(preset: string): void {
|
|
118
|
+
try {
|
|
119
|
+
window.localStorage.setItem(PERM_KEY, preset);
|
|
120
|
+
} catch {
|
|
121
|
+
/* 忽略 */
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 服务面 stash:ctx 属性直读要求 fiber inject 声明(工程事实 #2),
|
|
3
|
+
* 统一在 apply 里 ctx.inject(...) 拿 scope 后存此处,组件只读 stash。
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type {
|
|
7
|
+
ClientContext,
|
|
8
|
+
CommandUiFace,
|
|
9
|
+
ConversationFace,
|
|
10
|
+
ModelDirectoriesFace,
|
|
11
|
+
SessionsFace,
|
|
12
|
+
SidebarRightFace,
|
|
13
|
+
} from './host-types';
|
|
14
|
+
import { diagError, runGuarded } from './diag';
|
|
15
|
+
import { installI18n } from './i18n';
|
|
16
|
+
|
|
17
|
+
export interface ServiceStash {
|
|
18
|
+
conversation?: ConversationFace;
|
|
19
|
+
sessions?: SessionsFace;
|
|
20
|
+
commandUi?: CommandUiFace;
|
|
21
|
+
modelDirectories?: ModelDirectoriesFace;
|
|
22
|
+
sidebarRight?: SidebarRightFace;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const stash: ServiceStash = {};
|
|
26
|
+
|
|
27
|
+
export function installServices(ctx: ClientContext): void {
|
|
28
|
+
// 消费 conversation 服务(本模块 inject 声明已含 'slots' 与 'conversation')。
|
|
29
|
+
runGuarded('stash.conversation', () => {
|
|
30
|
+
ctx.inject(['conversation'], (scope: Record<string, unknown>) => {
|
|
31
|
+
stash.conversation = scope['conversation'] as ConversationFace;
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// sessions / commandUi:被消费服务自身的 static inject 链由宿主装配。
|
|
36
|
+
runGuarded('stash.services', () => {
|
|
37
|
+
ctx.inject(['slots', 'sessions', 'commandUi'], (scope: Record<string, unknown>) => {
|
|
38
|
+
stash.sessions = scope['sessions'] as SessionsFace;
|
|
39
|
+
stash.commandUi = scope['commandUi'] as CommandUiFace;
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// 右侧栏控制器(小球唤回侧栏 Codinput 用)。与 sidebarRightTabs 同批
|
|
44
|
+
// inject——侧栏标签注册已经用过这个组合,服务面存在即成功。
|
|
45
|
+
runGuarded('stash.sidebarRight', () => {
|
|
46
|
+
ctx.inject(['slots', 'sidebarRightTabs', 'sidebarRight'], (scope: Record<string, unknown>) => {
|
|
47
|
+
stash.sidebarRight = scope['sidebarRight'] as SidebarRightFace;
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// 共享模型目录(官方 ui-model-selection 的服务)。工程事实 #2:必须连带
|
|
52
|
+
// 声明被消费服务自身的 static inject 链(sessions/remote/remote.session)——
|
|
53
|
+
// directoryFor/select 内部经 cordis accessor 读 remote.session,accessor
|
|
54
|
+
// 校验的是调用方 fiber 的 inject 声明,缺了会抛 "without inject"。
|
|
55
|
+
runGuarded('stash.modelDirectories', () => {
|
|
56
|
+
ctx.inject(['modelDirectories', 'sessions', 'remote', 'remote.session'], (scope: Record<string, unknown>) => {
|
|
57
|
+
stash.modelDirectories = scope['modelDirectories'] as ModelDirectoriesFace;
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// 文案国际化:宿主 locale 服务就绪后注册词典并绑定翻译函数。
|
|
62
|
+
runGuarded('i18n.install', () => {
|
|
63
|
+
ctx.inject(['locale'], (scope: Record<string, unknown>) => {
|
|
64
|
+
installI18n(scope['locale']);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 服务缺席时统一报诊断口,不打断渲染。 */
|
|
70
|
+
export function requireService<K extends keyof ServiceStash>(name: K): ServiceStash[K] | undefined {
|
|
71
|
+
const svc = stash[name];
|
|
72
|
+
if (!svc) diagError('services', new Error(`service unavailable: ${String(name)}`));
|
|
73
|
+
return svc;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function safeCall(scope: string, fn: () => void): void {
|
|
77
|
+
runGuarded(scope, fn);
|
|
78
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 设置 → Codinput 分组:启用接管、显示行号、编辑器字体、发送/换行快捷键、
|
|
3
|
+
* 默认视图。行结构与控件对齐官方设置(T1PP:标题 + 描述 + 发丝线 +
|
|
4
|
+
* 36px 选择器胶囊);settings.section(list,须带 label/order——工程事实
|
|
5
|
+
* #3),偏好 localStorage 持久化。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useEffect, useRef, useState } from 'react';
|
|
9
|
+
import type { SlotRegisterOptions } from './host-types';
|
|
10
|
+
import { DEFAULT_PREFS, loadPrefs, savePrefs, type CodinputPrefs, type Hotkey } from './prefs';
|
|
11
|
+
import { formatCombo, comboFromEventLike } from './hotkey';
|
|
12
|
+
import { IconChevronOfficial } from './icons';
|
|
13
|
+
import { MenuSurface } from './components/MenuSurface';
|
|
14
|
+
import { t, useT } from './i18n';
|
|
15
|
+
|
|
16
|
+
export const SETTINGS_SECTION_ID = 'codinput';
|
|
17
|
+
|
|
18
|
+
export function settingsRegistration(): { options: SlotRegisterOptions; component: unknown } {
|
|
19
|
+
return {
|
|
20
|
+
options: {
|
|
21
|
+
name: 'settings.section',
|
|
22
|
+
id: SETTINGS_SECTION_ID,
|
|
23
|
+
order: 60,
|
|
24
|
+
label: () => 'Codinput',
|
|
25
|
+
registrant: 'dsh-codinput',
|
|
26
|
+
},
|
|
27
|
+
component: CodinputSettingsSection,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 快捷键录制器:点击进入录制态,按任意组合完成录入,Esc 取消。 */
|
|
32
|
+
function HotkeyRecorder(props: {
|
|
33
|
+
value: Hotkey;
|
|
34
|
+
conflictWith?: Hotkey;
|
|
35
|
+
ariaLabel: string;
|
|
36
|
+
onChange(value: Hotkey): void;
|
|
37
|
+
}): JSX.Element {
|
|
38
|
+
const { value, conflictWith, ariaLabel, onChange } = props;
|
|
39
|
+
const [recording, setRecording] = useState(false);
|
|
40
|
+
const [conflict, setConflict] = useState(false);
|
|
41
|
+
const btnRef = useRef<HTMLButtonElement | null>(null);
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (!recording) return;
|
|
45
|
+
const onDown = (event: PointerEvent): void => {
|
|
46
|
+
if (btnRef.current && event.target instanceof Node && btnRef.current.contains(event.target)) return;
|
|
47
|
+
setRecording(false);
|
|
48
|
+
};
|
|
49
|
+
window.addEventListener('pointerdown', onDown, true);
|
|
50
|
+
return () => window.removeEventListener('pointerdown', onDown, true);
|
|
51
|
+
}, [recording]);
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<button
|
|
55
|
+
ref={btnRef}
|
|
56
|
+
type="button"
|
|
57
|
+
className="dci-set-selector"
|
|
58
|
+
data-recording={recording || undefined}
|
|
59
|
+
data-conflict={conflict || undefined}
|
|
60
|
+
aria-label={ariaLabel}
|
|
61
|
+
onClick={() => setRecording(true)}
|
|
62
|
+
onBlur={() => setRecording(false)}
|
|
63
|
+
onKeyDown={(e) => {
|
|
64
|
+
if (!recording) return;
|
|
65
|
+
e.preventDefault();
|
|
66
|
+
e.stopPropagation();
|
|
67
|
+
if (e.key === 'Escape') {
|
|
68
|
+
setRecording(false);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (e.key === 'Shift' || e.key === 'Control' || e.key === 'Alt' || e.key === 'Meta') return;
|
|
72
|
+
const combo = comboFromEventLike(e.nativeEvent, e.key);
|
|
73
|
+
if (conflictWith !== undefined && combo === conflictWith) {
|
|
74
|
+
setConflict(true);
|
|
75
|
+
window.setTimeout(() => setConflict(false), 1500);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
onChange(combo);
|
|
79
|
+
setRecording(false);
|
|
80
|
+
btnRef.current?.blur();
|
|
81
|
+
}}
|
|
82
|
+
>
|
|
83
|
+
{recording ? t('set.recording') : formatCombo(value)}
|
|
84
|
+
</button>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** 官方设置行(标题 + 描述 + 右侧控件,发丝线分隔)。 */
|
|
89
|
+
function Row(props: { title: string; desc?: string; children: React.ReactNode }): JSX.Element {
|
|
90
|
+
const { title, desc, children } = props;
|
|
91
|
+
return (
|
|
92
|
+
<div className="dci-set-row">
|
|
93
|
+
<div className="dci-set-rowText">
|
|
94
|
+
<div className="dci-set-title">{title}</div>
|
|
95
|
+
{desc ? <div className="dci-set-desc">{desc}</div> : null}
|
|
96
|
+
</div>
|
|
97
|
+
<div className="dci-set-control">{children}</div>
|
|
98
|
+
</div>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** 官方选择器:36px 胶囊触发器 + 官方 Menu 下拉(锚点下方弹出)。 */
|
|
103
|
+
function Selector(props: {
|
|
104
|
+
value: string;
|
|
105
|
+
options: readonly { value: string; label: string }[];
|
|
106
|
+
onChange(value: string): void;
|
|
107
|
+
ariaLabel: string;
|
|
108
|
+
}): JSX.Element {
|
|
109
|
+
const { value, options, onChange, ariaLabel } = props;
|
|
110
|
+
const [open, setOpen] = useState(false);
|
|
111
|
+
const btnRef = useRef<HTMLButtonElement | null>(null);
|
|
112
|
+
const [anchor, setAnchor] = useState<{ left: number; top: number; right: number; bottom: number } | null>(null);
|
|
113
|
+
const current = options.find((o) => o.value === value);
|
|
114
|
+
return (
|
|
115
|
+
<>
|
|
116
|
+
<button
|
|
117
|
+
ref={btnRef}
|
|
118
|
+
type="button"
|
|
119
|
+
className="dci-set-selector"
|
|
120
|
+
aria-haspopup="menu"
|
|
121
|
+
aria-expanded={open}
|
|
122
|
+
aria-label={ariaLabel}
|
|
123
|
+
onClick={() => {
|
|
124
|
+
if (!open) setAnchor(btnRef.current?.getBoundingClientRect() ?? null);
|
|
125
|
+
setOpen(!open);
|
|
126
|
+
}}
|
|
127
|
+
>
|
|
128
|
+
{current?.label ?? value}
|
|
129
|
+
<IconChevronOfficial size={14} />
|
|
130
|
+
</button>
|
|
131
|
+
{open && anchor ? (
|
|
132
|
+
<MenuSurface anchor={anchor} side="bottom" alignEnd onClose={() => setOpen(false)} minWidth={200}>
|
|
133
|
+
{options.map((opt) => (
|
|
134
|
+
<button
|
|
135
|
+
key={opt.value}
|
|
136
|
+
type="button"
|
|
137
|
+
className="dci-item"
|
|
138
|
+
onClick={() => {
|
|
139
|
+
onChange(opt.value);
|
|
140
|
+
setOpen(false);
|
|
141
|
+
}}
|
|
142
|
+
>
|
|
143
|
+
<span className="dci-itemLabel">{opt.label}</span>
|
|
144
|
+
</button>
|
|
145
|
+
))}
|
|
146
|
+
</MenuSurface>
|
|
147
|
+
) : null}
|
|
148
|
+
</>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function Switch(props: { on: boolean; label: string; onToggle(): void }): JSX.Element {
|
|
153
|
+
const { on, label, onToggle } = props;
|
|
154
|
+
return (
|
|
155
|
+
<button
|
|
156
|
+
type="button"
|
|
157
|
+
role="switch"
|
|
158
|
+
className="dci-switch"
|
|
159
|
+
aria-checked={on}
|
|
160
|
+
aria-label={label}
|
|
161
|
+
onClick={onToggle}
|
|
162
|
+
/>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function CodinputSettingsSection(): JSX.Element {
|
|
167
|
+
const [prefs, setPrefs] = useState<CodinputPrefs>(() => loadPrefs());
|
|
168
|
+
useT();
|
|
169
|
+
|
|
170
|
+
const update = (patch: Partial<CodinputPrefs>): void => {
|
|
171
|
+
setPrefs(savePrefs(patch));
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
return (
|
|
175
|
+
<div className="dci-settings">
|
|
176
|
+
<Row title={t("set.enabled")} desc={t("set.enabledDesc")}>
|
|
177
|
+
<Switch on={prefs.enabled} label={t("set.enabled")} onToggle={() => update({ enabled: !prefs.enabled })} />
|
|
178
|
+
</Row>
|
|
179
|
+
<Row title={t("set.lineNumbers")} desc={t("set.lineNumbersDesc")}>
|
|
180
|
+
<Switch on={prefs.lineNumbers} label={t("set.lineNumbers")} onToggle={() => update({ lineNumbers: !prefs.lineNumbers })} />
|
|
181
|
+
</Row>
|
|
182
|
+
<Row title={t("set.font")} desc={t("set.fontDesc")}>
|
|
183
|
+
<input
|
|
184
|
+
type="text"
|
|
185
|
+
className="dci-set-input"
|
|
186
|
+
value={prefs.fontFamily}
|
|
187
|
+
spellCheck={false}
|
|
188
|
+
aria-label={t("set.font")}
|
|
189
|
+
onChange={(e) => update({ fontFamily: e.target.value })}
|
|
190
|
+
/>
|
|
191
|
+
</Row>
|
|
192
|
+
<Row title={t("set.sendKey")} desc={t("set.sendKeyDesc")}>
|
|
193
|
+
<HotkeyRecorder
|
|
194
|
+
value={prefs.sendHotkey}
|
|
195
|
+
conflictWith={prefs.lineBreakHotkey}
|
|
196
|
+
ariaLabel={t("set.sendKey")}
|
|
197
|
+
onChange={(v) => update({ sendHotkey: v })}
|
|
198
|
+
/>
|
|
199
|
+
</Row>
|
|
200
|
+
<Row title={t("set.lineBreakKey")} desc={t("set.lineBreakKeyDesc")}>
|
|
201
|
+
<HotkeyRecorder
|
|
202
|
+
value={prefs.lineBreakHotkey}
|
|
203
|
+
conflictWith={prefs.sendHotkey}
|
|
204
|
+
ariaLabel={t("set.lineBreakKey")}
|
|
205
|
+
onChange={(v) => update({ lineBreakHotkey: v })}
|
|
206
|
+
/>
|
|
207
|
+
</Row>
|
|
208
|
+
<Row title={t("set.defaultView")} desc={t("set.defaultViewDesc")}>
|
|
209
|
+
<Selector
|
|
210
|
+
value={prefs.defaultEdit && prefs.defaultPreview ? 'split' : prefs.defaultPreview ? 'preview' : 'edit'}
|
|
211
|
+
ariaLabel={t("set.defaultView")}
|
|
212
|
+
onChange={(v) =>
|
|
213
|
+
update({
|
|
214
|
+
defaultEdit: v !== 'preview',
|
|
215
|
+
defaultPreview: v !== 'edit',
|
|
216
|
+
})
|
|
217
|
+
}
|
|
218
|
+
options={[
|
|
219
|
+
{ value: 'edit', label: t('head.editOnly') },
|
|
220
|
+
{ value: 'split', label: t('head.split') },
|
|
221
|
+
{ value: 'preview', label: t('head.previewOnly') },
|
|
222
|
+
]}
|
|
223
|
+
/>
|
|
224
|
+
</Row>
|
|
225
|
+
<Row title={t("set.reset")} desc={t("set.resetAll")}>
|
|
226
|
+
<button
|
|
227
|
+
type="button"
|
|
228
|
+
className="dci-set-selector"
|
|
229
|
+
onClick={() => setPrefs(savePrefs({ ...DEFAULT_PREFS }))}
|
|
230
|
+
>
|
|
231
|
+
{t("set.resetAction")}
|
|
232
|
+
</button>
|
|
233
|
+
</Row>
|
|
234
|
+
</div>
|
|
235
|
+
);
|
|
236
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 侧边模式:官方 Sidebar(dockkit)标签页承载,入口与标签芯片同名
|
|
3
|
+
* 「Codinput」。两段式注册——sidebarRightTabs.register(类型定义)+
|
|
4
|
+
* sidebar.right.pane.tab(keyed,key = 定义 id)。停靠、分栏、全屏、
|
|
5
|
+
* 浮动由官方 dockkit 承担;侧栏与主输入读写同一台输入机,草稿天然同源。
|
|
6
|
+
*
|
|
7
|
+
* 唯一输入组件约定:body **挂载**期间经 sideinput 总线声明承载本会话
|
|
8
|
+
* 输入(形态 = side),主输入遮蔽条目随之隐藏——收起侧栏不让位(可见性
|
|
9
|
+
* 只用于判断「需不需要右上角小球唤出」)。标签关闭(body 卸载)后撤销
|
|
10
|
+
* 声明,主输入原样恢复。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { useEffect, useRef, useState } from 'react';
|
|
14
|
+
import type { SessionStandard, SidebarTabInfo, SlotRegisterOptions } from './host-types';
|
|
15
|
+
import { claimSidebarInput, useSidebarCarryOwner, type SidebarCarry } from './sideinput';
|
|
16
|
+
import { CodinputSurface } from './components/CodinputSurface';
|
|
17
|
+
import { t, useT } from './i18n';
|
|
18
|
+
|
|
19
|
+
export const SIDEBAR_TAB_ID = 'dsh-codinput';
|
|
20
|
+
export const SIDEBAR_TAB_KIND = 'codinput';
|
|
21
|
+
|
|
22
|
+
/** 第一段:标签类型定义(页面型:无 patterns,经指南页打开)。 */
|
|
23
|
+
export function sidebarTabDefinition(): Record<string, unknown> {
|
|
24
|
+
return {
|
|
25
|
+
id: SIDEBAR_TAB_ID,
|
|
26
|
+
kind: SIDEBAR_TAB_KIND,
|
|
27
|
+
priority: 'extension',
|
|
28
|
+
title: () => 'Codinput',
|
|
29
|
+
guide: [
|
|
30
|
+
{
|
|
31
|
+
order: 0,
|
|
32
|
+
title: () => 'Codinput',
|
|
33
|
+
description: () => t('side.guideDesc'),
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 第二段:keyed 槽位 body 注册选项与组件(key = 定义 id)。 */
|
|
40
|
+
export function sidebarBodyRegistration(): { options: SlotRegisterOptions; component: unknown } {
|
|
41
|
+
return {
|
|
42
|
+
options: {
|
|
43
|
+
name: 'sidebar.right.pane.tab',
|
|
44
|
+
key: SIDEBAR_TAB_ID,
|
|
45
|
+
registrant: 'dsh-codinput',
|
|
46
|
+
},
|
|
47
|
+
component: SidebarCodinputBody,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* session 槽位标准 props 直达官方输入机。useTabInfo 是槽位声明的注入
|
|
53
|
+
* hook(宿主按 use<Name> 约定随 props 下发,缺席说明宿主形状漂移——
|
|
54
|
+
* 此时按 visible=false 处理,退回「不隐藏主输入」的旧行为)。
|
|
55
|
+
* useTabInfo 内部订阅 dockkit store,展开/激活/浮动变化都会重渲染。
|
|
56
|
+
*/
|
|
57
|
+
export function SidebarCodinputBody(props: SessionStandard & { useTabInfo?: () => SidebarTabInfo }): JSX.Element {
|
|
58
|
+
const { sessionId, useInput, inputActions, useProjection, useTabInfo } = props;
|
|
59
|
+
const state = useInput((s) => s);
|
|
60
|
+
useT();
|
|
61
|
+
// 注入 hook 的有无逐渲染稳定(渲染器 useMemo 绑定),条件调用合规。
|
|
62
|
+
const info = useTabInfo ? useTabInfo() : undefined;
|
|
63
|
+
const visible = info?.tab.visible === true;
|
|
64
|
+
const expanded = info?.sidebar.expanded === true;
|
|
65
|
+
|
|
66
|
+
// 承载声明挂在 body 生命周期上(挂载即承载,与可见性无关);可见性与
|
|
67
|
+
// 侧栏展开态随后续渲染实时上报(小球据此判断「已收起」)。
|
|
68
|
+
const carryRef = useRef<SidebarCarry | null>(null);
|
|
69
|
+
const [token, setToken] = useState(0);
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
const carry = claimSidebarInput(sessionId, { visible, expanded });
|
|
72
|
+
carryRef.current = carry;
|
|
73
|
+
setToken(carry.token);
|
|
74
|
+
return () => {
|
|
75
|
+
carryRef.current = null;
|
|
76
|
+
carry.release();
|
|
77
|
+
};
|
|
78
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
79
|
+
}, [sessionId]);
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
carryRef.current?.set({ visible, expanded });
|
|
82
|
+
}, [visible, expanded]);
|
|
83
|
+
|
|
84
|
+
// 同一会话被复制出第二个 Codinput 标签(分栏)时,只有最早声明的 body
|
|
85
|
+
// 渲染编辑面——输入入口唯一,另一个 body 出提示而不是第二个编辑器。
|
|
86
|
+
const owner = useSidebarCarryOwner(sessionId, token);
|
|
87
|
+
if (!owner) {
|
|
88
|
+
return (
|
|
89
|
+
<div className="dci-side-notice" role="note">
|
|
90
|
+
{t("side.notice")}
|
|
91
|
+
</div>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<div
|
|
97
|
+
style={{
|
|
98
|
+
position: 'absolute',
|
|
99
|
+
inset: 0,
|
|
100
|
+
minHeight: 0,
|
|
101
|
+
display: 'flex',
|
|
102
|
+
flexDirection: 'column',
|
|
103
|
+
}}
|
|
104
|
+
>
|
|
105
|
+
<CodinputSurface
|
|
106
|
+
sessionId={sessionId}
|
|
107
|
+
state={state}
|
|
108
|
+
actions={inputActions}
|
|
109
|
+
useProjection={useProjection}
|
|
110
|
+
side={true}
|
|
111
|
+
/>
|
|
112
|
+
</div>
|
|
113
|
+
);
|
|
114
|
+
}
|