mechanica 2.0.0-alpha.4 → 2.0.0-alpha.5

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/bin/mechanica.js CHANGED
File without changes
@@ -1,4 +1,11 @@
1
1
  import { Fragment, createBlock, createCommentVNode, createElementBlock, createElementVNode, createTextVNode, createVNode, defineComponent, inject, mergeProps, normalizeClass, onMounted, openBlock, ref, renderList, renderSlot, shallowReactive, toDisplayString, unref, withCtx, withModifiers } from "vue";
2
+ //#region \0plugin-vue:export-helper
3
+ var _plugin_vue_export_helper_default = (sfc, props) => {
4
+ const target = sfc.__vccOpts || sfc;
5
+ for (const [key, val] of props) target[key] = val;
6
+ return target;
7
+ };
8
+ //#endregion
2
9
  //#region \0svg-glob:C:\Users\Den\Desktop\CURVES\mechanica\packages\mechanica\src\editor\icons
3
10
  var contents = {
4
11
  "align-center": `<path d="M4 6h16M7 12h10M6 18h12"/>`,
@@ -543,13 +550,6 @@ var VIcon_default = /* @__PURE__ */ defineComponent({
543
550
  }
544
551
  });
545
552
  //#endregion
546
- //#region \0plugin-vue:export-helper
547
- var _plugin_vue_export_helper_default = (sfc, props) => {
548
- const target = sfc.__vccOpts || sfc;
549
- for (const [key, val] of props) target[key] = val;
550
- return target;
551
- };
552
- //#endregion
553
553
  //#region src/editor/ui/dialog.ts
554
554
  var dialogKey = Symbol("mech-dialog");
555
555
  /** Create the reactive dialog store (provided once by the editor shell). */
@@ -869,4 +869,4 @@ var ImagePickerDialog_default = /*#__PURE__*/ _plugin_vue_export_helper_default(
869
869
  }
870
870
  }), [["__scopeId", "data-v-4ef9dd0d"]]);
871
871
  //#endregion
872
- export { renderCrop as a, useDialog as c, contents as d, readImageSize as i, _plugin_vue_export_helper_default as l, VDialog_default as n, createDialogStore as o, analyzeImageFile as r, dialogKey as s, ImagePickerDialog_default as t, VIcon_default as u };
872
+ export { renderCrop as a, useDialog as c, _plugin_vue_export_helper_default as d, readImageSize as i, VIcon_default as l, VDialog_default as n, createDialogStore as o, analyzeImageFile as r, dialogKey as s, ImagePickerDialog_default as t, contents as u };
@@ -1,4 +1,4 @@
1
- import { a as renderCrop, c as useDialog, i as readImageSize, l as _plugin_vue_export_helper_default, n as VDialog_default, r as analyzeImageFile, s as dialogKey, t as ImagePickerDialog_default, u as VIcon_default } from "./ImagePickerDialog-BfjNtRoR.js";
1
+ import { a as renderCrop, c as useDialog, d as _plugin_vue_export_helper_default, i as readImageSize, l as VIcon_default, n as VDialog_default, r as analyzeImageFile, s as dialogKey, t as ImagePickerDialog_default } from "./ImagePickerDialog-CY-mn2UP.js";
2
2
  import { Fragment, Teleport, Transition, computed, createBlock, createCommentVNode, createElementBlock, createElementVNode, createSlots, createTextVNode, createVNode, defineComponent, inject, mergeProps, nextTick, normalizeClass, normalizeStyle, onBeforeUnmount, onErrorCaptured, onMounted, openBlock, provide, reactive, ref, renderList, renderSlot, resolveDynamicComponent, shallowRef, toDisplayString, unref, vShow, watch, withCtx, withDirectives, withKeys, withModifiers } from "vue";
3
3
  import { deepEqual, getDefaultValue } from "mechanica-shared";
4
4
  import { unfoldSchema } from "compact-json-schema";
@@ -19,9 +19,146 @@ function resolveFieldEditor(schema) {
19
19
  if (schema.type && editors.has(schema.type)) return editors.get(schema.type);
20
20
  return editors.get("string");
21
21
  }
22
+ /**
23
+ * The non-breaking character a keyboard event asks for, or `null`. Matches Word's
24
+ * muscle memory: Ctrl/Cmd+Shift+Space → nbsp, Ctrl/Cmd+Shift+Minus → nb-hyphen.
25
+ * Keyed off `event.code` (physical key) so it fires on any keyboard layout, the
26
+ * same way the editor's other shortcuts stay layout-independent.
27
+ */
28
+ function typographyCharForEvent(event) {
29
+ if (!(event.ctrlKey || event.metaKey) || !event.shiftKey || event.altKey) return null;
30
+ if (event.code === "Space") return "\xA0";
31
+ if (event.code === "Minus") return "‑";
32
+ return null;
33
+ }
34
+ /**
35
+ * Insert `char` at the caret of an `<input>`/`<textarea>`, replacing any
36
+ * selection, and leave the caret after it. Returns the field's new value so the
37
+ * caller can emit it. `setRangeText` keeps native undo working.
38
+ */
39
+ function insertAtCursor(el, char) {
40
+ const start = el.selectionStart ?? el.value.length;
41
+ const end = el.selectionEnd ?? start;
42
+ el.setRangeText(char, start, end, "end");
43
+ return el.value;
44
+ }
45
+ var HTML_ESCAPES = {
46
+ "&": "&amp;",
47
+ "<": "&lt;",
48
+ ">": "&gt;"
49
+ };
50
+ function escapeHtml(text) {
51
+ return text.replace(/[&<>]/g, (ch) => HTML_ESCAPES[ch] ?? ch);
52
+ }
53
+ /**
54
+ * HTML that mirrors `text` for the plain-field marker overlay: every character
55
+ * keeps its exact glyph advance (so the layer lines up with the real field), but
56
+ * the two non-breaking characters are wrapped in marker spans the CSS lights up.
57
+ * The characters themselves stay in the spans — that's what preserves the width.
58
+ */
59
+ function highlightTypographyHtml(text) {
60
+ return escapeHtml(text).replaceAll("\xA0", `<span class="mech-typo__nbsp"> </span>`).replaceAll("‑", `<span class="mech-typo__nbhyphen">‑</span>`);
61
+ }
62
+ /**
63
+ * vuewrite `parser`: scans a block's text and emits a one-character style span
64
+ * over every non-breaking character. vuewrite merges these at *render* time only
65
+ * — they never enter the stored model, so the Markdown on disk stays clean while
66
+ * the editor still shows where the glue is.
67
+ */
68
+ function typographyParser(text) {
69
+ const spans = [];
70
+ for (let i = 0; i < text.length; i++) if (text[i] === "\xA0") spans.push({
71
+ start: i,
72
+ end: i + 1,
73
+ style: "nbsp"
74
+ });
75
+ else if (text[i] === "‑") spans.push({
76
+ start: i,
77
+ end: i + 1,
78
+ style: "nbHyphen"
79
+ });
80
+ return spans;
81
+ }
82
+ //#endregion
83
+ //#region src/editor/components/TypographyInput.vue?vue&type=script&setup=true&lang.ts
84
+ var _hoisted_1$19 = { class: "mech-typo" };
85
+ var _hoisted_2$15 = [
86
+ "value",
87
+ "placeholder",
88
+ "rows"
89
+ ];
90
+ var _hoisted_3$12 = ["value", "placeholder"];
91
+ var _hoisted_4$9 = ["innerHTML"];
22
92
  //#endregion
23
- //#region src/editor/fields/editors/StringField.vue?vue&type=script&setup=true&lang.ts
24
- var _hoisted_1$20 = ["value", "placeholder"];
93
+ //#region src/editor/components/TypographyInput.vue
94
+ var TypographyInput_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PURE__ */ defineComponent({
95
+ __name: "TypographyInput",
96
+ props: {
97
+ modelValue: {},
98
+ multiline: { type: Boolean },
99
+ placeholder: {},
100
+ rows: { default: 3 }
101
+ },
102
+ emits: ["update:modelValue"],
103
+ setup(__props, { emit: __emit }) {
104
+ const props = __props;
105
+ const emit = __emit;
106
+ const fieldRef = ref();
107
+ const overlayRef = ref();
108
+ const highlighted = computed(() => highlightTypographyHtml(props.modelValue ?? ""));
109
+ function onInput(event) {
110
+ emit("update:modelValue", event.target.value);
111
+ nextTick(syncScroll);
112
+ }
113
+ function onKeyDown(event) {
114
+ const char = typographyCharForEvent(event);
115
+ if (!char) return;
116
+ event.preventDefault();
117
+ const el = fieldRef.value;
118
+ if (!el) return;
119
+ emit("update:modelValue", insertAtCursor(el, char));
120
+ nextTick(syncScroll);
121
+ }
122
+ function syncScroll() {
123
+ const el = fieldRef.value;
124
+ const overlay = overlayRef.value;
125
+ if (!el || !overlay) return;
126
+ overlay.scrollTop = el.scrollTop;
127
+ overlay.scrollLeft = el.scrollLeft;
128
+ }
129
+ return (_ctx, _cache) => {
130
+ return openBlock(), createElementBlock("div", _hoisted_1$19, [__props.multiline ? (openBlock(), createElementBlock("textarea", {
131
+ key: 0,
132
+ ref_key: "fieldRef",
133
+ ref: fieldRef,
134
+ class: "mech-input mech-textarea",
135
+ value: __props.modelValue,
136
+ placeholder: __props.placeholder,
137
+ rows: __props.rows,
138
+ onInput,
139
+ onKeydown: onKeyDown,
140
+ onScroll: syncScroll
141
+ }, null, 40, _hoisted_2$15)) : (openBlock(), createElementBlock("input", {
142
+ key: 1,
143
+ ref_key: "fieldRef",
144
+ ref: fieldRef,
145
+ class: "mech-input",
146
+ type: "text",
147
+ value: __props.modelValue,
148
+ placeholder: __props.placeholder,
149
+ onInput,
150
+ onKeydown: onKeyDown,
151
+ onScroll: syncScroll
152
+ }, null, 40, _hoisted_3$12)), createElementVNode("div", {
153
+ ref_key: "overlayRef",
154
+ ref: overlayRef,
155
+ class: normalizeClass(["mech-typo__overlay", __props.multiline ? "is-multiline" : "is-single"]),
156
+ "aria-hidden": "true",
157
+ innerHTML: highlighted.value
158
+ }, null, 10, _hoisted_4$9)]);
159
+ };
160
+ }
161
+ }), [["__scopeId", "data-v-0086c808"]]);
25
162
  //#endregion
26
163
  //#region src/editor/fields/editors/StringField.vue
27
164
  var StringField_default = /* @__PURE__ */ defineComponent({
@@ -33,22 +170,16 @@ var StringField_default = /* @__PURE__ */ defineComponent({
33
170
  emits: ["update:modelValue"],
34
171
  setup(__props, { emit: __emit }) {
35
172
  const emit = __emit;
36
- const onInput = (event) => emit("update:modelValue", event.target.value);
37
173
  return (_ctx, _cache) => {
38
- return openBlock(), createElementBlock("input", {
39
- class: "mech-input",
40
- type: "text",
41
- value: __props.modelValue,
174
+ return openBlock(), createBlock(TypographyInput_default, {
175
+ "model-value": __props.modelValue,
42
176
  placeholder: __props.schema.placeholder,
43
- onInput
44
- }, null, 40, _hoisted_1$20);
177
+ "onUpdate:modelValue": _cache[0] || (_cache[0] = ($event) => emit("update:modelValue", $event))
178
+ }, null, 8, ["model-value", "placeholder"]);
45
179
  };
46
180
  }
47
181
  });
48
182
  //#endregion
49
- //#region src/editor/fields/editors/TextField.vue?vue&type=script&setup=true&lang.ts
50
- var _hoisted_1$19 = ["value", "placeholder"];
51
- //#endregion
52
183
  //#region src/editor/fields/editors/TextField.vue
53
184
  var TextField_default = /* @__PURE__ */ defineComponent({
54
185
  __name: "TextField",
@@ -59,15 +190,14 @@ var TextField_default = /* @__PURE__ */ defineComponent({
59
190
  emits: ["update:modelValue"],
60
191
  setup(__props, { emit: __emit }) {
61
192
  const emit = __emit;
62
- const onInput = (event) => emit("update:modelValue", event.target.value);
63
193
  return (_ctx, _cache) => {
64
- return openBlock(), createElementBlock("textarea", {
65
- class: "mech-input mech-textarea",
66
- value: __props.modelValue,
194
+ return openBlock(), createBlock(TypographyInput_default, {
195
+ multiline: "",
196
+ "model-value": __props.modelValue,
67
197
  placeholder: __props.schema.placeholder,
68
- rows: "3",
69
- onInput
70
- }, null, 40, _hoisted_1$19);
198
+ rows: 3,
199
+ "onUpdate:modelValue": _cache[0] || (_cache[0] = ($event) => emit("update:modelValue", $event))
200
+ }, null, 8, ["model-value", "placeholder"]);
71
201
  };
72
202
  }
73
203
  });
@@ -1477,6 +1607,14 @@ var decorator = (style) => {
1477
1607
  }
1478
1608
  };
1479
1609
  case "color": return { style: `color: ${style.meta?.color};` };
1610
+ case "nbsp": return {
1611
+ tag: "span",
1612
+ class: "rt-nbsp"
1613
+ };
1614
+ case "nbHyphen": return {
1615
+ tag: "span",
1616
+ class: "rt-nbhyphen"
1617
+ };
1480
1618
  default: return;
1481
1619
  }
1482
1620
  };
@@ -2065,9 +2203,28 @@ var RichTextEditor_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/*
2065
2203
  autosizeMarkdown();
2066
2204
  nextTick(() => fromMarkdown = false);
2067
2205
  }
2206
+ /** The nbsp / non-breaking-hyphen shortcuts in the raw Markdown view too. */
2207
+ function onMarkdownKeyDown(event) {
2208
+ const char = typographyCharForEvent(event);
2209
+ if (!char || !markdownRef.value) return;
2210
+ event.preventDefault();
2211
+ const md = insertAtCursor(markdownRef.value, char);
2212
+ markdown.value = md;
2213
+ fromMarkdown = true;
2214
+ model.value = markdownToBlocks(md, model.value, { softBreaks: true });
2215
+ autosizeMarkdown();
2216
+ nextTick(() => fromMarkdown = false);
2217
+ }
2068
2218
  function onKeyDown(event) {
2069
2219
  const editor = editorRef.value;
2070
2220
  if (!editor) return;
2221
+ const nb = typographyCharForEvent(event);
2222
+ if (nb) {
2223
+ event.preventDefault();
2224
+ editor.insertText(nb);
2225
+ editor.pushHistory("insertText");
2226
+ return;
2227
+ }
2071
2228
  if ((event.ctrlKey || event.metaKey) && !event.shiftKey && !event.altKey) {
2072
2229
  const style = {
2073
2230
  KeyB: "bold",
@@ -2192,6 +2349,7 @@ var RichTextEditor_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/*
2192
2349
  class: "mech-rte__surface",
2193
2350
  renderer: unref(renderer),
2194
2351
  decorator: unref(decorator),
2352
+ parser: unref(typographyParser),
2195
2353
  "html-parser": unref(htmlParser),
2196
2354
  onKeydown: onKeyDown,
2197
2355
  onContextmenu: onContextMenu
@@ -2213,6 +2371,7 @@ var RichTextEditor_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/*
2213
2371
  "modelValue",
2214
2372
  "renderer",
2215
2373
  "decorator",
2374
+ "parser",
2216
2375
  "html-parser"
2217
2376
  ])) : (openBlock(), createElementBlock("textarea", {
2218
2377
  key: 2,
@@ -2222,11 +2381,12 @@ var RichTextEditor_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/*
2222
2381
  value: markdown.value,
2223
2382
  spellcheck: "false",
2224
2383
  placeholder: __props.placeholder,
2225
- onInput: onMarkdownInput
2384
+ onInput: onMarkdownInput,
2385
+ onKeydown: onMarkdownKeyDown
2226
2386
  }, null, 40, _hoisted_8))])], 2);
2227
2387
  };
2228
2388
  }
2229
- }), [["__scopeId", "data-v-f118319c"]]);
2389
+ }), [["__scopeId", "data-v-6729e5a2"]]);
2230
2390
  //#endregion
2231
2391
  //#region src/editor/dialogs/RichTextDialog.vue
2232
2392
  var RichTextDialog_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PURE__ */ defineComponent({
package/dist/composer.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import './editor.css';
2
2
  import { c as ANCHOR_H, i as renderBlocks, l as ANCHOR_V, t as createMechanica } from "./chunks/create-mechanica-CnBFiyfJ.js";
3
- import { C as registerBuiltinFieldEditors, N as VSelect_default, P as VPopover_default, S as uid, _ as findParentSlot, a as FieldControl_default, b as relocateBlock, d as humanize, f as cloneBlock, g as findParentList, h as findBlock, i as SchemaField_default, m as ensureSlotList, n as createSaveQueue, o as shortcutChar, p as duplicateBlock, t as SaveConflictError, u as toBlockMeta, v as moveBlock, x as removeBlock, y as placeBlock } from "./chunks/editor-BYMvxQTM.js";
4
- import { d as contents, l as _plugin_vue_export_helper_default, u as VIcon_default } from "./chunks/ImagePickerDialog-BfjNtRoR.js";
3
+ import { C as registerBuiltinFieldEditors, N as VSelect_default, P as VPopover_default, S as uid, _ as findParentSlot, a as FieldControl_default, b as relocateBlock, d as humanize, f as cloneBlock, g as findParentList, h as findBlock, i as SchemaField_default, m as ensureSlotList, n as createSaveQueue, o as shortcutChar, p as duplicateBlock, t as SaveConflictError, u as toBlockMeta, v as moveBlock, x as removeBlock, y as placeBlock } from "./chunks/editor-9tquvDcS.js";
4
+ import { d as _plugin_vue_export_helper_default, l as VIcon_default, u as contents } from "./chunks/ImagePickerDialog-CY-mn2UP.js";
5
5
  import { Fragment, Teleport, computed, createApp, createBlock, createCommentVNode, createElementBlock, createElementVNode, createTextVNode, createVNode, defineComponent, inject, nextTick, normalizeClass, normalizeStyle, onBeforeUnmount, onMounted, onScopeDispose, openBlock, provide, reactive, ref, renderList, renderSlot, resolveComponent, toDisplayString, unref, vModelText, watch, withCtx, withDirectives, withKeys, withModifiers } from "vue";
6
6
  import { buildPreviewData, getDefaultValue, isBinding, registerFieldSchemas, resolveBindings, walkTree } from "mechanica-shared";
7
7
  import { unfoldSchema } from "compact-json-schema";
package/dist/editor.css CHANGED
@@ -467,6 +467,43 @@
467
467
  padding: 8px 0;
468
468
  color: var(--mech-muted);
469
469
  font-size: 13px;
470
+ }.mech-typo[data-v-0086c808] {
471
+ position: relative;
472
+ display: block;
473
+ width: 100%;
474
+ }
475
+ .mech-typo__overlay[data-v-0086c808] {
476
+ position: absolute;
477
+ inset: 0;
478
+ box-sizing: border-box;
479
+ border: 1px solid transparent;
480
+ z-index: 1;
481
+ overflow: hidden;
482
+ pointer-events: none;
483
+ color: transparent;
484
+ font: inherit;
485
+ font-size: 13.5px;
486
+ }
487
+ .mech-typo__overlay.is-single[data-v-0086c808] {
488
+ padding: 0 12px;
489
+ line-height: 36px;
490
+ white-space: pre;
491
+ }
492
+ .mech-typo__overlay.is-multiline[data-v-0086c808] {
493
+ padding: 9px 12px;
494
+ line-height: 1.45;
495
+ white-space: pre-wrap;
496
+ overflow-wrap: break-word;
497
+ }
498
+ .mech-typo__overlay[data-v-0086c808] .mech-typo__nbsp,
499
+ .mech-typo__overlay[data-v-0086c808] .mech-typo__nbhyphen {
500
+ border-radius: 3px;
501
+ background-color: color-mix(in srgb, var(--mech-accent) 15%, transparent);
502
+ }
503
+ .mech-typo__overlay[data-v-0086c808] .mech-typo__nbsp {
504
+ background-image: radial-gradient(circle at center, var(--mech-accent) 0 1px, transparent 1.4px);
505
+ background-repeat: no-repeat;
506
+ background-position: center;
470
507
  }.mech-popover[data-v-4884cec4] {
471
508
  position: fixed;
472
509
  z-index: 2147483680;
@@ -1124,60 +1161,60 @@ from {
1124
1161
  }
1125
1162
  .mech-rt-table[data-v-0b304924] .vw-table-cell {
1126
1163
  font-size: 13.5px;
1127
- }.mech-rte[data-v-f118319c] {
1164
+ }.mech-rte[data-v-6729e5a2] {
1128
1165
  display: flex;
1129
1166
  flex-direction: column;
1130
1167
  gap: 6px;
1131
1168
  }
1132
- .mech-rte__switchbar[data-v-f118319c] {
1169
+ .mech-rte__switchbar[data-v-6729e5a2] {
1133
1170
  display: flex;
1134
1171
  justify-content: flex-end;
1135
1172
  margin-top: -25px;
1136
1173
  }
1137
- .mech-rte__box[data-v-f118319c] {
1174
+ .mech-rte__box[data-v-6729e5a2] {
1138
1175
  display: flex;
1139
1176
  flex-direction: column;
1140
1177
  border-radius: var(--mech-radius);
1141
1178
  transition: background 0.12s, border-color 0.12s, box-shadow 0.12s;
1142
1179
  }
1143
- .mech-rte--full .mech-rte__box[data-v-f118319c] {
1180
+ .mech-rte--full .mech-rte__box[data-v-6729e5a2] {
1144
1181
  border: 1px solid var(--mech-input-border);
1145
1182
  background: var(--mech-bg);
1146
1183
  overflow: hidden;
1147
1184
  }
1148
- .mech-rte--full .mech-rte__box[data-v-f118319c]:focus-within {
1185
+ .mech-rte--full .mech-rte__box[data-v-6729e5a2]:focus-within {
1149
1186
  border-color: var(--mech-accent);
1150
1187
  box-shadow: 0 0 0 3px var(--mech-ring);
1151
1188
  }
1152
- .mech-rte--minimal .mech-rte__box[data-v-f118319c] {
1189
+ .mech-rte--minimal .mech-rte__box[data-v-6729e5a2] {
1153
1190
  border: 1px solid transparent;
1154
1191
  background: var(--mech-field-bg);
1155
1192
  }
1156
- .mech-rte--minimal .mech-rte__box[data-v-f118319c]:hover {
1193
+ .mech-rte--minimal .mech-rte__box[data-v-6729e5a2]:hover {
1157
1194
  background: var(--mech-field-bg-hover);
1158
1195
  }
1159
- .mech-rte--minimal .mech-rte__box[data-v-f118319c]:focus-within {
1196
+ .mech-rte--minimal .mech-rte__box[data-v-6729e5a2]:focus-within {
1160
1197
  background: var(--mech-bg);
1161
1198
  border-color: var(--mech-accent);
1162
1199
  box-shadow: 0 0 0 3px var(--mech-ring);
1163
1200
  }
1164
- .mech-rte__bar[data-v-f118319c] {
1201
+ .mech-rte__bar[data-v-6729e5a2] {
1165
1202
  display: flex;
1166
1203
  align-items: center;
1167
1204
  gap: 2px;
1168
1205
  padding: 5px 6px;
1169
1206
  border-bottom: 1px solid var(--mech-border);
1170
1207
  }
1171
- .mech-rte__toggle[data-v-f118319c] {
1208
+ .mech-rte__toggle[data-v-6729e5a2] {
1172
1209
  margin-left: auto;
1173
1210
  }
1174
- .mech-rte__divider[data-v-f118319c] {
1211
+ .mech-rte__divider[data-v-6729e5a2] {
1175
1212
  width: 1px;
1176
1213
  align-self: stretch;
1177
1214
  margin: 4px 4px;
1178
1215
  background: var(--mech-border);
1179
1216
  }
1180
- .mech-rte__insert-item[data-v-f118319c] {
1217
+ .mech-rte__insert-item[data-v-6729e5a2] {
1181
1218
  display: flex;
1182
1219
  align-items: center;
1183
1220
  gap: 8px;
@@ -1194,26 +1231,26 @@ from {
1194
1231
  text-align: left;
1195
1232
  cursor: pointer;
1196
1233
  }
1197
- .mech-rte__insert-item[data-v-f118319c]:hover {
1234
+ .mech-rte__insert-item[data-v-6729e5a2]:hover {
1198
1235
  background: var(--mech-hover);
1199
1236
  }
1200
- .mech-rte__insert-icon[data-v-f118319c] {
1237
+ .mech-rte__insert-icon[data-v-6729e5a2] {
1201
1238
  width: 16px;
1202
1239
  height: 16px;
1203
1240
  flex: none;
1204
1241
  color: var(--mech-muted);
1205
1242
  }
1206
- .mech-rte__insert-icon[data-v-f118319c] svg {
1243
+ .mech-rte__insert-icon[data-v-6729e5a2] svg {
1207
1244
  display: block;
1208
1245
  width: 100%;
1209
1246
  height: 100%;
1210
1247
  }
1211
- .mech-rte__surface[data-v-f118319c] .mech-rte__menu-target {
1248
+ .mech-rte__surface[data-v-6729e5a2] .mech-rte__menu-target {
1212
1249
  border-radius: var(--mech-radius-sm);
1213
1250
  background: var(--mech-accent-soft);
1214
1251
  box-shadow: 0 0 0 3px var(--mech-accent-soft);
1215
1252
  }
1216
- .mech-rte__surface[data-v-f118319c] {
1253
+ .mech-rte__surface[data-v-6729e5a2] {
1217
1254
  padding: 11px 13px;
1218
1255
  min-height: 64px;
1219
1256
  line-height: 1.6;
@@ -1223,56 +1260,56 @@ from {
1223
1260
  white-space: pre-wrap;
1224
1261
  overflow-y: auto;
1225
1262
  }
1226
- .mech-rte__surface[data-v-f118319c] p {
1263
+ .mech-rte__surface[data-v-6729e5a2] p {
1227
1264
  margin: 0 0 0.6em;
1228
1265
  }
1229
- .mech-rte__surface[data-v-f118319c] h1,
1230
- .mech-rte__surface[data-v-f118319c] h2,
1231
- .mech-rte__surface[data-v-f118319c] h3 {
1266
+ .mech-rte__surface[data-v-6729e5a2] h1,
1267
+ .mech-rte__surface[data-v-6729e5a2] h2,
1268
+ .mech-rte__surface[data-v-6729e5a2] h3 {
1232
1269
  font-weight: 700;
1233
1270
  margin: 0.85em 0 0.35em;
1234
1271
  }
1235
- .mech-rte__surface[data-v-f118319c] h1 {
1272
+ .mech-rte__surface[data-v-6729e5a2] h1 {
1236
1273
  font-size: 1.5em;
1237
1274
  }
1238
- .mech-rte__surface[data-v-f118319c] h2 {
1275
+ .mech-rte__surface[data-v-6729e5a2] h2 {
1239
1276
  font-size: 1.3em;
1240
1277
  }
1241
- .mech-rte__surface[data-v-f118319c] h3 {
1278
+ .mech-rte__surface[data-v-6729e5a2] h3 {
1242
1279
  font-size: 1.1em;
1243
1280
  }
1244
- .mech-rte__surface[data-v-f118319c] ul,
1245
- .mech-rte__surface[data-v-f118319c] ol {
1281
+ .mech-rte__surface[data-v-6729e5a2] ul,
1282
+ .mech-rte__surface[data-v-6729e5a2] ol {
1246
1283
  margin: 0 0 0.6em;
1247
1284
  padding-left: 0;
1248
1285
  }
1249
- .mech-rte__surface[data-v-f118319c] li {
1286
+ .mech-rte__surface[data-v-6729e5a2] li {
1250
1287
  margin: 0.15em 0 0.15em 1.7em;
1251
1288
  list-style: disc;
1252
1289
  }
1253
- .mech-rte__surface[data-v-f118319c] li.ol {
1290
+ .mech-rte__surface[data-v-6729e5a2] li.ol {
1254
1291
  list-style: decimal;
1255
1292
  }
1256
- .mech-rte__surface[data-v-f118319c] :last-child {
1293
+ .mech-rte__surface[data-v-6729e5a2] :last-child {
1257
1294
  margin-bottom: 0;
1258
1295
  }
1259
- .mech-rte__surface[data-v-f118319c] b {
1296
+ .mech-rte__surface[data-v-6729e5a2] b {
1260
1297
  font-weight: 700;
1261
1298
  }
1262
- .mech-rte__surface[data-v-f118319c] i {
1299
+ .mech-rte__surface[data-v-6729e5a2] i {
1263
1300
  font-style: italic;
1264
1301
  }
1265
- .mech-rte__surface[data-v-f118319c] u {
1302
+ .mech-rte__surface[data-v-6729e5a2] u {
1266
1303
  text-decoration: underline;
1267
1304
  }
1268
- .mech-rte__surface[data-v-f118319c] s {
1305
+ .mech-rte__surface[data-v-6729e5a2] s {
1269
1306
  text-decoration: line-through;
1270
1307
  }
1271
- .mech-rte__surface[data-v-f118319c] a {
1308
+ .mech-rte__surface[data-v-6729e5a2] a {
1272
1309
  color: var(--mech-accent);
1273
1310
  text-decoration: underline;
1274
1311
  }
1275
- .mech-rte__surface[data-v-f118319c] code {
1312
+ .mech-rte__surface[data-v-6729e5a2] code {
1276
1313
  font-family: ui-monospace, "SFMono-Regular", Menlo, monospace;
1277
1314
  font-size: 0.88em;
1278
1315
  background: var(--mech-field-bg);
@@ -1280,7 +1317,17 @@ from {
1280
1317
  border-radius: 4px;
1281
1318
  border: 1px solid var(--mech-border);
1282
1319
  }
1283
- .mech-rte__surface[data-v-f118319c] .rt-callout {
1320
+ .mech-rte__surface[data-v-6729e5a2] .rt-nbsp,
1321
+ .mech-rte__surface[data-v-6729e5a2] .rt-nbhyphen {
1322
+ border-radius: 3px;
1323
+ background-color: color-mix(in srgb, var(--mech-accent) 15%, transparent);
1324
+ }
1325
+ .mech-rte__surface[data-v-6729e5a2] .rt-nbsp {
1326
+ background-image: radial-gradient(circle at center, var(--mech-accent) 0 1px, transparent 1.4px);
1327
+ background-repeat: no-repeat;
1328
+ background-position: center;
1329
+ }
1330
+ .mech-rte__surface[data-v-6729e5a2] .rt-callout {
1284
1331
  margin: 0.6em 0;
1285
1332
  padding: 9px 13px;
1286
1333
  border: 1px solid var(--c-border);
@@ -1290,24 +1337,24 @@ from {
1290
1337
  --c-border: #b9c8f5;
1291
1338
  --c-soft: #eef2fe;
1292
1339
  }
1293
- .mech-rte__surface[data-v-f118319c] .rt-callout--tip {
1340
+ .mech-rte__surface[data-v-6729e5a2] .rt-callout--tip {
1294
1341
  --c-border: #aee0c4;
1295
1342
  --c-soft: #eaf8f0;
1296
1343
  }
1297
- .mech-rte__surface[data-v-f118319c] .rt-callout--warning {
1344
+ .mech-rte__surface[data-v-6729e5a2] .rt-callout--warning {
1298
1345
  --c-border: #f4d39a;
1299
1346
  --c-soft: #fdf4e3;
1300
1347
  }
1301
- .mech-rte--minimal .mech-rte__surface[data-v-f118319c] {
1348
+ .mech-rte--minimal .mech-rte__surface[data-v-6729e5a2] {
1302
1349
  min-height: 52px;
1303
1350
  max-height: 220px;
1304
1351
  }
1305
- .mech-rte--minimal .mech-rte__markdown[data-v-f118319c] {
1352
+ .mech-rte--minimal .mech-rte__markdown[data-v-6729e5a2] {
1306
1353
  min-height: 52px;
1307
1354
  max-height: 220px;
1308
1355
  resize: none;
1309
1356
  }
1310
- .mech-rte__markdown[data-v-f118319c] {
1357
+ .mech-rte__markdown[data-v-6729e5a2] {
1311
1358
  border: none;
1312
1359
  resize: none;
1313
1360
  font-family: ui-monospace, "SFMono-Regular", Menlo, monospace;
@@ -1318,7 +1365,7 @@ from {
1318
1365
  width: 100%;
1319
1366
  box-sizing: border-box;
1320
1367
  }
1321
- .mech-rte__code[data-v-f118319c] {
1368
+ .mech-rte__code[data-v-6729e5a2] {
1322
1369
  margin: 0.4em 0;
1323
1370
  padding: 10px 12px;
1324
1371
  background: var(--mech-field-bg);
@@ -1328,7 +1375,7 @@ from {
1328
1375
  font-size: 12.5px;
1329
1376
  overflow-x: auto;
1330
1377
  }
1331
- .mech-rte__placeholder[data-v-f118319c] {
1378
+ .mech-rte__placeholder[data-v-6729e5a2] {
1332
1379
  position: absolute;
1333
1380
  color: var(--mech-placeholder);
1334
1381
  pointer-events: none;
package/dist/editor.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import './editor.css';
2
2
  import { f as createRouter, h as mechanicaKey, i as renderBlocks, n as pushStateUpdate, r as createComposedComponent, s as registerElements } from "./chunks/create-mechanica-CnBFiyfJ.js";
3
3
  import { i as getDataEntries, t as buildPreviewContent } from "./chunks/preview-G5_AFI5P.js";
4
- import { A as groupPagesByFolder, C as registerBuiltinFieldEditors, D as VSegmented_default, E as createContextMenu, M as pathFromName, O as fetchPages, P as VPopover_default, T as contextMenuKey, _ as findParentSlot, b as relocateBlock, c as compareBlocks, f as cloneBlock, h as findBlock, j as pageThumbUrl, k as filterPages, l as createContentBlock, n as createSaveQueue, o as shortcutChar, p as duplicateBlock, r as SchemaForm_default, s as blockAvailableIn, t as SaveConflictError, u as toBlockMeta, v as moveBlock, w as registerRichTextWidgets, x as removeBlock, y as placeBlock } from "./chunks/editor-BYMvxQTM.js";
5
- import { c as useDialog, l as _plugin_vue_export_helper_default, n as VDialog_default, o as createDialogStore, s as dialogKey, u as VIcon_default } from "./chunks/ImagePickerDialog-BfjNtRoR.js";
4
+ import { A as groupPagesByFolder, C as registerBuiltinFieldEditors, D as VSegmented_default, E as createContextMenu, M as pathFromName, O as fetchPages, P as VPopover_default, T as contextMenuKey, _ as findParentSlot, b as relocateBlock, c as compareBlocks, f as cloneBlock, h as findBlock, j as pageThumbUrl, k as filterPages, l as createContentBlock, n as createSaveQueue, o as shortcutChar, p as duplicateBlock, r as SchemaForm_default, s as blockAvailableIn, t as SaveConflictError, u as toBlockMeta, v as moveBlock, w as registerRichTextWidgets, x as removeBlock, y as placeBlock } from "./chunks/editor-9tquvDcS.js";
5
+ import { c as useDialog, d as _plugin_vue_export_helper_default, l as VIcon_default, n as VDialog_default, o as createDialogStore, s as dialogKey } from "./chunks/ImagePickerDialog-CY-mn2UP.js";
6
6
  import { Fragment, Teleport, Transition, TransitionGroup, computed, createApp, createBlock, createCommentVNode, createElementBlock, createElementVNode, createTextVNode, createVNode, defineComponent, inject, markRaw, mergeProps, nextTick, normalizeClass, normalizeStyle, onBeforeUnmount, onErrorCaptured, onMounted, onScopeDispose, openBlock, provide, reactive, ref, renderList, resolveComponent, resolveDynamicComponent, shallowRef, toDisplayString, unref, useTemplateRef, vModelText, watch, withCtx, withDirectives, withModifiers } from "vue";
7
7
  import { getDefaultValue, localeLabel, localePath, parseLocalePath, registerFieldSchemas } from "mechanica-shared";
8
8
  import { blocksList } from "virtual:mechanica/blocks";
@@ -0,0 +1,39 @@
1
+ /** Non-breaking space — glues short words so a line never breaks between them. */
2
+ export declare const NBSP = "\u00A0";
3
+ /** Non-breaking hyphen — keeps hyphenated words (из-за, кто-то) on one line. */
4
+ export declare const NB_HYPHEN = "\u2011";
5
+ /**
6
+ * The non-breaking character a keyboard event asks for, or `null`. Matches Word's
7
+ * muscle memory: Ctrl/Cmd+Shift+Space → nbsp, Ctrl/Cmd+Shift+Minus → nb-hyphen.
8
+ * Keyed off `event.code` (physical key) so it fires on any keyboard layout, the
9
+ * same way the editor's other shortcuts stay layout-independent.
10
+ */
11
+ export declare function typographyCharForEvent(event: KeyboardEvent): string | null;
12
+ /**
13
+ * Insert `char` at the caret of an `<input>`/`<textarea>`, replacing any
14
+ * selection, and leave the caret after it. Returns the field's new value so the
15
+ * caller can emit it. `setRangeText` keeps native undo working.
16
+ */
17
+ export declare function insertAtCursor(el: HTMLInputElement | HTMLTextAreaElement, char: string): string;
18
+ /** Whether the text carries any non-breaking character worth marking. */
19
+ export declare function hasTypographyChar(text: string): boolean;
20
+ /**
21
+ * HTML that mirrors `text` for the plain-field marker overlay: every character
22
+ * keeps its exact glyph advance (so the layer lines up with the real field), but
23
+ * the two non-breaking characters are wrapped in marker spans the CSS lights up.
24
+ * The characters themselves stay in the spans — that's what preserves the width.
25
+ */
26
+ export declare function highlightTypographyHtml(text: string): string;
27
+ /** A display-only style span (vuewrite's `Style` shape, structurally typed). */
28
+ export interface TypographySpan {
29
+ start: number;
30
+ end: number;
31
+ style: string;
32
+ }
33
+ /**
34
+ * vuewrite `parser`: scans a block's text and emits a one-character style span
35
+ * over every non-breaking character. vuewrite merges these at *render* time only
36
+ * — they never enter the stored model, so the Markdown on disk stays clean while
37
+ * the editor still shows where the glue is.
38
+ */
39
+ export declare function typographyParser(text: string): TypographySpan[];
package/dist/widgets.js CHANGED
@@ -1,4 +1,4 @@
1
- import { s as dialogKey, t as ImagePickerDialog_default } from "./chunks/ImagePickerDialog-BfjNtRoR.js";
1
+ import { s as dialogKey, t as ImagePickerDialog_default } from "./chunks/ImagePickerDialog-CY-mn2UP.js";
2
2
  import { inject } from "vue";
3
3
  //#region src/editor/widget-api.ts
4
4
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mechanica",
3
- "version": "2.0.0-alpha.4",
3
+ "version": "2.0.0-alpha.5",
4
4
  "description": "Build Vue 3 websites with a visual block editor",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -63,7 +63,7 @@
63
63
  "prepublishOnly": "bun run build"
64
64
  },
65
65
  "dependencies": {
66
- "mechanica-shared": "2.0.0-alpha.4",
66
+ "mechanica-shared": "workspace:*",
67
67
  "@vue/compiler-sfc": "^3.5.38",
68
68
  "compact-json-schema": "^0.1.5",
69
69
  "estree-walker": "^3.0.3",
@@ -0,0 +1,131 @@
1
+ <template>
2
+ <div class="mech-typo">
3
+ <textarea
4
+ v-if="multiline"
5
+ ref="fieldRef"
6
+ class="mech-input mech-textarea"
7
+ :value="modelValue"
8
+ :placeholder="placeholder"
9
+ :rows="rows"
10
+ @input="onInput"
11
+ @keydown="onKeyDown"
12
+ @scroll="syncScroll"
13
+ />
14
+ <input
15
+ v-else
16
+ ref="fieldRef"
17
+ class="mech-input"
18
+ type="text"
19
+ :value="modelValue"
20
+ :placeholder="placeholder"
21
+ @input="onInput"
22
+ @keydown="onKeyDown"
23
+ @scroll="syncScroll"
24
+ />
25
+ <!-- Marker layer: a transparent mirror of the text sitting exactly over the
26
+ field, lighting up the two invisible non-breaking characters. Metrics
27
+ mirror .mech-input so the glyphs line up; pointer-events:none keeps the
28
+ real field fully interactive underneath. -->
29
+ <div
30
+ ref="overlayRef"
31
+ class="mech-typo__overlay"
32
+ :class="multiline ? 'is-multiline' : 'is-single'"
33
+ aria-hidden="true"
34
+ v-html="highlighted"
35
+ />
36
+ </div>
37
+ </template>
38
+
39
+ <script setup lang="ts">
40
+ import { computed, nextTick, ref } from 'vue'
41
+ import { highlightTypographyHtml, insertAtCursor, typographyCharForEvent } from '../lib/typography'
42
+
43
+ const props = withDefaults(
44
+ defineProps<{ modelValue?: string; multiline?: boolean; placeholder?: string; rows?: number }>(),
45
+ { rows: 3 },
46
+ )
47
+ const emit = defineEmits<{ 'update:modelValue': [string] }>()
48
+
49
+ const fieldRef = ref<HTMLInputElement | HTMLTextAreaElement>()
50
+ const overlayRef = ref<HTMLElement>()
51
+
52
+ const highlighted = computed(() => highlightTypographyHtml(props.modelValue ?? ''))
53
+
54
+ function onInput(event: Event): void {
55
+ emit('update:modelValue', (event.target as HTMLInputElement | HTMLTextAreaElement).value)
56
+ nextTick(syncScroll)
57
+ }
58
+
59
+ // Ctrl/Cmd+Shift+Space → nbsp, Ctrl/Cmd+Shift+Minus → non-breaking hyphen.
60
+ function onKeyDown(event: KeyboardEvent): void {
61
+ const char = typographyCharForEvent(event)
62
+ if (!char) return
63
+ event.preventDefault()
64
+ const el = fieldRef.value
65
+ if (!el) return
66
+ emit('update:modelValue', insertAtCursor(el, char))
67
+ nextTick(syncScroll)
68
+ }
69
+
70
+ // The overlay is a separate scroll box; keep it locked to the field's scroll so
71
+ // the markers stay glued to their characters as the text scrolls.
72
+ function syncScroll(): void {
73
+ const el = fieldRef.value
74
+ const overlay = overlayRef.value
75
+ if (!el || !overlay) return
76
+ overlay.scrollTop = el.scrollTop
77
+ overlay.scrollLeft = el.scrollLeft
78
+ }
79
+ </script>
80
+
81
+ <style lang="scss" scoped>
82
+ .mech-typo {
83
+ position: relative;
84
+ display: block;
85
+ width: 100%;
86
+ }
87
+
88
+ // Transparent mirror layer. Its box metrics (border/padding/font) match
89
+ // .mech-input so each character sits at the same x/y as in the real field.
90
+ .mech-typo__overlay {
91
+ position: absolute;
92
+ inset: 0;
93
+ box-sizing: border-box;
94
+ border: 1px solid transparent;
95
+ z-index: 1;
96
+ overflow: hidden;
97
+ pointer-events: none;
98
+ color: transparent;
99
+ font: inherit;
100
+ font-size: 13.5px;
101
+
102
+ &.is-single {
103
+ padding: 0 12px;
104
+ // Content-box height (38px field − 2×1px border) → the single line centers
105
+ // vertically the same way an <input> centers its text.
106
+ line-height: 36px;
107
+ white-space: pre;
108
+ }
109
+ &.is-multiline {
110
+ padding: 9px 12px;
111
+ line-height: 1.45;
112
+ white-space: pre-wrap;
113
+ overflow-wrap: break-word;
114
+ }
115
+
116
+ // The glue markers. A translucent tint (not a solid fill) so the real field's
117
+ // text — notably the non-breaking hyphen's glyph — stays visible underneath.
118
+ :deep(.mech-typo__nbsp),
119
+ :deep(.mech-typo__nbhyphen) {
120
+ border-radius: 3px;
121
+ background-color: color-mix(in srgb, var(--mech-accent) 15%, transparent);
122
+ }
123
+ // nbsp is a blank in the field, so also paint a small centered dot — the
124
+ // classic "visible whitespace" cue, saying "there's a space, and it's tied".
125
+ :deep(.mech-typo__nbsp) {
126
+ background-image: radial-gradient(circle at center, var(--mech-accent) 0 1px, transparent 1.4px);
127
+ background-repeat: no-repeat;
128
+ background-position: center;
129
+ }
130
+ }
131
+ </style>
@@ -1,15 +1,13 @@
1
1
  <template>
2
- <input
3
- class="mech-input"
4
- type="text"
5
- :value="modelValue"
2
+ <TypographyInput
3
+ :model-value="modelValue"
6
4
  :placeholder="schema.placeholder"
7
- @input="onInput"
5
+ @update:model-value="emit('update:modelValue', $event)"
8
6
  />
9
7
  </template>
10
8
 
11
9
  <script setup lang="ts">
10
+ import TypographyInput from '../../components/TypographyInput.vue'
12
11
  defineProps<{ modelValue?: string; schema: Record<string, any> }>()
13
12
  const emit = defineEmits<{ 'update:modelValue': [string] }>()
14
- const onInput = (event: Event) => emit('update:modelValue', (event.target as HTMLInputElement).value)
15
13
  </script>
@@ -1,15 +1,15 @@
1
1
  <template>
2
- <textarea
3
- class="mech-input mech-textarea"
4
- :value="modelValue"
2
+ <TypographyInput
3
+ multiline
4
+ :model-value="modelValue"
5
5
  :placeholder="schema.placeholder"
6
- rows="3"
7
- @input="onInput"
6
+ :rows="3"
7
+ @update:model-value="emit('update:modelValue', $event)"
8
8
  />
9
9
  </template>
10
10
 
11
11
  <script setup lang="ts">
12
+ import TypographyInput from '../../components/TypographyInput.vue'
12
13
  defineProps<{ modelValue?: string; schema: Record<string, any> }>()
13
14
  const emit = defineEmits<{ 'update:modelValue': [string] }>()
14
- const onInput = (event: Event) => emit('update:modelValue', (event.target as HTMLTextAreaElement).value)
15
15
  </script>
@@ -103,6 +103,7 @@
103
103
  class="mech-rte__surface"
104
104
  :renderer="renderer"
105
105
  :decorator="decorator"
106
+ :parser="typographyParser"
106
107
  :html-parser="htmlParser"
107
108
  @keydown="onKeyDown"
108
109
  @contextmenu="onContextMenu"
@@ -132,6 +133,7 @@
132
133
  spellcheck="false"
133
134
  :placeholder="placeholder"
134
135
  @input="onMarkdownInput"
136
+ @keydown="onMarkdownKeyDown"
135
137
  />
136
138
  </div>
137
139
  </div>
@@ -150,6 +152,7 @@ import WidgetBoundary from './WidgetBoundary.vue'
150
152
  import { allRichTextWidgets, type RichTextWidget } from './widgets'
151
153
  import { richTextEditorRefKey } from './keys'
152
154
  import { contextMenuKey, type ContextMenuItem } from '../../lib/context-menu'
155
+ import { insertAtCursor, typographyCharForEvent, typographyParser } from '../../lib/typography'
153
156
  import { renderer, decorator, htmlParser, blockTypes } from './config'
154
157
 
155
158
  const props = withDefaults(
@@ -464,12 +467,35 @@ function onMarkdownInput(event: Event): void {
464
467
  nextTick(() => (fromMarkdown = false))
465
468
  }
466
469
 
470
+ /** The nbsp / non-breaking-hyphen shortcuts in the raw Markdown view too. */
471
+ function onMarkdownKeyDown(event: KeyboardEvent): void {
472
+ const char = typographyCharForEvent(event)
473
+ if (!char || !markdownRef.value) return
474
+ event.preventDefault()
475
+ const md = insertAtCursor(markdownRef.value, char)
476
+ markdown.value = md
477
+ fromMarkdown = true
478
+ model.value = markdownToBlocks(md, model.value, { softBreaks: true })
479
+ autosizeMarkdown()
480
+ nextTick(() => (fromMarkdown = false))
481
+ }
482
+
467
483
  // ── Keyboard: format shortcuts + Markdown prefixes + list continuation ────────────
468
484
 
469
485
  function onKeyDown(event: KeyboardEvent): void {
470
486
  const editor = editorRef.value
471
487
  if (!editor) return
472
488
 
489
+ // Ctrl/Cmd+Shift+Space → nbsp, Ctrl/Cmd+Shift+Minus → non-breaking hyphen.
490
+ // insertText applies pending styles, so glue typed inside a bold run stays bold.
491
+ const nb = typographyCharForEvent(event)
492
+ if (nb) {
493
+ event.preventDefault()
494
+ editor.insertText(nb)
495
+ editor.pushHistory('insertText')
496
+ return
497
+ }
498
+
473
499
  if ((event.ctrlKey || event.metaKey) && !event.shiftKey && !event.altKey) {
474
500
  const style = ({ KeyB: 'bold', KeyI: 'italic', KeyU: 'underline' } as Record<string, string>)[event.code]
475
501
  if (style) {
@@ -691,6 +717,18 @@ function onKeyDown(event: KeyboardEvent): void {
691
717
  border-radius: 4px;
692
718
  border: 1px solid var(--mech-border);
693
719
  }
720
+ // Non-breaking glue, made visible (editor-only — driven by typographyParser,
721
+ // never stored, never rendered on the published page).
722
+ :deep(.rt-nbsp),
723
+ :deep(.rt-nbhyphen) {
724
+ border-radius: 3px;
725
+ background-color: color-mix(in srgb, var(--mech-accent) 15%, transparent);
726
+ }
727
+ :deep(.rt-nbsp) {
728
+ background-image: radial-gradient(circle at center, var(--mech-accent) 0 1px, transparent 1.4px);
729
+ background-repeat: no-repeat;
730
+ background-position: center;
731
+ }
694
732
  :deep(.rt-callout) {
695
733
  margin: 0.6em 0;
696
734
  padding: 9px 13px;
@@ -47,6 +47,12 @@ export const decorator = (style: Style): Record<string, unknown> | undefined =>
47
47
  return { tag: 'a', attrs: { href: style.meta?.href, target: '_blank', rel: 'noopener' } }
48
48
  case 'color':
49
49
  return { style: `color: ${style.meta?.color};` }
50
+ // Display-only markers for the non-breaking space / hyphen, produced by
51
+ // `typographyParser` at render time (never stored). See lib/typography.ts.
52
+ case 'nbsp':
53
+ return { tag: 'span', class: 'rt-nbsp' }
54
+ case 'nbHyphen':
55
+ return { tag: 'span', class: 'rt-nbhyphen' }
50
56
  default:
51
57
  return undefined
52
58
  }
@@ -0,0 +1,80 @@
1
+ // Non-breaking typography: inserting and *seeing* the two invisible glue
2
+ // characters that matter for good (especially Russian) typography — the
3
+ // non-breaking space (U+00A0) and the non-breaking hyphen (U+2011). The pieces
4
+ // here are DOM-light and pure so they're unit-testable; the plain-field overlay
5
+ // (TypographyInput.vue) and the rich-text editor share them.
6
+
7
+ /** Non-breaking space — glues short words so a line never breaks between them. */
8
+ export const NBSP = ' '
9
+ /** Non-breaking hyphen — keeps hyphenated words (из-за, кто-то) on one line. */
10
+ export const NB_HYPHEN = '‑'
11
+
12
+ /**
13
+ * The non-breaking character a keyboard event asks for, or `null`. Matches Word's
14
+ * muscle memory: Ctrl/Cmd+Shift+Space → nbsp, Ctrl/Cmd+Shift+Minus → nb-hyphen.
15
+ * Keyed off `event.code` (physical key) so it fires on any keyboard layout, the
16
+ * same way the editor's other shortcuts stay layout-independent.
17
+ */
18
+ export function typographyCharForEvent(event: KeyboardEvent): string | null {
19
+ if (!(event.ctrlKey || event.metaKey) || !event.shiftKey || event.altKey) return null
20
+ if (event.code === 'Space') return NBSP
21
+ if (event.code === 'Minus') return NB_HYPHEN
22
+ return null
23
+ }
24
+
25
+ /**
26
+ * Insert `char` at the caret of an `<input>`/`<textarea>`, replacing any
27
+ * selection, and leave the caret after it. Returns the field's new value so the
28
+ * caller can emit it. `setRangeText` keeps native undo working.
29
+ */
30
+ export function insertAtCursor(el: HTMLInputElement | HTMLTextAreaElement, char: string): string {
31
+ const start = el.selectionStart ?? el.value.length
32
+ const end = el.selectionEnd ?? start
33
+ el.setRangeText(char, start, end, 'end')
34
+ return el.value
35
+ }
36
+
37
+ /** Whether the text carries any non-breaking character worth marking. */
38
+ export function hasTypographyChar(text: string): boolean {
39
+ return text.includes(NBSP) || text.includes(NB_HYPHEN)
40
+ }
41
+
42
+ const HTML_ESCAPES: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;' }
43
+
44
+ function escapeHtml(text: string): string {
45
+ return text.replace(/[&<>]/g, (ch) => HTML_ESCAPES[ch] ?? ch)
46
+ }
47
+
48
+ /**
49
+ * HTML that mirrors `text` for the plain-field marker overlay: every character
50
+ * keeps its exact glyph advance (so the layer lines up with the real field), but
51
+ * the two non-breaking characters are wrapped in marker spans the CSS lights up.
52
+ * The characters themselves stay in the spans — that's what preserves the width.
53
+ */
54
+ export function highlightTypographyHtml(text: string): string {
55
+ return escapeHtml(text)
56
+ .replaceAll(NBSP, `<span class="mech-typo__nbsp">${NBSP}</span>`)
57
+ .replaceAll(NB_HYPHEN, `<span class="mech-typo__nbhyphen">${NB_HYPHEN}</span>`)
58
+ }
59
+
60
+ /** A display-only style span (vuewrite's `Style` shape, structurally typed). */
61
+ export interface TypographySpan {
62
+ start: number
63
+ end: number
64
+ style: string
65
+ }
66
+
67
+ /**
68
+ * vuewrite `parser`: scans a block's text and emits a one-character style span
69
+ * over every non-breaking character. vuewrite merges these at *render* time only
70
+ * — they never enter the stored model, so the Markdown on disk stays clean while
71
+ * the editor still shows where the glue is.
72
+ */
73
+ export function typographyParser(text: string): TypographySpan[] {
74
+ const spans: TypographySpan[] = []
75
+ for (let i = 0; i < text.length; i++) {
76
+ if (text[i] === NBSP) spans.push({ start: i, end: i + 1, style: 'nbsp' })
77
+ else if (text[i] === NB_HYPHEN) spans.push({ start: i, end: i + 1, style: 'nbHyphen' })
78
+ }
79
+ return spans
80
+ }