dsh-multiline-input 0.0.0-stage → 0.1.1

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/client.js ADDED
@@ -0,0 +1,644 @@
1
+ /**
2
+ * Client half of dsh-multiline-input.
3
+ *
4
+ * Single/multi-line composer toggle for the DSH Web GUI:
5
+ *
6
+ * - a shortcut command (default Ctrl+E) registered through `ctx.shortcuts`,
7
+ * so the binding appears in Settings → General → Keyboard shortcuts and
8
+ * every edit there is validated and persisted by DSH itself;
9
+ * - a hint tab riding the composer card's top shoulder, rendered as its own
10
+ * row in the composer dock stack (`conversation.input.dock`): the row is in
11
+ * flow, so a dock card above the composer (goal bar, todos, queue, cost
12
+ * strip) can never be overlapped — the old floating overlay painted over
13
+ * them and swallowed their buttons;
14
+ * - a taller composer floor in multi-line mode, driven by
15
+ * `<html data-dsh-multiline-input="multi">` and one CSS rule;
16
+ * - Enter / Shift+Enter swapped in multi-line mode by re-dispatching the
17
+ * same keydown with the opposite `shiftKey`. DSH's own composer keymap
18
+ * keeps deciding newline vs submit, so undo history, the IME guard,
19
+ * busy-enter (queue / steer) and menu arbitration all stay intact.
20
+ *
21
+ * Hand-written bundle: the module loader materializes `factory(require)` and
22
+ * takes its return value as the module exports. React comes from the browser
23
+ * module table; no Harness Client package is imported.
24
+ *
25
+ * Reference contracts (source checkout, 0.2.1-rc.3):
26
+ * - packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
27
+ * - packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx
28
+ * (composer stack: `conversation.input.dock` rows above the card)
29
+ * - packages/client/ui-conversation/src/client/skeleton/InputBar.module.css
30
+ * (`.root` side clearance, `.card` card-width cap)
31
+ * - packages/client/ui-conversation/src/client/input/editor/DraftEditor.tsx
32
+ * - packages/client/ui-conversation/src/client/input/editor/keymap.ts
33
+ * - packages/client/ui-conversation/src/client/contract/input.ts
34
+ * - packages/client/ui-conversation/src/client/contract/slots.ts
35
+ * - packages/extensions/cordis-client-runner/src/client/slot-catalog.ts
36
+ * - packages/client/shortcuts/src/client/types.ts
37
+ * - packages/client/modules/README.zh.md
38
+ */
39
+ window.__ModuleLoader__.load({
40
+ id: 'dsh-multiline-input',
41
+ factory(require) {
42
+ const React = require('react')
43
+ const h = React.createElement
44
+
45
+ /** Locale namespace and the style element id (a reload must not stack styles). */
46
+ const NS = 'dsh-multiline-input'
47
+ const STYLE_ID = 'dsh-multiline-input-style'
48
+
49
+ /** Device-local mode preference; the mode itself is not Session data. */
50
+ const MODE_STORAGE_KEY = 'dsh-multiline-input.mode.v1'
51
+ /** `<html>` marker read by the height rule. */
52
+ const MODE_ATTRIBUTE = 'data-dsh-multiline-input'
53
+
54
+ /** Shortcut command id; dotted form so DSH can persist user overrides. */
55
+ const COMMAND_ID = 'multiline-input.toggle'
56
+ /**
57
+ * The hint lives in the composer dock stack — the in-flow rows directly
58
+ * above the card (goal 10, queue 20, todos 0, cost strip 5). A high order
59
+ * keeps the tab last, flush on the card's top shoulder.
60
+ */
61
+ const HINT_SLOT = 'conversation.input.dock'
62
+ const HINT_ID = 'ml-input-hint'
63
+ const HINT_ORDER = 1000
64
+ /** Multi-line floor in text lines (the requested 3–5 range). */
65
+ const MIN_LINES = 4
66
+
67
+ const MODE_SINGLE = 'single'
68
+ const MODE_MULTI = 'multi'
69
+
70
+ /**
71
+ * Default bindings. Desktop may bind any combination (the native keyboard
72
+ * bridge outranks editors). Web only accepts the documented allowlist, so
73
+ * it uses Primary+Shift+E; Linux Web has no allowed letter chord and stays
74
+ * unbound until the user picks one in Settings.
75
+ */
76
+ const DEFAULTS = {
77
+ 'desktop:macos': { code: 'KeyE', modifiers: ['primary'] },
78
+ 'desktop:windows': { code: 'KeyE', modifiers: ['primary'] },
79
+ 'desktop:linux': { code: 'KeyE', modifiers: ['primary'] },
80
+ 'web:macos': { code: 'KeyE', modifiers: ['primary', 'shift'] },
81
+ 'web:windows': { code: 'KeyE', modifiers: ['primary', 'shift'] },
82
+ }
83
+
84
+ const DICT = {
85
+ zh: {
86
+ toMulti: '多行输入',
87
+ toSingle: '单行输入',
88
+ submitHint: 'Shift+Enter 提交',
89
+ toggleLabel: '切换多行输入',
90
+ hintTitle: '点击切换输入模式;快捷键可在「设置 → 通用 → 快捷键」中修改',
91
+ ariaLabel: '{action}({keys})',
92
+ },
93
+ en: {
94
+ toMulti: 'Multiline input',
95
+ toSingle: 'Single-line input',
96
+ submitHint: 'Shift+Enter to send',
97
+ toggleLabel: 'Toggle multiline input',
98
+ hintTitle: 'Click to switch the input mode. Edit the shortcut in Settings → General → Keyboard shortcuts.',
99
+ ariaLabel: '{action} ({keys})',
100
+ },
101
+ }
102
+
103
+ const CSS = `
104
+ /* The hint is its own row in the composer dock stack — the in-flow column of
105
+ strips directly above the input card (goal bar, todos, queue, cost strip).
106
+ Occupying real layout space is what makes the tab safe: the old version
107
+ floated in \`conversation.input.overlay\` at \`bottom: 100%\`, painted over
108
+ whatever DSH docked above the card and swallowed that strip's buttons. */
109
+ .dmi-hint-row {
110
+ box-sizing: border-box;
111
+ display: flex;
112
+ justify-content: flex-end;
113
+ /* The card's own column: InputBar's root pads by the shared side clearance
114
+ and the card is capped at the shared card width, centered. The two
115
+ declarations mirror that so the tab's right edge lands on the card's. */
116
+ width: min(
117
+ calc(100% - 2 * var(--dsh-composer-side-clearance, 16px)),
118
+ var(--dsh-composer-card-max-width, 100%)
119
+ );
120
+ margin-inline: auto;
121
+ /* The stack's row gap would detach the tab from the card's top edge;
122
+ cancelling it keeps the "tab on the shoulder" seam. The live gap is read
123
+ in \`MultiLineHint\` (6px docked, 8px in the hero composer) into
124
+ \`--dmi-stack-gap\`; the fallbacks only cover the first frame. */
125
+ margin-bottom: calc(-1 * var(--dmi-stack-gap, var(--dsh-composer-stack-gap, 6px)));
126
+ padding-right: calc(var(--dsw-radius-panel, 16px) + 4px);
127
+ pointer-events: none;
128
+ }
129
+ /* A small tab riding on the card's top shoulder: same fill as the card, so it
130
+ reads as one piece, with the card's own hairline continuing under it. */
131
+ .dmi-hint {
132
+ display: flex;
133
+ align-items: center;
134
+ justify-content: flex-end;
135
+ box-sizing: border-box;
136
+ /* Hug the text: the tab and its hairline only span the hint. */
137
+ width: max-content;
138
+ max-width: 100%;
139
+ height: 24px;
140
+ padding: 0 12px;
141
+ background: var(--dsw-specific-input-major);
142
+ border-bottom: 0.5px solid var(--dsw-alias-border-l2);
143
+ border-radius: var(--dsw-radius-md) var(--dsw-radius-md) 0 0;
144
+ pointer-events: none;
145
+ white-space: nowrap;
146
+ }
147
+ .dmi-hint-button {
148
+ display: inline-flex;
149
+ align-items: center;
150
+ gap: 6px;
151
+ margin: 0;
152
+ padding: 1px 6px;
153
+ border: 0;
154
+ border-radius: var(--dsw-radius-sm, 6px);
155
+ background: transparent;
156
+ color: var(--dsw-alias-label-tertiary);
157
+ font: inherit;
158
+ font-size: 12px;
159
+ line-height: 18px;
160
+ cursor: pointer;
161
+ pointer-events: auto;
162
+ }
163
+ .dmi-hint-button:hover {
164
+ background: var(--dsw-alias-interactive-bg-hover);
165
+ color: var(--dsw-alias-label-secondary);
166
+ }
167
+ .dmi-hint-key {
168
+ color: var(--dsw-alias-label-secondary);
169
+ font-weight: 500;
170
+ }
171
+ .dmi-hint-sep {
172
+ color: var(--dsw-alias-label-caption);
173
+ opacity: 0.6;
174
+ }
175
+ html[${MODE_ATTRIBUTE}="multi"] [data-composer-card] [data-composer-input][contenteditable="true"] {
176
+ min-height: calc(${MIN_LINES} * 1lh + 8px);
177
+ }
178
+ @supports not (height: 1lh) {
179
+ html[${MODE_ATTRIBUTE}="multi"] [data-composer-card] [data-composer-input][contenteditable="true"] {
180
+ min-height: ${MIN_LINES * 24 + 8}px;
181
+ }
182
+ }
183
+ @media (max-width: 720px) {
184
+ .dmi-hint-sep,
185
+ .dmi-hint-meta {
186
+ display: none;
187
+ }
188
+ }
189
+ `
190
+
191
+ //#region Pure helpers (also exported for node --test)
192
+
193
+ /**
194
+ * Coerce any stored value into a known mode.
195
+ * @param value - raw preference value.
196
+ * @returns the normalized mode.
197
+ */
198
+ function normalizeMode(value) {
199
+ return value === MODE_MULTI ? MODE_MULTI : MODE_SINGLE
200
+ }
201
+
202
+ /**
203
+ * Cycle between the two modes.
204
+ * @param mode - current mode.
205
+ * @returns the other mode.
206
+ */
207
+ function nextMode(mode) {
208
+ return normalizeMode(mode) === MODE_MULTI ? MODE_SINGLE : MODE_MULTI
209
+ }
210
+
211
+ /**
212
+ * Read the persisted mode, treating unavailable storage as single-line.
213
+ * @param store - `localStorage`-like object, or undefined.
214
+ * @returns the stored mode or the default.
215
+ */
216
+ function readMode(store) {
217
+ try {
218
+ return normalizeMode(store?.getItem(MODE_STORAGE_KEY))
219
+ } catch {
220
+ return MODE_SINGLE
221
+ }
222
+ }
223
+
224
+ /**
225
+ * Persist one mode; a blocked storage backend only costs the preference.
226
+ * @param store - `localStorage`-like object, or undefined.
227
+ * @param mode - mode to persist.
228
+ */
229
+ function writeMode(store, mode) {
230
+ try {
231
+ store?.setItem(MODE_STORAGE_KEY, normalizeMode(mode))
232
+ } catch {
233
+ /* Preference persistence is best-effort. */
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Project the mode onto the document root consumed by the height rule.
239
+ * @param element - document root, or null in non-DOM tests.
240
+ * @param mode - mode to apply.
241
+ */
242
+ function applyModeAttribute(element, mode) {
243
+ if (element === null || element === undefined) return
244
+ if (typeof element.setAttribute !== 'function' || typeof element.removeAttribute !== 'function') return
245
+ if (normalizeMode(mode) === MODE_MULTI) element.setAttribute(MODE_ATTRIBUTE, MODE_MULTI)
246
+ else element.removeAttribute(MODE_ATTRIBUTE)
247
+ }
248
+
249
+ const MODIFIER_LABELS = { control: 'Ctrl', meta: 'Cmd', alt: 'Alt', shift: 'Shift' }
250
+
251
+ /**
252
+ * Human label for one physical key code (`KeyE` → `E`, `Digit1` → `1`).
253
+ * @param code - KeyboardEvent.code value.
254
+ * @returns the key label, or an empty string.
255
+ */
256
+ function keyLabel(code) {
257
+ if (typeof code !== 'string' || code === '') return ''
258
+ return code.replace(/^Key/u, '').replace(/^Digit/u, '')
259
+ }
260
+
261
+ /**
262
+ * Render a binding as `Ctrl+E` style text.
263
+ * @param binding - `{ code, modifiers }` or null.
264
+ * @param platform - `macos`, `windows`, or `linux`.
265
+ * @returns the label, or null when there is no binding.
266
+ */
267
+ function formatShortcut(binding, platform) {
268
+ if (binding === null || binding === undefined || typeof binding.code !== 'string') return null
269
+ const modifiers = Array.isArray(binding.modifiers) ? binding.modifiers : []
270
+ const labels = modifiers.map((modifier) => {
271
+ if (modifier === 'primary') return platform === 'macos' ? 'Cmd' : 'Ctrl'
272
+ return MODIFIER_LABELS[modifier] ?? modifier
273
+ })
274
+ const key = keyLabel(binding.code)
275
+ if (key === '') return null
276
+ return [...labels, key].join('+')
277
+ }
278
+
279
+ /**
280
+ * Find this command in the DSH shortcut catalog and render its binding.
281
+ * @param rows - `ctx.shortcuts.catalog` snapshot.
282
+ * @param platform - `ctx.shortcuts.platform`.
283
+ * @returns the label, or null when unbound / not registered yet.
284
+ */
285
+ function labelFromCatalog(rows, platform) {
286
+ if (!Array.isArray(rows)) return null
287
+ const row = rows.find((entry) => entry !== null && entry !== undefined && entry.id === COMMAND_ID)
288
+ if (row === undefined) return null
289
+ return formatShortcut(row.binding, platform)
290
+ }
291
+
292
+ /**
293
+ * Decide whether the multi-line keymap should take over one keydown.
294
+ * @param input - flattened event and composer facts.
295
+ * @returns `newline` (Enter), `submit` (Shift+Enter), or null to pass.
296
+ */
297
+ function shouldIntercept(input) {
298
+ const {
299
+ mode, key, shiftKey, ctrlKey, metaKey, altKey,
300
+ isComposing, keyCode, inComposer, editable, phase, menuOpen,
301
+ } = input
302
+ if (normalizeMode(mode) !== MODE_MULTI) return null
303
+ if (key !== 'Enter') return null
304
+ if (ctrlKey === true || metaKey === true || altKey === true) return null
305
+ if (isComposing === true || keyCode === 229) return null
306
+ if (inComposer !== true || editable !== true) return null
307
+ if (phase !== 'plain') return null
308
+ if (menuOpen === true) return null
309
+ return shiftKey === true ? 'submit' : 'newline'
310
+ }
311
+
312
+ //#endregion
313
+
314
+ //#region Stores and DOM helpers
315
+
316
+ /**
317
+ * Minimal snapshot store: one value, stable identity until it moves.
318
+ * @param initial - first snapshot.
319
+ * @returns a store consumable by `useSyncExternalStore`.
320
+ */
321
+ function createValueStore(initial) {
322
+ let value = initial
323
+ const listeners = new Set()
324
+ return {
325
+ getSnapshot: () => value,
326
+ subscribe(listener) {
327
+ listeners.add(listener)
328
+ return () => {
329
+ listeners.delete(listener)
330
+ }
331
+ },
332
+ set(next) {
333
+ if (Object.is(next, value)) return
334
+ value = next
335
+ for (const listener of [...listeners]) listener()
336
+ },
337
+ }
338
+ }
339
+
340
+ const COMPOSER_CARD = '[data-composer-card]'
341
+ const COMPOSER_INPUT = '[data-composer-input]'
342
+ const COMPOSER_OVERLAY = '[data-composer-overlay]'
343
+ const TRIGGER_MENU = '[role="listbox"]'
344
+ const EDITABLE_SELECTOR = 'input, textarea, select, [contenteditable="true"], [contenteditable=""]'
345
+
346
+ /** @returns the device `localStorage`, or undefined when blocked. */
347
+ function localStore() {
348
+ try {
349
+ return window.localStorage
350
+ } catch {
351
+ return undefined
352
+ }
353
+ }
354
+
355
+ /** @returns whether the page runs inside the Desktop shell. */
356
+ function isDesktopShell() {
357
+ try {
358
+ return window.dshDesktop !== undefined
359
+ } catch {
360
+ return false
361
+ }
362
+ }
363
+
364
+ /** @returns `macos`, `windows`, or `linux`, from the visiting device. */
365
+ function osPlatform() {
366
+ try {
367
+ const platform = typeof navigator.userAgentData?.platform === 'string'
368
+ ? navigator.userAgentData.platform
369
+ : navigator.platform
370
+ if (/mac/i.test(platform)) return 'macos'
371
+ if (/win/i.test(platform)) return 'windows'
372
+ return 'linux'
373
+ } catch {
374
+ return 'linux'
375
+ }
376
+ }
377
+
378
+ /** @returns the label to show before the shortcut catalog is ready. */
379
+ function initialShortcutLabel() {
380
+ const platform = osPlatform()
381
+ const profile = `${isDesktopShell() ? 'desktop' : 'web'}:${platform}`
382
+ return formatShortcut(DEFAULTS[profile] ?? null, platform)
383
+ }
384
+
385
+ //#endregion
386
+
387
+ //#region Hint component
388
+
389
+ /**
390
+ * Composer hint on the right shoulder above the card: current shortcut plus
391
+ * the target mode, click to toggle. Always visible, so the binding stays
392
+ * discoverable while typing. The outer row is the dock-stack slot that
393
+ * reserves the tab's 24px, so no strip above the card can be covered.
394
+ * @param props - injected stores, dictionary and toggle callback.
395
+ * @returns the hint row.
396
+ */
397
+ function MultiLineHint({ t, modeStore, shortcutStore, toggle }) {
398
+ const mode = React.useSyncExternalStore(modeStore.subscribe, modeStore.getSnapshot, modeStore.getSnapshot)
399
+ const shortcut = React.useSyncExternalStore(shortcutStore.subscribe, shortcutStore.getSnapshot, shortcutStore.getSnapshot)
400
+ const action = mode === MODE_MULTI ? t('toSingle') : t('toMulti')
401
+ const meta = mode === MODE_MULTI ? t('submitHint') : null
402
+ const rowRef = React.useRef(null)
403
+
404
+ // Cancel exactly the row gap the composer stack is using, so the tab's
405
+ // bottom hairline lands on the card's top edge. The docked composer gaps
406
+ // its rows by 6px and the hero composer by 8px; reading the live value
407
+ // (instead of trusting one of them) keeps both flush, and the observer
408
+ // covers a hero -> docked switch that reuses the same row.
409
+ React.useLayoutEffect(() => {
410
+ const row = rowRef.current
411
+ if (row === null) return undefined
412
+ // The slot outlet's own anchor is `display: contents`; the first flex
413
+ // ancestor carrying a row gap is the composer stack.
414
+ let stack = row.parentElement
415
+ while (stack !== null && !Number.isFinite(Number.parseFloat(window.getComputedStyle(stack).rowGap))) {
416
+ stack = stack.parentElement
417
+ }
418
+ if (stack === null) return undefined
419
+ const sync = () => {
420
+ const gap = Number.parseFloat(window.getComputedStyle(stack).rowGap)
421
+ if (Number.isFinite(gap)) row.style.setProperty('--dmi-stack-gap', `${gap}px`)
422
+ }
423
+ sync()
424
+ if (typeof ResizeObserver !== 'function') return undefined
425
+ const observer = new ResizeObserver(sync)
426
+ observer.observe(stack)
427
+ return () => {
428
+ observer.disconnect()
429
+ }
430
+ }, [])
431
+
432
+ return h(
433
+ 'div',
434
+ { ref: rowRef, className: 'dmi-hint-row', 'data-ml-input-hint': '' },
435
+ h(
436
+ 'div',
437
+ { className: 'dmi-hint' },
438
+ h(
439
+ 'button',
440
+ {
441
+ type: 'button',
442
+ className: 'dmi-hint-button',
443
+ title: t('hintTitle'),
444
+ 'aria-label': shortcut === null ? action : t('ariaLabel', { action, keys: shortcut }),
445
+ onMouseDown: (event) => {
446
+ event.preventDefault()
447
+ },
448
+ onClick: (event) => {
449
+ event.preventDefault()
450
+ event.stopPropagation()
451
+ toggle()
452
+ },
453
+ },
454
+ meta !== null && h('span', { className: 'dmi-hint-meta' }, meta),
455
+ meta !== null && h('span', { className: 'dmi-hint-sep', 'aria-hidden': true }, '·'),
456
+ shortcut !== null && h('span', { className: 'dmi-hint-key' }, shortcut),
457
+ h('span', { className: 'dmi-hint-label' }, action),
458
+ ),
459
+ ),
460
+ )
461
+ }
462
+
463
+ //#endregion
464
+
465
+ /**
466
+ * Register the dictionaries, styles, stores, keyboard takeover, shortcut
467
+ * command and the composer hint.
468
+ * @param ctx - client plugin context.
469
+ */
470
+ function apply(ctx) {
471
+ ctx.effect(() => ctx.locale.register(NS, DICT), 'ml-input: dictionaries')
472
+
473
+ ctx.effect(() => {
474
+ const element = document.createElement('style')
475
+ element.id = STYLE_ID
476
+ element.setAttribute('data-plugin', NS)
477
+ element.textContent = CSS
478
+ document.head.appendChild(element)
479
+ return () => {
480
+ element.remove()
481
+ }
482
+ }, 'ml-input: styles')
483
+
484
+ const t = ctx.locale.bind(NS)
485
+ const modeStore = createValueStore(readMode(localStore()))
486
+ const shortcutStore = createValueStore(initialShortcutLabel())
487
+
488
+ const setMode = (next) => {
489
+ const normalized = normalizeMode(next)
490
+ modeStore.set(normalized)
491
+ writeMode(localStore(), normalized)
492
+ applyModeAttribute(document.documentElement, normalized)
493
+ }
494
+ const toggle = () => {
495
+ setMode(nextMode(modeStore.getSnapshot()))
496
+ }
497
+
498
+ ctx.effect(() => {
499
+ applyModeAttribute(document.documentElement, modeStore.getSnapshot())
500
+ return () => {
501
+ applyModeAttribute(document.documentElement, MODE_SINGLE)
502
+ }
503
+ }, 'ml-input: mode attribute')
504
+
505
+ // Enter / Shift+Enter takeover. Capture on the document so the original
506
+ // keydown never reaches Lexical's root, then replay the opposite gesture
507
+ // on the editor; DSH's composer keymap owns both outcomes.
508
+ let replaying = false
509
+ ctx.effect(() => {
510
+ const onKeyDown = (event) => {
511
+ if (replaying || !(event instanceof KeyboardEvent)) return
512
+ const target = event.target instanceof Element
513
+ ? event.target
514
+ : (document.activeElement instanceof Element ? document.activeElement : null)
515
+ if (target === null) return
516
+ const editor = target.closest(COMPOSER_INPUT)
517
+ if (editor === null || !(editor instanceof HTMLElement)) return
518
+ if (editor.getAttribute('contenteditable') !== 'true') return
519
+ const card = editor.closest(COMPOSER_CARD)
520
+ if (card === null) return
521
+ const decision = shouldIntercept({
522
+ mode: modeStore.getSnapshot(),
523
+ key: event.key,
524
+ shiftKey: event.shiftKey,
525
+ ctrlKey: event.ctrlKey,
526
+ metaKey: event.metaKey,
527
+ altKey: event.altKey,
528
+ isComposing: event.isComposing,
529
+ keyCode: event.keyCode,
530
+ inComposer: true,
531
+ editable: true,
532
+ phase: editor.getAttribute('data-phase'),
533
+ menuOpen: card.querySelector(`${COMPOSER_OVERLAY} ${TRIGGER_MENU}`) !== null,
534
+ })
535
+ if (decision === null) return
536
+ event.preventDefault()
537
+ event.stopPropagation()
538
+ replaying = true
539
+ try {
540
+ editor.dispatchEvent(new KeyboardEvent('keydown', {
541
+ key: 'Enter',
542
+ code: 'Enter',
543
+ bubbles: true,
544
+ cancelable: true,
545
+ composed: true,
546
+ shiftKey: decision === 'newline',
547
+ }))
548
+ } finally {
549
+ replaying = false
550
+ }
551
+ }
552
+ document.addEventListener('keydown', onKeyDown, true)
553
+ return () => {
554
+ document.removeEventListener('keydown', onKeyDown, true)
555
+ }
556
+ }, 'ml-input: multiline enter')
557
+
558
+ // Shortcut command. The service may materialize after this plugin, so the
559
+ // registration waits for it; a conflicting default only costs the hotkey.
560
+ ctx.inject(['shortcuts'], (scope) => {
561
+ scope.effect(() => {
562
+ const refresh = () => {
563
+ shortcutStore.set(labelFromCatalog(scope.shortcuts.catalog.getSnapshot(), scope.shortcuts.platform))
564
+ }
565
+ const off = scope.shortcuts.catalog.subscribe(refresh)
566
+ refresh()
567
+ return () => {
568
+ off()
569
+ }
570
+ }, 'ml-input: shortcut label')
571
+
572
+ scope.effect(() => {
573
+ let dispose = () => {}
574
+ try {
575
+ dispose = scope.shortcuts.register({
576
+ id: COMMAND_ID,
577
+ label: () => t('toggleLabel'),
578
+ aliases: ['多行输入', '单行输入', 'multiline', 'multi-line', 'multiline-input', 'input mode'],
579
+ defaults: DEFAULTS,
580
+ regions: ['page', 'editable'],
581
+ modals: [],
582
+ resolve: (context) => {
583
+ if (context.modal !== null) return { status: 'pass' }
584
+ const target = context.target
585
+ if (target !== null
586
+ && target.closest(COMPOSER_CARD) === null
587
+ && target.closest(EDITABLE_SELECTOR) !== null) {
588
+ return { status: 'pass' }
589
+ }
590
+ return {
591
+ status: 'handled',
592
+ run: () => {
593
+ toggle()
594
+ },
595
+ }
596
+ },
597
+ })
598
+ } catch (error) {
599
+ console.warn('[dsh-multiline-input] shortcut registration skipped:', error)
600
+ }
601
+ return () => {
602
+ dispose()
603
+ }
604
+ }, 'ml-input: shortcut command')
605
+ })
606
+
607
+ ctx.slots.inject(HINT_SLOT, () => ctx.slots.register({
608
+ name: HINT_SLOT,
609
+ id: HINT_ID,
610
+ order: HINT_ORDER,
611
+ locale: NS,
612
+ inject: () => ({ t, modeStore, shortcutStore, toggle }),
613
+ }, MultiLineHint))
614
+ }
615
+
616
+ return {
617
+ inject: ['slots', 'locale'],
618
+ apply,
619
+ /**
620
+ * Pure internals for `node --test`; the module loader ignores this key.
621
+ */
622
+ __test: {
623
+ COMMAND_ID,
624
+ HINT_SLOT,
625
+ HINT_ID,
626
+ HINT_ORDER,
627
+ MODE_ATTRIBUTE,
628
+ MODE_STORAGE_KEY,
629
+ MODE_SINGLE,
630
+ MODE_MULTI,
631
+ MIN_LINES,
632
+ DEFAULTS,
633
+ normalizeMode,
634
+ nextMode,
635
+ readMode,
636
+ writeMode,
637
+ applyModeAttribute,
638
+ formatShortcut,
639
+ labelFromCatalog,
640
+ shouldIntercept,
641
+ },
642
+ }
643
+ },
644
+ })
@@ -0,0 +1,9 @@
1
+ # Bundle layer of dsh-multiline-input: one Host row.
2
+ #
3
+ # The feature is browser-only; this row exists so the package is a real bundle
4
+ # (the plugin manager refuses a package without a bundle patch) and so
5
+ # client-modules discovers the package's `dsh.client` declaration and serves
6
+ # `client.js` to the Web GUI.
7
+ - insert:
8
+ - id: multiline-input
9
+ name: 'dsh-multiline-input'