@kvass/rich-text 1.5.2 → 1.6.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kvass/rich-text",
3
- "version": "1.5.2",
3
+ "version": "1.6.0",
4
4
  "description": "rich text component for kvass",
5
5
  "main": "index.js",
6
6
  "scripts": {
package/src/Component.vue CHANGED
@@ -32,10 +32,9 @@
32
32
  v-for="(item, index) in getCommands('bubble')"
33
33
  :key="'bubble' + index"
34
34
  class="richtext__bubble-button"
35
- :class="{
36
- 'is-active': editor.isActive(item.isActiveKey || item.stateKey, item.commandProps),
37
- }"
35
+ :class="{ 'is-active': isItemActive(item) }"
38
36
  :disabled="disabled || item.disabled"
37
+ :title="labels[item.stateKey]"
39
38
  @click="() => onMenuBarButtonClick(item)"
40
39
  type="button"
41
40
  >
@@ -53,10 +52,9 @@
53
52
  v-for="(item, index) in getCommands('menu')"
54
53
  :key="'menubar' + index"
55
54
  type="button"
56
- :class="{
57
- 'is-active': editor.isActive(item.isActiveKey || item.stateKey, item.commandProps),
58
- }"
55
+ :class="{ 'is-active': isItemActive(item) }"
59
56
  :disabled="disabled || item.disabled"
57
+ :title="labels[item.stateKey]"
60
58
  @click="() => onMenuBarButtonClick(item)"
61
59
  >
62
60
  <FontAwesomeIcon :icon="item.icon" size="sm" />
@@ -96,6 +94,7 @@ import Image from '@tiptap/extension-image'
96
94
  import Superscript from '@tiptap/extension-superscript'
97
95
  import CallToAction from './extensions/cta'
98
96
  import StarterKit from '@tiptap/starter-kit'
97
+ import { LineCommands, LineShortcuts, isLineActive, runLineCommand } from './lines'
99
98
 
100
99
  import { Options } from '../index'
101
100
 
@@ -123,7 +122,7 @@ export default {
123
122
  },
124
123
  watch: {
125
124
  value(value) {
126
- if (this.editor.getHTML() === value) return
125
+ if (this.getValue() === value) return
127
126
  this.editor.commands.setContent(this.value, false)
128
127
  },
129
128
  disabled(value) {
@@ -273,8 +272,23 @@ export default {
273
272
  .filter(Boolean)
274
273
  .filter(cmd => !appearance || cmd.appearance[appearance])
275
274
  },
275
+ getValue() {
276
+ const { doc } = this.editor.state
277
+ if (Tiptap.isNodeEmpty(doc, { ignoreWhitespace: true }) && !/\S/.test(doc.textContent))
278
+ return ''
279
+ return this.editor.getHTML()
280
+ },
281
+ isItemActive(item) {
282
+ const name = item.isActiveKey || item.stateKey
283
+ if (LineCommands.includes(item.commandKey))
284
+ return isLineActive(this.editor.state, name, item.commandProps)
285
+
286
+ return this.editor.isActive(name, item.commandProps)
287
+ },
276
288
  onMenuBarButtonClick(item) {
277
289
  if (item.customFunc) return item.customFunc.call(this, { editor: this.editor })
290
+ if (LineCommands.includes(item.commandKey))
291
+ return runLineCommand(this.editor, item.commandKey, item.commandProps)
278
292
 
279
293
  return this.editor.chain().focus()[item.commandKey](item.commandProps).run()
280
294
  },
@@ -312,12 +326,7 @@ export default {
312
326
  this.linkUrl = null
313
327
  },
314
328
  bubbleShouldShow({ editor }) {
315
- return (
316
- this.getCommands()
317
- .filter(a => a.appearance.bubble)
318
- .map(a => a.stateKey)
319
- .some(k => editor.isActive(k)) || !this.editor.state.selection.empty
320
- )
329
+ return !editor.state.selection.empty
321
330
  },
322
331
  isInternalUrl(value) {
323
332
  if (!typeof value === 'string') return
@@ -342,18 +351,14 @@ export default {
342
351
  editable: !this.disabled,
343
352
  extensions: [
344
353
  StarterKit,
354
+ LineShortcuts,
345
355
  ...this.getCommands()
346
356
  .map(({ extension }) => extension)
347
357
  .flat()
348
358
  .filter(Boolean),
349
359
  ],
350
360
  content: this.value,
351
- onUpdate: () => {
352
- const html = this.editor.getHTML()
353
- if (/^<p>\s*<\/p>$/.test(html)) return this.$emit('input', '')
354
-
355
- return this.$emit('input', html)
356
- },
361
+ onUpdate: () => this.$emit('input', this.getValue()),
357
362
  onSelectionUpdate: ({ editor }) => {
358
363
  if (!this.bubbleShouldShow({ editor })) this.bubbleState = null
359
364
  },
package/src/lines.js ADDED
@@ -0,0 +1,152 @@
1
+ import { CommandManager, Extension, isActive } from '@tiptap/core'
2
+ import { EditorState, Selection, TextSelection } from '@tiptap/pm/state'
3
+ import { canSplit } from '@tiptap/pm/transform'
4
+
5
+ export const LineCommands = ['toggleHeading', 'toggleBulletList', 'toggleOrderedList']
6
+
7
+ const ListTypes = { toggleBulletList: 'bulletList', toggleOrderedList: 'orderedList' }
8
+
9
+ function isBreak(node) {
10
+ return Boolean(node) && node.type.name === 'hardBreak'
11
+ }
12
+
13
+ function closestList($pos) {
14
+ for (let depth = $pos.depth; depth > 0; depth--) {
15
+ const node = $pos.node(depth)
16
+ if (Object.values(ListTypes).includes(node.type.name)) return node
17
+ }
18
+ }
19
+
20
+ function trimSelection(doc, from, to) {
21
+ while (from < to) {
22
+ const $to = doc.resolve(to)
23
+ if (isBreak($to.nodeBefore)) {
24
+ to--
25
+ continue
26
+ }
27
+ if ($to.parentOffset > 0) break
28
+ const previous = Selection.findFrom(doc.resolve($to.before()), -1, true)
29
+ if (!previous || previous.from < from) break
30
+ to = previous.from
31
+ }
32
+
33
+ while (from < to) {
34
+ const $from = doc.resolve(from)
35
+ if (isBreak($from.nodeAfter)) {
36
+ from++
37
+ continue
38
+ }
39
+ if ($from.parentOffset < $from.parent.content.size) break
40
+ const next = Selection.findFrom(doc.resolve($from.after()), 1, true)
41
+ if (!next || next.from > to) break
42
+ from = next.from
43
+ }
44
+
45
+ return { from, to }
46
+ }
47
+
48
+ function splitSelectedLines(tr, commandKey) {
49
+ const { selection, doc } = tr
50
+ if (!(selection instanceof TextSelection)) return false
51
+
52
+ const { from, to } = trimSelection(doc, selection.from, selection.to)
53
+ const $from = doc.resolve(from)
54
+ const $to = doc.resolve(to)
55
+
56
+ const listType = ListTypes[commandKey]
57
+ const list = listType && closestList($from)
58
+ if (list && list.type.name !== listType) return false
59
+
60
+ const breaks = new Set()
61
+ let before
62
+ $from.parent.forEach((node, offset) => {
63
+ const pos = $from.start() + offset
64
+ if (isBreak(node) && pos < from) before = pos
65
+ })
66
+ if (before !== undefined) breaks.add(before)
67
+
68
+ if (listType)
69
+ doc.nodesBetween(from, to, (node, pos) => {
70
+ if (isBreak(node) && pos >= from && pos < to) breaks.add(pos)
71
+ })
72
+
73
+ let after
74
+ $to.parent.forEach((node, offset) => {
75
+ const pos = $to.start() + offset
76
+ if (after === undefined && isBreak(node) && pos >= to) after = pos
77
+ })
78
+ if (after !== undefined) breaks.add(after)
79
+
80
+ if (!breaks.size && from === selection.from && to === selection.to) return false
81
+
82
+ const mapFrom = tr.steps.length
83
+ Array.from(breaks)
84
+ .sort((a, b) => b - a)
85
+ .forEach(pos => {
86
+ const $after = tr.doc.resolve(pos + 1)
87
+ const inListItem = $after.depth > 1 && $after.node(-1).type.name === 'listItem'
88
+ const depth = inListItem && canSplit(tr.doc, pos + 1, 2) ? 2 : 1
89
+ if (canSplit(tr.doc, pos + 1, depth)) tr.split(pos + 1, depth).delete(pos, pos + 1)
90
+ })
91
+
92
+ const mapping = tr.mapping.slice(mapFrom)
93
+ tr.setSelection(
94
+ TextSelection.between(tr.doc.resolve(mapping.map(from)), tr.doc.resolve(mapping.map(to))),
95
+ )
96
+ return true
97
+ }
98
+
99
+ export function isLineActive(state, name, attributes) {
100
+ const { selection, doc, schema } = state
101
+ if (!(selection instanceof TextSelection)) return isActive(state, name, attributes)
102
+
103
+ const { from, to } = trimSelection(doc, selection.from, selection.to)
104
+ return isActive({ doc, schema, selection: TextSelection.create(doc, from, to) }, name, attributes)
105
+ }
106
+
107
+ export function runLineCommand(editor, commandKey, commandProps) {
108
+ const { state } = editor
109
+ const { tr } = state
110
+
111
+ if (!splitSelectedLines(tr, commandKey))
112
+ return editor.chain().focus()[commandKey](commandProps).run()
113
+
114
+ const split = EditorState.create({
115
+ schema: state.schema,
116
+ doc: tr.doc,
117
+ selection: tr.selection,
118
+ plugins: state.plugins,
119
+ })
120
+ const { tr: commandTr } = split
121
+ const ok = new CommandManager({ editor, state: split })
122
+ .createChain(commandTr)
123
+ [commandKey](commandProps)
124
+ .run()
125
+ if (!ok) return false
126
+
127
+ commandTr.steps.forEach(step => tr.step(step))
128
+ tr.setSelection(Selection.fromJSON(tr.doc, commandTr.selection.toJSON()))
129
+ if (commandTr.storedMarksSet) tr.setStoredMarks(commandTr.storedMarks)
130
+
131
+ editor.view.dispatch(tr)
132
+ editor.commands.focus()
133
+ return true
134
+ }
135
+
136
+ export const LineShortcuts = Extension.create({
137
+ name: 'lineShortcuts',
138
+ priority: 1001,
139
+
140
+ addKeyboardShortcuts() {
141
+ const heading = this.editor.extensionManager.extensions.find(e => e.name === 'heading')
142
+ const shortcuts = {
143
+ 'Mod-Shift-8': () => runLineCommand(this.editor, 'toggleBulletList'),
144
+ 'Mod-Shift-7': () => runLineCommand(this.editor, 'toggleOrderedList'),
145
+ 'Mod-Alt-0': () => runLineCommand(this.editor, 'setParagraph'),
146
+ }
147
+ heading.options.levels.forEach(level => {
148
+ shortcuts[`Mod-Alt-${level}`] = () => runLineCommand(this.editor, 'toggleHeading', { level })
149
+ })
150
+ return shortcuts
151
+ },
152
+ })