@brandup/ui-richeditor 1.0.36

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.
@@ -0,0 +1,119 @@
1
+ // Нормализация содержимого редактора: схлопывание пробелов по строкам и приведение
2
+ // верхнего уровня к абзацам <p> (модель многострочного режима).
3
+
4
+ import { cleanupFormatting } from "./selection";
5
+
6
+ /**
7
+ * Нормализует пробелы в редакторе: схлопывает повторяющиеся пробелы/табы в один
8
+ * и обрезает пробелы по краям каждой строки. BR и блочные элементы (DIV/P) —
9
+ * границы строк; инлайновое форматирование (b/i/s/u) на строки не влияет.
10
+ */
11
+ export function normalizeWhitespace(root: HTMLElement) {
12
+ type Item = { kind: "text"; node: Text } | { kind: "break" };
13
+ const items: Item[] = [];
14
+
15
+ const flatten = (node: Node) => {
16
+ for (const child of Array.from(node.childNodes)) {
17
+ if (child.nodeType === Node.TEXT_NODE) {
18
+ items.push({ kind: "text", node: child as Text });
19
+ } else if (child.nodeType === Node.ELEMENT_NODE) {
20
+ const el = child as HTMLElement;
21
+ if (el.tagName === "BR") {
22
+ items.push({ kind: "break" });
23
+ } else if (el.tagName === "DIV" || el.tagName === "P") {
24
+ items.push({ kind: "break" });
25
+ flatten(el);
26
+ items.push({ kind: "break" });
27
+ } else {
28
+ flatten(el); // инлайновый тег — не разрывает строку
29
+ }
30
+ }
31
+ }
32
+ };
33
+ flatten(root);
34
+
35
+ let atLineStart = true;
36
+ let pendingSpaceNode: Text | null = null; // узел, заканчивающийся пробелом (возможно хвостовым)
37
+
38
+ for (const item of items) {
39
+ if (item.kind === "break") {
40
+ if (pendingSpaceNode) {
41
+ pendingSpaceNode.data = pendingSpaceNode.data.replace(/ $/, "");
42
+ pendingSpaceNode = null;
43
+ }
44
+ atLineStart = true;
45
+ continue;
46
+ }
47
+
48
+ let text = item.node.data.replace(/[ \t]+/g, " ");
49
+ if (atLineStart) text = text.replace(/^ /, ""); // пробел в начале строки
50
+ if (pendingSpaceNode && text.startsWith(" ")) text = text.slice(1); // двойной пробел на границе узлов
51
+
52
+ item.node.data = text;
53
+ if (text.length === 0) continue;
54
+
55
+ atLineStart = false;
56
+ pendingSpaceNode = text.endsWith(" ") ? item.node : null;
57
+ }
58
+
59
+ if (pendingSpaceNode) pendingSpaceNode.data = pendingSpaceNode.data.replace(/ $/, ""); // хвост последней строки
60
+
61
+ cleanupFormatting(root); // убрать опустевшие теги, склеить узлы
62
+ }
63
+
64
+ /**
65
+ * Нормализует абзацы многострочного режима: удаляет пустые абзацы (без текстового содержимого).
66
+ * Если содержимого нет вовсе — редактор остаётся пустым (показывается placeholder).
67
+ */
68
+ export function normalizeParagraphs(root: HTMLElement) {
69
+ for (const el of Array.from(root.children)) {
70
+ if (el.tagName === "P" && (el.textContent ?? "").trim() === "") el.remove();
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Нормализует верхний уровень редактора к абзацам <p>: блуждающие текст/инлайн оборачиваются в <p>,
76
+ * <div> заменяются на <p>, пустые абзацы получают <br>-заполнитель (чтобы строка была видимой).
77
+ */
78
+ export function ensureParagraphs(root: HTMLElement) {
79
+ let run: ChildNode[] = [];
80
+
81
+ const flushRun = (before: Node | null) => {
82
+ if (!run.length) return;
83
+ const p = document.createElement("p");
84
+ for (const node of run) p.appendChild(node);
85
+ root.insertBefore(p, before);
86
+ run = [];
87
+ };
88
+
89
+ for (const node of Array.from(root.childNodes)) {
90
+ const el = node.nodeType === Node.ELEMENT_NODE ? (node as HTMLElement) : null;
91
+
92
+ if (el && (el.tagName === "P" || el.tagName === "DIV")) {
93
+ flushRun(node);
94
+ if (el.tagName === "DIV") {
95
+ const p = document.createElement("p");
96
+ while (el.firstChild) p.appendChild(el.firstChild);
97
+ root.replaceChild(p, el);
98
+ }
99
+ } else {
100
+ run.push(node);
101
+ }
102
+ }
103
+ flushRun(null);
104
+
105
+ for (const p of Array.from(root.querySelectorAll("p"))) {
106
+ if (!p.firstChild) {
107
+ p.appendChild(document.createElement("br")); // пустой абзац — заполнитель для видимости строки
108
+ continue;
109
+ }
110
+
111
+ // в непустом абзаце убираем краевые <br>-заполнители: иначе введённый текст
112
+ // оказывается рядом с лишним переносом (символ «съезжает» на новую строку).
113
+ // Внутренние <br> (мягкие переносы) сохраняются.
114
+ if ((p.textContent ?? "").length > 0) {
115
+ while (p.firstChild && p.firstChild.nodeName === "BR") p.removeChild(p.firstChild);
116
+ while (p.lastChild && p.lastChild.nodeName === "BR") p.removeChild(p.lastChild);
117
+ }
118
+ }
119
+ }
@@ -0,0 +1,127 @@
1
+ // Стили RichEditor: редактируемая область, отображение тегов форматирования
2
+ // и общий тулбар форматирования (живёт в document.body, см. ./toolbar).
3
+ // CSS-переменные --input-* предоставляет @brandup/ui-kit; заданы fallback'и для standalone-использования.
4
+
5
+ // .ui-richeditor — это сам редактируемый элемент (обёртки нет, тулбар общий в body)
6
+ .ui-richeditor {
7
+ appearance: none;
8
+ position: relative;
9
+ box-sizing: border-box;
10
+ outline: none;
11
+ word-wrap: anywhere;
12
+ white-space: pre-wrap; // сохраняем пробелы (в т.ч. ведущие/повторяющиеся)
13
+
14
+ // абзацы — без user-agent-полей, иначе первый <p> «съезжает» вниз (как новая строка)
15
+ & p {
16
+ margin: 0;
17
+ padding: 5px 0;
18
+
19
+ &:first-child {
20
+ padding-top: 0;
21
+ }
22
+
23
+ &:last-child {
24
+ padding-bottom: 0;
25
+ }
26
+ }
27
+
28
+ // видимые теги форматирования
29
+ & b,
30
+ & strong {
31
+ font-weight: 700;
32
+ }
33
+ & i,
34
+ & em {
35
+ font-style: italic;
36
+ }
37
+ & s,
38
+ & strike,
39
+ & del {
40
+ text-decoration: line-through;
41
+ }
42
+ & u,
43
+ & ins {
44
+ text-decoration: underline;
45
+ }
46
+
47
+ &:empty:after {
48
+ content: attr(data-placeholder);
49
+ display: block;
50
+ position: absolute;
51
+ left: 0;
52
+ top: 0;
53
+ right: 0;
54
+ color: var(--placeholder-color, #999);
55
+ font-weight: var(--placeholder-font-weight, inherit);
56
+ font-style: var(--placeholder-font-style, inherit);
57
+ box-sizing: border-box;
58
+ overflow: hidden;
59
+ white-space: nowrap;
60
+ text-overflow: ellipsis;
61
+ pointer-events: none;
62
+ }
63
+
64
+ &.multiline:empty:after {
65
+ white-space: normal;
66
+ text-overflow: unset;
67
+ }
68
+ }
69
+
70
+ // общий тулбар форматирования — единый для всех редакторов.
71
+ // По умолчанию в document.body (position: fixed, координаты задаёт ./toolbar);
72
+ // с классом .in-container — позиционируется над контейнером-родителем (position: absolute).
73
+ .ui-richeditor-toolbar {
74
+ display: none;
75
+ position: fixed;
76
+ z-index: 1000;
77
+ flex-flow: row nowrap;
78
+ align-items: center;
79
+ gap: 2px;
80
+ padding: 3px;
81
+ background-color: var(--input-fill, #fff);
82
+ border: var(--input-border-type, solid) var(--input-border-width, 1px) var(--input-border-color, #aaa);
83
+ border-radius: var(--input-border-radius, 0);
84
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
85
+
86
+ --svg-size: 18px;
87
+ --svg-fill: var(--input-color, #222);
88
+
89
+ &.visible {
90
+ display: flex;
91
+ }
92
+
93
+ // позиционирование относительно контейнера-родителя (например, .ui-textbox)
94
+ &.in-container {
95
+ position: absolute;
96
+ left: 0;
97
+ bottom: 100%;
98
+ margin-bottom: 6px;
99
+ }
100
+
101
+ & .format-button {
102
+ width: calc(var(--input-height, 46px) - 12px);
103
+ height: calc(var(--input-height, 46px) - 12px);
104
+ border-radius: calc(var(--input-border-radius, 0) - 2px);
105
+ border: 0;
106
+ background: var(--input-toolbar-button-fill, transparent);
107
+ cursor: pointer;
108
+ display: flex;
109
+ align-items: center;
110
+ justify-content: center;
111
+
112
+ & svg {
113
+ width: var(--svg-size, 18px);
114
+ height: var(--svg-size, 18px);
115
+ fill: var(--svg-fill, currentColor);
116
+ pointer-events: none;
117
+ }
118
+
119
+ &:hover {
120
+ background-color: var(--hover--input-toolbar-button-fill, rgba(0, 0, 0, 0.06));
121
+ }
122
+
123
+ &.active {
124
+ background-color: var(--hover--input-toolbar-button-fill, rgba(0, 0, 0, 0.12));
125
+ }
126
+ }
127
+ }