@sciflow/editor-start 0.0.1-beta → 0.0.2
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/README.md +83 -2
- package/THIRD_PARTY_LICENSES +9 -0
- package/bin/extract-translations.mjs +54 -0
- package/dist/bundle/sciflow-editor.css +1 -1
- package/dist/bundle/sciflow-editor.js +10590 -15324
- package/dist/index.d.ts +7 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -3
- package/dist/lib/citation-source-adapter.d.ts +22 -2
- package/dist/lib/citation-source-adapter.d.ts.map +1 -1
- package/dist/lib/citation-source-adapter.js +104 -48
- package/dist/lib/drag-preview.d.ts +13 -0
- package/dist/lib/drag-preview.d.ts.map +1 -0
- package/dist/lib/drag-preview.js +43 -0
- package/dist/lib/editor-element.d.ts +30 -4
- package/dist/lib/editor-element.d.ts.map +1 -1
- package/dist/lib/editor-element.js +131 -30
- package/dist/lib/format-bar.d.ts +26 -2
- package/dist/lib/format-bar.d.ts.map +1 -1
- package/dist/lib/format-bar.js +369 -56
- package/dist/lib/ghost-cursor.d.ts +10 -0
- package/dist/lib/ghost-cursor.d.ts.map +1 -0
- package/dist/lib/ghost-cursor.js +59 -0
- package/dist/lib/icons-generated.d.ts +14 -0
- package/dist/lib/icons-generated.d.ts.map +1 -0
- package/dist/lib/icons-generated.js +60 -0
- package/dist/lib/outline.d.ts +18 -10
- package/dist/lib/outline.d.ts.map +1 -1
- package/dist/lib/outline.js +184 -28
- package/dist/lib/reference-list.d.ts +10 -2
- package/dist/lib/reference-list.d.ts.map +1 -1
- package/dist/lib/reference-list.js +47 -9
- package/dist/lib/selection-editor.d.ts +83 -5
- package/dist/lib/selection-editor.d.ts.map +1 -1
- package/dist/lib/selection-editor.js +1225 -147
- package/dist/lib/selection-transform-registry.d.ts +16 -0
- package/dist/lib/selection-transform-registry.d.ts.map +1 -0
- package/dist/lib/selection-transform-registry.js +24 -0
- package/dist/lib/selection-ui-renderer.d.ts +22 -0
- package/dist/lib/selection-ui-renderer.d.ts.map +1 -0
- package/dist/lib/selection-ui-renderer.js +244 -0
- package/dist/lib/selection-widget-host.d.ts +67 -0
- package/dist/lib/selection-widget-host.d.ts.map +1 -0
- package/dist/lib/selection-widget-host.js +151 -0
- package/dist/lib/selection-widget-registry.d.ts +16 -0
- package/dist/lib/selection-widget-registry.d.ts.map +1 -0
- package/dist/lib/selection-widget-registry.js +30 -0
- package/dist/lib/test-fixtures/soak-documents.d.ts +13 -0
- package/dist/lib/test-fixtures/soak-documents.d.ts.map +1 -0
- package/dist/lib/test-fixtures/soak-documents.js +69 -0
- package/dist/tsconfig.lib.tsbuildinfo +1 -1
- package/package.json +22 -13
- package/dist/demo/demo.js +0 -56
- package/dist/demo/index.html +0 -169
- package/dist/lib/citation-drop.d.ts +0 -25
- package/dist/lib/citation-drop.d.ts.map +0 -1
- package/dist/lib/citation-drop.js +0 -53
package/dist/lib/format-bar.js
CHANGED
|
@@ -17,17 +17,31 @@ import { __decorate } from "tslib";
|
|
|
17
17
|
*
|
|
18
18
|
* The component code below is annotated to map those steps to specific hooks.
|
|
19
19
|
*/
|
|
20
|
-
import { css, html, LitElement, unsafeCSS } from 'lit';
|
|
20
|
+
import { css, html, LitElement, nothing, unsafeCSS } from 'lit';
|
|
21
21
|
import { customElement, property, state } from 'lit/decorators.js';
|
|
22
22
|
import { classMap } from 'lit/directives/class-map.js';
|
|
23
|
+
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
|
|
24
|
+
import { t, LOCALE_CHANGE_EVENT } from '@sciflow/editor-core';
|
|
25
|
+
import { materialIcons } from './icons-generated.js';
|
|
23
26
|
import formatBarCss from './format-bar.css?inline';
|
|
24
27
|
import { applyThemeStylesToRoot, subscribeToSciFlowTheme } from './theme.js';
|
|
28
|
+
/* Inline SVG icons for each style option (16×16). */
|
|
29
|
+
const styleIcons = {
|
|
30
|
+
paragraph: html `<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><rect x="2" y="3" width="12" height="1.5" rx=".75"/><rect x="2" y="7" width="10" height="1.5" rx=".75"/><rect x="2" y="11" width="8" height="1.5" rx=".75"/></svg>`,
|
|
31
|
+
'heading-1': html `<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><text x="1" y="13" font-size="13" font-weight="700" font-family="system-ui,sans-serif">H<tspan font-size="10">1</tspan></text></svg>`,
|
|
32
|
+
'heading-2': html `<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><text x="1" y="13" font-size="13" font-weight="700" font-family="system-ui,sans-serif">H<tspan font-size="10">2</tspan></text></svg>`,
|
|
33
|
+
'heading-3': html `<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><text x="1" y="13" font-size="13" font-weight="700" font-family="system-ui,sans-serif">H<tspan font-size="10">3</tspan></text></svg>`,
|
|
34
|
+
blockquote: html `<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M3.5 4C2.67 4 2 4.67 2 5.5v2C2 8.33 2.67 9 3.5 9H5l-1 3h1.6l1-3h.4V5.5C7 4.67 6.33 4 5.5 4h-2zm6 0C8.67 4 8 4.67 8 5.5v2C8 8.33 8.67 9 9.5 9H11l-1 3h1.6l1-3h.4V5.5c0-.83-.67-1.5-1.5-1.5h-2z"/></svg>`,
|
|
35
|
+
};
|
|
25
36
|
const STYLE_OPTIONS = [
|
|
26
|
-
{ label
|
|
27
|
-
{ label
|
|
28
|
-
{ label
|
|
29
|
-
{ label
|
|
37
|
+
{ get label() { return t('formatBar.styleParagraph'); }, value: 'paragraph', cssClass: 'style-option--paragraph' },
|
|
38
|
+
{ get label() { return t('formatBar.styleHeading1'); }, value: 'heading-1', level: 1, cssClass: 'style-option--h1' },
|
|
39
|
+
{ get label() { return t('formatBar.styleHeading2'); }, value: 'heading-2', level: 2, cssClass: 'style-option--h2' },
|
|
40
|
+
{ get label() { return t('formatBar.styleHeading3'); }, value: 'heading-3', level: 3, cssClass: 'style-option--h3' },
|
|
41
|
+
{ get label() { return t('cmd.toggleBlockquote.label'); }, value: 'blockquote', cssClass: 'style-option--blockquote', command: 'toggleBlockquote' },
|
|
30
42
|
];
|
|
43
|
+
/** SVG chevron shared by dropdown triggers. */
|
|
44
|
+
const chevronSvg = html `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"></polyline></svg>`;
|
|
31
45
|
/**
|
|
32
46
|
* Custom element that renders the demo formatting toolbar.
|
|
33
47
|
*
|
|
@@ -46,6 +60,8 @@ let SciFlowFormatBarElement = class SciFlowFormatBarElement extends LitElement {
|
|
|
46
60
|
/** Reference to the editor element. May be passed directly via property binding. */
|
|
47
61
|
this.editor = null;
|
|
48
62
|
this.selectionVersion = 0;
|
|
63
|
+
this.styleMenuOpen = false;
|
|
64
|
+
this.insertMenuOpen = false;
|
|
49
65
|
this.resolvedEditor = null;
|
|
50
66
|
this.themeStyleElements = [];
|
|
51
67
|
}
|
|
@@ -53,14 +69,27 @@ let SciFlowFormatBarElement = class SciFlowFormatBarElement extends LitElement {
|
|
|
53
69
|
/** Subscribe to editor events once the toolbar attaches to the DOM. */
|
|
54
70
|
connectedCallback() {
|
|
55
71
|
super.connectedCallback();
|
|
72
|
+
if (!this.hasAttribute('role')) {
|
|
73
|
+
this.setAttribute('role', 'toolbar');
|
|
74
|
+
}
|
|
75
|
+
if (!this.hasAttribute('aria-label')) {
|
|
76
|
+
this.setAttribute('aria-label', t('formatBar.toolbarAriaLabel'));
|
|
77
|
+
}
|
|
56
78
|
this.themeUnsub = subscribeToSciFlowTheme((cssTexts) => {
|
|
57
79
|
this.themeStyleElements = applyThemeStylesToRoot(this.renderRoot, this.themeStyleElements, cssTexts);
|
|
58
80
|
});
|
|
81
|
+
this.localeListener = () => this.requestUpdate();
|
|
82
|
+
document.addEventListener(LOCALE_CHANGE_EVENT, this.localeListener);
|
|
59
83
|
this.resolveEditorReference();
|
|
60
84
|
}
|
|
61
85
|
/** Remove listeners when the toolbar is detached. */
|
|
62
86
|
disconnectedCallback() {
|
|
63
87
|
this.detachEditorListeners();
|
|
88
|
+
this.removeOutsideClickHandler();
|
|
89
|
+
if (this.localeListener) {
|
|
90
|
+
document.removeEventListener(LOCALE_CHANGE_EVENT, this.localeListener);
|
|
91
|
+
this.localeListener = undefined;
|
|
92
|
+
}
|
|
64
93
|
this.themeUnsub?.();
|
|
65
94
|
this.themeUnsub = undefined;
|
|
66
95
|
super.disconnectedCallback();
|
|
@@ -80,47 +109,290 @@ let SciFlowFormatBarElement = class SciFlowFormatBarElement extends LitElement {
|
|
|
80
109
|
*/
|
|
81
110
|
render() {
|
|
82
111
|
const runner = this.getCommandRunner();
|
|
83
|
-
const availableCommands = runner?.available?.() ?? null;
|
|
84
|
-
const headingCommandsAvailable = typeof availableCommands?.setHeading === 'function' || typeof availableCommands?.setParagraph === 'function';
|
|
85
|
-
const activeStyle = this.getActiveBlockStyle();
|
|
86
|
-
const selectDisabled = !runner || !headingCommandsAvailable;
|
|
87
112
|
const commandMetadata = runner?.commandsMeta?.() ?? {};
|
|
88
113
|
const commandGroups = this.getCommandGroups(commandMetadata);
|
|
89
114
|
const tableGroup = 'table';
|
|
90
|
-
const
|
|
115
|
+
const mediaGroup = 'media';
|
|
116
|
+
const insertGroup = 'insert';
|
|
117
|
+
const blockGroup = 'block';
|
|
118
|
+
const primaryGroups = commandGroups.filter((group) => group !== tableGroup && group !== mediaGroup && group !== insertGroup && group !== blockGroup);
|
|
91
119
|
const tableGroups = commandGroups.filter((group) => group === tableGroup);
|
|
120
|
+
const selectionInTable = this.isSelectionInTable();
|
|
92
121
|
return html `
|
|
93
|
-
<
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
122
|
+
<div class="command-group">
|
|
123
|
+
${this.renderStyleDropdown(runner)}
|
|
124
|
+
${this.renderInsertDropdown(runner, commandMetadata)}
|
|
125
|
+
</div>
|
|
126
|
+
${primaryGroups.map((group) => html `
|
|
127
|
+
<div class="command-group">
|
|
128
|
+
${this.renderCommandGroup(runner, commandMetadata, group)}
|
|
129
|
+
</div>
|
|
130
|
+
`)}
|
|
131
|
+
<div class="command-group">
|
|
132
|
+
${this.renderFixedCommandButton(runner, 'undo', 'undo', t('formatBar.undo'), t('formatBar.undoDescription'))}
|
|
133
|
+
${this.renderFixedCommandButton(runner, 'redo', 'redo', t('formatBar.redo'), t('formatBar.redoDescription'))}
|
|
134
|
+
</div>
|
|
135
|
+
${tableGroups.length && selectionInTable
|
|
136
|
+
? html `<div class="table-row" role="toolbar" aria-label=${t('formatBar.tableAriaLabel')}>
|
|
137
|
+
<span class="table-row-label">${t('formatBar.tableLabel')}</span>
|
|
138
|
+
${this.renderTableSubGroups(runner, commandMetadata)}
|
|
139
|
+
</div>`
|
|
140
|
+
: nothing}
|
|
141
|
+
`;
|
|
142
|
+
}
|
|
143
|
+
/* --------------------------------------------------------- */
|
|
144
|
+
/* Style dropdown (Paragraph / Heading 1–3) */
|
|
145
|
+
/* --------------------------------------------------------- */
|
|
146
|
+
renderStyleDropdown(runner) {
|
|
147
|
+
const availableCommands = runner?.available?.() ?? null;
|
|
148
|
+
const headingCommandsAvailable = typeof availableCommands?.setHeading === 'function' || typeof availableCommands?.setParagraph === 'function';
|
|
149
|
+
const activeStyle = this.getActiveBlockStyle();
|
|
150
|
+
const selectDisabled = !runner || !headingCommandsAvailable;
|
|
151
|
+
const activeLabel = STYLE_OPTIONS.find((o) => o.value === activeStyle)?.label ?? t('formatBar.styleParagraph');
|
|
152
|
+
return html `
|
|
153
|
+
<div class="style-dropdown">
|
|
154
|
+
<button
|
|
155
|
+
type="button"
|
|
156
|
+
class="style-dropdown-trigger"
|
|
157
|
+
aria-label=${t('formatBar.textStyleAriaLabel')}
|
|
158
|
+
aria-haspopup="listbox"
|
|
159
|
+
aria-expanded=${this.styleMenuOpen ? 'true' : 'false'}
|
|
160
|
+
?disabled=${selectDisabled}
|
|
161
|
+
@click=${this.toggleStyleMenu}
|
|
162
|
+
>
|
|
163
|
+
${activeLabel}
|
|
164
|
+
<span class="dropdown-chevron">${chevronSvg}</span>
|
|
165
|
+
</button>
|
|
166
|
+
${this.styleMenuOpen
|
|
167
|
+
? html `
|
|
168
|
+
<div class="style-dropdown-menu" role="listbox" aria-label=${t('formatBar.textStyleAriaLabel')}>
|
|
169
|
+
${STYLE_OPTIONS.map((option) => {
|
|
170
|
+
const isActive = option.command
|
|
171
|
+
? this.isBlockquoteActive()
|
|
172
|
+
: option.value === activeStyle;
|
|
173
|
+
const canUse = option.command
|
|
174
|
+
? this.canUseCommand(option.command, runner)
|
|
175
|
+
: this.canUseStyleOption(option, availableCommands);
|
|
176
|
+
return html `
|
|
177
|
+
<button
|
|
178
|
+
type="button"
|
|
179
|
+
role="option"
|
|
180
|
+
class=${classMap({
|
|
181
|
+
'style-option': true,
|
|
182
|
+
[option.cssClass]: true,
|
|
183
|
+
active: isActive,
|
|
184
|
+
})}
|
|
185
|
+
aria-selected=${isActive ? 'true' : 'false'}
|
|
186
|
+
?disabled=${!canUse}
|
|
187
|
+
@click=${() => this.selectStyle(option, runner)}
|
|
188
|
+
>
|
|
189
|
+
<span class="style-option-icon">${styleIcons[option.value]}</span>
|
|
190
|
+
${option.label}
|
|
191
|
+
</button>
|
|
192
|
+
`;
|
|
193
|
+
})}
|
|
194
|
+
</div>
|
|
195
|
+
`
|
|
196
|
+
: nothing}
|
|
197
|
+
</div>
|
|
198
|
+
`;
|
|
199
|
+
}
|
|
200
|
+
toggleStyleMenu(e) {
|
|
201
|
+
e.stopPropagation();
|
|
202
|
+
this.insertMenuOpen = false;
|
|
203
|
+
this.styleMenuOpen = !this.styleMenuOpen;
|
|
204
|
+
if (this.styleMenuOpen) {
|
|
205
|
+
this.installOutsideClickHandler();
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
this.removeOutsideClickHandler();
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
selectStyle(option, runner) {
|
|
212
|
+
this.styleMenuOpen = false;
|
|
213
|
+
this.removeOutsideClickHandler();
|
|
214
|
+
if (option.command) {
|
|
215
|
+
const command = runner?.commands?.[option.command];
|
|
216
|
+
if (typeof command === 'function') {
|
|
217
|
+
const result = command();
|
|
218
|
+
if (result) {
|
|
219
|
+
this.selectionVersion++;
|
|
220
|
+
this.requestUpdate();
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
else if (option.value === 'paragraph') {
|
|
225
|
+
this.handleSetParagraph();
|
|
226
|
+
}
|
|
227
|
+
else if (option.level) {
|
|
228
|
+
this.handleSetHeading(option.level);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
isBlockquoteActive() {
|
|
232
|
+
const state = this.resolvedEditor?.editorView?.state;
|
|
233
|
+
if (!state)
|
|
234
|
+
return false;
|
|
235
|
+
const bqType = state.schema.nodes.blockquote;
|
|
236
|
+
if (!bqType)
|
|
237
|
+
return false;
|
|
238
|
+
const { from, to } = state.selection;
|
|
239
|
+
let found = false;
|
|
240
|
+
state.doc.nodesBetween(from, to, (node) => {
|
|
241
|
+
if (node.type === bqType) {
|
|
242
|
+
found = true;
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
return true;
|
|
246
|
+
});
|
|
247
|
+
return found;
|
|
248
|
+
}
|
|
249
|
+
canUseCommand(commandName, runner) {
|
|
250
|
+
const availability = runner?.available?.();
|
|
251
|
+
if (availability?.[commandName])
|
|
252
|
+
return availability[commandName]();
|
|
253
|
+
return typeof runner?.commands?.[commandName] === 'function';
|
|
254
|
+
}
|
|
255
|
+
/* --------------------------------------------------------- */
|
|
256
|
+
/* Insert dropdown (icon + chevron → popover) */
|
|
257
|
+
/* --------------------------------------------------------- */
|
|
258
|
+
renderInsertDropdown(runner, metadata) {
|
|
259
|
+
const availability = runner?.available?.() ?? null;
|
|
260
|
+
// Collect block-level insert commands (media group + table insert + footnote)
|
|
261
|
+
const insertCommands = Object.entries(metadata)
|
|
262
|
+
.filter(([name, meta]) => meta && (meta.group === 'media' || name === 'insertNativeTableFigure' || name === 'insertFootnote'))
|
|
263
|
+
.sort(([, a], [, b]) => (a?.order ?? 0) - (b?.order ?? 0));
|
|
264
|
+
if (insertCommands.length === 0) {
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
return html `
|
|
268
|
+
<div class="insert-dropdown">
|
|
269
|
+
<button
|
|
270
|
+
type="button"
|
|
271
|
+
class="insert-dropdown-trigger"
|
|
272
|
+
aria-label=${t('formatBar.insert')}
|
|
273
|
+
aria-haspopup="menu"
|
|
274
|
+
aria-expanded=${this.insertMenuOpen ? 'true' : 'false'}
|
|
275
|
+
@click=${this.toggleInsertMenu}
|
|
276
|
+
>
|
|
277
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>
|
|
278
|
+
<span class="dropdown-chevron">${chevronSvg}</span>
|
|
279
|
+
</button>
|
|
280
|
+
${this.insertMenuOpen
|
|
281
|
+
? html `
|
|
282
|
+
<div class="insert-dropdown-menu" role="menu">
|
|
283
|
+
${insertCommands.map(([name, meta]) => {
|
|
284
|
+
const canExecute = availability?.[name] ? availability[name]() : true;
|
|
285
|
+
return html `
|
|
286
|
+
<button
|
|
287
|
+
type="button"
|
|
288
|
+
role="menuitem"
|
|
289
|
+
class="insert-menu-item"
|
|
290
|
+
?disabled=${!canExecute}
|
|
291
|
+
@click=${() => this.executeInsertCommand(name, runner)}
|
|
292
|
+
>
|
|
293
|
+
${meta?.icon && materialIcons[meta.icon]
|
|
294
|
+
? html `<span class="cmd-icon" aria-hidden="true">${unsafeHTML(materialIcons[meta.icon])}</span>`
|
|
295
|
+
: nothing}
|
|
296
|
+
${meta?.label ?? name}
|
|
297
|
+
</button>
|
|
298
|
+
`;
|
|
299
|
+
})}
|
|
300
|
+
</div>
|
|
301
|
+
`
|
|
302
|
+
: nothing}
|
|
121
303
|
</div>
|
|
122
304
|
`;
|
|
123
305
|
}
|
|
306
|
+
toggleInsertMenu(e) {
|
|
307
|
+
e.stopPropagation();
|
|
308
|
+
this.styleMenuOpen = false;
|
|
309
|
+
this.insertMenuOpen = !this.insertMenuOpen;
|
|
310
|
+
if (this.insertMenuOpen) {
|
|
311
|
+
this.installOutsideClickHandler();
|
|
312
|
+
}
|
|
313
|
+
else {
|
|
314
|
+
this.removeOutsideClickHandler();
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
executeInsertCommand(commandName, runner) {
|
|
318
|
+
this.insertMenuOpen = false;
|
|
319
|
+
this.removeOutsideClickHandler();
|
|
320
|
+
if (!runner)
|
|
321
|
+
return;
|
|
322
|
+
const command = runner.commands?.[commandName];
|
|
323
|
+
if (typeof command === 'function') {
|
|
324
|
+
const result = command();
|
|
325
|
+
if (result) {
|
|
326
|
+
this.selectionVersion++;
|
|
327
|
+
this.requestUpdate();
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
/* --------------------------------------------------------- */
|
|
332
|
+
/* Shared dropdown close-on-outside-click */
|
|
333
|
+
/* --------------------------------------------------------- */
|
|
334
|
+
installOutsideClickHandler() {
|
|
335
|
+
this.removeOutsideClickHandler();
|
|
336
|
+
this.outsideClickHandler = (e) => {
|
|
337
|
+
const path = e.composedPath();
|
|
338
|
+
if (!path.includes(this)) {
|
|
339
|
+
this.styleMenuOpen = false;
|
|
340
|
+
this.insertMenuOpen = false;
|
|
341
|
+
this.removeOutsideClickHandler();
|
|
342
|
+
}
|
|
343
|
+
};
|
|
344
|
+
// Use capture + next frame so the current click doesn't immediately close.
|
|
345
|
+
requestAnimationFrame(() => {
|
|
346
|
+
if (this.outsideClickHandler) {
|
|
347
|
+
document.addEventListener('click', this.outsideClickHandler, true);
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
removeOutsideClickHandler() {
|
|
352
|
+
if (this.outsideClickHandler) {
|
|
353
|
+
document.removeEventListener('click', this.outsideClickHandler, true);
|
|
354
|
+
this.outsideClickHandler = undefined;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
/* --------------------------------------------------------- */
|
|
358
|
+
/* Fixed command buttons (undo / redo) */
|
|
359
|
+
/* --------------------------------------------------------- */
|
|
360
|
+
/**
|
|
361
|
+
* Render fixed toolbar commands that are not driven by metadata groups.
|
|
362
|
+
*/
|
|
363
|
+
renderFixedCommandButton(runner, commandName, icon, label, description) {
|
|
364
|
+
const command = runner?.commands?.[commandName];
|
|
365
|
+
if (typeof command !== 'function') {
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
368
|
+
const availability = runner?.available?.();
|
|
369
|
+
const canExecute = availability?.[commandName] ? availability[commandName]() : true;
|
|
370
|
+
const handleClick = () => {
|
|
371
|
+
if (!canExecute) {
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
const result = command();
|
|
375
|
+
if (result) {
|
|
376
|
+
this.selectionVersion++;
|
|
377
|
+
this.requestUpdate();
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
return html `
|
|
381
|
+
<button
|
|
382
|
+
type="button"
|
|
383
|
+
aria-label=${label}
|
|
384
|
+
title=${description}
|
|
385
|
+
?disabled=${!canExecute}
|
|
386
|
+
data-tooltip=${description}
|
|
387
|
+
@click=${handleClick}
|
|
388
|
+
>
|
|
389
|
+
<span class="cmd-icon" aria-hidden="true">${unsafeHTML(materialIcons[icon] ?? '')}</span>
|
|
390
|
+
</button>
|
|
391
|
+
`;
|
|
392
|
+
}
|
|
393
|
+
/* --------------------------------------------------------- */
|
|
394
|
+
/* Editor resolution & event wiring */
|
|
395
|
+
/* --------------------------------------------------------- */
|
|
124
396
|
/**
|
|
125
397
|
* Find the target editor element and attach listeners for selection changes.
|
|
126
398
|
* This mirrors the wiring a framework component would perform in `useEffect`.
|
|
@@ -186,20 +458,9 @@ let SciFlowFormatBarElement = class SciFlowFormatBarElement extends LitElement {
|
|
|
186
458
|
getCommandRunner() {
|
|
187
459
|
return this.resolvedEditor?.commands ?? null;
|
|
188
460
|
}
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
const value = select.value;
|
|
193
|
-
if (value === 'paragraph') {
|
|
194
|
-
this.handleSetParagraph();
|
|
195
|
-
return;
|
|
196
|
-
}
|
|
197
|
-
const parsed = Number.parseInt(value.split('-')[1] ?? '', 10);
|
|
198
|
-
if (!Number.isInteger(parsed)) {
|
|
199
|
-
return;
|
|
200
|
-
}
|
|
201
|
-
this.handleSetHeading(parsed);
|
|
202
|
-
}
|
|
461
|
+
/* --------------------------------------------------------- */
|
|
462
|
+
/* Heading / paragraph commands */
|
|
463
|
+
/* --------------------------------------------------------- */
|
|
203
464
|
/** Apply a specific heading level after ensuring the editor can handle it. */
|
|
204
465
|
handleSetHeading(level) {
|
|
205
466
|
const runner = this.getCommandRunner();
|
|
@@ -306,13 +567,59 @@ let SciFlowFormatBarElement = class SciFlowFormatBarElement extends LitElement {
|
|
|
306
567
|
}
|
|
307
568
|
return commands.setHeading?.(level) ?? false;
|
|
308
569
|
}
|
|
570
|
+
/** True if the current selection is inside a table (cell or header). */
|
|
571
|
+
isSelectionInTable() {
|
|
572
|
+
const state = this.resolvedEditor?.editorView?.state;
|
|
573
|
+
if (!state) {
|
|
574
|
+
return false;
|
|
575
|
+
}
|
|
576
|
+
const { table, table_cell, table_header } = state.schema.nodes;
|
|
577
|
+
if (!table || !table_cell || !table_header) {
|
|
578
|
+
return false;
|
|
579
|
+
}
|
|
580
|
+
const { $from } = state.selection;
|
|
581
|
+
for (let d = $from.depth; d > 0; d--) {
|
|
582
|
+
const node = $from.node(d);
|
|
583
|
+
if (node.type === table || node.type === table_cell || node.type === table_header) {
|
|
584
|
+
return true;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
return false;
|
|
588
|
+
}
|
|
589
|
+
/**
|
|
590
|
+
* Render table commands split into logical sub-groups (columns, rows,
|
|
591
|
+
* merge/split, header toggles, navigation/delete) separated visually.
|
|
592
|
+
*/
|
|
593
|
+
renderTableSubGroups(runner, metadata) {
|
|
594
|
+
const subGroups = [
|
|
595
|
+
['insertNativeTableFigure'],
|
|
596
|
+
['addColumnBefore', 'addColumnAfter', 'deleteColumn'],
|
|
597
|
+
['addRowBefore', 'addRowAfter', 'deleteRow'],
|
|
598
|
+
['mergeCells', 'splitCell'],
|
|
599
|
+
['toggleHeaderRow', 'toggleHeaderColumn'],
|
|
600
|
+
['goToNextCell', 'deleteTable'],
|
|
601
|
+
];
|
|
602
|
+
return subGroups.map((names) => {
|
|
603
|
+
const buttons = this.renderCommandGroup(runner, metadata, 'table', names);
|
|
604
|
+
return html `<div class="command-group">${buttons}</div>`;
|
|
605
|
+
});
|
|
606
|
+
}
|
|
309
607
|
/**
|
|
310
608
|
* Render a set of command buttons that share the same metadata group.
|
|
311
609
|
* Groups are sorted by each command's declared order so feature authors control layout.
|
|
610
|
+
* When onlyCommandNames is set, only those commands in the group are rendered.
|
|
312
611
|
*/
|
|
313
|
-
renderCommandGroup(runner, metadata, group) {
|
|
612
|
+
renderCommandGroup(runner, metadata, group, onlyCommandNames) {
|
|
314
613
|
return Object.entries(metadata)
|
|
315
|
-
.filter(([, meta]) =>
|
|
614
|
+
.filter(([name, meta]) => {
|
|
615
|
+
if (meta?.group !== group || !(meta.icon || meta.label)) {
|
|
616
|
+
return false;
|
|
617
|
+
}
|
|
618
|
+
if (onlyCommandNames !== undefined && !onlyCommandNames.includes(name)) {
|
|
619
|
+
return false;
|
|
620
|
+
}
|
|
621
|
+
return true;
|
|
622
|
+
})
|
|
316
623
|
.sort(([, a], [, b]) => (a?.order ?? 0) - (b?.order ?? 0))
|
|
317
624
|
.map(([name, meta]) => this.renderCommandButton(name, meta, runner));
|
|
318
625
|
}
|
|
@@ -345,13 +652,13 @@ let SciFlowFormatBarElement = class SciFlowFormatBarElement extends LitElement {
|
|
|
345
652
|
class=${classMap({ active: isActive })}
|
|
346
653
|
aria-label=${meta.label ?? name}
|
|
347
654
|
title=${meta.description ?? meta.label ?? name}
|
|
348
|
-
|
|
655
|
+
aria-pressed=${meta.isActive ? (isActive ? 'true' : 'false') : undefined}
|
|
349
656
|
?disabled=${!canExecute}
|
|
350
657
|
data-tooltip=${meta.description ?? meta.label ?? name}
|
|
351
658
|
@click=${handleClick}
|
|
352
659
|
>
|
|
353
660
|
${meta.icon
|
|
354
|
-
? html `<span class="
|
|
661
|
+
? html `<span class="cmd-icon" aria-hidden="true">${unsafeHTML(materialIcons[meta.icon] ?? '')}</span>`
|
|
355
662
|
: meta.label ?? name}
|
|
356
663
|
</button>
|
|
357
664
|
`;
|
|
@@ -395,6 +702,12 @@ __decorate([
|
|
|
395
702
|
__decorate([
|
|
396
703
|
state()
|
|
397
704
|
], SciFlowFormatBarElement.prototype, "selectionVersion", void 0);
|
|
705
|
+
__decorate([
|
|
706
|
+
state()
|
|
707
|
+
], SciFlowFormatBarElement.prototype, "styleMenuOpen", void 0);
|
|
708
|
+
__decorate([
|
|
709
|
+
state()
|
|
710
|
+
], SciFlowFormatBarElement.prototype, "insertMenuOpen", void 0);
|
|
398
711
|
SciFlowFormatBarElement = __decorate([
|
|
399
712
|
customElement('sciflow-formatbar')
|
|
400
713
|
], SciFlowFormatBarElement);
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module ghost-cursor
|
|
3
|
+
*
|
|
4
|
+
* ProseMirror feature that keeps the cursor position visible when the editor
|
|
5
|
+
* loses focus. A thin blue bar is rendered at the last cursor position so
|
|
6
|
+
* the user always knows where the next insertion (e.g. Cite, Insert ref) will land.
|
|
7
|
+
*/
|
|
8
|
+
import type { Feature } from '@sciflow/editor-core';
|
|
9
|
+
export declare const ghostCursorFeature: Feature;
|
|
10
|
+
//# sourceMappingURL=ghost-cursor.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ghost-cursor.d.ts","sourceRoot":"","sources":["../../src/lib/ghost-cursor.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAIH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,sBAAsB,CAAC;AAsDpD,eAAO,MAAM,kBAAkB,EAAE,OAKhC,CAAC"}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module ghost-cursor
|
|
3
|
+
*
|
|
4
|
+
* ProseMirror feature that keeps the cursor position visible when the editor
|
|
5
|
+
* loses focus. A thin blue bar is rendered at the last cursor position so
|
|
6
|
+
* the user always knows where the next insertion (e.g. Cite, Insert ref) will land.
|
|
7
|
+
*/
|
|
8
|
+
import { Plugin, PluginKey } from 'prosemirror-state';
|
|
9
|
+
import { Decoration, DecorationSet } from 'prosemirror-view';
|
|
10
|
+
const ghostCursorKey = new PluginKey('ghostCursor');
|
|
11
|
+
const ghostCursorPlugin = new Plugin({
|
|
12
|
+
key: ghostCursorKey,
|
|
13
|
+
state: {
|
|
14
|
+
init: () => true,
|
|
15
|
+
apply(tr, focused) {
|
|
16
|
+
const next = tr.getMeta(ghostCursorKey);
|
|
17
|
+
return typeof next === 'boolean' ? next : focused;
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
props: {
|
|
21
|
+
handleDOMEvents: {
|
|
22
|
+
blur(view) {
|
|
23
|
+
view.dispatch(view.state.tr.setMeta(ghostCursorKey, false));
|
|
24
|
+
return false;
|
|
25
|
+
},
|
|
26
|
+
focus(view) {
|
|
27
|
+
view.dispatch(view.state.tr.setMeta(ghostCursorKey, true));
|
|
28
|
+
return false;
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
decorations(state) {
|
|
32
|
+
const focused = ghostCursorKey.getState(state);
|
|
33
|
+
if (focused !== false)
|
|
34
|
+
return DecorationSet.empty;
|
|
35
|
+
const { selection } = state;
|
|
36
|
+
// For a range selection, highlight the selected text instead of showing a caret
|
|
37
|
+
if (!selection.empty) {
|
|
38
|
+
return DecorationSet.create(state.doc, [
|
|
39
|
+
Decoration.inline(selection.from, selection.to, {
|
|
40
|
+
class: 'prosemirror-ghost-selection',
|
|
41
|
+
}),
|
|
42
|
+
]);
|
|
43
|
+
}
|
|
44
|
+
// For a collapsed cursor, insert a widget caret marker
|
|
45
|
+
const el = document.createElement('span');
|
|
46
|
+
el.className = 'prosemirror-ghost-cursor';
|
|
47
|
+
el.setAttribute('aria-hidden', 'true');
|
|
48
|
+
return DecorationSet.create(state.doc, [
|
|
49
|
+
Decoration.widget(selection.head, el, { side: 0, key: 'ghost-cursor' }),
|
|
50
|
+
]);
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
export const ghostCursorFeature = {
|
|
55
|
+
name: 'ghost-cursor',
|
|
56
|
+
addPlugins() {
|
|
57
|
+
return [ghostCursorPlugin];
|
|
58
|
+
},
|
|
59
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-generated – do not edit.
|
|
3
|
+
* Run `node scripts/generate-icons.mjs` to regenerate.
|
|
4
|
+
*
|
|
5
|
+
* Icons: Material Symbols Rounded, weight 400, unfilled.
|
|
6
|
+
* Source: @material-symbols/svg-400 (Apache License 2.0, Copyright Google LLC)
|
|
7
|
+
* https://github.com/google/material-design-icons
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Map of icon names (as used in CommandMetadata.icon) → inline SVG markup.
|
|
11
|
+
* Only the icons actually used by the editor are included (~29 icons).
|
|
12
|
+
*/
|
|
13
|
+
export declare const materialIcons: Record<string, string>;
|
|
14
|
+
//# sourceMappingURL=icons-generated.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"icons-generated.d.ts","sourceRoot":"","sources":["../../src/lib/icons-generated.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAWH;;;GAGG;AACH,eAAO,MAAM,aAAa,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAwChD,CAAC"}
|