Skip to content

Commit 7cf65de

Browse files
fix(files): add suggestions bar
Signed-off-by: julia.kirschenheuter <[email protected]>
1 parent fb70ab7 commit 7cf65de

5 files changed

Lines changed: 229 additions & 42 deletions

File tree

src/components/Editor.vue

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
</template>
5959
<ContentContainer v-show="contentLoaded"
6060
ref="contentWrapper" />
61+
<SuggestionsBar v-if="isRichEditor && isEmptyContent && contentLoaded" />
6162
</MainContainer>
6263
<Reader v-if="isResolvingConflict"
6364
:content="syncError.data.outsideChange"
@@ -126,6 +127,7 @@ import Assistant from './Assistant.vue'
126127
import Translate from './Modal/Translate.vue'
127128
import { generateRemoteUrl } from '@nextcloud/router'
128129
import { fetchNode } from '../services/WebdavClient.ts'
130+
import SuggestionsBar from './SuggestionsBar.vue'
129131
130132
export default {
131133
name: 'Editor',
@@ -141,6 +143,7 @@ export default {
141143
Status,
142144
Assistant,
143145
Translate,
146+
SuggestionsBar,
144147
},
145148
mixins: [
146149
isMobile,
@@ -271,6 +274,7 @@ export default {
271274
contentWrapper: null,
272275
translateModal: false,
273276
translateContent: '',
277+
isEmptyContent: true,
274278
}
275279
},
276280
computed: {
@@ -612,6 +616,11 @@ export default {
612616
this.emit('update:content', {
613617
markdown: proseMirrorMarkdown,
614618
})
619+
/**
620+
* Empty document has an empty document and an empty paragraph (open and close blocks)
621+
*/
622+
const EMPTY_DOCUMENT_SIZE = 4
623+
this.isEmptyContent = editor.state.doc.nodeSize <= EMPTY_DOCUMENT_SIZE
615624
},
616625
617626
onSync({ steps, document }) {

src/components/Menu/ActionInsertLink.vue

Lines changed: 4 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,6 @@
6666
<script>
6767
import { NcActions, NcActionButton, NcActionInput } from '@nextcloud/vue'
6868
import { getLinkWithPicker } from '@nextcloud/vue/dist/Components/NcRichText.js'
69-
import { FilePickerType, getFilePickerBuilder } from '@nextcloud/dialogs'
7069
import { generateUrl } from '@nextcloud/router'
7170
import { loadState } from '@nextcloud/initial-state'
7271
@@ -76,6 +75,7 @@ import { Document, Loading, LinkOff, Web, Shape } from '../icons.js'
7675
import { BaseActionEntry } from './BaseActionEntry.js'
7776
import { useFileMixin } from '../Editor.provider.js'
7877
import { useMenuIDMixin } from './MenuBar.provider.js'
78+
import { buildFilePicker } from '../../helpers/filePicker.js'
7979
8080
export default {
8181
name: 'ActionInsertLink',
@@ -122,12 +122,7 @@ export default {
122122
this.startPath = this.relativePath.split('/').slice(0, -1).join('/')
123123
}
124124
125-
const filePicker = getFilePickerBuilder(t('text', 'Select file or folder to link to'))
126-
.startAt(this.startPath)
127-
.allowDirectories(true)
128-
.setMultiSelect(false)
129-
.setType(FilePickerType.Choose)
130-
.build()
125+
const filePicker = buildFilePicker(this.startPath)
131126
132127
filePicker.pick()
133128
.then((file) => {
@@ -173,42 +168,9 @@ export default {
173168
* @param {string} text Text part of the link
174169
*/
175170
setLink(url, text) {
176-
// Heuristics for determining if we need a https:// prefix.
177-
const noPrefixes = [
178-
/^[a-zA-Z]+:/, // url with protocol ("mailTo:[email protected]")
179-
/^\//, // absolute path
180-
/\?fileId=/, // relative link with fileId
181-
/^\.\.?\//, // relative link starting with ./ or ../
182-
/^[^.]*[/$]/, // no dots before first '/' - not a domain name
183-
/^#/, // url fragment
184-
]
185-
if (url && !noPrefixes.find(regex => url.match(regex))) {
186-
url = 'https://' + url
187-
}
188-
189-
// Avoid issues when parsing urls later on in markdown that might be entered in an invalid format (e.g. "mailto: [email protected]")
190-
const href = url.replaceAll(' ', '%20')
191-
const chain = this.$editor.chain()
192-
// Check if any text is selected, if not insert the link using the given text property
193-
if (this.$editor.view.state?.selection.empty) {
194-
chain.insertContent({
195-
type: 'paragraph',
196-
content: [{
197-
type: 'text',
198-
marks: [{
199-
type: 'link',
200-
attrs: {
201-
href,
202-
},
203-
}],
204-
text,
205-
}],
206-
})
207-
} else {
208-
chain.setLink({ href })
209-
}
210-
chain.focus().run()
171+
this.$editor.chain().setOrInsertLink(url, text).focus().run()
211172
},
173+
212174
/**
213175
* Remove link markup at current position
214176
* Triggered by the "remove link" button

src/components/SuggestionsBar.vue

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
<template>
2+
<div class="container-suggestions">
3+
<NcButton ref="linkFileOrFolder"
4+
type="tertiary"
5+
size="normal"
6+
@click="linkFile">
7+
<template #icon>
8+
<Document :size="20" />
9+
</template>
10+
{{ t('text', 'Link to file or folder') }}
11+
</NcButton>
12+
13+
<NcButton type="tertiary"
14+
size="normal"
15+
@click="$callChooseLocalAttachment">
16+
<template #icon>
17+
<Document :size="20" />
18+
</template>
19+
{{ t('text', 'Upload') }}
20+
</NcButton>
21+
22+
<NcButton type="tertiary"
23+
size="normal"
24+
@click="insertTable">
25+
<template #icon>
26+
<Table :size="20" />
27+
</template>
28+
{{ t('text', 'Insert Table') }}
29+
</NcButton>
30+
31+
<NcButton type="tertiary"
32+
size="normal"
33+
@click="linkPicker">
34+
<template #icon>
35+
<Shape :size="20" />
36+
</template>
37+
{{ t('text', 'Smart Picker') }}
38+
</NcButton>
39+
</div>
40+
</template>
41+
42+
<script>
43+
import { NcButton } from '@nextcloud/vue'
44+
import { Document, Table, Shape } from './icons.js'
45+
import { useActionChooseLocalAttachmentMixin } from './Editor/MediaHandler.provider.js'
46+
import { getLinkWithPicker } from '@nextcloud/vue/dist/Components/NcRichText.js'
47+
import { useEditorMixin, useFileMixin } from './Editor.provider.js'
48+
import { generateUrl } from '@nextcloud/router'
49+
import { buildFilePicker } from '../helpers/filePicker.js'
50+
51+
export default {
52+
name: 'SuggestionsBar',
53+
components: {
54+
Table,
55+
Document,
56+
NcButton,
57+
Shape,
58+
},
59+
mixins: [
60+
useActionChooseLocalAttachmentMixin,
61+
useEditorMixin,
62+
useFileMixin,
63+
],
64+
65+
data: () => {
66+
return {
67+
startPath: null,
68+
}
69+
},
70+
71+
computed: {
72+
relativePath() {
73+
return this.$file?.relativePath ?? '/'
74+
},
75+
},
76+
77+
methods: {
78+
/**
79+
* Open smart picker dialog
80+
* Triggered by the "Smart Picker" button
81+
*/
82+
linkPicker() {
83+
getLinkWithPicker(null, true)
84+
.then(link => {
85+
const chain = this.$editor.chain()
86+
if (this.$editor.view.state?.selection.empty) {
87+
chain.focus().insertPreview(link).run()
88+
} else {
89+
chain.setLink({ href: link }).focus().run()
90+
}
91+
})
92+
.catch(error => {
93+
console.error('Smart picker promise rejected', error)
94+
})
95+
},
96+
97+
/**
98+
* Insert table
99+
* Triggered by the "Insert table" button
100+
*/
101+
insertTable() {
102+
this.$editor.chain().focus().insertTable()?.run()
103+
},
104+
105+
/**
106+
* Open dialog and ask user which file to link to
107+
* Triggered by the "link to file or folder" button
108+
*/
109+
linkFile() {
110+
if (this.startPath === null) {
111+
this.startPath = this.relativePath.split('/').slice(0, -1).join('/')
112+
}
113+
114+
const filePicker = buildFilePicker(this.startPath)
115+
116+
filePicker.pick()
117+
.then((file) => {
118+
const client = OC.Files.getClient()
119+
client.getFileInfo(file).then((_status, fileInfo) => {
120+
const url = new URL(generateUrl(`/f/${fileInfo.id}`), window.origin)
121+
this.setLink(url.href, fileInfo.name)
122+
this.startPath = fileInfo.path + (fileInfo.type === 'dir' ? `/${fileInfo.name}/` : '')
123+
})
124+
})
125+
.catch(() => {
126+
// do not close menu but keep focus
127+
this.$refs.linkFileOrFolder.$el.focus()
128+
})
129+
},
130+
131+
/**
132+
* Save user entered URL as a link markup
133+
* Triggered when the user submits the ActionInput
134+
*
135+
* @param {string} url href attribute of the link
136+
* @param {string} text Text part of the link
137+
*/
138+
setLink(url, text) {
139+
this.$editor.chain().setOrInsertLink(url, text).focus().run()
140+
},
141+
},
142+
143+
}
144+
145+
</script>
146+
147+
<style scoped lang="scss">
148+
149+
.container-suggestions {
150+
display: flex;
151+
justify-content: center;
152+
align-items: flex-end;
153+
align-content: flex-end;
154+
position: sticky;
155+
}
156+
</style>

src/helpers/filePicker.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import { FilePickerType, getFilePickerBuilder } from '@nextcloud/dialogs'
7+
8+
export const buildFilePicker = (startPath) => {
9+
return getFilePickerBuilder(t('text', 'Select file or folder to link to'))
10+
.startAt(startPath)
11+
.allowDirectories(true)
12+
.setMultiSelect(false)
13+
.setType(FilePickerType.Choose)
14+
.build()
15+
}

src/marks/Link.js

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { markInputRule } from '@tiptap/core'
77
import TipTapLink from '@tiptap/extension-link'
88
import { domHref, parseHref } from './../helpers/links.js'
99
import { linkClicking } from '../plugins/links.js'
10+
import { getLinkWithPicker } from '@nextcloud/vue/dist/Components/NcRichText.js'
1011

1112
const PROTOCOLS_TO_LINK_TO = ['http:', 'https:', 'mailto:', 'tel:']
1213

@@ -88,6 +89,50 @@ const Link = TipTapLink.extend({
8889
]
8990
},
9091

92+
addCommands() {
93+
return {
94+
/**
95+
* Check if any text is selected, if not insert the link using the given text property
96+
*
97+
* @param {string} url href attribute of the link
98+
* @param {string} text Text part of the link
99+
*/
100+
setOrInsertLink: (url, text) => ({ state, chain }) => {
101+
// Heuristics for determining if we need a https:// prefix.
102+
const noPrefixes = [
103+
/^[a-zA-Z]+:/, // url with protocol ("mailTo:[email protected]")
104+
/^\//, // absolute path
105+
/\?fileId=/, // relative link with fileId
106+
/^\.\.?\//, // relative link starting with ./ or ../
107+
/^[^.]*[/$]/, // no dots before first '/' - not a domain name
108+
/^#/, // url fragment
109+
]
110+
if (url && !noPrefixes.find(regex => url.match(regex))) {
111+
url = 'https://' + url
112+
}
113+
// Avoid issues when parsing urls later on in markdown that might be entered in an invalid format (e.g. "mailto: [email protected]")
114+
const href = url.replaceAll(' ', '%20')
115+
if (state.selection.empty) {
116+
return chain().insertContent({
117+
type: 'paragraph',
118+
content: [{
119+
type: 'text',
120+
marks: [{
121+
type: 'link',
122+
attrs: {
123+
href,
124+
},
125+
}],
126+
text,
127+
}],
128+
}).run()
129+
} else {
130+
return chain().setLink({ href }).run()
131+
}
132+
},
133+
}
134+
},
135+
91136
addProseMirrorPlugins() {
92137
const plugins = this.parent()
93138
// remove upstream link click handle plugin

0 commit comments

Comments
 (0)