Skip to content

Commit 7124483

Browse files
fix(files): add suggestions bar
Signed-off-by: julia.kirschenheuter <julia.kirschenheuter@nextcloud.com>
1 parent b35560c commit 7124483

5 files changed

Lines changed: 234 additions & 42 deletions

File tree

src/components/Editor.vue

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