mirror of
https://github.com/beekeeper-studio/beekeeper-studio.git
synced 2026-03-13 10:12:54 +08:00
Merge pull request #2849 from beekeeper-studio/feat/editable-json-viewer
Make JSON Row Viewer Editable
This commit is contained in:
@@ -70,6 +70,8 @@
|
||||
"ini": "^5.0.0",
|
||||
"javascript-time-ago": "^2.0.8",
|
||||
"jquery": "^3.5.0",
|
||||
"json-pointer": "^0.6.2",
|
||||
"json-source-map": "^0.6.1",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"knex": "^2.4.1",
|
||||
"knex-firebird-dialect": "1.4.6",
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
.CodeMirror.text-editor {
|
||||
.text-editor {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
padding-left: 1rem;
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
.CodeMirror.text-editor.bks-partial-read-only {
|
||||
.CodeMirror-lines {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.bks-editable-marker {
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.bks-editable-marker:hover, .bks-editable-marker-active {
|
||||
background-color: rgba($theme-base, 0.08);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
@import "./text-editor-plugins/partial-read-only.scss";
|
||||
|
||||
.text-editor {
|
||||
&.remove-json-root-brackets .CodeMirror-code {
|
||||
& > div:first-child, & > div:last-child {
|
||||
@@ -35,4 +37,10 @@
|
||||
.CodeMirror-guttermaker-subtle {
|
||||
color: rgba($theme-base, 0.25);
|
||||
}
|
||||
|
||||
.bks-error-marker {
|
||||
text-decoration-line: underline;
|
||||
text-decoration-style: wavy;
|
||||
text-decoration-color: rgba($brand-danger, 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ export enum AppEvent {
|
||||
switchUserKeymap = 'switchUserKeymap',
|
||||
updateJsonViewerSidebar = 'updateJsonViewerSidebar',
|
||||
jsonViewerSidebarExpandPath = 'jsonViewerSidebarExpandPath',
|
||||
jsonViewerSidebarValueChange = 'jsonViewerSidebarValueChange',
|
||||
switchingTab = 'switchingTab',
|
||||
switchedTab = 'switchedTab',
|
||||
}
|
||||
|
||||
@@ -43,11 +43,15 @@ import {
|
||||
import { AppEvent } from "@/common/AppEvent";
|
||||
import { keymapTypes } from "@/lib/db/types"
|
||||
import { EditorMarker, LineGutter } from "@/lib/editor/utils";
|
||||
import { TextEditorPlugin } from "@/lib/editor/plugins/TextEditorPlugin";
|
||||
import rawLog from '@bksLogger'
|
||||
|
||||
interface InitializeOptions {
|
||||
userKeymap?: typeof keymapTypes[number]['value']
|
||||
}
|
||||
|
||||
const log = rawLog.scope('TextEditor')
|
||||
|
||||
export default {
|
||||
props: [
|
||||
"value",
|
||||
@@ -87,6 +91,7 @@ export default {
|
||||
activeLineGutters: [],
|
||||
wasEditorFocused: false,
|
||||
firstInitialization: true,
|
||||
initializedPlugins: [],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -188,6 +193,12 @@ export default {
|
||||
unfoldAll() {
|
||||
CodeMirror.commands.unfoldAll(this.editor)
|
||||
},
|
||||
plugins() {
|
||||
this.destroyPlugins();
|
||||
if (this.editor) {
|
||||
this.initializePlugins(this.editor);
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
focusEditor() {
|
||||
@@ -342,11 +353,7 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
if (this.plugins) {
|
||||
this.plugins.forEach((plugin: (cm: CodeMirror.Editor) => void) => {
|
||||
plugin(cm);
|
||||
});
|
||||
}
|
||||
this.initializePlugins(cm)
|
||||
|
||||
if (this.firstInitialization && this.focus) {
|
||||
cm.focus();
|
||||
@@ -374,7 +381,8 @@ export default {
|
||||
let markInstance: TextMarker;
|
||||
if (marker.type === "error") {
|
||||
markInstance = this.editor.markText(marker.from, marker.to, {
|
||||
className: "error",
|
||||
className: "bks-error-marker",
|
||||
attributes: { title: marker.message },
|
||||
});
|
||||
} else if (marker.type === "highlight") {
|
||||
markInstance = this.editor.markText(marker.from, marker.to, {
|
||||
@@ -436,13 +444,39 @@ export default {
|
||||
this.activeLineGutters.push(lineGutter)
|
||||
})
|
||||
},
|
||||
initializePlugins(editor: CodeMirror.Editor) {
|
||||
this.plugins?.forEach((plugin: ((editor: CodeMirror.Editor) => Function) | TextEditorPlugin) => {
|
||||
try {
|
||||
if (typeof plugin === "function") {
|
||||
const destroy = plugin(editor);
|
||||
this.initializedPlugins.push({ destroy });
|
||||
} else {
|
||||
plugin.initialize(editor);
|
||||
this.initializedPlugins.push(plugin);
|
||||
}
|
||||
} catch (e) {
|
||||
log.error("Error initializing plugin", e)
|
||||
}
|
||||
});
|
||||
},
|
||||
destroyEditor() {
|
||||
this.destroyPlugins();
|
||||
if (this.editor) {
|
||||
this.editor
|
||||
.getWrapperElement()
|
||||
.parentNode.removeChild(this.editor.getWrapperElement());
|
||||
}
|
||||
},
|
||||
destroyPlugins() {
|
||||
this.initializedPlugins.forEach((plugin: TextEditorPlugin) => {
|
||||
try {
|
||||
plugin.destroy()
|
||||
} catch (e) {
|
||||
log.error("Error destroying plugin", e)
|
||||
}
|
||||
})
|
||||
this.initializedPlugins = []
|
||||
},
|
||||
showContextMenu(event) {
|
||||
const hasSelectedText = this.editor.getSelection();
|
||||
const selectionDepClass = hasSelectedText ? "" : "disabled";
|
||||
@@ -543,9 +577,18 @@ export default {
|
||||
menu.options = menu.options.filter((option) => !option.write);
|
||||
}
|
||||
|
||||
const customOptions = this.contextMenuOptions
|
||||
? this.contextMenuOptions(event, menu.options)
|
||||
: undefined;
|
||||
let customOptions: typeof menu.options | false | undefined;
|
||||
|
||||
for (const plugin of this.initializedPlugins) {
|
||||
if (plugin.beforeOpeningContextMenu) {
|
||||
customOptions = plugin.beforeOpeningContextMenu(event, menu.options)
|
||||
}
|
||||
}
|
||||
|
||||
// FIXME remove this in favor of plugin
|
||||
if (this.contextMenuOptions) {
|
||||
customOptions = this.contextMenuOptions(event, customOptions || menu.options);
|
||||
}
|
||||
|
||||
if (customOptions === false) {
|
||||
return;
|
||||
|
||||
@@ -45,7 +45,6 @@
|
||||
</div>
|
||||
</div>
|
||||
<text-editor
|
||||
:read-only="true"
|
||||
:fold-gutter="true"
|
||||
:fold-all="foldAll"
|
||||
:unfold-all="unfoldAll"
|
||||
@@ -57,6 +56,7 @@
|
||||
:line-wrapping="wrapText"
|
||||
:line-gutters="lineGutters"
|
||||
:line-numbers="false"
|
||||
:extra-keybindings="disableReplaceKeybindings"
|
||||
/>
|
||||
<div class="empty-state" v-show="empty">
|
||||
Open a table to view its data
|
||||
@@ -86,17 +86,20 @@ import {
|
||||
import { mapGetters } from "vuex";
|
||||
import { EditorMarker, LineGutter } from "@/lib/editor/utils";
|
||||
import { persistJsonFold } from "@/lib/editor/plugins/persistJsonFold";
|
||||
import PartialReadOnlyPlugin from "@/lib/editor/plugins/PartialReadOnlyPlugin";
|
||||
import JsonViewerUpsell from '@/components/upsell/JsonViewerSidebarUpsell.vue'
|
||||
import rawLog from "@bksLogger";
|
||||
import _ from "lodash";
|
||||
import globals from '@/common/globals'
|
||||
import JsonSourceMap from "json-source-map";
|
||||
import JsonPointer from "json-pointer";
|
||||
import { typedArrayToString } from '@/common/utils'
|
||||
|
||||
const log = rawLog.scope("json-viewer");
|
||||
|
||||
export default Vue.extend({
|
||||
components: { TextEditor, JsonViewerUpsell },
|
||||
props: ["value", "hidden", "expandablePaths", "dataId", "title", "reinitialize", "signs", "binaryEncoding"],
|
||||
props: ["value", "hidden", "expandablePaths", "editablePaths", "dataId", "title", "reinitialize", "signs", "binaryEncoding"],
|
||||
data() {
|
||||
return {
|
||||
reinitializeTextEditor: 0,
|
||||
@@ -104,6 +107,11 @@ export default Vue.extend({
|
||||
foldAll: 0,
|
||||
unfoldAll: 0,
|
||||
restoredTruncatedPaths: [],
|
||||
editableRangeErrors: [],
|
||||
disableReplaceKeybindings: {
|
||||
[this.cmCtrlOrCmd("R")]: () => false,
|
||||
[this.cmCtrlOrCmd("Shift-R")]: () => false,
|
||||
},
|
||||
wrapText: false,
|
||||
};
|
||||
},
|
||||
@@ -136,14 +144,7 @@ export default Vue.extend({
|
||||
return { name: "javascript", json: true };
|
||||
},
|
||||
text() {
|
||||
if (this.empty) {
|
||||
return "";
|
||||
}
|
||||
if (this.filter) {
|
||||
const filtered = deepFilterObjectProps(this.processedValue, this.filter);
|
||||
return JSON.stringify(filtered, this.replacer, 2);
|
||||
}
|
||||
return JSON.stringify(this.processedValue, this.replacer, 2);
|
||||
return this.sourceMap.json
|
||||
},
|
||||
debouncedFilter: {
|
||||
get() {
|
||||
@@ -153,6 +154,18 @@ export default Vue.extend({
|
||||
this.filter = value;
|
||||
}, 500),
|
||||
},
|
||||
sourceMap() {
|
||||
return JsonSourceMap.stringify(this.filteredValue, null, 2);
|
||||
},
|
||||
filteredValue() {
|
||||
if (this.empty) {
|
||||
return {}
|
||||
}
|
||||
if (!this.filter) {
|
||||
return this.processedValue
|
||||
}
|
||||
return deepFilterObjectProps(this.processedValue, this.filter);
|
||||
},
|
||||
processedValue() {
|
||||
const clonedValue = _.cloneDeep(this.value)
|
||||
eachPaths(clonedValue, (path, value) => {
|
||||
@@ -225,6 +238,14 @@ export default Vue.extend({
|
||||
log.warn(e);
|
||||
}
|
||||
})
|
||||
_.forEach(this.editableRangeErrors, ({ error, from, to }) => {
|
||||
markers.push({
|
||||
type: "error",
|
||||
from,
|
||||
to,
|
||||
message: error.message,
|
||||
});
|
||||
})
|
||||
return markers;
|
||||
},
|
||||
lines() {
|
||||
@@ -243,6 +264,33 @@ export default Vue.extend({
|
||||
})
|
||||
return lineGutters;
|
||||
},
|
||||
editableRanges() {
|
||||
const editablePaths = this.editablePaths
|
||||
|
||||
if (_.isEmpty(editablePaths)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const ranges = []
|
||||
|
||||
editablePaths.forEach((path: string) => {
|
||||
const pointer = JsonPointer.compile(path.split("."))
|
||||
const position = this.sourceMap.pointers[pointer]
|
||||
|
||||
if (!position) {
|
||||
log.warn(`Unable to find editable path \`${path}\` in value object.`)
|
||||
return
|
||||
}
|
||||
|
||||
ranges.push({
|
||||
id: path,
|
||||
from: { line: position.value.line, ch: position.value.column },
|
||||
to: { line: position.valueEnd.line, ch: position.valueEnd.column },
|
||||
})
|
||||
})
|
||||
|
||||
return ranges
|
||||
},
|
||||
menuOptions() {
|
||||
return [
|
||||
{
|
||||
@@ -281,7 +329,10 @@ export default Vue.extend({
|
||||
]
|
||||
},
|
||||
textEditorPlugins() {
|
||||
return [persistJsonFold]
|
||||
return [
|
||||
persistJsonFold,
|
||||
new PartialReadOnlyPlugin(this.editableRanges, this.handleEditableRangeChange),
|
||||
]
|
||||
},
|
||||
...mapGetters(["expandFKDetailsByDefault"]),
|
||||
},
|
||||
@@ -295,6 +346,15 @@ export default Vue.extend({
|
||||
expandPath(path: ExpandablePath) {
|
||||
this.$emit("expandPath", path);
|
||||
},
|
||||
handleEditableRangeChange: _.debounce(function (range, value) {
|
||||
this.editableRangeErrors = []
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
this.$emit("bks-json-value-change", {key: range.id, value: parsed});
|
||||
} catch (error) {
|
||||
this.editableRangeErrors.push({ id: range.id, error, from: range.from, to: range.to })
|
||||
}
|
||||
}, 250),
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
:reinitialize="reinitializeJsonViewer"
|
||||
:signs="signs"
|
||||
:binary-encoding="$bksConfig.ui.general.binaryEncoding"
|
||||
:editable-paths="editablePaths"
|
||||
@bks-json-value-change="handleJsonValueChange"
|
||||
@expandPath="handleExpandPath"
|
||||
/>
|
||||
</template>
|
||||
@@ -30,6 +32,7 @@ export default Vue.extend({
|
||||
return {
|
||||
value: {},
|
||||
expandablePaths: [],
|
||||
editablePaths: [],
|
||||
signs: {},
|
||||
};
|
||||
},
|
||||
@@ -54,7 +57,10 @@ export default Vue.extend({
|
||||
handleExpandPath(expandablePaths: string[]) {
|
||||
this.trigger(AppEvent.jsonViewerSidebarExpandPath, expandablePaths)
|
||||
},
|
||||
update(options: { value: Record<string, unknown>; expandablePaths: string[], signs: Record<string, LineGutter['type']>}) {
|
||||
handleJsonValueChange(detail: { key: string; value: unknown }) {
|
||||
this.trigger(AppEvent.jsonViewerSidebarValueChange, detail)
|
||||
},
|
||||
update(options: { value: Record<string, unknown>; expandablePaths: string[], signs: Record<string, LineGutter['type']>, editablePaths: string[] }) {
|
||||
this.value = options.value
|
||||
this.expandablePaths = options.expandablePaths
|
||||
this.signs = options.signs
|
||||
|
||||
@@ -368,12 +368,13 @@ export default Vue.extend({
|
||||
initialized: false,
|
||||
internalColumnPrefix: "__beekeeper_internal_",
|
||||
internalIndexColumn: "__beekeeper_internal_index",
|
||||
selectedRowIndex: null,
|
||||
|
||||
/** This is true when we switch to minimal mode while TableTable is not active */
|
||||
enabledMinimalModeWhileInactive: false,
|
||||
|
||||
selectedRow: null,
|
||||
selectedRowIndex: null,
|
||||
selectedRowPosition: null,
|
||||
selectedRowData: {},
|
||||
expandablePaths: {},
|
||||
};
|
||||
@@ -568,12 +569,24 @@ export default Vue.extend({
|
||||
selectedRowDataSigns() {
|
||||
const signs = {}
|
||||
for (const pendingUpdate of this.pendingChanges.updates) {
|
||||
if (pendingUpdate.rowIndex === this.selectedRowIndex) {
|
||||
if (pendingUpdate.rowIndex === this.selectedRowPosition) {
|
||||
signs[pendingUpdate.column] = "changed"
|
||||
}
|
||||
}
|
||||
return signs
|
||||
},
|
||||
editablePaths() {
|
||||
if (!this.table.columns) return []
|
||||
|
||||
const paths = []
|
||||
for (const column of this.table.columns) {
|
||||
if(this.isPrimaryKey(column.columnName) || this.isForeignKey(column.columnName) || this.isGeneratedColumn(column.columnName)) {
|
||||
continue
|
||||
}
|
||||
paths.push(column.columnName)
|
||||
}
|
||||
return paths
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
@@ -906,7 +919,11 @@ export default Vue.extend({
|
||||
this.$root.$emit(AppEvent.closeTab)
|
||||
},
|
||||
isPrimaryKey(column) {
|
||||
return this.primaryKeys.includes(column);
|
||||
return this.primaryKeys?.includes(column);
|
||||
},
|
||||
isForeignKey(column: string) {
|
||||
const keyDatas: any[] = Object.entries(this.tableKeys).filter((entry) => entry[0] === column);
|
||||
return keyDatas && keyDatas.length > 0
|
||||
},
|
||||
isGeneratedColumn(columnName: string) {
|
||||
const column: ExtendedTableColumn = this.table.columns.find((col: ExtendedTableColumn) => col.columnName === columnName);
|
||||
@@ -1226,7 +1243,7 @@ export default Vue.extend({
|
||||
}
|
||||
|
||||
// reflect changes in the detail view
|
||||
if (this.indexRowOf(cell.getRow()) === this.selectedRowIndex) {
|
||||
if (this.positionRowOf(cell.getRow()) === this.selectedRowIndex) {
|
||||
cell.getRow().invalidateForeignCache(cell.getField())
|
||||
this.updateJsonViewer()
|
||||
}
|
||||
@@ -1274,12 +1291,13 @@ export default Vue.extend({
|
||||
oldValue: cell.getOldValue(),
|
||||
cell: cell,
|
||||
value: cell.getValue(0),
|
||||
rowIndex: this.indexRowOf(cell.getRow())
|
||||
rowIndex: this.positionRowOf(cell.getRow())
|
||||
}
|
||||
// remove existing pending updates with identical pKey-column combo
|
||||
let pendingUpdates = _.reject(this.pendingChanges.updates, { 'key': payload.key })
|
||||
pendingUpdates.push(payload)
|
||||
this.$set(this.pendingChanges, 'updates', pendingUpdates)
|
||||
this.updateDetailView()
|
||||
}
|
||||
},
|
||||
cloneSelection(range?: RangeComponent) {
|
||||
@@ -1709,7 +1727,7 @@ export default Vue.extend({
|
||||
this.tabulator.setPage(page)
|
||||
if (!this.active) this.forceRedraw = true
|
||||
},
|
||||
indexRowOf(row: RowComponent) {
|
||||
positionRowOf(row: RowComponent) {
|
||||
return (this.limit * (this.page - 1)) + (row.getPosition() || 0)
|
||||
},
|
||||
updateJsonViewer(options: { range?: RangeComponent } = {}) {
|
||||
@@ -1717,15 +1735,16 @@ export default Vue.extend({
|
||||
const row = range.getRows()[0]
|
||||
if (!row) {
|
||||
this.selectedRow = null
|
||||
this.selectedRowIndex = null
|
||||
this.selectedRowPosition = null
|
||||
this.selectedRowData = {}
|
||||
return
|
||||
}
|
||||
const position = this.indexRowOf(row)
|
||||
const position = this.positionRowOf(row)
|
||||
const data = row.getData("withForeignData")
|
||||
const cachedExpandablePaths = row.getExpandablePaths()
|
||||
this.selectedRow = row
|
||||
this.selectedRowIndex = position
|
||||
this.selectedRowPosition = position
|
||||
this.selectedRowIndex = this.primaryKeys?.map((key: string) => data[key]).join(',');
|
||||
this.selectedRowData = this.$bks.cleanData(data, this.tableColumns)
|
||||
this.expandablePaths = this.rawTableKeys
|
||||
.filter((key) => !row.hasForeignData([key.fromColumn]))
|
||||
@@ -1739,6 +1758,7 @@ export default Vue.extend({
|
||||
value: this.selectedRowData,
|
||||
expandablePaths: this.expandablePaths,
|
||||
signs: this.selectedRowDataSigns,
|
||||
editablePaths: this.editablePaths,
|
||||
}
|
||||
|
||||
this.trigger(AppEvent.updateJsonViewerSidebar, updatedData)
|
||||
@@ -1843,6 +1863,7 @@ export default Vue.extend({
|
||||
value: this.selectedRowData,
|
||||
expandablePaths: this.expandablePaths,
|
||||
signs: this.selectedRowDataSigns,
|
||||
editablePaths: this.editablePaths,
|
||||
}
|
||||
|
||||
this.trigger(AppEvent.updateJsonViewerSidebar, data)
|
||||
@@ -1862,6 +1883,7 @@ export default Vue.extend({
|
||||
value: this.selectedRowData,
|
||||
expandablePaths: this.expandablePaths,
|
||||
signs: this.selectedRowDataSigns,
|
||||
editablePaths: this.editablePaths,
|
||||
}
|
||||
this.trigger(AppEvent.updateJsonViewerSidebar, data)
|
||||
this.registerHandlers([
|
||||
@@ -1869,6 +1891,10 @@ export default Vue.extend({
|
||||
event: AppEvent.jsonViewerSidebarExpandPath,
|
||||
handler: this.expandForeignKey,
|
||||
},
|
||||
{
|
||||
event: AppEvent.jsonViewerSidebarValueChange,
|
||||
handler: this.handleJsonValueChange,
|
||||
},
|
||||
])
|
||||
},
|
||||
handleTabInactive() {
|
||||
@@ -1877,8 +1903,15 @@ export default Vue.extend({
|
||||
event: AppEvent.jsonViewerSidebarExpandPath,
|
||||
handler: this.expandForeignKey,
|
||||
},
|
||||
{
|
||||
event: AppEvent.jsonViewerSidebarValueChange,
|
||||
handler: this.handleJsonValueChange,
|
||||
},
|
||||
])
|
||||
},
|
||||
handleJsonValueChange({key, value}) {
|
||||
this.selectedRow?.getCell(key).setValue(value)
|
||||
},
|
||||
debouncedSaveTab: _.debounce(function(tab) {
|
||||
this.$store.dispatch('tabs/save', tab)
|
||||
}, 300),
|
||||
|
||||
229
apps/studio/src/lib/editor/plugins/PartialReadOnlyPlugin.ts
Normal file
229
apps/studio/src/lib/editor/plugins/PartialReadOnlyPlugin.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* This plugin sets the whole editor to be read-only except for some defined
|
||||
* ranges. **/
|
||||
|
||||
import _ from "lodash";
|
||||
import { EditorRange, isPositionWithin } from "@/lib/editor/utils";
|
||||
import { MenuOption, TextEditorPlugin } from "./TextEditorPlugin";
|
||||
import CodeMirror from "codemirror";
|
||||
// FIXME (azmi): maybe use json-ource-map instead?
|
||||
|
||||
const PARTIAL_READ_ONLY_CLASSNAME = "bks-partial-read-only";
|
||||
const EDITABLE_MARKER_CLASSNAME = "bks-editable-marker";
|
||||
const EDITABLE_MARKER_ACTIVE_CLASSNAME = "bks-editable-marker-active";
|
||||
const EDITABLE_MARKER_ATTR_ID = "data-bks-editable-id";
|
||||
|
||||
export default class PartialReadOnlyPlugin extends TextEditorPlugin {
|
||||
name = "partialReadOnly";
|
||||
rangeMap = new Map<
|
||||
string,
|
||||
{
|
||||
range: EditorRange;
|
||||
marker: CodeMirror.TextMarker;
|
||||
}
|
||||
>();
|
||||
activeMarkerId?: string;
|
||||
editor: CodeMirror.Editor;
|
||||
|
||||
constructor(
|
||||
private ranges: EditorRange[],
|
||||
private onEditableRangeChange?: (range: EditorRange, value: string) => void
|
||||
) {
|
||||
super();
|
||||
|
||||
this.handleBeforeChange = this.handleBeforeChange.bind(this);
|
||||
this.handleChange = this.handleChange.bind(this);
|
||||
this.handleCursorActivity = this.handleCursorActivity.bind(this);
|
||||
this.handleMouseOver = this.handleMouseOver.bind(this);
|
||||
this.handleMouseOut = this.handleMouseOut.bind(this);
|
||||
}
|
||||
|
||||
initialize(editor: CodeMirror.Editor) {
|
||||
if (editor.getOption("readOnly")) {
|
||||
throw new Error(
|
||||
"PartialReadOnlyPlugin requires `readOnly` to be disabled."
|
||||
);
|
||||
}
|
||||
|
||||
this.editor = editor;
|
||||
|
||||
this.editor.getWrapperElement().classList.add(PARTIAL_READ_ONLY_CLASSNAME);
|
||||
|
||||
this.ranges.forEach((range) => {
|
||||
const marker = editor.markText(range.from, range.to, {
|
||||
className: EDITABLE_MARKER_CLASSNAME,
|
||||
inclusiveLeft: true, // text inserted on the left will be included
|
||||
inclusiveRight: true, // text inserted on the right will be included
|
||||
clearWhenEmpty: false,
|
||||
// @ts-expect-error not fully typed
|
||||
attributes: { [EDITABLE_MARKER_ATTR_ID]: range.id },
|
||||
});
|
||||
this.rangeMap.set(range.id, {
|
||||
range,
|
||||
marker,
|
||||
});
|
||||
});
|
||||
|
||||
this.editor.on("beforeChange", this.handleBeforeChange);
|
||||
this.editor.on("change", this.handleChange);
|
||||
this.editor.on("cursorActivity", this.handleCursorActivity);
|
||||
|
||||
this.setupMarkerHoverHandlers();
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.rangeMap.forEach((range) => range.marker.clear());
|
||||
if (this.editor) {
|
||||
this.editor.off("beforeChange", this.handleBeforeChange);
|
||||
this.editor.off("change", this.handleChange);
|
||||
this.editor.off("cursorActivity", this.handleCursorActivity);
|
||||
|
||||
this.removeMarkerHoverHandlers();
|
||||
|
||||
this.editor
|
||||
.getWrapperElement()
|
||||
.classList.remove(PARTIAL_READ_ONLY_CLASSNAME);
|
||||
}
|
||||
}
|
||||
|
||||
beforeOpeningContextMenu(_event: MouseEvent, menuOptions: MenuOption[]) {
|
||||
return menuOptions.filter((option) => !option.write);
|
||||
}
|
||||
|
||||
private setupMarkerHoverHandlers(): void {
|
||||
const wrapper = this.editor.getWrapperElement();
|
||||
wrapper.addEventListener("mouseover", this.handleMouseOver);
|
||||
wrapper.addEventListener("mouseout", this.handleMouseOut);
|
||||
}
|
||||
|
||||
private removeMarkerHoverHandlers(): void {
|
||||
const wrapper = this.editor.getWrapperElement();
|
||||
wrapper.removeEventListener("mouseover", this.handleMouseOver);
|
||||
wrapper.removeEventListener("mouseout", this.handleMouseOut);
|
||||
}
|
||||
|
||||
private handleBeforeChange(
|
||||
cm: CodeMirror.Editor,
|
||||
changeObj: CodeMirror.EditorChangeCancellable
|
||||
) {
|
||||
if (changeObj.origin !== "setValue") {
|
||||
let markers = cm.findMarks(changeObj.from, changeObj.to);
|
||||
if (markers.length === 0) {
|
||||
markers = cm.findMarksAt(changeObj.from);
|
||||
}
|
||||
|
||||
for (const marker of markers) {
|
||||
if (
|
||||
this.getMarkerId(marker) &&
|
||||
isPositionWithin(changeObj, marker.find())
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
changeObj.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
private handleChange(
|
||||
cm: CodeMirror.Editor,
|
||||
changeObj: CodeMirror.EditorChangeLinkedList
|
||||
) {
|
||||
// Skip the whole process if there is no callback
|
||||
if (!this.onEditableRangeChange) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (changeObj.origin !== "setValue") {
|
||||
const markers = cm.findMarksAt(changeObj.from);
|
||||
for (const marker of markers) {
|
||||
const id = this.getMarkerId(marker)
|
||||
if (!id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const markerPos = marker.find();
|
||||
const range: EditorRange = {
|
||||
id,
|
||||
from: markerPos.from,
|
||||
to: markerPos.to,
|
||||
}
|
||||
const value = this.getMarkerValue(marker);
|
||||
this.onEditableRangeChange(range, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleCursorActivity(cm: CodeMirror.Editor) {
|
||||
const cursor = cm.getCursor();
|
||||
const markers = cm.findMarksAt(cursor);
|
||||
const activeMarker = this.findActiveMarker(markers);
|
||||
|
||||
let activeMarkerId: string;
|
||||
|
||||
if (activeMarker) {
|
||||
activeMarkerId = this.getMarkerId(activeMarker);
|
||||
setTimeout(() => {
|
||||
this.highlightMarker(activeMarkerId);
|
||||
});
|
||||
}
|
||||
|
||||
if (this.activeMarkerId && this.activeMarkerId !== activeMarkerId) {
|
||||
this.unHighlightMarker(this.activeMarkerId);
|
||||
}
|
||||
|
||||
this.activeMarkerId = activeMarkerId;
|
||||
}
|
||||
|
||||
private handleMouseOver(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.classList.contains(EDITABLE_MARKER_CLASSNAME)) {
|
||||
const markerId = target.getAttribute(EDITABLE_MARKER_ATTR_ID);
|
||||
if (markerId) {
|
||||
this.highlightMarker(markerId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleMouseOut(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.classList.contains(EDITABLE_MARKER_CLASSNAME)) {
|
||||
const markerId = target.getAttribute(EDITABLE_MARKER_ATTR_ID);
|
||||
if (markerId && markerId !== this.activeMarkerId) {
|
||||
this.unHighlightMarker(markerId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
highlightMarker(markerId: string) {
|
||||
const markerEls = this.editor
|
||||
.getWrapperElement()
|
||||
.querySelectorAll(`[${EDITABLE_MARKER_ATTR_ID}="${markerId}"]`);
|
||||
|
||||
markerEls.forEach((el) => {
|
||||
el.classList.toggle(EDITABLE_MARKER_ACTIVE_CLASSNAME, true);
|
||||
});
|
||||
}
|
||||
|
||||
unHighlightMarker(markerId: string) {
|
||||
const markerEls = this.editor
|
||||
.getWrapperElement()
|
||||
.querySelectorAll(`[${EDITABLE_MARKER_ATTR_ID}="${markerId}"]`);
|
||||
markerEls.forEach((el) => {
|
||||
el.classList.toggle(EDITABLE_MARKER_ACTIVE_CLASSNAME, false);
|
||||
});
|
||||
}
|
||||
|
||||
findActiveMarker(markers: CodeMirror.TextMarker[]) {
|
||||
return markers.find((marker) => this.getMarkerId(marker));
|
||||
}
|
||||
|
||||
getMarkerValue(marker: CodeMirror.TextMarker) {
|
||||
const { from, to } = marker.find();
|
||||
return this.editor.getRange(from, to);
|
||||
}
|
||||
|
||||
getMarkerId(marker: CodeMirror.TextMarker) {
|
||||
// @ts-expect-error not fully typed
|
||||
return marker.attributes?.[EDITABLE_MARKER_ATTR_ID];
|
||||
}
|
||||
}
|
||||
16
apps/studio/src/lib/editor/plugins/TextEditorPlugin.ts
Normal file
16
apps/studio/src/lib/editor/plugins/TextEditorPlugin.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export interface MenuOption {
|
||||
name: string;
|
||||
handler: Function;
|
||||
shortcut: string;
|
||||
write: boolean;
|
||||
}
|
||||
|
||||
export abstract class TextEditorPlugin {
|
||||
abstract name: string;
|
||||
abstract initialize(editor: CodeMirror.Editor): void;
|
||||
abstract destroy(): void;
|
||||
beforeOpeningContextMenu?(
|
||||
event: MouseEvent,
|
||||
menuOptions: MenuOption[]
|
||||
): void | false | MenuOption[];
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
**/
|
||||
|
||||
import _ from "lodash";
|
||||
// FIXME (azmi): maybe use json-source-map instead?
|
||||
import { findKeyPosition } from "@/lib/data/jsonViewer";
|
||||
import { getLocation, JSONPath } from "jsonc-parser";
|
||||
import CodeMirror from "codemirror";
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import * as CodeMirrorPlugins from "@/lib/editor/CodeMirrorPlugins";
|
||||
import { TableOrView } from "../db/models";
|
||||
import CodeMirror from "codemirror";
|
||||
|
||||
export interface EditorMarker {
|
||||
export interface EditorRange {
|
||||
id?: string;
|
||||
from: { line: number; ch: number };
|
||||
to: { line: number; ch: number };
|
||||
}
|
||||
|
||||
export interface EditorMarker extends EditorRange {
|
||||
message?: string;
|
||||
element?: HTMLElement;
|
||||
onClick?: (event: MouseEvent) => void;
|
||||
@@ -15,6 +20,17 @@ export interface LineGutter {
|
||||
type: "changed";
|
||||
}
|
||||
|
||||
/** Checks if `target` is within `container` */
|
||||
export function isPositionWithin(
|
||||
target: { from: CodeMirror.Position; to: CodeMirror.Position },
|
||||
container: { from: CodeMirror.Position; to: CodeMirror.Position }
|
||||
) {
|
||||
return (
|
||||
CodeMirror.cmpPos(target.from, container.from) >= 0 &&
|
||||
CodeMirror.cmpPos(target.to, container.to) <= 0
|
||||
);
|
||||
}
|
||||
|
||||
export const plugins = {
|
||||
autoquote: CodeMirrorPlugins.registerAutoquote,
|
||||
autoComplete: CodeMirrorPlugins.registerAutoComplete,
|
||||
|
||||
17
yarn.lock
17
yarn.lock
@@ -7407,6 +7407,11 @@ follow-redirects@^1.15.6:
|
||||
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.9.tgz#a604fa10e443bf98ca94228d9eebcc2e8a2c8ee1"
|
||||
integrity sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==
|
||||
|
||||
foreach@^2.0.4:
|
||||
version "2.0.6"
|
||||
resolved "https://registry.yarnpkg.com/foreach/-/foreach-2.0.6.tgz#87bcc8a1a0e74000ff2bf9802110708cfb02eb6e"
|
||||
integrity sha512-k6GAGDyqLe9JaebCsFCoudPPWfihKu8pylYXRlqP1J7ms39iPoTtk2fviNglIeQEwdh0bQeKJ01ZPyuyQvKzwg==
|
||||
|
||||
foreground-child@^3.1.0:
|
||||
version "3.3.0"
|
||||
resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.3.0.tgz#0ac8644c06e431439f8561db8ecf29a7b5519c77"
|
||||
@@ -9008,11 +9013,23 @@ json-parse-even-better-errors@^2.3.0:
|
||||
resolved "https://registry.yarnpkg.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz#7c47805a94319928e05777405dc12e1f7a4ee02d"
|
||||
integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==
|
||||
|
||||
json-pointer@^0.6.2:
|
||||
version "0.6.2"
|
||||
resolved "https://registry.yarnpkg.com/json-pointer/-/json-pointer-0.6.2.tgz#f97bd7550be5e9ea901f8c9264c9d436a22a93cd"
|
||||
integrity sha512-vLWcKbOaXlO+jvRy4qNd+TI1QUPZzfJj1tpJ3vAXDych5XJf93ftpUKe5pKCrzyIIwgBJcOcCVRUfqQP25afBw==
|
||||
dependencies:
|
||||
foreach "^2.0.4"
|
||||
|
||||
json-schema-traverse@^0.4.1:
|
||||
version "0.4.1"
|
||||
resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660"
|
||||
integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==
|
||||
|
||||
json-source-map@^0.6.1:
|
||||
version "0.6.1"
|
||||
resolved "https://registry.yarnpkg.com/json-source-map/-/json-source-map-0.6.1.tgz#e0b1f6f4ce13a9ad57e2ae165a24d06e62c79a0f"
|
||||
integrity sha512-1QoztHPsMQqhDq0hlXY5ZqcEdUzxQEIxgFkKl4WUp2pgShObl+9ovi4kRh2TfvAfxAoHOJ9vIMEqk3k4iex7tg==
|
||||
|
||||
json-stable-stringify-without-jsonify@^1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651"
|
||||
|
||||
Reference in New Issue
Block a user