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/CHANGELOG.md +22 -0
- package/LICENSE +21 -0
- package/README.md +136 -2
- package/ROADMAP.md +33 -0
- package/assets/icon-180.png +0 -0
- package/assets/screenshot-1-single-line.png +0 -0
- package/assets/screenshot-2-multiline.png +0 -0
- package/client.js +644 -0
- package/cordis.patch.yml +9 -0
- package/docs/PLAN-2026-10-27.md +227 -0
- package/docs/RELEASE-CHECKLIST-0.1.0.md +69 -0
- package/docs/RELEASE-CHECKLIST-0.1.1.md +58 -0
- package/icon.svg +4 -0
- package/index.js +17 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +70 -4
- package/screenshots.json +4 -0
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
|
+
})
|
package/cordis.patch.yml
ADDED
|
@@ -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'
|