From 4fb7405466b0420a7c055ed76f0e1779cee8f61f Mon Sep 17 00:00:00 2001 From: Chris Kruining Date: Mon, 24 Feb 2025 17:01:47 +1100 Subject: [PATCH] getting the hang of the editContext api --- app.config.ts | 15 - bun.lock | 26 +- package.json | 8 +- src/components/textarea/textarea.tsx | 93 +------ src/features/edit-context/context.ts | 324 ++++++++++++++++++++++ src/features/edit-context/index.ts | 3 + src/features/edit-context/tokenizer.ts | 194 +++++++++++++ src/features/file/grid.tsx | 3 +- src/features/file/tree.tsx | 2 +- src/features/source/source.ts | 51 ++-- src/routes/(editor)/experimental/grid.tsx | 2 +- 11 files changed, 587 insertions(+), 134 deletions(-) create mode 100644 src/features/edit-context/context.ts create mode 100644 src/features/edit-context/index.ts create mode 100644 src/features/edit-context/tokenizer.ts diff --git a/app.config.ts b/app.config.ts index cac6d26..0d91e94 100644 --- a/app.config.ts +++ b/app.config.ts @@ -4,29 +4,14 @@ import devtools from 'solid-devtools/vite'; export default defineConfig({ vite: { - resolve: { - alias: [ - { find: '@', replacement: 'F:\\Github\\calque\\node_modules\\' }, - ], - }, html: { cspNonce: 'KAAS_IS_AWESOME', }, - // css: { - // postcss: { - // }, - // }, plugins: [ devtools({ autoname: true, }), solidSvg(), - { - name: 'temp', - configResolved(config) { - console.log(config.resolve.alias); - }, - } ], }, solid: { diff --git a/bun.lock b/bun.lock index 3398afe..2973470 100644 --- a/bun.lock +++ b/bun.lock @@ -6,17 +6,19 @@ "dependencies": { "@solid-primitives/clipboard": "^1.6.0", "@solid-primitives/destructure": "^0.2.0", + "@solid-primitives/event-listener": "^2.4.0", "@solid-primitives/i18n": "^2.2.0", "@solid-primitives/scheduled": "^1.5.0", - "@solid-primitives/selection": "^0.1.0", + "@solid-primitives/selection": "^0.1.1", "@solid-primitives/storage": "^4.3.1", "@solid-primitives/timer": "^1.4.0", "@solidjs/meta": "^0.29.4", "@solidjs/router": "^0.15.3", - "@solidjs/start": "^1.1.0", + "@solidjs/start": "^1.1.1", "dexie": "^4.0.11", "flag-icons": "^7.3.2", "iterator-helpers-polyfill": "^3.0.1", + "rehype-dom-parse": "^5.0.2", "rehype-parse": "^9.0.1", "rehype-remark": "^10.0.0", "rehype-stringify": "^10.0.1", @@ -33,7 +35,7 @@ "vinxi": "^0.5.3", }, "devDependencies": { - "@happy-dom/global-registrator": "^17.0.3", + "@happy-dom/global-registrator": "^17.1.1", "@sinonjs/fake-timers": "^14.0.0", "@solidjs/testing-library": "^0.8.10", "@testing-library/jest-dom": "^6.6.3", @@ -167,7 +169,7 @@ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.20.2", "", { "os": "win32", "cpu": "x64" }, "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ=="], - "@happy-dom/global-registrator": ["@happy-dom/global-registrator@17.0.3", "", { "dependencies": { "happy-dom": "^17.0.3" } }, "sha512-isCCWywZq8XPE3A5y7pRyFIsAgij+3eVXgQNCbexGRP00/+nctmf4SfQxC3vV3MmEaOXaNj7IiiSC0BtSHQZgg=="], + "@happy-dom/global-registrator": ["@happy-dom/global-registrator@17.1.1", "", { "dependencies": { "happy-dom": "^17.1.1" } }, "sha512-if4TVRU4SnQwpOC9pN/a892nXPpctqER/rYIS9E/YsqwpaPANhMjFM7/+Ibd748gP6OuDJbspQ0axFrCscI1og=="], "@ioredis/commands": ["@ioredis/commands@1.2.0", "", {}, "sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg=="], @@ -347,7 +349,7 @@ "@solid-primitives/scheduled": ["@solid-primitives/scheduled@1.5.0", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-RVw24IRNh1FQ4DCMb3OahB70tXIwc5vH8nhR4nNPsXwUPQeuOkLsDI5BlxaPk0vyZgqw9lDpufgI3HnPwplgDw=="], - "@solid-primitives/selection": ["@solid-primitives/selection@0.1.0", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-LtqSZVVascQpBeIHS2RZ8UNvLjCa/aDEqrd9WQtqhz14vkeHAwBP33THdWDYn55b6UaOfWQlVYni3r4NfHXK0w=="], + "@solid-primitives/selection": ["@solid-primitives/selection@0.1.1", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-BPkTf8sW/t7GmcDj1A0Fa0xsyCjxh4D0qUAlcbxDkyAH4pG0bfuJ4wCKX+2qgmG/bkBQHiK/UexJtFhlQ1MS4Q=="], "@solid-primitives/static-store": ["@solid-primitives/static-store@0.0.8", "", { "dependencies": { "@solid-primitives/utils": "^6.2.3" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-ZecE4BqY0oBk0YG00nzaAWO5Mjcny8Fc06CdbXadH9T9lzq/9GefqcSe/5AtdXqjvY/DtJ5C6CkcjPZO0o/eqg=="], @@ -363,15 +365,15 @@ "@solidjs/router": ["@solidjs/router@0.15.3", "", { "peerDependencies": { "solid-js": "^1.8.6" } }, "sha512-iEbW8UKok2Oio7o6Y4VTzLj+KFCmQPGEpm1fS3xixwFBdclFVBvaQVeibl1jys4cujfAK5Kn6+uG2uBm3lxOMw=="], - "@solidjs/start": ["@solidjs/start@1.1.0", "", { "dependencies": { "@tanstack/server-functions-plugin": "^1.99.5", "@vinxi/plugin-directives": "^0.5.0", "@vinxi/server-components": "^0.5.0", "defu": "^6.1.2", "error-stack-parser": "^2.1.4", "html-to-image": "^1.11.11", "radix3": "^1.1.0", "seroval": "^1.0.2", "seroval-plugins": "^1.0.2", "shiki": "^1.26.1", "source-map-js": "^1.0.2", "terracotta": "^1.0.4", "tinyglobby": "^0.2.2", "vite-plugin-solid": "^2.11.1" }, "peerDependencies": { "vinxi": "^0.5.3" } }, "sha512-7MNhNVt8uF7tdvLkvJhj4357vg3Ha+yqJP8XhQ6IbSZbsyk/xMkYmfc1h6w4GWiWZ5tn1DvS1uqGXjLFbKRy6g=="], + "@solidjs/start": ["@solidjs/start@1.1.1", "", { "dependencies": { "@tanstack/server-functions-plugin": "^1.103.1", "@vinxi/plugin-directives": "^0.5.0", "@vinxi/server-components": "^0.5.0", "defu": "^6.1.2", "error-stack-parser": "^2.1.4", "html-to-image": "^1.11.11", "radix3": "^1.1.0", "seroval": "^1.0.2", "seroval-plugins": "^1.0.2", "shiki": "^1.26.1", "source-map-js": "^1.0.2", "terracotta": "^1.0.4", "tinyglobby": "^0.2.2", "vite-plugin-solid": "^2.11.1" }, "peerDependencies": { "vinxi": "^0.5.3" } }, "sha512-vJuXJlhHvP/hSdKQ+iuvBU2bw0S+IKQYOyldnRoCvrX7Nmu1p3npnACSlhNNkN06IqSX3MVde8D8Lr9xXEMjRQ=="], "@solidjs/testing-library": ["@solidjs/testing-library@0.8.10", "", { "dependencies": { "@testing-library/dom": "^10.4.0" }, "peerDependencies": { "@solidjs/router": ">=0.9.0", "solid-js": ">=1.0.0" }, "optionalPeers": ["@solidjs/router"] }, "sha512-qdeuIerwyq7oQTIrrKvV0aL9aFeuwTd86VYD3afdq5HYEwoox1OBTJy4y8A3TFZr8oAR0nujYgCzY/8wgHGfeQ=="], - "@tanstack/directive-functions-plugin": ["@tanstack/directive-functions-plugin@1.102.2", "", { "dependencies": { "@babel/code-frame": "7.26.2", "@babel/core": "^7.26.8", "@babel/plugin-syntax-jsx": "^7.25.9", "@babel/plugin-syntax-typescript": "^7.25.9", "@babel/template": "^7.26.8", "@babel/traverse": "^7.26.8", "@babel/types": "^7.26.8", "@tanstack/router-utils": "^1.102.2", "@types/babel__code-frame": "^7.0.6", "@types/babel__core": "^7.20.5", "@types/babel__template": "^7.4.4", "@types/babel__traverse": "^7.20.6", "babel-dead-code-elimination": "^1.0.9", "dedent": "^1.5.3", "tiny-invariant": "^1.3.3" } }, "sha512-wM4ovyuYx0rBvcaR9ay+CtuPK8AEqFv6rF4LbPqHx7EufzBi7aJt70RqPdHt3bP3dcEuJbcRGFxthZ+WUt/Q5Q=="], + "@tanstack/directive-functions-plugin": ["@tanstack/directive-functions-plugin@1.106.0", "", { "dependencies": { "@babel/code-frame": "7.26.2", "@babel/core": "^7.26.8", "@babel/plugin-syntax-jsx": "^7.25.9", "@babel/plugin-syntax-typescript": "^7.25.9", "@babel/template": "^7.26.8", "@babel/traverse": "^7.26.8", "@babel/types": "^7.26.8", "@tanstack/router-utils": "^1.102.2", "@types/babel__code-frame": "^7.0.6", "@types/babel__core": "^7.20.5", "@types/babel__template": "^7.4.4", "@types/babel__traverse": "^7.20.6", "babel-dead-code-elimination": "^1.0.9", "dedent": "^1.5.3", "tiny-invariant": "^1.3.3" } }, "sha512-eAK+8tGl+ZZimROdnuHoAc1MVKvmQWh7WTQbh531607NQAuD/7TgbTAiSBfTJYW8qeGovrSp/qq+dnhjqTy3xQ=="], "@tanstack/router-utils": ["@tanstack/router-utils@1.102.2", "", { "dependencies": { "@babel/generator": "^7.26.8", "@babel/parser": "^7.26.8", "ansis": "^3.11.0", "diff": "^7.0.0" } }, "sha512-Uwl2nbrxhCzviaHHBLNPhSC/OMpZLdOTxTJndUSsXTzWUP4IoQcVmngaIsxi9iriE3ArC1VXuanUAkfGmimNOQ=="], - "@tanstack/server-functions-plugin": ["@tanstack/server-functions-plugin@1.102.2", "", { "dependencies": { "@babel/code-frame": "7.26.2", "@babel/core": "^7.26.8", "@babel/plugin-syntax-jsx": "^7.25.9", "@babel/plugin-syntax-typescript": "^7.25.9", "@babel/template": "^7.26.8", "@babel/traverse": "^7.26.8", "@babel/types": "^7.26.8", "@tanstack/directive-functions-plugin": "1.102.2", "@types/babel__code-frame": "^7.0.6", "@types/babel__core": "^7.20.5", "@types/babel__template": "^7.4.4", "@types/babel__traverse": "^7.20.6", "babel-dead-code-elimination": "^1.0.9", "dedent": "^1.5.3", "tiny-invariant": "^1.3.3" } }, "sha512-l7nPcYjUaiO4ogjfE5hAPsuByFsn/5LqtdsC7f0I1VVgMxHp08OVVRH2rrgdR24ehRr3RkrUQoFviVyBSxcfCA=="], + "@tanstack/server-functions-plugin": ["@tanstack/server-functions-plugin@1.106.0", "", { "dependencies": { "@babel/code-frame": "7.26.2", "@babel/core": "^7.26.8", "@babel/plugin-syntax-jsx": "^7.25.9", "@babel/plugin-syntax-typescript": "^7.25.9", "@babel/template": "^7.26.8", "@babel/traverse": "^7.26.8", "@babel/types": "^7.26.8", "@tanstack/directive-functions-plugin": "1.106.0", "@types/babel__code-frame": "^7.0.6", "@types/babel__core": "^7.20.5", "@types/babel__template": "^7.4.4", "@types/babel__traverse": "^7.20.6", "babel-dead-code-elimination": "^1.0.9", "dedent": "^1.5.3", "tiny-invariant": "^1.3.3" } }, "sha512-uFqiICV0b9t7jluQLroUt9/2PayEKu4hWTS5nezWsW8kNaK0Pw4avdLK+8mYjYuJLBhuQrQZ/mSEMqdJYgYv0Q=="], "@testing-library/dom": ["@testing-library/dom@10.4.0", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "chalk": "^4.1.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "pretty-format": "^27.0.2" } }, "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ=="], @@ -795,7 +797,7 @@ "h3": ["h3@1.13.0", "", { "dependencies": { "cookie-es": "^1.2.2", "crossws": ">=0.2.0 <0.4.0", "defu": "^6.1.4", "destr": "^2.0.3", "iron-webcrypto": "^1.2.1", "ohash": "^1.1.4", "radix3": "^1.1.2", "ufo": "^1.5.4", "uncrypto": "^0.1.3", "unenv": "^1.10.0" } }, "sha512-vFEAu/yf8UMUcB4s43OaDaigcqpQd14yanmOsn+NcRX3/guSKncyE2rOYhq8RIchgJrPSs/QiIddnTTR1ddiAg=="], - "happy-dom": ["happy-dom@17.0.3", "", { "dependencies": { "webidl-conversions": "^7.0.0", "whatwg-mimetype": "^3.0.0" } }, "sha512-1vWCwpeguN02wQF8kGeaj69FDX19bXKQXmyUKcE+O0WLY0uhS0RPTLCJR8Omy8hrjMHwV3dUJ24JUrK07aOA9Q=="], + "happy-dom": ["happy-dom@17.1.1", "", { "dependencies": { "webidl-conversions": "^7.0.0", "whatwg-mimetype": "^3.0.0" } }, "sha512-OSTkBlmD/6Do7gCd7nZB5iFq1bF9VQg/iFmjHmxvVX2S1UiOpo6sT+aFNnu3XUsB8hCZb9+GZ0G1g1TaMiAggw=="], "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], @@ -803,6 +805,8 @@ "hast-util-embedded": ["hast-util-embedded@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-is-element": "^3.0.0" } }, "sha512-naH8sld4Pe2ep03qqULEtvYr7EjrLK2QHY8KJR6RJkTUjPGObe1vnx585uzem2hGra+s1q08DZZpfgDVYRbaXA=="], + "hast-util-from-dom": ["hast-util-from-dom@5.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "hastscript": "^9.0.0", "web-namespaces": "^2.0.0" } }, "sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q=="], + "hast-util-from-html": ["hast-util-from-html@2.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.1.0", "hast-util-from-parse5": "^8.0.0", "parse5": "^7.0.0", "vfile": "^6.0.0", "vfile-message": "^4.0.0" } }, "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw=="], "hast-util-from-parse5": ["hast-util-from-parse5@8.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "devlop": "^1.0.0", "hastscript": "^9.0.0", "property-information": "^7.0.0", "vfile": "^6.0.0", "vfile-location": "^5.0.0", "web-namespaces": "^2.0.0" } }, "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg=="], @@ -1183,6 +1187,8 @@ "regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="], + "rehype-dom-parse": ["rehype-dom-parse@5.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-from-dom": "^5.0.0", "unified": "^11.0.0" } }, "sha512-8CqP11KaqvtWsMqVEC2yM3cZWZsDNqqpr8nPvogjraLuh45stabgcpXadCAxu1n6JaUNJ/Xr3GIqXP7okbNqLg=="], + "rehype-minify-whitespace": ["rehype-minify-whitespace@6.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-minify-whitespace": "^1.0.0" } }, "sha512-Zk0pyQ06A3Lyxhe9vGtOtzz3Z0+qZ5+7icZ/PL/2x1SHPbKao5oB/g/rlc6BCTajqBb33JcOe71Ye1oFsuYbnw=="], "rehype-parse": ["rehype-parse@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-from-html": "^2.0.0", "unified": "^11.0.0" } }, "sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag=="], @@ -1523,8 +1529,6 @@ "@solid-primitives/resize-observer/@solid-primitives/static-store": ["@solid-primitives/static-store@0.1.0", "", { "dependencies": { "@solid-primitives/utils": "^6.3.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-6Coau0Kv/dF83UQpbBzc+gnJafOQAPe2jCbB4jmTK5UocsR5cWmFBVRm3kin+nZFVaO4WkuELw0cKANWgTVh8Q=="], - "@solidjs/start/vite-plugin-solid": ["vite-plugin-solid@2.11.1", "", { "dependencies": { "@babel/core": "^7.23.3", "@types/babel__core": "^7.20.4", "babel-preset-solid": "^1.8.4", "merge-anything": "^5.1.7", "solid-refresh": "^0.6.3", "vitefu": "^1.0.4" }, "peerDependencies": { "@testing-library/jest-dom": "^5.16.6 || ^5.17.0 || ^6.*", "solid-js": "^1.7.2", "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0" }, "optionalPeers": ["@testing-library/jest-dom"] }, "sha512-X9vbbK6AOOA6yxSsNl1VTuUq5y4BG9AR6Z5F/J1ZC2VO7ll8DlSCbOL+RcZXlRbxn0ptE6OI5832nGQhq4yXKQ=="], - "@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], "@testing-library/dom/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], diff --git a/package.json b/package.json index b349e55..d8ac624 100644 --- a/package.json +++ b/package.json @@ -8,17 +8,19 @@ "dependencies": { "@solid-primitives/clipboard": "^1.6.0", "@solid-primitives/destructure": "^0.2.0", + "@solid-primitives/event-listener": "^2.4.0", "@solid-primitives/i18n": "^2.2.0", "@solid-primitives/scheduled": "^1.5.0", - "@solid-primitives/selection": "^0.1.0", + "@solid-primitives/selection": "^0.1.1", "@solid-primitives/storage": "^4.3.1", "@solid-primitives/timer": "^1.4.0", "@solidjs/meta": "^0.29.4", "@solidjs/router": "^0.15.3", - "@solidjs/start": "^1.1.0", + "@solidjs/start": "^1.1.1", "dexie": "^4.0.11", "flag-icons": "^7.3.2", "iterator-helpers-polyfill": "^3.0.1", + "rehype-dom-parse": "^5.0.2", "rehype-parse": "^9.0.1", "rehype-remark": "^10.0.0", "rehype-stringify": "^10.0.1", @@ -35,7 +37,7 @@ "vinxi": "^0.5.3" }, "devDependencies": { - "@happy-dom/global-registrator": "^17.0.3", + "@happy-dom/global-registrator": "^17.1.1", "@sinonjs/fake-timers": "^14.0.0", "@solidjs/testing-library": "^0.8.10", "@testing-library/jest-dom": "^6.6.3", diff --git a/src/components/textarea/textarea.tsx b/src/components/textarea/textarea.tsx index 5960114..9d987b1 100644 --- a/src/components/textarea/textarea.tsx +++ b/src/components/textarea/textarea.tsx @@ -1,8 +1,8 @@ -import { Component, createEffect, createMemo, createSignal, For, onMount, untrack } from 'solid-js'; -import { debounce } from '@solid-primitives/scheduled'; +import { Component, createEffect, createMemo, createSignal, For, on, untrack } from 'solid-js'; import { createSelection, getTextNodes } from '@solid-primitives/selection'; -import { createSource } from '~/features/source'; import { isServer } from 'solid-js/web'; +import { createEditContext } from '~/features/edit-context'; +import { createSource } from '~/features/source'; import css from './textarea.module.css'; interface TextareaProps { @@ -16,75 +16,25 @@ interface TextareaProps { } export function Textarea(props: TextareaProps) { - const [selection, setSelection] = createSelection(); const [editorRef, setEditorRef] = createSignal(); - - const source = createSource(props.value); + const source = createSource(() => props.value); + const [text] = createEditContext(editorRef, () => source.out); createEffect(() => { props.oninput?.(source.in); }); - createEffect(() => { - source.in = props.value; - }); + createEffect(on(() => [editorRef(), source.spellingErrors] as const, ([ref, errors]) => { + createHighlights(ref, 'spelling-error', errors); + })); - const mutate = debounce(() => { - const [, start, end] = selection(); - const ref = editorRef(); + createEffect(on(() => [editorRef(), source.grammarErrors] as const, ([ref, errors]) => { + createHighlights(ref, 'grammar-error', errors); + })); - if (ref) { - source.out = ref.innerHTML; - - ref.style.height = `1px`; - ref.style.height = `${2 + ref.scrollHeight}px`; - - setSelection([ref, start, end]); - } - }, 300); - - onMount(() => { - const ref = editorRef()!; - - console.log(EditContext); - - const context = new EditContext({ - text: source.out, - }); - - const sub = (event) => context.addEventListener(event, (e: Event) => console.log(event, e)); - - sub('textupdate'); - sub('textformatupdate'); - sub('characterboundupdate'); - - console.log(context); - - ref.editContext = context; - - const resize = () => context.updateControlBounds(ref.getBoundingClientRect()); - - window.addEventListener('resize', resize); - resize(); - - // new MutationObserver(mutate).observe(ref, { - // subtree: true, - // childList: true, - // characterData: true, - // }); - }); - - createEffect(() => { - createHighlights(editorRef()!, 'spelling-error', source.spellingErrors); - }); - - createEffect(() => { - createHighlights(editorRef()!, 'grammar-error', source.grammarErrors); - }); - - createEffect(() => { - createHighlights(editorRef()!, 'search-results', source.queryResults); - }); + createEffect(on(() => [editorRef(), source.queryResults] as const, ([ref, errors]) => { + createHighlights(ref, 'search-results', errors); + })); return <> @@ -94,7 +44,7 @@ export function Textarea(props: TextareaProps) { class={`${css.textarea} ${props.class}`} dir="auto" lang={props.lang} - innerHTML={source.out} + innerHTML={text()} data-placeholder={props.placeholder ?? ''} on:keydown={e => e.stopPropagation()} on:pointerdown={e => e.stopPropagation()} @@ -176,20 +126,7 @@ const findMarkerNode = (node: Node | null) => { return node; }; -const spellChecker = checker(/\w+/gi); -const grammarChecker = checker(/\w+\s+\w+/gi); -function checker(regex: RegExp) { - return (subject: string, lang: string): [number, number][] => { - // return []; - - const threshold = .75//.99; - - return Array.from(subject.matchAll(regex)).filter(() => Math.random() >= threshold).map(({ 0: match, index }) => { - return [index, index + match.length] as const; - }); - } -} const createHighlights = (node: Node, type: string, ranges: [number, number][]) => { queueMicrotask(() => { diff --git a/src/features/edit-context/context.ts b/src/features/edit-context/context.ts new file mode 100644 index 0000000..cbebf4b --- /dev/null +++ b/src/features/edit-context/context.ts @@ -0,0 +1,324 @@ +import { createEventListenerMap, DocumentEventListener, WindowEventListener } from "@solid-primitives/event-listener"; +import { Accessor, createEffect, createMemo, onMount } from "solid-js"; +import { createStore } from "solid-js/store"; +import { isServer } from "solid-js/web"; +import { createSelection, getTextNodes } from "@solid-primitives/selection"; +import { visit } from "unist-util-visit"; +import type { Root, Text } from 'hast'; +import { unified } from "unified"; +import rehypeParse from "rehype-parse"; + +type EditContext = [Accessor]; + +export function createEditContext(ref: Accessor, value: Accessor): EditContext { + if (isServer) { + return [createMemo(() => value())]; + } + + if (!("EditContext" in window)) { + throw new Error('`EditContext` is not implemented'); + } + + const context = new EditContext({ + text: value(), + }); + + const [store, setStore] = createStore({ + text: value(), + isComposing: false, + + // Bounds + characterBounds: new Array(), + controlBounds: new DOMRect(), + selectionBounds: new DOMRect(), + }); + + const ast = createMemo(() => unified().use(rehypeParse).parse(store.text)); + const indices = createMemo(() => { + const root = ref(); + + if (!root) { + return []; + } + + const nodes = getTextNodes(root); + const indices: { node: Node, text: { start: number, end: number }, html: { start: number, end: number } }[] = []; + + let index = 0; + visit(ast(), n => n.type === 'text', (node) => { + const { position, value } = node as Text; + const end = index + value.length; + + if (position) { + indices.push({ node: nodes.shift()!, text: { start: index, end }, html: { start: position.start.offset!, end: position.end.offset! } }); + } + + index = end; + }); + + return indices; + }); + const [selection, setSelection] = createSelection(); + + createEffect(() => { + console.log(indices()); + }); + + createEventListenerMap(context, { + textupdate(e: TextUpdateEvent) { + const { updateRangeStart: start, updateRangeEnd: end } = e; + + setStore('text', `${store.text.slice(0, start)}${e.text}${store.text.slice(end)}`); + + updateSelection(toRange(ref()!, start, end)); + + setTimeout(() => { + console.log('hmmm', e, start, end); + context.updateSelection(start, end); + + + setSelection([ref()!, start, end]); + }, 1000); + }, + + compositionstart() { + setStore('isComposing', true); + }, + + compositionend() { + setStore('isComposing', false); + }, + + characterboundsupdate(e: CharacterBoundsUpdateEvent) { + context.updateCharacterBounds(e.rangeStart, []); + }, + + textformatupdate(e: TextFormatUpdateEvent) { + const formats = e.getTextFormats(); + + for (const format of formats) { + console.log(format); + } + }, + }); + + function updateControlBounds() { + context.updateControlBounds(ref()!.getBoundingClientRect()); + } + + function updateSelection(range: Range) { + const [start, end] = toIndices(ref()!, range); + + let index = 0; + let mappedStart = -1; + let mappedEnd = -1; + + visit(ast(), n => n.type === 'text', (node) => { + const { position, value } = node as Text; + + if (position) { + if (index <= start && (index + value.length) >= start) { + mappedStart = position.start.offset! + range.startOffset; + } + + if (index <= end && (index + value.length) >= end) { + mappedEnd = position.start.offset! + range.endOffset; + } + } + + index += value.length; + }); + + context.updateSelection(mappedStart, mappedEnd); + context.updateSelectionBounds(range.getBoundingClientRect()); + + setSelection([ref()!, start, end]); + } + + WindowEventListener({ + onresize() { + updateControlBounds() + }, + }); + + createEventListenerMap(() => ref()!, { + keydown(e: KeyboardEvent) { + // keyCode === 229 is a special code that indicates an IME event. + // https://developer.mozilla.org/en-US/docs/Web/API/Element/keydown_event#keydown_events_with_ime + if (e.keyCode === 229) { + return; + } + + const start = context.selectionStart; + const end = context.selectionEnd; + + if (e.key === 'Tab') { + e.preventDefault(); + + context.updateText(start, end, '\t'); + // updateSelection(start + 1, start + 1); + } else if (e.key === 'Enter') { + context.updateText(start, end, '\n'); + + // updateSelection(start + 1, start + 1); + } + }, + }); + + DocumentEventListener({ + onSelectionchange(e) { + const selection = document.getSelection()!; + + if (selection.rangeCount < 1) { + return; + } + + if (document.activeElement !== ref()) { + return; + } + + updateSelection(selection.getRangeAt(0)!); + }, + }); + + onMount(() => { + updateControlBounds(); + }); + + createEffect((last?: HTMLElement) => { + if (last !== undefined) { + last.editContext = undefined; + } + + const el = ref(); + + if (el === undefined) { + return; + } + + el.editContext = context; + + return el; + }); + + createEffect(() => { + context.updateText(0, 0, value()); + }); + + return [createMemo(() => store.text)]; +} + +declare global { + interface HTMLElement { + editContext?: EditContext; + } + + interface TextFormat { + readonly rangeStart: number; + readonly rangeEnd: number; + readonly underlineStyle: 'none' | 'solid' | 'double' | 'dotted' | 'sadhed' | 'wavy'; + readonly underlineThickness: 'none' | 'thin' | 'thick'; + } + + interface CharacterBoundsUpdateEvent extends Event { + readonly rangeStart: number; + readonly rangeEnd: number; + } + + interface TextFormatUpdateEvent extends Event { + getTextFormats(): TextFormat[]; + } + + interface TextUpdateEvent extends Event { + readonly updateRangeStart: number; + readonly updateRangeEnd: number; + readonly text: string; + readonly selectionStart: number; + readonly selectionEnd: number; + } + + interface EditContextEventMap { + characterboundsupdate: CharacterBoundsUpdateEvent; + compositionstart: Event; + compositionend: Event; + textformatupdate: TextFormatUpdateEvent; + textupdate: TextUpdateEvent; + } + + interface EditContext extends EventTarget { + readonly text: string; + readonly selectionStart: number; + readonly selectionEnd: number; + readonly characterBoundsRangeStart: number; + + oncharacterboundsupdate?: (event: CharacterBoundsUpdateEvent) => any; + oncompositionstart?: (event: Event) => any; + oncompositionend?: (event: Event) => any; + ontextformatupdate?: (event: TextFormatUpdateEvent) => any; + ontextupdate?: (event: TextUpdateEvent) => any; + + attachedElements(): [HTMLElement]; + characterBounds(): DOMRect[]; + updateText(rangeStart: number, rangeEnd: number, text: string): void; + updateSelection(start: number, end: number): void; + updateControlBounds(controlBounds: DOMRect): void; + updateSelectionBounds(selectionBounds: DOMRect): void; + updateCharacterBounds(rangeStart: number, characterBounds: DOMRect[]): void; + + addEventListener(type: K, listener: (this: EditContext, ev: EditContextEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void; + } + + interface EditContextConstructor { + new(): EditContext; + new(options: Partial<{ text: string, selectionStart: number, selectionEnd: number }>): EditContext; + readonly prototype: EditContext; + } + + var EditContext: EditContextConstructor; +} + +const offsetOf = (node: Node, nodes: Node[]) => nodes.slice(0, nodes.indexOf(node)).reduce((t, n) => t + n.textContent!.length, 0); + +const toRange = (root: Node, start: number, end: number): Range => { + let index = 0; + let startNode = null; + let endNode = null; + + for (const node of getTextNodes(root)) { + const length = node.textContent!.length; + + if (index <= start && (index + length) >= start) { + startNode = [node, Math.abs(end - index)] as const; + } + + if (index <= end && (index + length) >= end) { + endNode = [node, Math.abs(end - index)] as const; + } + + if (startNode !== null && endNode !== null) { + break; + } + + index += length; + } + + const range = new Range(); + + if (startNode !== null) { + range.setStart(...startNode); + } + + if (endNode !== null) { + range.setEnd(...endNode); + } + + return range; +}; + +const toIndices = (node: Node, range: Range): [number, number] => { + const nodes = getTextNodes(node); + const start = offsetOf(range.startContainer, nodes) + range.startOffset; + const end = offsetOf(range.endContainer, nodes) + range.endOffset; + + return [start, end]; +}; \ No newline at end of file diff --git a/src/features/edit-context/index.ts b/src/features/edit-context/index.ts new file mode 100644 index 0000000..ad427c1 --- /dev/null +++ b/src/features/edit-context/index.ts @@ -0,0 +1,3 @@ + + +export { createEditContext } from './context'; \ No newline at end of file diff --git a/src/features/edit-context/tokenizer.ts b/src/features/edit-context/tokenizer.ts new file mode 100644 index 0000000..c07fc9b --- /dev/null +++ b/src/features/edit-context/tokenizer.ts @@ -0,0 +1,194 @@ +const WHITESPACE = [" ", "\n", "\t"]; + +function getOpenTagName(htmlString: string, pos: number) { + let tagName = ""; + let char = htmlString.charAt(pos); + + while (char !== ">" && char !== " " && char !== "/" && char !== "") { + tagName += char; + char = htmlString.charAt(++pos); + } + + return tagName; +} + +function getCloseTagName(htmlString: string, pos: number) { + let tagName = ""; + let char = htmlString.charAt(pos); + + while (char !== ">" && char !== "") { + tagName += char; + char = htmlString.charAt(++pos); + } + + return tagName; +} + +function getWhiteSpace(htmlString: string, pos: number) { + let whitespace = ""; + let char = htmlString.charAt(pos); + + while (WHITESPACE.includes(char) && char !== "") { + whitespace += char; + char = htmlString.charAt(++pos); + } + + return whitespace; +} + +function getAttributeName(htmlString: string, pos: number) { + let attributeName = ""; + let char = htmlString.charAt(pos); + + while (char !== "=" && char !== " " && char !== ">" && char !== "") { + attributeName += char; + char = htmlString.charAt(++pos); + } + + return attributeName; +} + +function getAttributeValue(htmlString: string, pos: number, quote: string) { + let attributeValue = ""; + let char = htmlString.charAt(pos); + + const isAtEnd = (c) => { + if (quote) { + return c === quote || c === ""; + } + return c === " " || c === ">" || c === "/" || c === ""; + }; + + while (!isAtEnd(char)) { + attributeValue += char; + char = htmlString.charAt(++pos); + } + + return attributeValue; +} + +function getText(htmlString: string, pos: number) { + let text = ""; + let char = htmlString.charAt(pos); + + while (char !== "<" && char !== "") { + text += char; + char = htmlString.charAt(++pos); + } + + return text; +} + +export function tokenizeHTML(htmlString: string) { + let pos = 0; + let isInTag = false; + let isInAttribute = false; + let isAfterAttributeEqual = false; + + const tokens = []; + + while (pos < htmlString.length) { + const char = htmlString.charAt(pos); + const nextChar = htmlString.charAt(pos + 1); + + if (char === "<" && nextChar !== "/" && !isInTag && !isInAttribute) { + isInTag = true; + tokens.push({ type: "openTagStart", value: "<", pos }); + pos++; + const tagName = getOpenTagName(htmlString, pos); + tokens.push({ type: "tagName", value: tagName, pos }); + pos += tagName.length; + continue; + } + + if (WHITESPACE.includes(char) && isInTag) { + const whitespace = getWhiteSpace(htmlString, pos); + tokens.push({ type: "whitespace", value: whitespace, pos }); + pos += whitespace.length; + isInAttribute = false; + continue; + } + + if (char === ">" && isInTag && !isInAttribute) { + isInTag = false; + tokens.push({ type: "openTagEnd", value: ">", pos }); + pos++; + continue; + } + + if (isInTag && !isInAttribute && char === "/" && nextChar === ">") { + isInTag = false; + tokens.push({ type: "selfClose", value: "/>", pos }); + pos += 2; + continue; + } + + if (isInTag && !isInAttribute) { + isInAttribute = true; + const attributeName = getAttributeName(htmlString, pos); + tokens.push({ type: "attributeName", value: attributeName, pos }); + pos += attributeName.length; + + if (htmlString.charAt(pos) !== "=" && htmlString.charAt(pos) !== "'" && htmlString.charAt(pos) !== '"') { + isInAttribute = false; + } + + continue; + } + + if (char === "=" && isInAttribute && isInTag) { + isAfterAttributeEqual = true; + tokens.push({ type: "equal", value: "=", pos }); + pos++; + continue; + } + + if (isAfterAttributeEqual && isInAttribute && isInTag) { + const hasQuote = char === "'" || char === '"'; + const quote = hasQuote ? char : ""; + + if (hasQuote) { + tokens.push({ type: "quoteStart", value: quote, pos }); + pos++; + } + + const attributeValue = getAttributeValue(htmlString, pos, quote); + tokens.push({ type: "attributeValue", value: attributeValue, pos }); + pos += attributeValue.length; + + if (hasQuote && htmlString.charAt(pos) === quote) { + tokens.push({ type: "quoteEnd", value: quote, pos }); + pos++; + } + + isInAttribute = false; + isAfterAttributeEqual = false; + continue; + } + + if (!isInTag && char === "<" && nextChar === "/") { + tokens.push({ type: "closeTagStart", value: "") { + tokens.push({ type: "closeTagEnd", value: ">", pos }); + pos++; + } + + continue; + } + + if (!isInTag) { + const text = getText(htmlString, pos); + tokens.push({ type: "text", value: text, pos }); + pos += text.length; + continue; + } + } + + return tokens; +} \ No newline at end of file diff --git a/src/features/file/grid.tsx b/src/features/file/grid.tsx index 59696f7..42f0c4a 100644 --- a/src/features/file/grid.tsx +++ b/src/features/file/grid.tsx @@ -1,10 +1,9 @@ import { Accessor, Component, createEffect, createMemo, createSignal, For, JSX, Show, untrack } from "solid-js"; -import { decode, Mutation } from "~/utilities"; +import { Mutation } from "~/utilities"; import { Column, GridApi as GridCompApi, Grid as GridComp } from "~/components/grid"; import { createDataSet, DataSetNode, DataSetRowNode } from "~/features/dataset"; import { SelectionItem } from "../selectable"; import { useI18n } from "../i18n"; -import { debounce } from "@solid-primitives/scheduled"; import css from "./grid.module.css" import { Textarea } from "~/components/textarea"; diff --git a/src/features/file/tree.tsx b/src/features/file/tree.tsx index 73210cb..cbf0b99 100644 --- a/src/features/file/tree.tsx +++ b/src/features/file/tree.tsx @@ -1,4 +1,4 @@ -import { Accessor, children, Component, createContext, createEffect, createMemo, createResource, createSignal, For, InitializedResource, JSX, onCleanup, ParentComponent, Setter, Show, useContext } from "solid-js"; +import { Accessor, Component, createContext, createMemo, createResource, createSignal, For, JSX, onCleanup, ParentComponent, Setter, Show, useContext } from "solid-js"; import { AiFillFile, AiFillFolder, AiFillFolderOpen } from "solid-icons/ai"; import { SelectionProvider, selectable } from "~/features/selectable"; import { debounce } from "@solid-primitives/scheduled"; diff --git a/src/features/source/source.ts b/src/features/source/source.ts index 941a1dc..9b8f744 100644 --- a/src/features/source/source.ts +++ b/src/features/source/source.ts @@ -1,15 +1,17 @@ -import { createEffect, onMount } from "solid-js"; +import { Accessor, createEffect } from "solid-js"; import { createStore } from "solid-js/store"; import { unified } from 'unified' -import { Text, Root } from 'hast' import { visit } from "unist-util-visit"; import { decode } from "~/utilities"; import remarkParse from 'remark-parse' import remarkRehype from 'remark-rehype' import remarkStringify from 'remark-stringify' import rehypeParse from 'rehype-parse' +import rehypeDomParse from 'rehype-dom-parse' import rehypeRemark from 'rehype-remark' import rehypeStringify from 'rehype-stringify' +import type { Text, Root } from 'hast' +import { isServer } from "solid-js/web"; interface SourceStore { in: string; @@ -34,29 +36,12 @@ export interface Source { // TODO :: make this configurable, right now we can only do markdown <--> html. const inToOutProcessor = unified().use(remarkParse).use(remarkRehype).use(rehypeStringify); -const outToInProcessor = unified().use(rehypeParse).use(rehypeRemark).use(remarkStringify, { bullet: '-' }); +const outToInProcessor = unified().use(isServer ? rehypeParse : rehypeDomParse).use(rehypeRemark).use(remarkStringify, { bullet: '-' }); -export function createSource(initalValue: string): Source { - const ast = inToOutProcessor.runSync(inToOutProcessor.parse(initalValue)); - const out = String(inToOutProcessor.stringify(ast)); - const plain = String(unified().use(plainTextStringify).stringify(ast)); +export function createSource(value: Accessor): Source { + const [store, setStore] = createStore({ in: '', out: '', plain: '', query: '', metadata: { spellingErrors: [], grammarErrors: [], queryResults: [] } }); - const [store, setStore] = createStore({ in: initalValue, out, plain, query: '', metadata: { spellingErrors: [], grammarErrors: [], queryResults: [] } }); - - createEffect(() => { - const value = store.plain; - - setStore('metadata', { - spellingErrors: spellChecker(value, ''), - grammarErrors: grammarChecker(value, ''), - }); - }); - - createEffect(() => { - setStore('metadata', 'queryResults', findMatches(store.plain, store.query).toArray()); - }); - - return { + const src: Source = { get in() { return store.in; }, @@ -102,6 +87,26 @@ export function createSource(initalValue: string): Source { return store.metadata.queryResults; }, }; + + createEffect(() => { + src.in = value(); + }); + src.in = value(); + + createEffect(() => { + const value = store.plain; + + setStore('metadata', { + spellingErrors: spellChecker(value, ''), + grammarErrors: grammarChecker(value, ''), + }); + }); + + createEffect(() => { + setStore('metadata', 'queryResults', findMatches(store.plain, store.query).toArray()); + }); + + return src; } function plainTextStringify() { diff --git a/src/routes/(editor)/experimental/grid.tsx b/src/routes/(editor)/experimental/grid.tsx index 8b13ada..a8824c1 100644 --- a/src/routes/(editor)/experimental/grid.tsx +++ b/src/routes/(editor)/experimental/grid.tsx @@ -1,7 +1,7 @@ import { Sidebar } from '~/components/sidebar'; import { CellEditor, Column, DataSetGroupNode, DataSetNode, DataSetRowNode, Grid, GridApi } from '~/components/grid'; import { people, Person } from './experimental.data'; -import { Component, createEffect, createMemo, createSignal, For, Match, Switch } from 'solid-js'; +import { Component, createEffect, createMemo, createSignal, For } from 'solid-js'; import { MutarionKind, Mutation } from '~/utilities'; import { Table } from '~/components/table'; import { createDataSet } from '~/features/dataset';