diff --git a/package.json b/package.json index 8813d940c7..1c65720e10 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "dotenv": "^16.3.1", "firebase-admin": "^11.10.1", "js-beautify": "^1.15.1", + "js-levenshtein": "^1.1.6", "loops": "^1.0.1", "ms": "^2.1.3", "nanoid": "^4.0.1", @@ -59,6 +60,7 @@ "@types/esprima": "^4.0.3", "@types/grecaptcha": "^3.0.4", "@types/js-beautify": "^1.14.3", + "@types/js-levenshtein": "^1.1.3", "@types/node-statsd": "^0.1.6", "@types/three": "^0.149.0", "@types/throttle-debounce": "^5.0.0", diff --git a/src/components/codemirror-widgets/open-button.tsx b/src/components/codemirror-widgets/open-button.tsx index ede25dd23c..18280cbcbb 100644 --- a/src/components/codemirror-widgets/open-button.tsx +++ b/src/components/codemirror-widgets/open-button.tsx @@ -6,30 +6,17 @@ import { runGameHeadless } from '../../lib/engine' interface OpenButtonProps { kind: EditorKind text: string + range: { from: number, to: number }, } export default function OpenButton(props: OpenButtonProps) { const { label, icon: Icon } = editors[props.kind] - return ( ) -} \ No newline at end of file +} diff --git a/src/components/popups-etc/editor-modal.tsx b/src/components/popups-etc/editor-modal.tsx index 6078ca51d6..025e06e6a2 100644 --- a/src/components/popups-etc/editor-modal.tsx +++ b/src/components/popups-etc/editor-modal.tsx @@ -3,14 +3,15 @@ import { useEffect } from 'preact/hooks' import { IoClose } from 'react-icons/io5' import tinykeys from 'tinykeys' import { usePopupCloseClick } from '../../lib/utils/popup-close-click' -import { codeMirror, editors, openEditor } from '../../lib/state' +import { codeMirror, editors, openEditor, codeMirrorEditorText, _foldRanges, _widgets, OpenEditor } from '../../lib/state' import styles from './editor-modal.module.css' +import levenshtein from 'js-levenshtein' +import { runGameHeadless } from '../../lib/engine' export default function EditorModal() { const Content = openEditor.value ? editors[openEditor.value.kind].modalContent : () => null const text = useSignal(openEditor.value?.text ?? '') - // Sync code changes with editor text useSignalEffect(() => { if (openEditor.value) text.value = openEditor.value.text }) @@ -29,7 +30,7 @@ export default function EditorModal() { insert: _text } }) - + openEditor.value = { ..._openEditor, text: _text, @@ -40,6 +41,59 @@ export default function EditorModal() { } }) + // the challenge now is making the editor keep track of what map editor it's currently focused on and streaming the changes in the map editor + // it's tricky because maps can grow and shrink + + useEffect(() => { + // just do this to sync the editor text with the code mirror text + + const code = codeMirror.value?.state.doc.toString() ?? ''; + const levenshtainDistances = _foldRanges.value.map((foldRange, foldRangeIndex) => { + const widgetKind = _widgets.value[foldRangeIndex]?.value.spec.widget.props.kind; + + // if the widget kind is not the same as the open editor kind, don't do anything + if (widgetKind !== openEditor.value?.kind) return -1; + + const theCode = code.slice(foldRange?.from, foldRange?.to); + + const distance = levenshtein(text.value, theCode) + return distance; + }); + + + + // if (levenshtainDistances.length === 0) alert(`You are currently editing a deleted ${openEditor.value?.kind}`); + if (levenshtainDistances.length === 0) return; + + // compute the index of the min distance + let indexOfMinDistance = 0; + levenshtainDistances.forEach((distance, didx) => { + if (levenshtainDistances[indexOfMinDistance]! < 0) indexOfMinDistance = didx; + const min = levenshtainDistances[indexOfMinDistance]!; + if (distance >= 0 && distance <= min) indexOfMinDistance = didx; + }); + + // update the open editor if the index is not -1 + if (indexOfMinDistance !== -1) { + const editRange = _foldRanges.value[indexOfMinDistance] + const openEditorCode = code.slice(editRange?.from, editRange?.to) + + // the map editor needs to get the bitmaps after running the code + if (openEditor.value?.kind === 'map') runGameHeadless(code ?? ''); + + openEditor.value = { + ...openEditor.value as OpenEditor, + editRange: { + from: editRange!.from, + to: editRange!.to + }, + text: openEditorCode + } + } + + }, [codeMirrorEditorText.value]); + + usePopupCloseClick(styles.content!, () => openEditor.value = null, !!openEditor.value) useEffect(() => tinykeys(window, { 'Escape': () => openEditor.value = null @@ -54,4 +108,4 @@ export default function EditorModal() { ) -} \ No newline at end of file +} diff --git a/src/lib/codemirror/init.ts b/src/lib/codemirror/init.ts index e24aba3c11..f8d5b1f77a 100644 --- a/src/lib/codemirror/init.ts +++ b/src/lib/codemirror/init.ts @@ -10,6 +10,7 @@ import SearchBox from '../../components/search-box' import { EditorView, keymap, lineNumbers, highlightActiveLineGutter, highlightSpecialChars, drawSelection, dropCursor, rectangularSelection, crosshairCursor, highlightActiveLine } from '@codemirror/view' import { lintGutter } from "@codemirror/lint"; import type { NormalizedError } from '../state' +import { codeMirrorEditorText } from '../state' export function diagnosticsFromErrorLog(view: EditorView, errorLog: NormalizedError[]) { return errorLog.filter(error => error.line) @@ -25,6 +26,10 @@ export function diagnosticsFromErrorLog(view: EditorView, errorLog: NormalizedEr } export const initialExtensions = (onUpdate: any, onRunShortcut: any, yCollab?: any) => ([ + EditorView.updateListener.of(update => { + const newEditorText = update.state.doc.toString(); + codeMirrorEditorText.value = newEditorText; + }), lintGutter(), lineNumbers(), highlightActiveLineGutter(), diff --git a/src/lib/codemirror/widgets.ts b/src/lib/codemirror/widgets.ts index 906b2976c5..92a0495ba9 100644 --- a/src/lib/codemirror/widgets.ts +++ b/src/lib/codemirror/widgets.ts @@ -5,7 +5,7 @@ import { palette } from '../../../engine/src/base' import { FromTo, getTag, makeWidget } from './util' import OpenButton from '../../components/codemirror-widgets/open-button' import Swatch from '../../components/codemirror-widgets/swatch' -import { editorKinds, editors } from '../state' +import { editorKinds, editors, _foldRanges, _widgets } from '../state' const OpenButtonWidget = makeWidget(OpenButton) const SwatchWidget = makeWidget(Swatch) @@ -21,10 +21,11 @@ function makeValue(state: EditorState) { for (const kind of editorKinds) { const tag = getTag(editors[kind].label, node, syntax, state.doc) if (!tag) continue + if (tag.nameFrom === tag.nameTo) continue widgets.push(Decoration.replace({ - widget: new OpenButtonWidget({ kind, text: tag.text }) + widget: new OpenButtonWidget({ kind, text: tag.text, range: { from: tag.textFrom, to: tag.textTo } }) }).range(tag.nameFrom, tag.nameTo)) if (kind === 'palette') { @@ -41,6 +42,8 @@ function makeValue(state: EditorState) { } }) + _foldRanges.value = foldRanges; + _widgets.value = widgets; return { decorations: Decoration.set(widgets), foldRanges diff --git a/src/lib/state.ts b/src/lib/state.ts index 2cc2fff760..e2075114ea 100644 --- a/src/lib/state.ts +++ b/src/lib/state.ts @@ -1,4 +1,5 @@ -import type { EditorView } from '@codemirror/view' +import type { EditorView, Decoration } from '@codemirror/view' +import type { Range } from '@codemirror/state' import { Signal, signal } from '@preact/signals' import { IoColorPalette, IoImage, IoMap, IoMusicalNotes } from 'react-icons/io5' import type { FromTo } from './codemirror/util' @@ -106,6 +107,7 @@ export type RoomState = { } export const codeMirror = signal(null) +export const codeMirrorEditorText = signal(''); export const muted = signal(false) export const errorLog = signal([]) export const openEditor = signal(null) @@ -114,6 +116,8 @@ export const editSessionLength = signal(new Date()); export const showKeyBinding = signal(false); export const showSaveConflictModal = signal(false); export const continueSaving = signal(true); +export const _foldRanges = signal([]); +export const _widgets = signal[]>([]); export const LAST_SAVED_SESSION_ID = 'lastSavedSessionId'; export type ThemeType = "dark" | "light" | "busker"; diff --git a/yarn.lock b/yarn.lock index df47dc5eec..08f1d6d16c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1734,6 +1734,11 @@ resolved "https://registry.yarnpkg.com/@types/js-beautify/-/js-beautify-1.14.3.tgz#6ced76f79935e37e0d613110dea369881d93c1ff" integrity sha512-FMbQHz+qd9DoGvgLHxeqqVPaNRffpIu5ZjozwV8hf9JAGpIOzuAf4wGbRSo8LNITHqGjmmVjaMggTT5P4v4IHg== +"@types/js-levenshtein@^1.1.3": + version "1.1.3" + resolved "https://registry.yarnpkg.com/@types/js-levenshtein/-/js-levenshtein-1.1.3.tgz#a6fd0bdc8255b274e5438e0bfb25f154492d1106" + integrity sha512-jd+Q+sD20Qfu9e2aEXogiO3vpOC1PYJOUdyN9gvs4Qrvkg4wF43L5OhqrPeokdv8TL0/mXoYfpkcoGZMNN2pkQ== + "@types/json5@^0.0.30": version "0.0.30" resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.30.tgz#44cb52f32a809734ca562e685c6473b5754a7818" @@ -3971,6 +3976,11 @@ js-cookie@^3.0.5: resolved "https://registry.yarnpkg.com/js-cookie/-/js-cookie-3.0.5.tgz#0b7e2fd0c01552c58ba86e0841f94dc2557dcdbc" integrity sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw== +js-levenshtein@^1.1.6: + version "1.1.6" + resolved "https://registry.yarnpkg.com/js-levenshtein/-/js-levenshtein-1.1.6.tgz#c6cee58eb3550372df8deb85fad5ce66ce01d59d" + integrity sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g== + js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"