@live-change/wysiwyg-frontend 0.2.4 → 0.2.6

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.
@@ -1,6 +1,6 @@
1
1
  <template>
2
2
  <div>
3
- <DocumentEditor :document="document" :type="'rich'" :purpose="'test'"
3
+ <DocumentEditor targetType="Example" target="one" :type="'rich'" :purpose="'test'"
4
4
  :initialContent="emptyContent"
5
5
  :config="contentConfig" />
6
6
  </div>
@@ -8,9 +8,9 @@
8
8
 
9
9
  <script setup>
10
10
 
11
- import DocumentEditor from "./DocumentEditor.vue"
11
+ import DocumentEditor from "./components/DocumentEditor.vue"
12
12
  import { ref } from 'vue'
13
- import { basicMarks, messageNodes, richEditorNodes } from "./contentConfig.js"
13
+ import { basicMarks, messageNodes, richEditorNodes } from "./components/contentConfig.js"
14
14
 
15
15
  const contentConfig = {
16
16
  marks: {
@@ -15,10 +15,10 @@
15
15
  </template>
16
16
 
17
17
  <script setup>
18
- import ContentView from "./ContentView.js"
19
- import Editor from "./Editor.vue"
18
+ import ContentView from "./components/ContentView.js"
19
+ import Editor from "./components/Editor.vue"
20
20
  import { ref } from 'vue'
21
- import { basicMarks, messageNodes, richEditorNodes } from "./contentConfig.js"
21
+ import { basicMarks, messageNodes, richEditorNodes } from "./components/contentConfig.js"
22
22
 
23
23
  const contentConfig = {
24
24
  marks: {
@@ -16,7 +16,7 @@ function ContentView(props, context) {
16
16
  return renderNode(doc, r, config.marks, config.nodes)
17
17
  }
18
18
  return h('div', {}, [
19
- h('h1', {}, 'Content View'),
19
+ h('h1', {}, 'Empty document'),
20
20
  ])
21
21
  }
22
22
 
@@ -0,0 +1,163 @@
1
+ <template>
2
+ <div class="relative">
3
+ <slot v-if="editor" name="menu" :editor="editor" :saveState="saveState">
4
+ <EditorMenu :editor="editor" :config="config" :saveState="saveState">
5
+ <template #before="scope"><slot name="beforeMenu" v-bind="{ ...scope, editor, saveState }" /></template>
6
+ <template #after="scope"><slot name="afterMenu" v-bind="{ ...scope, editor, saveState }" /></template>
7
+ <template #begin="scope"><slot name="menuBegin" v-bind="{ ...scope, editor, saveState }" /></template>
8
+ <template #end="scope"><slot name="menuEnd" v-bind="{ ...scope, editor, saveState }" /></template>
9
+ </EditorMenu>
10
+ </slot>
11
+ <editor-content :editor="editor" class="content" />
12
+ </div>
13
+ </template>
14
+
15
+ <script setup>
16
+
17
+ import { useEditor, EditorContent } from '@tiptap/vue-3'
18
+ import {
19
+ ref, computed, watch, provide, defineEmits, defineProps, getCurrentInstance, onUnmounted, inject, onMounted
20
+ } from 'vue'
21
+ import { toRefs, useDebounceFn } from '@vueuse/core'
22
+ import EditorMenu from "./EditorMenu.vue"
23
+ import { getExtensions } from "./contentConfigExtensions.js"
24
+ import { getSchemaSpecFromConfig, serializeSchema } from "./schemaJson.js"
25
+
26
+ import { live, path, useApi, inboxReader } from '@live-change/vue3-ssr'
27
+ import ProseMirrorCollab from "./ProseMirrorCollab.js"
28
+ import { sendableSteps, receiveTransaction, getVersion } from 'prosemirror-collab'
29
+ import { Step } from 'prosemirror-transform'
30
+ import RemoteAuthority from "./RemoteAuthority";
31
+
32
+ const props = defineProps({
33
+ targetType: {
34
+ type: String,
35
+ required: true
36
+ },
37
+ target: {
38
+ type: String,
39
+ required: true
40
+ },
41
+ type: {
42
+ type: String,
43
+ required: true
44
+ },
45
+ purpose: {
46
+ type: String,
47
+ required: true
48
+ },
49
+ config: {
50
+ type: Object,
51
+ required: true
52
+ },
53
+ initialContent: {
54
+ type: Object,
55
+ default: () => ({ type: 'doc', content: [ ] })
56
+ },
57
+ })
58
+
59
+ const emit = defineEmits(['update:saveState', 'update:version'])
60
+
61
+ const isMounted = ref(false)
62
+ onMounted(() => isMounted.value = true)
63
+
64
+ const appContext = getCurrentInstance().appContext
65
+ const api = useApi(appContext)
66
+ const clientID = api.windowId
67
+
68
+ const { targetType, target } = props
69
+
70
+ const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms))
71
+
72
+ const extensions = getExtensions(props.config)
73
+ const authority = new RemoteAuthority(appContext, props.targetType, props.target, props.type, {
74
+ purpose: props.purpose,
75
+ initialContent: props.initialContent
76
+ })
77
+
78
+ const saveState = computed(() => authority.synchronizationState ?? 'loading')
79
+ emit('update:saveState', saveState.value)
80
+ watch(() => saveState.value, async (state) => {
81
+ emit('update:saveState', state)
82
+ })
83
+
84
+ const version = ref()
85
+ watch(() => version.value, async (v) => {
86
+ emit('update:version', v)
87
+ })
88
+
89
+ authority.onNewSteps.push(() => {
90
+ console.log("ON NEW STEPS!")
91
+ const { state, view } = editor.value
92
+ const currentVersion = getVersion(state)
93
+ console.log("COLLAB VERSION", currentVersion)
94
+ let { steps, clientIDs } = authority.stepsSince(currentVersion, state.schema)
95
+ console.error("RECEIVE STEPS", steps, clientIDs, clientID)
96
+ const transaction = receiveTransaction(state, steps, clientIDs)
97
+ view.dispatch(transaction)
98
+ version.value = authority.remoteVersion
99
+ })
100
+ authority.onResynchronization.push(async function () {
101
+ const state = editor.value.reactiveState.value
102
+ const sendable = sendableSteps(state)
103
+ if (sendable) {
104
+ console.error("SEND STEPS", sendable.steps, sendable.version)
105
+ authority.receiveSteps(sendable.version, sendable.steps, sendable.clientID)
106
+ }
107
+ })
108
+
109
+ onUnmounted(() => authority.dispose())
110
+ const documentData = await authority.loadDocument()
111
+ version.value = authority.remoteVersion
112
+
113
+ console.log("CONFIG", props.config, 'EXTENSIONS', extensions
114
+ )
115
+ console.log("DOCUMENT DATA", documentData)
116
+ const editor = useEditor({
117
+ content: documentData.content,
118
+ extensions: [
119
+ ...extensions,
120
+ ProseMirrorCollab.configure({
121
+ version: documentData.version,
122
+ clientID
123
+ })
124
+ ],
125
+ onTransaction: ({ editor, transaction }) => {
126
+ console.log("EDITOR UPDATE", editor, transaction)
127
+ for(const step of transaction.steps) {
128
+ window.lastStep = step
129
+ }
130
+ saveDebounced()
131
+ }
132
+ })
133
+
134
+ const schemaSpec = editor.view
135
+ ? serializeSchema(editor.view.state.schema.spec)
136
+ : getSchemaSpecFromConfig(props.config)
137
+
138
+ async function save() {
139
+ const state = editor.value.reactiveState.value
140
+ const sendable = sendableSteps(state)
141
+ if (sendable) {
142
+ console.error("SEND STEPS", sendable.steps, sendable.version)
143
+ authority.receiveSteps(sendable.version, sendable.steps, sendable.clientID)
144
+ }
145
+ }
146
+ const saveDebounced = useDebounceFn(save, 100)
147
+
148
+ if(typeof window !== 'undefined') {
149
+ window.editor = editor
150
+ window.testStep = (json) => {
151
+ const testStep = Step.fromJSON(schema, json)
152
+ console.log("TS", testStep)
153
+ const transaction = editor.value.view.state.tr
154
+ transaction.step(testStep)
155
+ console.log("TRANSACTION", transaction)
156
+ transaction.apply(editor.value.view.state)
157
+ }
158
+ }
159
+ </script>
160
+
161
+ <style scoped>
162
+
163
+ </style>
@@ -1,7 +1,12 @@
1
1
  <template>
2
2
  <div class="relative">
3
3
  <slot v-if="editor" name="menu" :editor="editor">
4
- <EditorMenu :editor="editor" :config="config" />
4
+ <EditorMenu :editor="editor" :config="config">
5
+ <template #beforeMenu="scope"><slot name="before" v-bind="{ ...scope, editor }" /></template>
6
+ <template #afterMenu="scope"><slot name="after" v-bind="{ ...scope, editor }" /></template>
7
+ <template #begin="scope"><slot name="menuBegin" v-bind="{ ...scope, editor, saveState }" /></template>
8
+ <template #end="scope"><slot name="menuEnd" v-bind="{ ...scope, editor, saveState }" /></template>
9
+ </EditorMenu>
5
10
  </slot>
6
11
  <editor-content :editor="editor" class="content" />
7
12
  </div>
@@ -0,0 +1,148 @@
1
+ <template>
2
+ <div class="sticky" style="top: 90px; z-index: 1">
3
+ <slot name="before" />
4
+ <div class="surface-card p-1 shadow-2 border-round flex flex-row flex-wrap">
5
+
6
+ <slot name="begin" />
7
+
8
+ <div class="p-buttonset mr-1 mb-1">
9
+ <Button v-if="config.marks.bold" label="B" class="p-button-sm font-bold"
10
+ :class="{ 'p-button-outlined': !editor.isActive('bold') }"
11
+ @click="editor.chain().focus().toggleBold().run()" />
12
+ <Button v-if="config.marks.italic" label="I" class="p-button-sm font-italic"
13
+ :class="{ 'p-button-outlined': !editor.isActive('italic') }"
14
+ @click="editor.chain().focus().toggleItalic().run()" />
15
+ <Button v-if="config.marks.underline" label="U" class="p-button-sm underline"
16
+ :class="{ 'p-button-outlined': !editor.isActive('underline') }"
17
+ @click="editor.chain().focus().toggleUnderline().run()" />
18
+ <Button v-if="config.marks.strike" label="S" class="p-button-sm line-through"
19
+ :class="{ 'p-button-outlined': !editor.isActive('strike') }"
20
+ @click="editor.chain().focus().toggleStrike().run()" />
21
+ </div>
22
+
23
+ <div class="p-buttonset mr-1 mb-1">
24
+ <Button v-if="config.nodes.bulletList" icon="fa-solid fa-list-ul" class="p-button-sm"
25
+ :class="{ 'p-button-outlined': !editor.isActive('bulletlist') }"
26
+ @click="editor.chain().focus().toggleBulletList().run()" />
27
+ <Button v-if="config.nodes.orderedList" icon="fa-solid fa-list-ol" class="p-button-sm"
28
+ :class="{ 'p-button-outlined': !editor.isActive('orderedlist') }"
29
+ @click="editor.chain().focus().toggleOrderedList().run()" />
30
+ </div>
31
+
32
+ <div v-if="config.nodes.heading" class="p-buttonset mr-1 mb-1">
33
+ <Button label="H1" class="p-button-sm"
34
+ :class="{ 'p-button-outlined': !editor.isActive('heading', { level: 1 }) }"
35
+ @click="editor.chain().focus().toggleHeading({ level: 1 }).run()" />
36
+ <Button label="H2" class="p-button-sm"
37
+ :class="{ 'p-button-outlined': !editor.isActive('heading', { level: 2 }) }"
38
+ @click="editor.chain().focus().toggleHeading({ level: 2 }).run()" />
39
+ <Button label="H3" class="p-button-sm"
40
+ :class="{ 'p-button-outlined': !editor.isActive('heading', { level: 3 }) }"
41
+ @click="editor.chain().focus().toggleHeading({ level: 3 }).run()" />
42
+ <Button label="H4" class="p-button-sm"
43
+ :class="{ 'p-button-outlined': !editor.isActive('heading', { level: 4 }) }"
44
+ @click="editor.chain().focus().toggleHeading({ level: 4 }).run()" />
45
+ <Button label="H5" class="p-button-sm"
46
+ :class="{ 'p-button-outlined': !editor.isActive('heading', { level: 5 }) }"
47
+ @click="editor.chain().focus().toggleHeading({ level: 5 }).run()" />
48
+ <Button label="H6" class="p-button-sm"
49
+ :class="{ 'p-button-outlined': !editor.isActive('heading', { level: 6 }) }"
50
+ @click="editor.chain().focus().toggleHeading({ level: 6 }).run()" />
51
+ </div>
52
+
53
+ <div class="p-buttonset mr-1 mb-1">
54
+ <Button v-if="config.nodes.blockquote" icon="fa-solid fa-quote-left" class="p-button-sm"
55
+ :class="{ 'p-button-outlined': !editor.isActive('blockquote') }"
56
+ @click="editor.chain().focus().toggleBlockquote().run()" />
57
+ <Button v-if="config.nodes.codeBlock" icon="fa-solid fa-code" class="p-button-sm"
58
+ :class="{ 'p-button-outlined': !editor.isActive('codeblock') }"
59
+ @click="editor.chain().focus().toggleCodeBlock().run()" />
60
+ </div>
61
+
62
+ <div class="p-buttonset mr-1 mb-1">
63
+ <Button v-if="config.nodes.hardBreak" label="BR" class="p-button-sm p-button-outlined"
64
+ @click="editor.chain().focus().setHardBreak().run()" />
65
+ <Button v-if="config.nodes.horizontalRule" label="HR" class="p-button-sm p-button-outlined"
66
+ @click="editor.chain().focus().setHorizontalRule().run()" />
67
+ </div>
68
+
69
+ <!-- <div class="p-buttonset mr-1 mb-1">-->
70
+ <!-- <Button v-if="config.nodes.hardBreak" icon="pi pi-image" class="p-button-sm"
71
+ :class="{ 'p-button-outlined': true }"
72
+ @click="editor.chain().focus().setImage().run()" />-->
73
+ <FileInput @input="handleImageUpload" class="inline-block"
74
+ accept="image/png,image/jpeg,image/webp,.jpg,.jpeg,.png,.webp">
75
+ <Button v-if="config.nodes.image" icon="pi pi-image" class="p-button-sm p-button-outlined mr-1 mb-1" />
76
+ </FileInput>
77
+ <!-- </div>-->
78
+
79
+ <span v-if="saveState"
80
+ class="p-button p-component p-button-sm p-button-outlined p-button-secondary cursor-auto
81
+ inline-block mr-1 mb-1"
82
+ style="width: 100px">
83
+ <span v-if="saveState == 'saving'" class="inline-block mr-2 relative" style="width: 20px">
84
+ &nbsp;
85
+ <ProgressSpinner class="absolute"
86
+ style="width: 22px; height: 22px; top: -2px; left: 0"
87
+ strokeWidth="4" fill="var(--surface-ground)" animationDuration=".5s" />
88
+ </span>
89
+
90
+ <span v-else class="pi pi-check p-button-icon p-button-icon-left"></span>
91
+ <span class="p-button-label">{{ saveState[0].toUpperCase() + saveState.slice(1) }}</span>
92
+ </span>
93
+
94
+ <slot name="end" />
95
+
96
+ </div>
97
+ <slot name="after" />
98
+ </div>
99
+ </template>
100
+
101
+ <script setup>
102
+ import Button from "primevue/button"
103
+ import ProgressSpinner from "primevue/progressspinner"
104
+
105
+ import { inject, getCurrentInstance } from "vue"
106
+ import { toRefs } from "@vueuse/core"
107
+ import { uploadImage } from "@live-change/image-frontend"
108
+ import { FileInput } from "@live-change/upload-frontend"
109
+
110
+ const appContext = getCurrentInstance().appContext
111
+ const workingZone = inject('workingZone')
112
+
113
+ const props = defineProps({
114
+ editor: {
115
+ type: Object,
116
+ required: true
117
+ },
118
+ config: {
119
+ type: Object,
120
+ required: true
121
+ },
122
+ saveState: {
123
+ type: String,
124
+ default: null
125
+ }
126
+ })
127
+
128
+ const { editor, config, saveState } = toRefs(props)
129
+
130
+ async function handleImageUpload(file) {
131
+ const upload = uploadImage('test', { file },
132
+ { preparedPreview: true, appContext, generateId: true })
133
+ await workingZone.addPromise('disconnectContact', (async () => {
134
+ editor.value.chain().focus().setImage({ image: upload.id }).run()
135
+ await upload.prepare()
136
+ })())
137
+ console.log("START UPLOAD!")
138
+ await upload.upload()
139
+ }
140
+
141
+ window.editor = editor.value
142
+ </script>
143
+
144
+ <style scoped>
145
+ .p-buttonset .p-button:not(:last-child) { /* buttonset bug fix */
146
+ border-right: 0 none !important;
147
+ }
148
+ </style>
@@ -0,0 +1,251 @@
1
+ import { useApi, inboxReader } from '@live-change/vue3-ssr'
2
+ import { ref } from 'vue'
3
+
4
+ import { Step } from "prosemirror-transform"
5
+
6
+ import Debug from "debug"
7
+ const debug = Debug("wysiwyg:collab")
8
+
9
+ const stepsThrottle = 100
10
+
11
+ class RemoteAuthority{
12
+ constructor(appContext, targetType, target, type, options) {
13
+ this.appContext = appContext
14
+ this.api = useApi(appContext)
15
+
16
+ this.targetType = targetType
17
+ this.target = target
18
+ this.type = type
19
+ this.options = options
20
+ this.onNewSteps = []
21
+ this.onResynchronization = []
22
+
23
+ this.remoteVersion = undefined
24
+ this.receivedSteps = []
25
+
26
+ this.sentSteps = []
27
+ this.sentVersion = undefined
28
+
29
+ this.handleStepsTimeout = null
30
+
31
+ this.waitingForResync = true
32
+ this.blockNextResync = false
33
+ this.pendingRequests = 0
34
+
35
+ this.synchronizationState = ref('loading')
36
+
37
+ this.api.source.on('connect', () => {
38
+ this.resynchronize()
39
+ })
40
+ }
41
+
42
+ resynchronize() {
43
+ debug("RESYNCHRONIZATION REQUESTED!")
44
+ if(this.blockNextResync) return
45
+ if(!this.stepsReader) return
46
+ debug("BUCKET", this.stepsReader.observable.getValue()?.length, this.stepsReader.bucketSize,
47
+ JSON.stringify(this.stepsReader.observable.getValue()))
48
+ debug("QUEUE", this.stepsReader.queue.length)
49
+ if(this.stepsReader.observable.getValue() === undefined
50
+ || this.stepsReader.observable.getValue().length == this.stepsReader.bucketSize
51
+ || this.stepsReader.queue.length > 0) { /// wait for reader
52
+ this.waitingForResync = true
53
+ this.blockNextResync = true
54
+ debug("WAIT FOR READER")
55
+ return
56
+ }
57
+ debug("RESYNCHRONIZE!")
58
+ this.waitingForResync = false
59
+ this.sentSteps = []
60
+ this.sentVersion = -1
61
+
62
+ if(this.pendingRequests == 0) {
63
+ this.synchronizationState.value = 'saved' // will be set to saving after any save attempt
64
+ }
65
+ for(const callback of this.onResynchronization) {
66
+ callback()
67
+ }
68
+ }
69
+
70
+ handleSteps() {
71
+ for(const listener of this.onNewSteps) listener()
72
+ if(this.receivedSteps.length > 1000) this.receivedSteps = this.receivedSteps.slice(-100)
73
+ this.handleStepsTimeout = null
74
+ this.blockNextResync = false
75
+ if(this.waitingForResync) {
76
+ this.resynchronize()
77
+ }
78
+ }
79
+ handleStepsThrottled() {
80
+ if(this.handleStepsTimeout === null) {
81
+ this.handleStepsTimeout = setTimeout(() => this.handleSteps(), stepsThrottle)
82
+ }
83
+ }
84
+
85
+ async startInboxReader() {
86
+ const inboxPrefix = JSON.stringify(JSON.stringify(this.targetType)+':'+JSON.stringify(this.target))+':'
87
+ const identifiers = { targetType: this.targetType, target: this.target }
88
+ console.log("START INBOX READER FROM", this.remoteVersion)
89
+ this.stepsReader = inboxReader(
90
+ (rawPosition, bucketSize) => {
91
+ console.log("RP", rawPosition)
92
+ const positionCounter = (+rawPosition.split(':').pop().replace(/"/g, '')) + 1
93
+ console.log("PC", positionCounter)
94
+ const position = inboxPrefix + JSON.stringify(positionCounter.toFixed().padStart(10, '0'))
95
+ const path = ['prosemirror', 'steps', { ...identifiers , gt: position, limit: bucketSize }]
96
+ console.log("P", path)
97
+ return path
98
+ },
99
+ (message) => {
100
+ debug("MESSAGE!", message, this.remoteVersion)
101
+ const modifiedVersion = +message.id.split(':').pop().replace(/"/g, '')
102
+ const originalVersion = modifiedVersion - message.steps.length
103
+ if(originalVersion != this.remoteVersion) throw new Error("message out of order!")
104
+ this.remoteVersion = modifiedVersion
105
+ this.receivedSteps.push(...message.steps.map(step => ({ step, window: message.window })))
106
+ this.handleStepsThrottled()
107
+ },
108
+ inboxPrefix + JSON.stringify((this.remoteVersion - 1).toFixed().padStart(10, '0')),
109
+ {
110
+ bucketSize: 32,
111
+ context: this.appContext
112
+ }
113
+ )
114
+ }
115
+
116
+ async loadDocument() {
117
+ debug("LOAD DOCUMENT!")
118
+ const identifier = { targetType: this.targetType, target: this.target }
119
+ let documentData = await this.api.get(['prosemirror', 'document', identifier])
120
+ if(!documentData) {
121
+ documentData = {
122
+ ...identifier,
123
+ type: this.type,
124
+ purpose: this.options?.purpose ?? 'document',
125
+ content: this.options?.initialContent ?? { type: 'doc', content: [ ] },
126
+ version: 1
127
+ }
128
+ documentData = await this.api.actions.prosemirror.createDocumentIfNotExists(documentData)
129
+ }
130
+ this.remoteVersion = documentData.version
131
+ this.waitingForResync = false
132
+ if(typeof window != 'undefined') {
133
+ await this.startInboxReader()
134
+ }
135
+ this.synchronizationState.value = 'loaded'
136
+ return documentData
137
+ }
138
+
139
+ async startWithLoadedDocument(documentData) {
140
+ debug("START WITH LOADED DOCUMENT!")
141
+ this.remoteVersion = documentData.version
142
+ this.waitingForResync = false
143
+ await this.startInboxReader()
144
+ this.synchronizationState.value = 'loaded'
145
+ return documentData
146
+ }
147
+
148
+ dispose() {
149
+ if(this.stepsReader) {
150
+ this.stepsReader.dispose()
151
+ }
152
+ }
153
+
154
+ async receiveSteps(version, steps, clientId) {
155
+ if(this.waitingForResync) return
156
+ debug("RECEIVE STEPS", version, '<', this.remoteVersion)
157
+ if(version < this.remoteVersion) return
158
+
159
+ const stepsJson = steps.map((step, index) => ({ version: version + index, step: step.toJSON() }))
160
+ debug("STEPS JSON", stepsJson)
161
+
162
+ /// REMOVE DUPLICATED STEPS:
163
+ let firstOriginalStepIndex = 0
164
+ let resynchronization = false
165
+ debug("VERSION", version, "SENT VERSION", this.sentVersion)
166
+ if(version <= this.sentVersion - this.sentSteps.length) {
167
+ debug("STEPS BUFFER OVERFLOW, WAITING FOR RESYNCHRONIZATION")
168
+ this.waitingForResync = true
169
+ return
170
+ }
171
+ if(version <= this.sentVersion) { // found resend, check for duplicated steps
172
+ for(let i = 0; i < stepsJson.length; i++) {
173
+ const step = stepsJson[i]
174
+ const sentStep = this.sentSteps.find(({ version }) => version == step.version)
175
+ if(sentStep) {
176
+ //debug('FOUND SENT STEP WITH THE SAME VERSION', sentStep, '==', step)
177
+ if(sentStep.json != step.json) {
178
+ resynchronization = true
179
+ break
180
+ }
181
+ firstOriginalStepIndex = i + 1
182
+ } else break
183
+ }
184
+ }
185
+ stepsJson.splice(0, firstOriginalStepIndex)
186
+ debug("STEPS JSON AFTER DUPLICATED REMOVAL", stepsJson.length)
187
+ if(resynchronization && this.sentSteps.length > 0) {
188
+ debug("RESYNCHRONIZATION!")
189
+ const firstVersion = stepsJson[0].version
190
+ this.sentSteps = this.sentSteps.filter(({ version }) => version < firstVersion)
191
+ }
192
+ this.sentSteps = this.sentSteps.concat(stepsJson)
193
+ if(this.sentSteps.length > 200) this.sentSteps = this.sentSteps.slice(-100)
194
+
195
+ if(stepsJson.length == 0) {
196
+ debug("NOTHING TO SEND")
197
+ return
198
+ }
199
+ this.sentVersion = stepsJson[0].version
200
+
201
+ if(this.pendingRequests == 0) this.synchronizationState.value = 'saving'
202
+ let result
203
+ try {
204
+ this.pendingRequests++
205
+ result = await this.api.actions.prosemirror.edit({
206
+ targetType: this.targetType,
207
+ target: this.target,
208
+ type: this.type,
209
+ version: this.sentVersion, steps: stepsJson.map(({step}) => step),
210
+ window: api.windowId,
211
+ continuation: firstOriginalStepIndex > 0
212
+ })
213
+ } finally {
214
+ this.pendingRequests--
215
+ }
216
+ if(result == 'saved') {
217
+ if(this.pendingRequests == 0) this.synchronizationState.value = 'saved'
218
+ } else {
219
+ this.resynchronize()
220
+ }
221
+ }
222
+
223
+ stepsSince(version, schema) {
224
+ debug("GET STEPS SINCE", version, this.receivedSteps)
225
+ const firstStepVersion = this.remoteVersion - this.receivedSteps.length
226
+ debug(
227
+ "COLLAB REQUESTED VERSION:", version,
228
+ " FIRST STEP VERSION:", firstStepVersion,
229
+ " REMOTE VERSION:", this.remoteVersion
230
+ )
231
+ if(version < firstStepVersion) {
232
+ console.error("COLLAB REQUESTED BACK STEPS!")
233
+ console.info("STARTING TIME TRAVEL!")
234
+ this.receivedSteps = []
235
+ if(!this.stepsReader) throw new Error("steps reader not initialized!")
236
+ this.stepsReader.dispose()
237
+ this.remoteVersion = version
238
+ this.startInboxReader()
239
+ return { steps: [], clientIDs: [] }
240
+ }
241
+ const versionDiff = version - firstStepVersion
242
+ const stepsData = this.receivedSteps.slice(versionDiff)
243
+ console.log("SD", stepsData)
244
+ debug("PARSING STEPS", stepsData.length)
245
+ const steps = stepsData.map(stepData => Step.fromJSON(schema, stepData.step))
246
+ const clientIDs = stepsData.map(stepData => stepData.window)
247
+ return { steps, clientIDs }
248
+ }
249
+ }
250
+
251
+ export default RemoteAuthority
package/index.js CHANGED
@@ -1,13 +1,13 @@
1
- import * as config from './front/src/contentConfig.js'
2
- import * as extensions from './front/src/contentConfigExtensions.js'
1
+ import * as config from './front/src/components/contentConfig.js'
2
+ import * as extensions from './front/src/components/contentConfigExtensions.js'
3
3
  export { config, extensions }
4
4
 
5
- import ContentView from "./front/src/ContentView.js"
5
+ import ContentView from "./front/src/components/ContentView.js"
6
6
  export { ContentView }
7
7
 
8
- import DocumentEditor from "./front/src/DocumentEditor.vue"
9
- import Editor from "./front/src/Editor.vue"
10
- import EditorMenu from "./front/src/EditorMenu.vue"
11
- import ImageComponent from "./front/src/ImageComponent.vue"
12
- import ImageNode from "./front/src/ImageNode.js"
8
+ import DocumentEditor from "./front/src/components/DocumentEditor.vue"
9
+ import Editor from "./front/src/components/Editor.vue"
10
+ import EditorMenu from "./front/src/components/EditorMenu.vue"
11
+ import ImageComponent from "./front/src/components/ImageComponent.vue"
12
+ import ImageNode from "./front/src/components/ImageNode.js"
13
13
  export { DocumentEditor, Editor, EditorMenu, ImageComponent, ImageNode }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@live-change/wysiwyg-frontend",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "scripts": {
5
5
  "memDev": "lcli memDev --enableSessions --initScript ./init.js --dbAccess",
6
6
  "localDevInit": "rm tmp.db; lcli localDev --enableSessions --initScript ./init.js",
@@ -21,16 +21,16 @@
21
21
  },
22
22
  "dependencies": {
23
23
  "@fortawesome/fontawesome-free": "^6.1.1",
24
- "@live-change/cli": "0.6.14",
25
- "@live-change/dao": "0.5.6",
26
- "@live-change/dao-vue3": "0.5.6",
27
- "@live-change/dao-websocket": "0.5.6",
28
- "@live-change/framework": "0.6.14",
29
- "@live-change/password-authentication-service": "0.2.51",
30
- "@live-change/prosemirror-service": "0.2.51",
31
- "@live-change/secret-code-service": "0.2.51",
32
- "@live-change/secret-link-service": "0.2.51",
33
- "@live-change/session-service": "0.2.51",
24
+ "@live-change/cli": "0.7.4",
25
+ "@live-change/dao": "0.5.8",
26
+ "@live-change/dao-vue3": "0.5.8",
27
+ "@live-change/dao-websocket": "0.5.8",
28
+ "@live-change/framework": "0.7.4",
29
+ "@live-change/password-authentication-service": "0.3.2",
30
+ "@live-change/prosemirror-service": "0.3.2",
31
+ "@live-change/secret-code-service": "0.3.2",
32
+ "@live-change/secret-link-service": "0.3.2",
33
+ "@live-change/session-service": "0.3.2",
34
34
  "@live-change/vue3-components": "0.2.15",
35
35
  "@live-change/vue3-ssr": "0.2.15",
36
36
  "@tiptap/extension-blockquote": "^2.0.0-beta.29",
@@ -60,8 +60,8 @@
60
60
  "cross-env": "^7.0.3",
61
61
  "get-port-sync": "1.0.1",
62
62
  "primeflex": "^3.2.1",
63
- "primeicons": "^5.0.0",
64
- "primevue": "^3.15.0",
63
+ "primeicons": "^6.0.1",
64
+ "primevue": "^3.18.1",
65
65
  "prosemirror-collab": "^1.3.0",
66
66
  "rollup-plugin-node-builtins": "^2.1.2",
67
67
  "rollup-plugin-visualizer": "5.6.0",
@@ -73,7 +73,7 @@
73
73
  "vue3-scroll-border": "0.1.2"
74
74
  },
75
75
  "devDependencies": {
76
- "@live-change/codeceptjs-helper": "0.6.14",
76
+ "@live-change/codeceptjs-helper": "0.7.4",
77
77
  "@wdio/selenium-standalone-service": "^7.20.8",
78
78
  "codeceptjs": "^3.3.4",
79
79
  "generate-password": "1.7.0",
@@ -85,5 +85,5 @@
85
85
  "author": "",
86
86
  "license": "ISC",
87
87
  "description": "",
88
- "gitHead": "f4696b12b62875bdca61a3b3c3b584c3992792c8"
88
+ "gitHead": "323185f6d910912093b3c92df102d0f938cef367"
89
89
  }
@@ -1,173 +0,0 @@
1
- <template>
2
- <div class="relative">
3
- <slot v-if="editor" name="menu" :editor="editor">
4
- <EditorMenu :editor="editor" :config="config" />
5
- </slot>
6
- <editor-content :editor="editor" class="content" />
7
- </div>
8
- </template>
9
-
10
- <script setup>
11
-
12
- import { useEditor, EditorContent } from '@tiptap/vue-3'
13
- import { ref, computed, watch, provide, defineEmits, defineProps, getCurrentInstance, onUnmounted } from 'vue'
14
- import { toRefs, useDebounceFn } from '@vueuse/core'
15
- import EditorMenu from "./EditorMenu.vue"
16
- import { getExtensions } from "./contentConfigExtensions.js"
17
- import { getSchemaSpecFromConfig } from "./schemaJson.js"
18
-
19
- import { live, path, useApi, inboxReader } from '@live-change/vue3-ssr'
20
- import ProseMirrorCollab from "./ProseMirrorCollab.js"
21
- import { sendableSteps, receiveTransaction, getVersion } from 'prosemirror-collab'
22
- import { Step } from 'prosemirror-transform'
23
-
24
- const props = defineProps({
25
- document: {
26
- type: String,
27
- required: true
28
- },
29
- type: {
30
- type: String,
31
- required: true
32
- },
33
- purpose: {
34
- type: String,
35
- required: true
36
- },
37
- config: {
38
- type: Object,
39
- required: true
40
- },
41
- initialContent: {
42
- type: Object,
43
- default: () => ({ type: 'doc', content: [ ] })
44
- }
45
- })
46
-
47
- const appContext = getCurrentInstance().appContext
48
- const api = useApi(appContext)
49
-
50
- const { document } = props
51
-
52
- let documentData = await api.get(['prosemirror', 'document', { document }])
53
- if(!documentData) {
54
- documentData = {
55
- id: document,
56
- document: props.document,
57
- type: props.type,
58
- purpose: props.purpose,
59
- content: props.initialContent,
60
- version: 0
61
- }
62
- documentData = await api.command(['prosemirror', 'createDocumentIfNotExists'], documentData)
63
- }
64
-
65
- const extensions = getExtensions(props.config)
66
- //console.log("CONFIG", props.config, 'EXTENSIONS', extensions)
67
- const editor = useEditor({
68
- content: documentData.content,
69
- extensions: [
70
- ...extensions,
71
- ProseMirrorCollab.configure({
72
- version: documentData.version,
73
- clientID: api.windowId
74
- })
75
- ],
76
- onTransaction: ({ editor, transaction }) => {
77
- console.log("EDITOR UPDATE", editor, transaction)
78
- saveDebounced()
79
- }
80
- })
81
-
82
- let sentSteps = []
83
- let sentVersion = 0
84
-
85
- const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms))
86
-
87
- async function save() {
88
- const state = editor.value.reactiveState.value
89
- const sendable = sendableSteps(state)
90
- if(sendable) {
91
- let version = sendable.version
92
- let firstOriginalStepIndex = 0
93
- let resynchronization = false
94
- console.log("SENDABLE STEPS", sendable)
95
- const serializedSteps = sendable.steps.map((step, index) => ({ version: version + index, step: step.toJSON() }))
96
- console.log("SERIALIZED STEPS", serializedSteps)
97
- if(sendable.version <= sentVersion) { // found resend, check for duplicated steps
98
- for(let i = 0; i < serializedSteps.length; i++) {
99
- const step = serializedSteps[i]
100
- const sentStep = sentSteps.find(({ version }) => version == step.version)
101
- if(sentStep) {
102
- console.log('FOUND SENT STEP WITH THE SAME VERSION', sentStep, '==', step)
103
- if(JSON.stringify(sentStep) != JSON.stringify(step)) {
104
- resynchronization = true
105
- break
106
- }
107
- firstOriginalStepIndex = i + 1
108
- } else break
109
- }
110
- }
111
- serializedSteps.splice(0, firstOriginalStepIndex)
112
- if(resynchronization && sentSteps.length > 0) {
113
- console.log("RESYNCHRONIZATION!")
114
- const firstVersion = serializedSteps[0].version
115
- sentSteps = sentSteps.filter(({ version }) => version < firstVersion)
116
- }
117
- if(serializedSteps.length == 0) {
118
- console.log("NOTHING TO SEND")
119
- return
120
- }
121
- sentVersion = serializedSteps[0].version
122
- sentSteps = sentSteps.concat(serializedSteps)
123
- if(sentSteps.length > 200) sentSteps = sentSteps.slice(-100)
124
- const data = {
125
- document, type: props.type,
126
- version: sentVersion, steps: serializedSteps.map(({ step }) => step), window: api.windowId,
127
- continuation: firstOriginalStepIndex > 0
128
- }
129
- console.log("SEND DATA", data)
130
- //await sleep(1000)
131
- await api.command(['prosemirror', 'doSteps'], data)
132
- }
133
- }
134
- const saveDebounced = useDebounceFn(save, 100)
135
-
136
- api.source.on('connect', () => {
137
- save()
138
- })
139
-
140
- if(typeof window != 'undefined') {
141
- const stepsReader = inboxReader(
142
- (rawPosition, bucketSize) => {
143
- const positionCounter = +rawPosition.split(':').pop().replace(/"/g, '')
144
- const position = `${JSON.stringify(document)}:${JSON.stringify(positionCounter.toFixed().padStart(10, '0'))}`
145
- return ['prosemirror', 'steps', { document, gt: position, limit: bucketSize }]
146
- },
147
- (message) => {
148
- console.log("MESSAGE!", message)
149
- const modifiedVersion = +message.id.split(':').pop().replace(/"/g, '')
150
- const originalVersion = modifiedVersion - message.steps.length
151
- const state = editor.value.reactiveState.value
152
- const currentVersion = getVersion(state)
153
- if(currentVersion < originalVersion) throw new Error("COLLAB REQUESTED BACK STEPS!")
154
- console.log("COLLAB VERSION", currentVersion, "ORIGINAL VERSION", originalVersion, "MODIFIED VERSION", modifiedVersion)
155
- const versionDiff = currentVersion - originalVersion
156
- const newSteps = message.steps.slice(versionDiff)
157
- console.log("RUNNING STEPS", newSteps.length, "/", message.steps.length)
158
- const steps = newSteps.map(stepJson => Step.fromJSON(editor.value.schema, stepJson))
159
- const clientIDs = steps.map(step => message.window)
160
- const transaction = receiveTransaction(state, steps, clientIDs)
161
- editor.value.view.dispatch(transaction)
162
- },
163
- `${JSON.stringify(document)}:${JSON.stringify(documentData.version.toFixed().padStart(10, '0'))}`
164
- )
165
- onUnmounted(() => stepsReader.dispose())
166
- }
167
-
168
- const schemaSpec = ref(getSchemaSpecFromConfig(props.config))
169
- </script>
170
-
171
- <style scoped>
172
-
173
- </style>
@@ -1,123 +0,0 @@
1
- <template>
2
- <div class="surface-card p-1 shadow-2 border-round sticky flex flex-row flex-wrap"
3
- style="top: 90px; z-index: 1">
4
- <div class="p-buttonset mr-1 mb-1">
5
- <Button v-if="config.marks.bold" label="B" class="p-button-sm font-bold"
6
- :class="{ 'p-button-outlined': !editor.isActive('bold') }"
7
- @click="editor.chain().focus().toggleBold().run()" />
8
- <Button v-if="config.marks.italic" label="I" class="p-button-sm font-italic"
9
- :class="{ 'p-button-outlined': !editor.isActive('italic') }"
10
- @click="editor.chain().focus().toggleItalic().run()" />
11
- <Button v-if="config.marks.underline" label="U" class="p-button-sm underline"
12
- :class="{ 'p-button-outlined': !editor.isActive('underline') }"
13
- @click="editor.chain().focus().toggleUnderline().run()" />
14
- <Button v-if="config.marks.strike" label="S" class="p-button-sm line-through"
15
- :class="{ 'p-button-outlined': !editor.isActive('strike') }"
16
- @click="editor.chain().focus().toggleStrike().run()" />
17
- </div>
18
-
19
- <div class="p-buttonset mr-1 mb-1">
20
- <Button v-if="config.nodes.bulletList" icon="fa-solid fa-list-ul" class="p-button-sm"
21
- :class="{ 'p-button-outlined': !editor.isActive('bulletlist') }"
22
- @click="editor.chain().focus().toggleBulletList().run()" />
23
- <Button v-if="config.nodes.orderedList" icon="fa-solid fa-list-ol" class="p-button-sm"
24
- :class="{ 'p-button-outlined': !editor.isActive('orderedlist') }"
25
- @click="editor.chain().focus().toggleOrderedList().run()" />
26
- </div>
27
-
28
- <div v-if="config.nodes.heading" class="p-buttonset mr-1 mb-1">
29
- <Button label="H1" class="p-button-sm"
30
- :class="{ 'p-button-outlined': !editor.isActive('heading', { level: 1 }) }"
31
- @click="editor.chain().focus().toggleHeading({ level: 1 }).run()" />
32
- <Button label="H2" class="p-button-sm"
33
- :class="{ 'p-button-outlined': !editor.isActive('heading', { level: 2 }) }"
34
- @click="editor.chain().focus().toggleHeading({ level: 2 }).run()" />
35
- <Button label="H3" class="p-button-sm"
36
- :class="{ 'p-button-outlined': !editor.isActive('heading', { level: 3 }) }"
37
- @click="editor.chain().focus().toggleHeading({ level: 3 }).run()" />
38
- <Button label="H4" class="p-button-sm"
39
- :class="{ 'p-button-outlined': !editor.isActive('heading', { level: 4 }) }"
40
- @click="editor.chain().focus().toggleHeading({ level: 4 }).run()" />
41
- <Button label="H5" class="p-button-sm"
42
- :class="{ 'p-button-outlined': !editor.isActive('heading', { level: 5 }) }"
43
- @click="editor.chain().focus().toggleHeading({ level: 5 }).run()" />
44
- <Button label="H6" class="p-button-sm"
45
- :class="{ 'p-button-outlined': !editor.isActive('heading', { level: 6 }) }"
46
- @click="editor.chain().focus().toggleHeading({ level: 6 }).run()" />
47
- </div>
48
-
49
- <div class="p-buttonset mr-1 mb-1">
50
- <Button v-if="config.nodes.blockquote" icon="fa-solid fa-quote-left" class="p-button-sm"
51
- :class="{ 'p-button-outlined': !editor.isActive('blockquote') }"
52
- @click="editor.chain().focus().toggleBlockquote().run()" />
53
- <Button v-if="config.nodes.codeBlock" icon="fa-solid fa-code" class="p-button-sm"
54
- :class="{ 'p-button-outlined': !editor.isActive('codeblock') }"
55
- @click="editor.chain().focus().toggleCodeBlock().run()" />
56
- </div>
57
-
58
- <div class="p-buttonset mr-1 mb-1">
59
- <Button v-if="config.nodes.hardBreak" label="BR" class="p-button-sm"
60
- :class="{ 'p-button-outlined': true }"
61
- @click="editor.chain().focus().setHardBreak().run()" />
62
- <Button v-if="config.nodes.horizontalRule" label="HR" class="p-button-sm"
63
- :class="{ 'p-button-outlined': true }"
64
- @click="editor.chain().focus().setHorizontalRule().run()" />
65
- </div>
66
-
67
- <div class="p-buttonset mr-1 mb-1">
68
- <!-- <Button v-if="config.nodes.hardBreak" icon="pi pi-image" class="p-button-sm"
69
- :class="{ 'p-button-outlined': true }"
70
- @click="editor.chain().focus().setImage().run()" />-->
71
- <FileInput @input="handleImageUpload" class="inline-block"
72
- accept="image/png,image/jpeg,image/webp,.jpg,.jpeg,.png,.webp">
73
- <Button v-if="config.nodes.hardBreak" icon="pi pi-image" class="p-button-sm"
74
- :class="{ 'p-button-outlined': true }" />
75
- </FileInput>
76
- </div>
77
-
78
- </div>
79
- </template>
80
-
81
- <script setup>
82
- import Button from "primevue/button"
83
-
84
- import { inject, getCurrentInstance } from "vue"
85
- import { toRefs } from "@vueuse/core"
86
- import { uploadImage } from "@live-change/image-frontend"
87
- import { FileInput } from "@live-change/upload-frontend"
88
-
89
- const appContext = getCurrentInstance().appContext
90
- const workingZone = inject('workingZone')
91
-
92
- const props = defineProps({
93
- editor: {
94
- type: Object,
95
- required: true
96
- },
97
- config: {
98
- type: Object,
99
- required: true
100
- }
101
- })
102
-
103
- const { editor, config } = toRefs(props)
104
-
105
- async function handleImageUpload(file) {
106
- const upload = uploadImage('test', { file },
107
- { preparedPreview: true, appContext, generateId: true })
108
- await workingZone.addPromise('disconnectContact', (async () => {
109
- editor.value.chain().focus().setImage({ image: upload.id }).run()
110
- await upload.prepare()
111
- })())
112
- console.log("START UPLOAD!")
113
- await upload.upload()
114
- }
115
-
116
- window.editor = editor.value
117
- </script>
118
-
119
- <style scoped>
120
- .p-buttonset .p-button:not(:last-child) { /* buttonset bug fix */
121
- border-right: 0 none !important;
122
- }
123
- </style>