[File UI] Added support for custom line positioning in file editor.

- Adds the ability to select lines from the editor, which in turn will
  highlight them.

- Adds the ability to load a file and scroll at a specific line if the
  URL has with the `line` argument.

- Adds the ability to maximize the file editor modal.
This commit is contained in:
Fabio Manganiello 2024-09-05 01:37:11 +02:00
parent cc621cdca6
commit 4e5c740908
Signed by untrusted user: blacklight
GPG key ID: D90FBA7F76362774
2 changed files with 160 additions and 26 deletions

View file

@ -9,10 +9,15 @@
<div class="editor-body"> <div class="editor-body">
<div class="line-numbers" ref="lineNumbers"> <div class="line-numbers" ref="lineNumbers">
<span class="line-number" v-for="n in lines" :key="n" v-text="n" /> <span class="line-number"
:class="{selected: selectedLine === n}"
v-for="n in lines"
:key="n"
@click="selectedLine = selectedLine === n ? null : n"
v-text="n" />
</div> </div>
<pre ref="pre"><code ref="content" v-html="displayedContent" /></pre> <pre ref="pre"><code ref="content" v-html="displayedContent" /><div class="selected-line" ref="selectedLine" v-if="selectedLine != null" /></pre>
<textarea ref="textarea" v-model="content" @scroll="syncScroll" @input.stop /> <textarea ref="textarea" v-model="content" @scroll="syncScroll" @input.stop />
</div> </div>
@ -54,6 +59,11 @@ export default {
default: false, default: false,
}, },
line: {
type: [String, Number],
default: null,
},
withSave: { withSave: {
type: Boolean, type: Boolean,
default: true, default: true,
@ -71,6 +81,7 @@ export default {
initialContentHash: 0, initialContentHash: 0,
loading: false, loading: false,
saving: false, saving: false,
selectedLine: null,
type: null, type: null,
} }
}, },
@ -139,6 +150,12 @@ export default {
} finally { } finally {
this.loading = false this.loading = false
} }
if (this.selectedLine) {
setTimeout(() => {
this.scrollToLine(this.selectedLine)
}, 1000)
}
}, },
async saveFile() { async saveFile() {
@ -190,6 +207,17 @@ export default {
}) })
}, },
scrollToLine(line) {
const offset = (line - 1) * parseFloat(getComputedStyle(this.$refs.pre).lineHeight)
this.$refs.textarea.scrollTo({
top: offset,
left: 0,
behavior: 'smooth',
})
return offset
},
highlightContent() { highlightContent() {
this.highlighting = true this.highlighting = true
@ -243,7 +271,7 @@ export default {
}, },
reset() { reset() {
this.setUrlArgs({file: null}) this.setUrlArgs({file: null, line: null})
this.removeBeforeUnload() this.removeBeforeUnload()
this.removeKeyListener() this.removeKeyListener()
}, },
@ -276,9 +304,36 @@ export default {
this.highlightedContent = this.content this.highlightedContent = this.content
} }
}, },
selectedLine(line) {
line = parseInt(line)
if (isNaN(line)) {
return
}
const textarea = this.$refs.textarea
const lines = this.content.split('\n')
const cursor = lines.slice(0, line - 1).join('\n').length + 1
textarea.setSelectionRange(cursor, cursor)
textarea.focus()
this.setUrlArgs({line})
this.$nextTick(() => {
const offset = this.scrollToLine(line)
this.$refs.selectedLine.style.top = `${offset}px`
})
},
}, },
mounted() { mounted() {
const args = this.getUrlArgs()
const line = parseInt(this.line || args.line || 0)
if (line) {
if (!isNaN(line)) {
this.selectedLine = line
}
}
this.loadFile() this.loadFile()
this.addBeforeUnload() this.addBeforeUnload()
this.addKeyListener() this.addKeyListener()
@ -332,8 +387,10 @@ $line-numbers-width: 2.5em;
} }
.editor-body { .editor-body {
font-family: $monospace-font;
pre, textarea, code, .line-numbers { pre, textarea, code, .line-numbers {
font-family: 'Fira Code', 'Noto Sans Mono', 'Inconsolata', 'Courier New', monospace; font-family: $monospace-font;
position: absolute; position: absolute;
top: 0; top: 0;
height: 100%; height: 100%;
@ -341,6 +398,10 @@ $line-numbers-width: 2.5em;
white-space: pre; white-space: pre;
} }
pre, textarea, code {
background: transparent;
}
.line-numbers { .line-numbers {
width: $line-numbers-width; width: $line-numbers-width;
background: $tab-bg; background: $tab-bg;
@ -356,6 +417,15 @@ $line-numbers-width: 2.5em;
width: 100%; width: 100%;
text-align: right; text-align: right;
padding-right: 0.25em; padding-right: 0.25em;
cursor: pointer;
&:hover {
background: $hover-bg;
}
&.selected {
background: $selected-bg;
}
} }
} }
@ -372,16 +442,32 @@ $line-numbers-width: 2.5em;
background: transparent; background: transparent;
overflow-wrap: normal; overflow-wrap: normal;
overflow-x: scroll; overflow-x: scroll;
z-index: 1; z-index: 2;
color: rgba(0, 0, 0, 0); color: rgba(0, 0, 0, 0);
caret-color: black; caret-color: black;
border: none; border: none;
outline: none; outline: none;
} }
.selected-line {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 1.5em;
background: rgba(110, 255, 160, 0.25);
}
} }
:deep(.floating-btn) { :deep(.floating-btn) {
z-index: 5; z-index: 5;
} }
// Fix for some hljs styles that render white text on white background
:deep(code) {
.hljs-subst {
color: $selected-fg !important;
}
}
} }
</style> </style>

View file

@ -8,6 +8,7 @@
<FileEditor ref="fileEditor" <FileEditor ref="fileEditor"
:file="file" :file="file"
:is-new="isNew" :is-new="isNew"
:line="line"
@save="$emit('save', $event)" @save="$emit('save', $event)"
v-if="file" /> v-if="file" />
</div> </div>
@ -26,10 +27,11 @@
import ConfirmDialog from "@/components/elements/ConfirmDialog"; import ConfirmDialog from "@/components/elements/ConfirmDialog";
import FileEditor from "./Editor"; import FileEditor from "./Editor";
import Modal from "@/components/Modal"; import Modal from "@/components/Modal";
import Utils from '@/Utils'
export default { export default {
emits: ['close', 'open', 'save'], emits: ['close', 'open', 'save'],
mixins: [Modal], mixins: [Modal, Utils],
components: { components: {
ConfirmDialog, ConfirmDialog,
FileEditor, FileEditor,
@ -47,6 +49,11 @@ export default {
default: false, default: false,
}, },
line: {
type: [String, Number],
default: null,
},
withSave: { withSave: {
type: Boolean, type: Boolean,
default: true, default: true,
@ -113,15 +120,28 @@ export default {
onClose() { onClose() {
this.$refs.fileEditor.reset() this.$refs.fileEditor.reset()
this.setUrlArgs({ maximized: null })
this.$emit('close') this.$emit('close')
}, },
}, },
watch: {
maximized() {
this.setUrlArgs({ maximized: this.maximized })
},
},
mounted() {
this.maximized = !!this.getUrlArgs().maximized
},
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@import "src/style/items"; @import "src/style/items";
$maximized-modal-header-height: 2.75em;
.file-editor-root { .file-editor-root {
.file-editor-modal { .file-editor-modal {
:deep(.modal) { :deep(.modal) {
@ -133,14 +153,18 @@ export default {
.modal-body { .modal-body {
width: 100%; width: 100%;
height: 50em;
min-width: 30em; min-width: 30em;
max-height: calc(100vh - 2em); max-height: calc(100vh - 2em);
} }
.content {
@extend .expand;
}
} }
&:not(.maximized) { &:not(.maximized) {
:deep(.body) { :deep(.modal) {
.body {
@include until($tablet) { @include until($tablet) {
width: calc(100vw - 2em); width: calc(100vw - 2em);
} }
@ -154,24 +178,48 @@ export default {
width: 100%; width: 100%;
min-width: 50em; min-width: 50em;
} }
.modal-body {
height: 50em;
}
}
} }
} }
&.maximized { &.maximized {
:deep(.body) { :deep(.modal) {
width: 100vw; width: calc(100vw - 2em);
height: 100vh; height: calc(100vh - 2em);
.content, .modal-body {
width: 100%;
height: 100%;
max-height: 100%;
}
.header {
height: $maximized-modal-header-height;
}
.body {
height: calc(100% - #{$maximized-modal-header-height});
max-height: 100%;
}
} }
} }
} }
.confirm-dialog-container { .confirm-dialog-container {
:deep(.modal) { :deep(.modal) {
.content { width: 35em !important;
.body { height: 9em !important;
min-width: 30em;
max-width: 100%; max-width: 100%;
} max-height: 100%;
.body {
width: 100% !important;
height: 100% !important;
justify-content: center;
} }
} }
} }