RSS Git Download  Clone
Raw Blame History 12kB 293 lines
import { Component, Inject, OnInit, OnDestroy, ViewChild, ElementRef, AfterViewInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatToolbarModule } from '@angular/material/toolbar';
import { MatTooltipModule } from '@angular/material/tooltip';
import { BreakpointObserver } from '@angular/cdk/layout';
import { I18nService } from '../services/i18n.service';
import { ThemeService } from '../services/theme.service';
import { CommonService } from '../services/common.service';
import { DialogCancelButtonComponent } from '../components/dialog-cancel-button.component';
import { RedisStateService } from '../services/redis-state.service';
import { SettingsService } from '../services/settings.service';

export interface JsonEditorDialogData {
    value: string;
    hideFormatSave?: boolean;
}

@Component({
    selector: 'p3xr-json-editor-dialog',
    standalone: true,
    imports: [
        CommonModule, MatDialogModule, MatButtonModule, MatIconModule,
        MatToolbarModule, MatTooltipModule, DialogCancelButtonComponent,
    ],
    template: `
        <mat-toolbar class="p3xr-dialog-toolbar p3xr-mat-layout-strong">
            <span mat-dialog-title class="p3xr-dialog-title p3xr-dialog-title-with-icon">
                <mat-icon>edit</mat-icon>
                <span>{{ strings().intention?.jsonViewEditor || 'JSON Editor' }}</span>
            </span>
            <button mat-icon-button (click)="close()">
                <mat-icon>close</mat-icon>
            </button>
        </mat-toolbar>

        @if (isJson) {
            <mat-dialog-content
                class="p3xr-dialog-content p3xr-dialog-content-mono p3xr-dialog-content-editor">
                <div #editorContainer class="p3xr-codemirror-host"></div>
            </mat-dialog-content>
        } @else {
            <mat-dialog-content class="p3xr-dialog-content p3xr-dialog-content-mono p3xr-dialog-content-message">
                {{ strings().label?.jsonViewNotParsable || 'Not valid JSON' }}
            </mat-dialog-content>
        }

        <mat-dialog-actions class="p3xr-dialog-actions">
            <button mat-raised-button class="btn-accent" type="button" (click)="toggleWrap()">
                <mat-icon>{{ lineWrap ? 'wrap_text' : 'notes' }}</mat-icon>
                <span class="hide-sm">{{ lineWrap ? (strings().intention?.unwrap || 'Unwrap') : (strings().intention?.wrap || 'Wrap') }}</span>
            </button>
            <span style="flex: 1"></span>
            <p3xr-dialog-cancel (cancel)="close()"></p3xr-dialog-cancel>

            @if (isJson && !isReadonly) {
                <button mat-raised-button class="btn-primary" type="button" (click)="save(false)"
                    [matTooltip]="strings().intention?.save || 'Save'">
                    <mat-icon>save</mat-icon>
                    <span class="hide-sm">{{ strings().intention?.save || 'Save' }}</span>
                </button>
                @if (!hideFormatSave) {
                    <button mat-raised-button class="btn-primary" type="button" (click)="save(true)"
                        [matTooltip]="strings().intention?.saveWithFormatJson || 'Save Formatted'">
                        <mat-icon>save</mat-icon>
                        <mat-icon>format_line_spacing</mat-icon>
                        <span class="hide-sm">{{ strings().intention?.saveWithFormatJson || 'Save Formatted' }}</span>
                    </button>
                }
            }
        </mat-dialog-actions>
    `,
    styles: [`
        .hide-sm { display: inline; }
        .p3xr-dialog-content-editor {
            padding: 0 !important;
            overflow: hidden !important;
            max-height: none !important;
        }
        .p3xr-dialog-content-message { min-height: 320px; }
        .p3xr-codemirror-host { height: calc(90vh - 100px); }
        .p3xr-codemirror-host .cm-editor { height: 100% !important; max-height: 100% !important; }
        .p3xr-codemirror-host .cm-scroller { overflow: auto !important; min-height: 0 !important; }
        @media (max-width: 959px) { .hide-sm { display: none; } }
    `],
})
export class JsonEditorDialogComponent implements OnInit, AfterViewInit, OnDestroy {
    @ViewChild('editorContainer') editorContainer!: ElementRef;

    isJson = false;
    isReadonly = false;
    hideFormatSave = false;
    lineWrap = true;
    minHeight = '400px';
    strings;

    private editorView: any;
    private wrapCompartment: any;
    private EditorViewClass: any;
    private obj: any;
    private resizeHandler: any;

    constructor(
        @Inject(MatDialogRef) private dialogRef: MatDialogRef<JsonEditorDialogComponent>,
        @Inject(MAT_DIALOG_DATA) private data: JsonEditorDialogData,
        @Inject(I18nService) private i18n: I18nService,
        @Inject(ThemeService) private theme: ThemeService,
        @Inject(CommonService) private common: CommonService,
        @Inject(BreakpointObserver) private breakpointObserver: BreakpointObserver,
        @Inject(RedisStateService) private state: RedisStateService,
        @Inject(SettingsService) private settings: SettingsService,
    ) {
        this.strings = this.i18n.strings;
    }

    ngOnInit(): void {
        try {
            this.obj = JSON.parse(this.data.value);
            this.isJson = true;
        } catch (e) {
            this.obj = undefined;
            this.isJson = false;
        }

        this.isReadonly = this.state.connection()?.readonly === true;
        this.hideFormatSave = this.data.hideFormatSave === true;
        this.updateMinHeight();
    }

    async ngAfterViewInit(): Promise<void> {
        if (!this.isJson || !this.editorContainer) return;

        const { EditorView, keymap, lineNumbers, highlightActiveLineGutter, highlightSpecialChars,
            drawSelection, highlightActiveLine, rectangularSelection, crosshairCursor } = await import(
            /* webpackChunkName: "codemirror-view" */
            '@codemirror/view'
        );
        const { EditorState, Compartment } = await import(
            /* webpackChunkName: "codemirror-state" */
            '@codemirror/state'
        );
        this.wrapCompartment = new Compartment();
        const { json } = await import(
            /* webpackChunkName: "codemirror-lang-json" */
            '@codemirror/lang-json'
        );
        const { defaultKeymap, history, historyKeymap } = await import(
            /* webpackChunkName: "codemirror-commands" */
            '@codemirror/commands'
        );
        const { bracketMatching, foldGutter, foldKeymap, indentOnInput, syntaxHighlighting, defaultHighlightStyle } = await import(
            /* webpackChunkName: "codemirror-language" */
            '@codemirror/language'
        );
        const { closeBrackets, closeBracketsKeymap } = await import(
            /* webpackChunkName: "codemirror-autocomplete" */
            '@codemirror/autocomplete'
        );
        const { searchKeymap, highlightSelectionMatches } = await import(
            /* webpackChunkName: "codemirror-search" */
            '@codemirror/search'
        );
        const { lintKeymap } = await import(
            /* webpackChunkName: "codemirror-lint" */
            '@codemirror/lint'
        );

        let themeExtension;
        if (this.theme.isDark()) {
            const { oneDark } = await import(
                /* webpackChunkName: "codemirror-theme-dark" */
                '@codemirror/theme-one-dark'
            );
            themeExtension = oneDark;
        } else {
            const { githubLight } = await import(
                /* webpackChunkName: "codemirror-theme-light" */
                '@uiw/codemirror-theme-github'
            );
            themeExtension = githubLight;
        }

        const doc = JSON.stringify(this.obj, null, this.settings.jsonFormat() ?? 2);

        this.EditorViewClass = EditorView;
        this.editorView = new EditorView({
            state: EditorState.create({
                doc: doc,
                extensions: [
                    lineNumbers(),
                    highlightActiveLineGutter(),
                    highlightSpecialChars(),
                    history(),
                    foldGutter(),
                    drawSelection(),
                    indentOnInput(),
                    syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
                    bracketMatching(),
                    closeBrackets(),
                    rectangularSelection(),
                    crosshairCursor(),
                    highlightActiveLine(),
                    highlightSelectionMatches(),
                    keymap.of([
                        ...closeBracketsKeymap,
                        ...defaultKeymap,
                        ...searchKeymap,
                        ...historyKeymap,
                        ...foldKeymap,
                        ...lintKeymap,
                    ]),
                    json(),
                    themeExtension,
                    EditorView.theme({
                        '&': {
                            'height': 'calc(90vh - 100px)',
                            'max-height': 'calc(90vh - 100px)',
                        },
                        '.cm-scroller': {
                            'overflow': 'auto',
                            'scrollbar-width': 'auto',
                        },
                        '.cm-scroller::-webkit-scrollbar': {
                            'height': '12px',
                            'display': 'block',
                        },
                        '.cm-scroller::-webkit-scrollbar-track': {
                            'background': 'rgba(128, 128, 128, 0.1)',
                        },
                        '.cm-scroller::-webkit-scrollbar-thumb': {
                            'background': 'rgba(128, 128, 128, 0.4)',
                            'border-radius': '6px',
                        },
                        '.cm-scroller::-webkit-scrollbar-thumb:hover': {
                            'background': 'rgba(128, 128, 128, 0.6)',
                        },
                    }),
                    this.wrapCompartment.of(this.lineWrap ? EditorView.lineWrapping : []),
                    EditorState.readOnly.of(this.isReadonly),
                ],
            }),
            parent: this.editorContainer.nativeElement,
        });

        // Resize handler
        this.resizeHandler = () => {
            this.updateMinHeight();
        };
        window.addEventListener('resize', this.resizeHandler);
    }

    ngOnDestroy(): void {
        if (this.resizeHandler) {
            window.removeEventListener('resize', this.resizeHandler);
        }
        if (this.editorView) {
            this.editorView.destroy();
            this.editorView = undefined;
        }
    }

    toggleWrap(): void {
        this.lineWrap = !this.lineWrap;
        if (this.editorView && this.wrapCompartment && this.EditorViewClass) {
            this.editorView.dispatch({
                effects: this.wrapCompartment.reconfigure(this.lineWrap ? this.EditorViewClass.lineWrapping : []),
            });
        }
    }

    save(format: boolean): void {
        try {
            const text = this.editorView.state.doc.toString();
            const parsed = JSON.parse(text);
            const result = JSON.stringify(parsed, null, format ? (this.settings.jsonFormat() ?? 2) : 0);
            this.dialogRef.close({ obj: result });
        } catch (e) {
            this.common.generalHandleError(e);
        }
    }

    close(): void {
        this.dialogRef.close(undefined);
    }

    private updateMinHeight(): void {
        const isMobile = this.breakpointObserver.isMatched('(max-width: 959px)');
        this.minHeight = isMobile ? '100%' : `${Math.max(10, window.innerHeight - 100)}px`;
    }
}