Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add diff mode #617

Open
wants to merge 14 commits into
base: main
Choose a base branch
from
65 changes: 63 additions & 2 deletions apps/codeimage/src/components/CustomEditor/CanvasEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,30 +1,40 @@
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 {
createCompartmentExtension,
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<EditorView>();
const activeEditorStore = getActiveEditorStore();
const {
state: editorState,
actions: {setFocused},
actions: {setFocused, setTabMetadata},
canvasEditorEvents,
} = getRootEditorStore();

const {setFocused: editorSetFocused} = createEditorFocus(
Expand All @@ -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<unknown>
> = {
diff: diffLineState,
};
const json = transaction.state.toJSON(state);
setTabMetadata(json);
});

createEffect(
on(
() => editorState.options.focused,
Expand All @@ -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},
),
Expand Down Expand Up @@ -107,6 +167,7 @@ export default function CanvasEditor(props: CanvasEditorProps) {

return (
<CustomEditor
dispatchTransaction={true}
onEditorViewChange={setEditorView}
onValueChange={activeEditorStore.setCode}
readOnly={props.readOnly}
Expand Down
23 changes: 19 additions & 4 deletions apps/codeimage/src/components/CustomEditor/CustomEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ import {
VoidProps,
} from 'solid-js';
import {createTabIcon} from '../../hooks/use-tab-icon';
import {diffMarkerControl} from './plugins/diff/extension';
import {syntaxHighlightColorDiff} from './plugins/diff/theme';

const EDITOR_BASE_SETUP: Extension = [
highlightSpecialChars(),
Expand All @@ -61,6 +63,7 @@ interface CustomEditorProps {
readOnly: boolean;
onEditorViewChange?: (view: EditorView | undefined) => void;
onValueChange?: (value: string) => void;
dispatchTransaction: boolean;
}

export default function CustomEditor(props: VoidProps<CustomEditorProps>) {
Expand All @@ -82,7 +85,9 @@ export default function CustomEditor(props: VoidProps<CustomEditorProps>) {
createExtension,
} = createCodeMirror({
value: editor()?.code,
onTransactionDispatched: tr => canvasEditorEvents.emit(tr),
onTransactionDispatched: props.dispatchTransaction
? tr => canvasEditorEvents.emit(tr)
: undefined,
onValueChange: props.onValueChange,
});

Expand Down Expand Up @@ -143,8 +148,6 @@ export default function CustomEditor(props: VoidProps<CustomEditorProps>) {
},
'.cm-cursor': {
borderLeftWidth: '2px',
height: '21px',
transform: 'translateY(-10%)',
},
});

Expand All @@ -169,7 +172,9 @@ export default function CustomEditor(props: VoidProps<CustomEditorProps>) {
},
});
};

createExtension(() =>
selectedLanguage()?.id === 'git-patch' ? syntaxHighlightColorDiff : [],
);
createEditorReadonly(editorView, () => props.readOnly);
createExtension(EditorView.lineWrapping);
createExtension(() =>
Expand Down Expand Up @@ -200,6 +205,16 @@ export default function CustomEditor(props: VoidProps<CustomEditorProps>) {
? 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);

Expand Down
Original file line number Diff line number Diff line change
@@ -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<boolean>();

function syncDispatch(tr: Transaction, other: EditorView) {
if (!tr.changes.empty && !tr.annotation(syncAnnotation)) {
const annotations: Annotation<unknown>[] = [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'));

Expand All @@ -22,19 +15,40 @@ interface PreviewExportEditorProps {

export default function PreviewExportEditor(props: PreviewExportEditorProps) {
const [editorView, setEditorView] = createSignal<EditorView>();
const {canvasEditorEvents} = getRootEditorStore();
const transactions = createEventStack<Transaction>();
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 (
<CustomEditor
dispatchTransaction={false}
onEditorViewChange={editorView => {
props.onSetEditorView(editorView);
setEditorView(editorView);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import {Annotation} from '@codemirror/state';

export const customEffectAnnotation = Annotation.define<string>();
Original file line number Diff line number Diff line change
@@ -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)',
},
},
},
});
Original file line number Diff line number Diff line change
@@ -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<DiffCheckboxState, DiffCheckboxState> = {
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 (
<div>
<Tooltip
open={tooltipOpen()}
onOpenChange={setTooltipOpen}
slotClasses={{
content: tooltip,
}}
placement={'right'}
content={title()}
theme={'secondary'}
openDelay={300}
closeDelay={0}
>
<IconButton
ref={el}
size={'xs'}
class={icon()}
aria-label={title()}
onClick={() => onClick(props.value)}
>
{label()}
</IconButton>
</Tooltip>
</div>
);
}