diff --git a/apps/codeimage/src/components/CustomEditor/CanvasEditor.tsx b/apps/codeimage/src/components/CustomEditor/CanvasEditor.tsx index c60ed646d..766f5419c 100644 --- a/apps/codeimage/src/components/CustomEditor/CanvasEditor.tsx +++ b/apps/codeimage/src/components/CustomEditor/CanvasEditor.tsx @@ -1,8 +1,10 @@ import {useI18n} from '@codeimage/locale'; import {getRootEditorStore} from '@codeimage/store/editor'; import {getActiveEditorStore} from '@codeimage/store/editor/activeEditor'; +import {EditorMetadataState} from '@codeimage/store/editor/model'; import {getUiStore} from '@codeimage/store/ui'; import {HStack, toast} from '@codeimage/ui'; +import {StateField, Transaction} from '@codemirror/state'; import {EditorView} from '@codemirror/view'; import {Button} from '@codeui/kit'; import { @@ -10,21 +12,29 @@ import { createEditorControlledValue, createEditorFocus, } from 'solid-codemirror'; -import {Accessor, createEffect, createSignal, on} from 'solid-js'; +import {Accessor, createEffect, createMemo, createSignal, on} from 'solid-js'; import {AppLocaleEntries} from '../../i18n'; import {SparklesIcon} from '../Icons/SparklesIcon'; import CustomEditor from './CustomEditor'; +import {customEffectAnnotation} from './plugins/customEffectAnnotation'; +import { + diffLineState, + dispatchReplaceDiffLineState, +} from './plugins/diff/state'; interface CanvasEditorProps { readOnly: boolean; } +const initMetadataAnnotationEvent = 'init-metadata'; + export default function CanvasEditor(props: CanvasEditorProps) { const [editorView, setEditorView] = createSignal(); const activeEditorStore = getActiveEditorStore(); const { state: editorState, - actions: {setFocused}, + actions: {setFocused, setTabMetadata}, + canvasEditorEvents, } = getRootEditorStore(); const {setFocused: editorSetFocused} = createEditorFocus( @@ -37,6 +47,24 @@ export default function CanvasEditor(props: CanvasEditorProps) { editorView, view => { if (!view) return; + + canvasEditorEvents.listen(transaction => { + if ( + !transaction.annotation(customEffectAnnotation) || + transaction.annotation(Transaction.userEvent) === + initMetadataAnnotationEvent + ) + return; + const state: Record< + keyof EditorMetadataState, + StateField + > = { + diff: diffLineState, + }; + const json = transaction.state.toJSON(state); + setTabMetadata(json); + }); + createEffect( on( () => editorState.options.focused, @@ -47,6 +75,38 @@ export default function CanvasEditor(props: CanvasEditorProps) { }, ), ); + + const metadata = createMemo(() => activeEditorStore.editor()?.metadata); + + let sendInitEvent = false; + createEffect( + on(editorView, editorView => { + if (!editorView) return; + createEffect( + on([metadata], ([metadata]) => { + if (!metadata) { + return; + } + const {diff} = metadata; + if (diff) { + dispatchReplaceDiffLineState( + editorView, + { + inserted: diff.inserted, + deleted: diff.deleted, + }, + sendInitEvent + ? { + userEvent: initMetadataAnnotationEvent, + } + : undefined, + ); + } + sendInitEvent = true; + }), + ); + }), + ); }, {defer: true}, ), @@ -107,6 +167,7 @@ export default function CanvasEditor(props: CanvasEditorProps) { return ( void; onValueChange?: (value: string) => void; + dispatchTransaction: boolean; } export default function CustomEditor(props: VoidProps) { @@ -82,7 +85,9 @@ export default function CustomEditor(props: VoidProps) { createExtension, } = createCodeMirror({ value: editor()?.code, - onTransactionDispatched: tr => canvasEditorEvents.emit(tr), + onTransactionDispatched: props.dispatchTransaction + ? tr => canvasEditorEvents.emit(tr) + : undefined, onValueChange: props.onValueChange, }); @@ -143,8 +148,6 @@ export default function CustomEditor(props: VoidProps) { }, '.cm-cursor': { borderLeftWidth: '2px', - height: '21px', - transform: 'translateY(-10%)', }, }); @@ -169,7 +172,9 @@ export default function CustomEditor(props: VoidProps) { }, }); }; - + createExtension(() => + selectedLanguage()?.id === 'git-patch' ? syntaxHighlightColorDiff : [], + ); createEditorReadonly(editorView, () => props.readOnly); createExtension(EditorView.lineWrapping); createExtension(() => @@ -200,6 +205,16 @@ export default function CustomEditor(props: VoidProps) { ? lineNumbers({formatNumber: lineNo => String(newLn(lineNo))}) : []; }); + + createExtension(() => + editorState.options.showDiffMode + ? diffMarkerControl({readOnly: props.readOnly}) + : [], + ); + + createExtension(() => { + return editorState.options.showLineNumbers ? lineNumbers() : []; + }); createExtension(() => themeConfiguration()?.editorTheme || []); createExtension(baseTheme); diff --git a/apps/codeimage/src/components/CustomEditor/PreviewExportEditor.tsx b/apps/codeimage/src/components/CustomEditor/PreviewExportEditor.tsx index fdcd457eb..f3b327410 100644 --- a/apps/codeimage/src/components/CustomEditor/PreviewExportEditor.tsx +++ b/apps/codeimage/src/components/CustomEditor/PreviewExportEditor.tsx @@ -1,18 +1,11 @@ import {getRootEditorStore} from '@codeimage/store/editor'; -import {Annotation, Transaction} from '@codemirror/state'; +import {Transaction} from '@codemirror/state'; import {EditorView} from '@codemirror/view'; +import {createEventStack} from '@solid-primitives/event-bus'; +import {createCompartmentExtension} from 'solid-codemirror'; import {createEffect, createSignal, lazy, on} from 'solid-js'; - -const syncAnnotation = Annotation.define(); - -function syncDispatch(tr: Transaction, other: EditorView) { - if (!tr.changes.empty && !tr.annotation(syncAnnotation)) { - const annotations: Annotation[] = [syncAnnotation.of(true)]; - const userEvent = tr.annotation(Transaction.userEvent); - if (userEvent) annotations.push(Transaction.userEvent.of(userEvent)); - other.dispatch({changes: tr.changes, annotations}); - } -} +import {diffMarkerStateIconGutter} from './plugins/diff/extension'; +import {syncDispatch} from './plugins/sync/sync'; const CustomEditor = lazy(() => import('./CustomEditor')); @@ -22,19 +15,40 @@ interface PreviewExportEditorProps { export default function PreviewExportEditor(props: PreviewExportEditorProps) { const [editorView, setEditorView] = createSignal(); + const {canvasEditorEvents} = getRootEditorStore(); + const transactions = createEventStack(); + canvasEditorEvents.listen(tr => transactions.emit(tr)); + const sync = (tr: Transaction, editorView: EditorView) => { + try { + syncDispatch(tr, editorView); + editorView.requestMeasure(); + } catch (e) { + console.error(e); + } + }; + + let unsubscribe: VoidFunction; createEffect( on(editorView, editorView => { + if (unsubscribe) unsubscribe(); if (!editorView) return; - getRootEditorStore().canvasEditorEvents.listen(tr => { - setTimeout(() => syncDispatch(tr, editorView), 250); - setInterval(() => editorView.requestMeasure()); + transactions.value().forEach(transaction => { + sync(transaction, editorView); + transactions.setValue(trs => trs.filter(tr => tr !== transaction)); + }); + unsubscribe = transactions.listen(({event: transaction, remove}) => { + sync(transaction, editorView); + remove(); }); }), ); + createCompartmentExtension(diffMarkerStateIconGutter, editorView); + return ( { props.onSetEditorView(editorView); setEditorView(editorView); diff --git a/apps/codeimage/src/components/CustomEditor/plugins/customEffectAnnotation.ts b/apps/codeimage/src/components/CustomEditor/plugins/customEffectAnnotation.ts new file mode 100644 index 000000000..fa137cdd0 --- /dev/null +++ b/apps/codeimage/src/components/CustomEditor/plugins/customEffectAnnotation.ts @@ -0,0 +1,3 @@ +import {Annotation} from '@codemirror/state'; + +export const customEffectAnnotation = Annotation.define(); diff --git a/apps/codeimage/src/components/CustomEditor/plugins/diff/DiffCheckbox.css.ts b/apps/codeimage/src/components/CustomEditor/plugins/diff/DiffCheckbox.css.ts new file mode 100644 index 000000000..a1553fab4 --- /dev/null +++ b/apps/codeimage/src/components/CustomEditor/plugins/diff/DiffCheckbox.css.ts @@ -0,0 +1,51 @@ +import {themeTokens, themeVars} from '@codeui/kit'; +import {createVar, style} from '@vanilla-extract/css'; +import {recipe} from '@vanilla-extract/recipes'; + +export const container = style({ + opacity: 1, + transition: 'opacity 150ms ease-in-out', + display: 'flex', + height: '100%', +}); + +export const tooltip = style({ + fontSize: themeTokens.fontSize.xs, +}); + +const iconBorder = createVar(); +export const icon = recipe({ + base: { + width: '20px', + height: '20px', + outline: 'none', + border: `1px solid ${iconBorder}`, + vars: { + [iconBorder]: '#cccccc20', + }, + ':active': { + transform: 'scale(1) !important', + }, + ':focus-visible': { + outline: `none`, + borderColor: themeVars.brand, + }, + selectors: { + '[data-theme-mode=light] &': { + background: 'rgba(0,0,0,0.05)', + color: 'black', + }, + '[data-theme-mode=light] &:hover': { + background: 'rgba(0,0,0,0.1)', + color: 'black', + }, + '[data-theme-mode=dark] &': { + background: 'rgba(0,0,0,0.3)', + color: 'white', + }, + '[data-theme-mode=dark] &:hover': { + background: 'rgba(0,0,0,0.5)', + }, + }, + }, +}); diff --git a/apps/codeimage/src/components/CustomEditor/plugins/diff/DiffCheckbox.tsx b/apps/codeimage/src/components/CustomEditor/plugins/diff/DiffCheckbox.tsx new file mode 100644 index 000000000..1c81bdd6d --- /dev/null +++ b/apps/codeimage/src/components/CustomEditor/plugins/diff/DiffCheckbox.tsx @@ -0,0 +1,76 @@ +import {IconButton, Tooltip} from '@codeui/kit'; +import {createSignal} from 'solid-js'; +import {icon, tooltip} from './DiffCheckbox.css'; + +export type DiffCheckboxState = 'added' | 'removed' | 'untouched'; + +interface DiffCheckboxProps { + value: DiffCheckboxState; + onChange: (value: DiffCheckboxState) => void; +} + +export function DiffCheckbox(props: DiffCheckboxProps) { + let el!: HTMLButtonElement; + const [tooltipOpen, setTooltipOpen] = createSignal(false); + const statesTransition: Record = { + added: 'removed', + removed: 'untouched', + untouched: 'added', + }; + + const onClick = (value: DiffCheckboxState) => { + props.onChange(statesTransition[value]); + if (el.matches(':hover').valueOf()) { + requestAnimationFrame(() => setTooltipOpen(true)); + } + }; + + const label = () => { + switch (props.value) { + case 'added': + return '+'; + case 'removed': + return '-'; + case 'untouched': + return null; + } + }; + + const title = () => { + switch (props.value) { + case 'added': + return 'Set to removed'; + case 'removed': + return 'Set to untouched'; + case 'untouched': + return 'Set to added'; + } + }; + + return ( +
+ + onClick(props.value)} + > + {label()} + + +
+ ); +} diff --git a/apps/codeimage/src/components/CustomEditor/plugins/diff/diffCheckboxMarker.tsx b/apps/codeimage/src/components/CustomEditor/plugins/diff/diffCheckboxMarker.tsx new file mode 100644 index 000000000..6436aba2f --- /dev/null +++ b/apps/codeimage/src/components/CustomEditor/plugins/diff/diffCheckboxMarker.tsx @@ -0,0 +1,50 @@ +import {EditorView, GutterMarker} from '@codemirror/view'; +import {createRoot, createSignal, onCleanup} from 'solid-js'; +import {DiffCheckbox, DiffCheckboxState} from './DiffCheckbox'; +import {container} from './DiffCheckbox.css'; +import {diffPluginEvents} from './diffEvents'; +import {dispatchUpdateDiffLineState} from './state'; + +export class DiffCheckboxMarker extends GutterMarker { + private dispose: VoidFunction | null = null; + + constructor(private readonly lineNumber: number) { + super(); + } + + eq(other: DiffCheckboxMarker) { + return this.lineNumber == other.lineNumber; + } + + destroy(dom: Node) { + super.destroy(dom); + this.dispose?.(); + } + + toDOM(view: EditorView): Node { + return createRoot(dispose => { + // eslint-disable-next-line solid/reactivity + this.dispose = dispose; + const [value, setValue] = createSignal('untouched'); + + const unsubscribe = diffPluginEvents.on('syncLine', ({state, line}) => { + if (line.number === this.lineNumber) { + setValue(state ?? 'untouched'); + } + }); + + onCleanup(() => unsubscribe()); + + return ( +
+ + dispatchUpdateDiffLineState(view, this.lineNumber, state) + } + /> +
+ ); + }) as Node; + } +} diff --git a/apps/codeimage/src/components/CustomEditor/plugins/diff/diffEvents.ts b/apps/codeimage/src/components/CustomEditor/plugins/diff/diffEvents.ts new file mode 100644 index 000000000..123cef5d7 --- /dev/null +++ b/apps/codeimage/src/components/CustomEditor/plugins/diff/diffEvents.ts @@ -0,0 +1,10 @@ +import {Line} from '@codemirror/state'; +import {createGlobalEmitter} from '@solid-primitives/event-bus'; +import {createRoot} from 'solid-js'; +import {DiffCheckboxState} from './DiffCheckbox'; + +export const diffPluginEvents = createRoot(() => + createGlobalEmitter<{ + syncLine: {state: DiffCheckboxState | null; line: Line}; + }>(), +); diff --git a/apps/codeimage/src/components/CustomEditor/plugins/diff/diffMarkerStateIcon.css.ts b/apps/codeimage/src/components/CustomEditor/plugins/diff/diffMarkerStateIcon.css.ts new file mode 100644 index 000000000..c989e0b68 --- /dev/null +++ b/apps/codeimage/src/components/CustomEditor/plugins/diff/diffMarkerStateIcon.css.ts @@ -0,0 +1,15 @@ +import {backgroundColorVar} from '@codeimage/ui'; +import {style} from '@vanilla-extract/css'; + +export const wrapper = style({ + height: '100%', +}); + +export const icon = style({ + height: '100%', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + paddingLeft: '4px', + backgroundColor: backgroundColorVar, +}); diff --git a/apps/codeimage/src/components/CustomEditor/plugins/diff/diffMarkerStateIcon.tsx b/apps/codeimage/src/components/CustomEditor/plugins/diff/diffMarkerStateIcon.tsx new file mode 100644 index 000000000..ea527265a --- /dev/null +++ b/apps/codeimage/src/components/CustomEditor/plugins/diff/diffMarkerStateIcon.tsx @@ -0,0 +1,70 @@ +import {backgroundColorVar} from '@codeimage/ui'; +import {GutterMarker} from '@codemirror/view'; +import {assignInlineVars} from '@vanilla-extract/dynamic'; +import {Show, createRoot, createSignal, onCleanup} from 'solid-js'; +import {DiffCheckboxState} from './DiffCheckbox'; +import {diffPluginEvents} from './diffEvents'; +import * as styles from './diffMarkerStateIcon.css'; +import {colors} from './theme'; + +interface MarkerStateSymbolOption { + label: string; + color: string; +} + +export class DiffGutterMarkerStateIcon extends GutterMarker { + private dispose: VoidFunction | null = null; + + symbols: Record = { + added: {label: '+', color: colors.addLine}, + removed: {label: '-', color: colors.removeLine}, + untouched: null, + }; + + constructor(public readonly lineNumber: number) { + super(); + } + + destroy(dom: Node) { + super.destroy(dom); + this.dispose?.(); + } + + eq(other: DiffGutterMarkerStateIcon): boolean { + return other.lineNumber === this.lineNumber; + } + + toDOM() { + return createRoot(dispose => { + // eslint-disable-next-line solid/reactivity + this.dispose = dispose; + const [state, setState] = createSignal('untouched'); + const currentSymbol = () => this.symbols[state()]; + + const unsubscribe = diffPluginEvents.on('syncLine', ({state, line}) => { + if (line.number === this.lineNumber) { + setState(state ?? 'untouched'); + } + }); + + onCleanup(() => unsubscribe()); + + return ( +
+ } when={currentSymbol()}> + {currentSymbol => ( +
+ {currentSymbol().label} +
+ )} +
+
+ ) as Node; + }); + } +} diff --git a/apps/codeimage/src/components/CustomEditor/plugins/diff/extension.ts b/apps/codeimage/src/components/CustomEditor/plugins/diff/extension.ts new file mode 100644 index 000000000..a4d6e6d8d --- /dev/null +++ b/apps/codeimage/src/components/CustomEditor/plugins/diff/extension.ts @@ -0,0 +1,42 @@ +import {gutter} from '@codemirror/view'; +import {DiffCheckboxMarker} from './diffCheckboxMarker'; +import {diffLineState} from './state'; +import {theme} from './theme'; +import {DiffGutterMarkerStateIcon} from './diffMarkerStateIcon'; + +interface DiffMarkerExtensionOptions { + readOnly: boolean; +} + +export const diffMarkerStateIconGutter = [ + gutter({ + class: 'cm-diffMarkerStateIconGutter', + renderEmptyElements: true, + lineMarker(view, line) { + return new DiffGutterMarkerStateIcon( + view.state.doc.lineAt(line.from).number, + ); + }, + lineMarkerChange: () => false, + widgetMarker: () => null, + }), +]; + +export const diffMarkerControl = (options: DiffMarkerExtensionOptions) => { + const base = [diffLineState, theme]; + if (options.readOnly) { + return base; + } + return [ + ...base, + gutter({ + class: 'cm-diffMarkerControlGutter', + renderEmptyElements: false, + lineMarker(view, line) { + return new DiffCheckboxMarker(view.state.doc.lineAt(line.from).number); + }, + lineMarkerChange: () => false, + widgetMarker: () => null, + }), + ]; +}; diff --git a/apps/codeimage/src/components/CustomEditor/plugins/diff/state.ts b/apps/codeimage/src/components/CustomEditor/plugins/diff/state.ts new file mode 100644 index 000000000..989fd6beb --- /dev/null +++ b/apps/codeimage/src/components/CustomEditor/plugins/diff/state.ts @@ -0,0 +1,252 @@ +import { + Annotation, + EditorState, + Line, + Range, + RangeSetBuilder, + StateEffect, + StateField, + TransactionSpec, +} from '@codemirror/state'; +import {Decoration, DecorationSet, EditorView} from '@codemirror/view'; +import {customEffectAnnotation} from '../customEffectAnnotation'; +import {diffPluginEvents} from './diffEvents'; + +export const diffExtensionConstants = { + name: 'diff', + decoratedLineDataAttribute: 'data-diff-line', + lineInserted: 'line-inserted', + lineDeleted: 'line-deleted', +}; +export const insertedLineDecoration = Decoration.line({ + attributes: { + [diffExtensionConstants.decoratedLineDataAttribute]: + diffExtensionConstants.lineInserted, + }, +}); + +export const removedLineDecoration = Decoration.line({ + attributes: { + [diffExtensionConstants.decoratedLineDataAttribute]: + diffExtensionConstants.lineDeleted, + }, +}); + +export type DiffCheckboxState = 'added' | 'removed' | 'untouched'; + +type DiffLineEffect = { + line: number; + state: DiffCheckboxState | null; +}; + +type DiffLineReplaceEffect = { + line: number; + state: DiffCheckboxState | null; +}; + +export const diffLineUpdateWithResetAnnotation = Annotation.define(); + +export const diffLineReplacerEffect = StateEffect.define< + DiffLineReplaceEffect[] +>({ + map: values => + values.map(val => ({ + line: val.line, + state: val.state, + })), +}); + +export const diffLineEffect = StateEffect.define(); + +export function dispatchUpdateDiffLineState( + view: EditorView, + lineNumber: number, + state: DiffCheckboxState, +) { + view.dispatch({ + effects: diffLineEffect.of({ + line: lineNumber, + state: state, + }), + annotations: [customEffectAnnotation.of(diffExtensionConstants.name)], + }); +} + +export function dispatchReplaceDiffLineState( + view: EditorView, + lineNumbersMap: {inserted: number[]; deleted: number[]}, + options: TransactionSpec = {}, +) { + const changes: DiffLineReplaceEffect[] = [ + ...lineNumbersMap.inserted.map(line => ({ + line: line, + state: 'added' as const, + })), + ...lineNumbersMap.deleted.map(line => ({ + line: line, + state: 'removed' as const, + })), + ].sort((a, b) => a.line - b.line); + + view.dispatch({ + userEvent: 'replace-diff-line', + effects: [diffLineReplacerEffect.of(changes)], + annotations: [customEffectAnnotation.of(diffExtensionConstants.name)], + ...options, + }); +} + +export function getDecorationByState( + state: DiffCheckboxState | null, +): Decoration | null { + switch (state) { + case 'untouched': + return null; + case 'added': + return insertedLineDecoration; + case 'removed': + return removedLineDecoration; + default: + return null; + } +} + +export class DiffLineStateJsonValue { + readonly inserted: number[] = []; + readonly deleted: number[] = []; + + constructor(obj: {inserted: number[]; deleted: number[]}) { + this.inserted = obj.inserted; + this.deleted = obj.deleted; + } + + static fromJSON( + value: DiffLineStateJsonValue, + state: EditorState, + ): DecorationSet { + console.log('from json'); + const builder = new RangeSetBuilder(); + value.inserted.forEach(inserted => { + const line = state.doc.line(inserted); + const add = getDecorationByState('added'); + add && builder.add(line.from, line.from, add); + }); + value.deleted.forEach(removed => { + const line = state.doc.line(removed); + const remove = getDecorationByState('removed'); + remove && builder.add(line.from, line.from, remove); + }); + return builder.finish(); + } + + static toJSON( + value: DecorationSet, + state: EditorState, + ): DiffLineStateJsonValue { + const inserted: number[] = []; + const deleted: number[] = []; + value.between(0, state.doc.length, (from, to, decoration) => { + const attr = + decoration.spec?.attributes[ + diffExtensionConstants.decoratedLineDataAttribute + ]; + if (!attr) return; + const line = state.doc.lineAt(from); + if (attr === diffExtensionConstants.lineInserted) { + inserted.push(line.number); + } else if (attr === diffExtensionConstants.lineDeleted) { + deleted.push(line.number); + } + }); + return {inserted, deleted} as DiffLineStateJsonValue; + } +} + +type LineChange = {line: Line; state: DiffCheckboxState}; + +const pushChange = (changes: LineChange[], change: LineChange) => { + const existingChange = changes.findIndex( + existingChange => existingChange.line.number === change.line.number, + ); + if (existingChange !== -1) { + changes[existingChange] = change; + } else { + changes.push(change); + } +}; + +export const diffLineState = StateField.define({ + create() { + return Decoration.none; + }, + update(decorationSet, transaction) { + decorationSet = decorationSet.map(transaction.changes); + const changes: LineChange[] = []; + const collect = (value: DiffLineEffect) => { + if (value.line > transaction.state.doc.lines || value.line < 0) { + return; + } + const line = transaction.state.doc.line(value.line); + pushChange(changes, {line, state: value.state ?? 'untouched'}); + }; + for (const effect of transaction.effects) { + if (effect.is(diffLineReplacerEffect)) { + // Reset old lines not present in changes + const changedLines = effect.value.map(({line}) => line); + Array.from( + {length: transaction.state.doc.lines}, + (_, index) => index + 1, + ).forEach(ln => { + if (!changedLines.includes(ln)) { + diffPluginEvents.emit('syncLine', { + state: 'untouched', + line: transaction.state.doc.line(ln), + }); + } + }); + decorationSet = Decoration.none; + effect.value.forEach(collect); + } else if (effect.is(diffLineEffect)) { + collect(effect.value); + } + } + + if (changes.length === 0) { + return decorationSet; + } + + const decorations = changes + // Decorations must be sorted by their position before to be added to the decorationSet + .sort((a, b) => a.line.number - b.line.number) + .reduce((acc, change) => { + if (change.state === 'added') { + return acc.concat( + insertedLineDecoration.range(change.line.from, change.line.from), + ); + } else if (change.state === 'removed') { + return acc.concat( + removedLineDecoration.range(change.line.from, change.line.from), + ); + } + return acc; + }, [] as Range[]); + + decorationSet = decorationSet.update({ + add: decorations, + filter: from => changes.some(change => change.line.from !== from), + }); + + changes.forEach(change => { + diffPluginEvents.emit('syncLine', change); + }); + + return decorationSet; + }, + fromJSON(value: DiffLineStateJsonValue, state): DecorationSet { + return DiffLineStateJsonValue.fromJSON(value, state); + }, + toJSON(value, state): DiffLineStateJsonValue { + return DiffLineStateJsonValue.toJSON(value, state); + }, + provide: field => EditorView.decorations.from(field) ?? Decoration.none, +}); diff --git a/apps/codeimage/src/components/CustomEditor/plugins/diff/theme.ts b/apps/codeimage/src/components/CustomEditor/plugins/diff/theme.ts new file mode 100644 index 000000000..5af145ac4 --- /dev/null +++ b/apps/codeimage/src/components/CustomEditor/plugins/diff/theme.ts @@ -0,0 +1,32 @@ +import {HighlightStyle, syntaxHighlighting} from '@codemirror/language'; +import {EditorView} from '@codemirror/view'; +import {tags} from '@lezer/highlight'; +import {diffExtensionConstants} from './state'; + +export const colors = { + removeLine: 'hsla(339, 100%, 57%, 25%)', + removeToken: 'hsla(339, 100%, 57%, 100%)', + addLine: 'hsl(120, 81%, 38%, 25%)', + addToken: 'hsl(120, 81%, 38%, 100%)', +}; + +const dataAttrRemoved = `${diffExtensionConstants.decoratedLineDataAttribute}=${diffExtensionConstants.lineDeleted}`; +const dataAttrAdded = `${diffExtensionConstants.decoratedLineDataAttribute}=${diffExtensionConstants.lineInserted}`; + +export const theme = [ + EditorView.theme({ + [`.cm-line[${dataAttrRemoved}]`]: { + backgroundColor: colors.removeLine, + }, + [`.cm-line[${dataAttrAdded}]`]: { + backgroundColor: colors.addLine, + }, + }), +]; + +export const syntaxHighlightColorDiff = syntaxHighlighting( + HighlightStyle.define([ + {tag: [tags.inserted], color: colors.addToken}, + {tag: [tags.deleted], color: colors.removeToken}, + ]), +); diff --git a/apps/codeimage/src/components/CustomEditor/plugins/sync/sync.ts b/apps/codeimage/src/components/CustomEditor/plugins/sync/sync.ts new file mode 100644 index 000000000..590439a40 --- /dev/null +++ b/apps/codeimage/src/components/CustomEditor/plugins/sync/sync.ts @@ -0,0 +1,34 @@ +import {Annotation, StateEffect, Transaction} from '@codemirror/state'; +import {EditorView} from '@codemirror/view'; +import {customEffectAnnotation} from '../customEffectAnnotation'; + +export const syncAnnotation = Annotation.define(); + +export function syncDispatch( + transaction: Transaction, + other: EditorView, +): boolean { + if (transaction.annotation(syncAnnotation)) { + return false; + } + const annotations: Annotation[] = [syncAnnotation.of(true)]; + const effects: StateEffect[] = []; + let changed = false; + if (!transaction.changes.empty) { + changed = true; + const userEvent = transaction.annotation(Transaction.userEvent); + if (userEvent) annotations.push(Transaction.userEvent.of(userEvent)); + } + if (!!transaction.annotation(customEffectAnnotation)) { + changed = true; + effects.push(...transaction.effects); + } + if (changed) { + other.dispatch({ + changes: transaction.changes, + effects, + annotations, + }); + } + return changed; +} diff --git a/apps/codeimage/src/components/Footer/Footer.tsx b/apps/codeimage/src/components/Footer/Footer.tsx index 1d0d826eb..d8c35fecc 100644 --- a/apps/codeimage/src/components/Footer/Footer.tsx +++ b/apps/codeimage/src/components/Footer/Footer.tsx @@ -1,15 +1,23 @@ import {Box, Link} from '@codeimage/ui'; import {createControlledDialog} from '@core/hooks/createControlledDialog'; import {Changelog} from '../Changelog/Changelog'; -import {link} from './Footer.css'; import * as styles from './Footer.css'; +import {link} from './Footer.css'; +import {Show} from 'solid-js'; +import {isDev} from 'solid-js/web'; export const Footer = () => { const openDialog = createControlledDialog(); - return (
+ + + window.toggleDevMode()} size="xs"> + Debug preview + + + -
+
}>{props.children}
diff --git a/apps/codeimage/src/components/PropertyEditor/EditorStyleForm.tsx b/apps/codeimage/src/components/PropertyEditor/EditorStyleForm.tsx index 956edbbcc..afad20148 100644 --- a/apps/codeimage/src/components/PropertyEditor/EditorStyleForm.tsx +++ b/apps/codeimage/src/components/PropertyEditor/EditorStyleForm.tsx @@ -6,7 +6,7 @@ import {getRootEditorStore} from '@codeimage/store/editor'; import {getActiveEditorStore} from '@codeimage/store/editor/activeEditor'; import {dispatchUpdateTheme} from '@codeimage/store/effects/onThemeChange'; import {getThemeStore} from '@codeimage/store/theme/theme.store'; -import {createSelectOptions, NumberField, Select} from '@codeui/kit'; +import {Checkbox, createSelectOptions, NumberField, Select} from '@codeui/kit'; import {appEnvironment} from '@core/configuration'; import {getUmami} from '@core/constants/umami'; import {DynamicSizedContainer} from '@ui/DynamicSizedContainer/DynamicSizedContainer'; @@ -47,7 +47,13 @@ export const EditorStyleForm: ParentComponent = () => { } = getActiveEditorStore(); const { state, - actions: {setShowLineNumbers, setFontWeight, setFontId, setEnableLigatures}, + actions: { + setShowLineNumbers, + setFontWeight, + setFontId, + setEnableLigatures, + setShowDiff, + }, computed: {selectedFont}, } = getRootEditorStore(); @@ -247,6 +253,20 @@ export const EditorStyleForm: ParentComponent = () => { + + + + } + > + + + + diff --git a/apps/codeimage/src/state/editor/config.store.ts b/apps/codeimage/src/state/editor/config.store.ts index eb1800484..477d4ac67 100644 --- a/apps/codeimage/src/state/editor/config.store.ts +++ b/apps/codeimage/src/state/editor/config.store.ts @@ -21,6 +21,13 @@ export interface ConfigState { ready: boolean; fonts: (CustomFontConfiguration & {type: 'web'})[]; systemFonts: (CustomFontConfiguration & {type: 'system'})[]; + devMode: boolean; +} + +declare global { + interface Window { + toggleDevMode: () => void; + } } function getDefaultConfig(): ConfigState { @@ -28,6 +35,7 @@ function getDefaultConfig(): ConfigState { ready: false, fonts: [...SUPPORTED_FONTS], systemFonts: [], + devMode: false, }; } @@ -56,6 +64,10 @@ export const EditorConfigStore = defineStore(() => getDefaultConfig()) .extend(withLocalFontManagementPlugin()) .extend(_ => { const fonts = createMemo(() => _.localFontsApi.state().fonts); + const toggleDevMode = () => _.set('devMode', debug => !debug); + onMount(() => { + window.toggleDevMode = toggleDevMode; + }); const buildSystemFontConfiguration = (font: LoadedFont) => ({ @@ -76,4 +88,8 @@ export const EditorConfigStore = defineStore(() => getDefaultConfig()) _.set('systemFonts', fontsConfiguration); }), ); + + return { + toggleDevMode, + }; }); diff --git a/apps/codeimage/src/state/editor/editor.ts b/apps/codeimage/src/state/editor/editor.ts index e46766eec..7da49704b 100644 --- a/apps/codeimage/src/state/editor/editor.ts +++ b/apps/codeimage/src/state/editor/editor.ts @@ -8,7 +8,7 @@ import type {Transaction} from '@codemirror/state'; import {appEnvironment} from '@core/configuration'; import {createEventBus} from '@solid-primitives/event-bus'; import {from, map, shareReplay} from 'rxjs'; -import {createSelector} from 'solid-js'; +import {createSelector, untrack} from 'solid-js'; import {SetStoreFunction} from 'solid-js/store'; import {defineStore, provideState} from 'statebuilder'; import {createCommand, withProxyCommands} from 'statebuilder/commands'; @@ -23,6 +23,12 @@ export function getInitialEditorState(): EditorState { languageId: appEnvironment.defaultState.editor.languageId, formatter: null, lineNumberStart: 1, + metadata: { + diff: { + deleted: [], + inserted: [], + }, + }, tab: { tabName: 'index.tsx', tabIcon: undefined, @@ -38,6 +44,7 @@ export function getInitialEditorUiOptions(): EditorUIOptions { fontWeight: appEnvironment.defaultState.editor.font.types[0].weight, focused: false, enableLigatures: true, + showDiffMode: false, }; } @@ -56,9 +63,11 @@ export function createEditorsStore() { setThemeId: string; setFontWeight: number; setShowLineNumbers: boolean; + setLineNumbersStart: number; setFromPersistedState: PersistedEditorState; setFromPreset: PresetData['editor']; setEnableLigatures: boolean; + setShowDiff: boolean; }>(), ); @@ -93,6 +102,9 @@ export function createEditorsStore() { .hold(store.commands.setEnableLigatures, (enable, {set}) => set('options', 'enableLigatures', enable), ) + .hold(store.commands.setShowDiff, (enable, {set}) => + set('options', 'showDiffMode', enable), + ) .hold(store.commands.setFromPreset, presetData => { store.set('options', presetData); store.dispatch(editorUpdateCommand, void 0); @@ -106,6 +118,9 @@ export function createEditorsStore() { id: editor.id, code: editor.code, lineNumberStart: editor.lineNumberStart, + metadata: { + diff: editor.metadata.diff, + }, })); return { options: {...state.options, ...persistedState.options}, @@ -117,6 +132,9 @@ export function createEditorsStore() { tab: {tabName: editor.tabName}, id: editor.id, lineNumberStart: editor.lineNumberStart, + metadata: { + diff: editor.metadata.diff, + }, }; }), }; @@ -139,7 +157,10 @@ export function createEditorsStore() { code: editor.code, tabName: editor.tab.tabName ?? '', id: editor.id, - lineNumberStart: editor.lineNumberStart ?? 1, + lineNumberStart: editor.lineNumberStart, + metadata: { + diff: editor.metadata.diff, + }, }; }), options: { @@ -148,6 +169,7 @@ export function createEditorsStore() { fontId: state.options.fontId, fontWeight: state.options.fontWeight, enableLigatures: state.options.enableLigatures ?? true, + showDiffMode: state.options.showDiffMode ?? false, }, }; }; @@ -159,6 +181,7 @@ export function createEditorsStore() { store.commands.setFontWeight, store.commands.setShowLineNumbers, store.commands.setEnableLigatures, + store.commands.setShowDiff, editorUpdateCommand, ]), ).pipe( @@ -225,6 +248,16 @@ export function createEditorsStore() { store.dispatch(editorUpdateCommand, void 0); }; + const setTabMetadata = (data: EditorState['metadata']) => { + untrack(() => { + const index = store.get.editors.findIndex(tab => isActive(tab.id)); + setEditors(index, 'metadata', { + diff: data.diff, + }); + }); + store.dispatch(editorUpdateCommand, void 0); + }; + const configuredFonts = () => [ ...configStore.get.fonts, ...configStore.get.systemFonts, @@ -249,7 +282,8 @@ export function createEditorsStore() { languageId: editor.languageId, id: editor.id, code: editor.code, - lineNumberStart: editor.lineNumberStart ?? 1, + metadata: editor.metadata, + lineNumberStart: editor.lineNumberStart, } as EditorState), ), ); @@ -286,6 +320,7 @@ export function createEditorsStore() { addEditor, removeEditor, setTabName, + setTabMetadata, setFromWorkspace, ...store.actions, setFontId(fontId: string) { diff --git a/apps/codeimage/src/state/editor/model.ts b/apps/codeimage/src/state/editor/model.ts index de265befd..4fbcde0b1 100644 --- a/apps/codeimage/src/state/editor/model.ts +++ b/apps/codeimage/src/state/editor/model.ts @@ -1,4 +1,5 @@ import {PersistedFrameState} from '@codeimage/store/frame/model'; +import {DiffLineStateJsonValue} from '../../components/CustomEditor/plugins/diff/state'; export interface EditorUIOptions { fontId: string; @@ -6,6 +7,7 @@ export interface EditorUIOptions { showLineNumbers: boolean; focused: boolean; themeId: string; + showDiffMode: boolean; } export interface TabState { @@ -13,6 +15,10 @@ export interface TabState { tabIcon?: string; } +export interface EditorMetadataState { + diff: DiffLineStateJsonValue | null; +} + export interface EditorState { id: string; code: string; @@ -20,6 +26,7 @@ export interface EditorState { formatter?: string | null; languageId: string; lineNumberStart: number; + metadata: EditorMetadataState; } export interface EditorUIOptions { @@ -39,6 +46,7 @@ export interface PersistedEditorState { tabName: string; languageId: string; lineNumberStart: number; + metadata: EditorState['metadata']; }[]; } diff --git a/packages/config/src/lib/base/languages.ts b/packages/config/src/lib/base/languages.ts index 571d7c54f..d0dd55eeb 100644 --- a/packages/config/src/lib/base/languages.ts +++ b/packages/config/src/lib/base/languages.ts @@ -720,6 +720,24 @@ export const SUPPORTED_LANGUAGES: readonly LanguageDefinition[] = [ }, ], }, + { + id: 'git-patch', + label: 'Patch', + color: '#fff', + plugin: () => + Promise.all([ + importLegacy(), + import('@codemirror/legacy-modes/mode/diff'), + ]).then(([cb, m]) => cb(m.diff)), + icons: [ + { + name: 'Git', + extension: '.patch', + content: () => import('material-icon-theme/icons/diff.svg?raw'), + matcher: /^.*\.(patch)$/, + }, + ], + }, { id: 'go', label: 'Go', diff --git a/packages/highlight/src/lib/plugins/line-numbers-style.ts b/packages/highlight/src/lib/plugins/line-numbers-style.ts index a0a12f84d..8ca503a40 100644 --- a/packages/highlight/src/lib/plugins/line-numbers-style.ts +++ b/packages/highlight/src/lib/plugins/line-numbers-style.ts @@ -6,6 +6,6 @@ export interface StyledLineNumbersOptions { export function styledLineNumbers(options: StyledLineNumbersOptions) { return EditorView.theme({ - '.cm-lineNumbers .cm-gutterElement': {color: options.color}, + '.cm-gutter .cm-gutterElement': {color: options.color}, }); }