From 4041236b2d34f14385869ba14d6ff79cceb8cf4b Mon Sep 17 00:00:00 2001 From: Chris Kruining Date: Tue, 18 Feb 2025 16:08:26 +1100 Subject: [PATCH 01/16] really not helpful for now it seems. just stick to content editable for the moment --- src/components/textarea/textarea.tsx | 36 ++++++++++++++++++++++------ 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/src/components/textarea/textarea.tsx b/src/components/textarea/textarea.tsx index 4a23a45..e622dec 100644 --- a/src/components/textarea/textarea.tsx +++ b/src/components/textarea/textarea.tsx @@ -1,9 +1,9 @@ -import { Component, createContext, createEffect, createMemo, createSignal, For, onMount, untrack, useContext } from 'solid-js'; +import { Accessor, Component, createContext, createEffect, createMemo, createSignal, For, onMount, untrack, useContext } from 'solid-js'; import { debounce } from '@solid-primitives/scheduled'; import { createSelection } from '@solid-primitives/selection'; import { createSource } from '~/features/source'; -import css from './textarea.module.css'; import { isServer } from 'solid-js/web'; +import css from './textarea.module.css'; interface TextareaProps { class?: string; @@ -44,11 +44,34 @@ export function Textarea(props: TextareaProps) { }, 300); onMount(() => { - new MutationObserver(mutate).observe(editorRef()!, { - subtree: true, - childList: true, - characterData: true, + 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, + // }); }); return <> @@ -56,7 +79,6 @@ export function Textarea(props: TextareaProps) {
Date: Mon, 24 Feb 2025 17:01:47 +1100 Subject: [PATCH 02/16] 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'; From fc22ce6027aba3a55270e9643016b3b4df99eaee Mon Sep 17 00:00:00 2001 From: Chris Kruining Date: Tue, 25 Feb 2025 16:21:21 +1100 Subject: [PATCH 03/16] got to a stable point again. next up is comming up with a decent API for modifications --- .vscode/launch.json | 2 +- app.config.ts | 1 + bun.lock | 151 ++++++++++--- package.json | 17 +- src/components/textarea/textarea.tsx | 16 +- src/features/edit-context/index.ts | 3 - src/features/edit-context/tokenizer.ts | 194 ----------------- .../createEditor-should-create-1.png | Bin 0 -> 21680 bytes src/features/editor/context.spec.tsx | 87 ++++++++ .../{edit-context => editor}/context.ts | 201 +++++++----------- src/features/editor/editor.tsx | 20 ++ src/features/editor/index.tsx | 4 + src/features/editor/map.ts | 114 ++++++++++ src/features/source/source.spec.ts | 6 +- .../(editor)/experimental/formatter.tsx | 5 +- src/utilities.spec.ts | 40 +++- src/utilities.ts | 3 + tsconfig.json | 1 + vitest.config.ts | 8 +- 19 files changed, 498 insertions(+), 375 deletions(-) delete mode 100644 src/features/edit-context/index.ts delete mode 100644 src/features/edit-context/tokenizer.ts create mode 100644 src/features/editor/__screenshots__/context.spec.tsx/createEditor-should-create-1.png create mode 100644 src/features/editor/context.spec.tsx rename src/features/{edit-context => editor}/context.ts (57%) create mode 100644 src/features/editor/editor.tsx create mode 100644 src/features/editor/index.tsx create mode 100644 src/features/editor/map.ts diff --git a/.vscode/launch.json b/.vscode/launch.json index 71c1b04..a6d750e 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -56,7 +56,7 @@ // The arguments to pass to the `bun` executable, if any. // Unlike `args`, these are passed to the executable itself, not the program. "runtimeArgs": [ - "--bun", + "run", "test" ], }, diff --git a/app.config.ts b/app.config.ts index 0d91e94..6c28aef 100644 --- a/app.config.ts +++ b/app.config.ts @@ -1,6 +1,7 @@ import { defineConfig } from '@solidjs/start/config'; import solidSvg from 'vite-plugin-solid-svg'; import devtools from 'solid-devtools/vite'; +import { resolve } from 'node:path'; export default defineConfig({ vite: { diff --git a/bun.lock b/bun.lock index 2973470..5577395 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "name": "calque", "dependencies": { "@solid-primitives/clipboard": "^1.6.0", + "@solid-primitives/context": "^0.3.0", "@solid-primitives/destructure": "^0.2.0", "@solid-primitives/event-listener": "^2.4.0", "@solid-primitives/i18n": "^2.2.0", @@ -27,7 +28,7 @@ "remark-stringify": "^11.0.0", "sitemap": "^8.0.0", "solid-icons": "^1.1.0", - "solid-js": "^1.9.4", + "solid-js": "^1.9.5", "ts-pattern": "^5.6.2", "unified": "^11.0.5", "unist-util-find": "^3.0.0", @@ -35,21 +36,23 @@ "vinxi": "^0.5.3", }, "devDependencies": { - "@happy-dom/global-registrator": "^17.1.1", + "@happy-dom/global-registrator": "^17.1.8", "@sinonjs/fake-timers": "^14.0.0", "@solidjs/testing-library": "^0.8.10", "@testing-library/jest-dom": "^6.6.3", "@testing-library/user-event": "^14.6.1", "@types/sinonjs__fake-timers": "^8.1.5", "@types/wicg-file-system-access": "^2023.10.5", - "@vitest/coverage-istanbul": "3.0.6", - "@vitest/coverage-v8": "3.0.6", - "bun-types": "^1.2.2", + "@vitest/browser": "^3.0.7", + "@vitest/coverage-istanbul": "3.0.7", + "@vitest/coverage-v8": "3.0.7", + "bun-types": "^1.2.3", "jsdom": "^26.0.0", + "playwright": "^1.50.1", "solid-devtools": "^0.33.0", "vite-plugin-solid": "^2.11.2", "vite-plugin-solid-svg": "^0.8.1", - "vitest": "^3.0.6", + "vitest": "^3.0.7", "workbox-window": "^7.3.0", }, }, @@ -103,6 +106,12 @@ "@bcoe/v8-coverage": ["@bcoe/v8-coverage@1.0.2", "", {}, "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA=="], + "@bundled-es-modules/cookie": ["@bundled-es-modules/cookie@2.0.1", "", { "dependencies": { "cookie": "^0.7.2" } }, "sha512-8o+5fRPLNbjbdGRRmJj3h6Hh1AQJf2dk3qQ/5ZFb+PXkRNiSoMGGUKlsgLfrxneb72axVJyIYji64E2+nNfYyw=="], + + "@bundled-es-modules/statuses": ["@bundled-es-modules/statuses@1.0.1", "", { "dependencies": { "statuses": "^2.0.1" } }, "sha512-yn7BklA5acgcBr+7w064fGV+SGIFySjCKpqjcWgBAIfrAkY+4GQTJJHQMeT3V/sgz23VTEVV8TtOmkvJAhFVfg=="], + + "@bundled-es-modules/tough-cookie": ["@bundled-es-modules/tough-cookie@0.1.6", "", { "dependencies": { "@types/tough-cookie": "^4.0.5", "tough-cookie": "^4.1.4" } }, "sha512-dvMHbL464C0zI+Yqxbz6kZ5TOEp7GLW+pry/RWndAR8MJQAXZ2rPmIs8tziTZjeIyhSNZgZbCePtfSbdWqStJw=="], + "@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.3.4", "", { "dependencies": { "mime": "^3.0.0" } }, "sha512-YLPHc8yASwjNkmcDMQMY35yiWjoKAKnhUbPRszBRS0YgH+IXtsMp61j+yTcnCE3oO2DgP0U3iejLC8FTtKDC8Q=="], "@csstools/color-helpers": ["@csstools/color-helpers@5.0.1", "", {}, "sha512-MKtmkA0BX87PKaO1NFRTFH+UnkgnmySQOvNxJubsadusqPEC2aJ9MOQiMceZJJ6oitUl/i0L6u0M1IrmAOmgBA=="], @@ -169,7 +178,15 @@ "@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.1.1", "", { "dependencies": { "happy-dom": "^17.1.1" } }, "sha512-if4TVRU4SnQwpOC9pN/a892nXPpctqER/rYIS9E/YsqwpaPANhMjFM7/+Ibd748gP6OuDJbspQ0axFrCscI1og=="], + "@happy-dom/global-registrator": ["@happy-dom/global-registrator@17.1.8", "", { "dependencies": { "happy-dom": "^17.1.8" } }, "sha512-8/INgMD5gqzhaGnRbcHvQ3cYa70ZbdUTMiCQg+4Pz22vogILU2Q1spnneunMVjAtx6DBRMO8rBnDeMREVVyADQ=="], + + "@inquirer/confirm": ["@inquirer/confirm@5.1.6", "", { "dependencies": { "@inquirer/core": "^10.1.7", "@inquirer/type": "^3.0.4" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-6ZXYK3M1XmaVBZX6FCfChgtponnL0R6I7k8Nu+kaoNkT828FVZTcca1MqmWQipaW2oNREQl5AaPCUOOCVNdRMw=="], + + "@inquirer/core": ["@inquirer/core@10.1.7", "", { "dependencies": { "@inquirer/figures": "^1.0.10", "@inquirer/type": "^3.0.4", "ansi-escapes": "^4.3.2", "cli-width": "^4.1.0", "mute-stream": "^2.0.0", "signal-exit": "^4.1.0", "wrap-ansi": "^6.2.0", "yoctocolors-cjs": "^2.1.2" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-AA9CQhlrt6ZgiSy6qoAigiA1izOa751ugX6ioSjqgJ+/Gd+tEN/TORk5sUYNjXuHWfW0r1n/a6ak4u/NqHHrtA=="], + + "@inquirer/figures": ["@inquirer/figures@1.0.10", "", {}, "sha512-Ey6176gZmeqZuY/W/nZiUyvmb1/qInjcpiZjXWi6nON+nxJpD1bxtSoBxNliGISae32n6OwbY+TSXPZ1CfS4bw=="], + + "@inquirer/type": ["@inquirer/type@3.0.4", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-2MNFrDY8jkFYc9Il9DgLsHhMzuHnOYM1+CUYVWbzu9oT0hC7V7EcYvdCKeoll/Fcci04A+ERZ9wcc7cQ8lTkIA=="], "@ioredis/commands": ["@ioredis/commands@1.2.0", "", {}, "sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg=="], @@ -193,6 +210,8 @@ "@mapbox/node-pre-gyp": ["@mapbox/node-pre-gyp@2.0.0", "", { "dependencies": { "consola": "^3.2.3", "detect-libc": "^2.0.0", "https-proxy-agent": "^7.0.5", "node-fetch": "^2.6.7", "nopt": "^8.0.0", "semver": "^7.5.3", "tar": "^7.4.0" }, "bin": { "node-pre-gyp": "bin/node-pre-gyp" } }, "sha512-llMXd39jtP0HpQLVI37Bf1m2ADlEb35GYSh1SDSLsBhR+5iCxiNGlT31yqbNtVHygHAtMy6dWFERpU2JgufhPg=="], + "@mswjs/interceptors": ["@mswjs/interceptors@0.37.6", "", { "dependencies": { "@open-draft/deferred-promise": "^2.2.0", "@open-draft/logger": "^0.3.0", "@open-draft/until": "^2.0.0", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "strict-event-emitter": "^0.5.1" } }, "sha512-wK+5pLK5XFmgtH3aQ2YVvA3HohS3xqV/OxuVOdNx9Wpnz7VE/fnC+e1A7ln6LFYeck7gOJ/dsZV6OLplOtAJ2w=="], + "@netlify/functions": ["@netlify/functions@2.8.2", "", { "dependencies": { "@netlify/serverless-functions-api": "1.26.1" } }, "sha512-DeoAQh8LuNPvBE4qsKlezjKj0PyXDryOFJfJKo3Z1qZLKzQ21sT314KQKPVjfvw6knqijj+IO+0kHXy/TJiqNA=="], "@netlify/node-cookies": ["@netlify/node-cookies@0.1.0", "", {}, "sha512-OAs1xG+FfLX0LoRASpqzVntVV/RpYkgpI0VrUnw2u0Q1qiZUzcPffxRK8HF3gc4GjuhG5ahOEMJ9bswBiZPq0g=="], @@ -207,6 +226,12 @@ "@nothing-but/utils": ["@nothing-but/utils@0.17.0", "", {}, "sha512-TuCHcHLOqDL0SnaAxACfuRHBNRgNJcNn9X0GiH5H3YSDBVquCr3qEIG3FOQAuMyZCbu9w8nk2CHhOsn7IvhIwQ=="], + "@open-draft/deferred-promise": ["@open-draft/deferred-promise@2.2.0", "", {}, "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA=="], + + "@open-draft/logger": ["@open-draft/logger@0.3.0", "", { "dependencies": { "is-node-process": "^1.2.0", "outvariant": "^1.4.0" } }, "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ=="], + + "@open-draft/until": ["@open-draft/until@2.1.0", "", {}, "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg=="], + "@parcel/watcher": ["@parcel/watcher@2.5.1", "", { "dependencies": { "detect-libc": "^1.0.3", "is-glob": "^4.0.3", "micromatch": "^4.0.5", "node-addon-api": "^7.0.0" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.1", "@parcel/watcher-darwin-arm64": "2.5.1", "@parcel/watcher-darwin-x64": "2.5.1", "@parcel/watcher-freebsd-x64": "2.5.1", "@parcel/watcher-linux-arm-glibc": "2.5.1", "@parcel/watcher-linux-arm-musl": "2.5.1", "@parcel/watcher-linux-arm64-glibc": "2.5.1", "@parcel/watcher-linux-arm64-musl": "2.5.1", "@parcel/watcher-linux-x64-glibc": "2.5.1", "@parcel/watcher-linux-x64-musl": "2.5.1", "@parcel/watcher-win32-arm64": "2.5.1", "@parcel/watcher-win32-ia32": "2.5.1", "@parcel/watcher-win32-x64": "2.5.1" } }, "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg=="], "@parcel/watcher-android-arm64": ["@parcel/watcher-android-arm64@2.5.1", "", { "os": "android", "cpu": "arm64" }, "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA=="], @@ -239,6 +264,8 @@ "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], + "@polka/url": ["@polka/url@1.0.0-next.28", "", {}, "sha512-8LduaNlMZGwdZ6qWrKlfa+2M4gahzFkprZiAt2TF8uS0qQgBizKXpXURqvTJ4WtmupWxaLqjRb2UCTe72mu+Aw=="], + "@redocly/ajv": ["@redocly/ajv@8.11.2", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2", "uri-js-replace": "^1.0.1" } }, "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg=="], "@redocly/config": ["@redocly/config@0.20.3", "", {}, "sha512-Nyyv1Bj7GgYwj/l46O0nkH1GTKWbO3Ixe7KFcn021aZipkZd+z8Vlu1BwkhqtVgivcKaClaExtWU/lDHkjBzag=="], @@ -327,6 +354,8 @@ "@solid-primitives/clipboard": ["@solid-primitives/clipboard@1.6.0", "", { "dependencies": { "@solid-primitives/utils": "^6.3.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-mZQVAqyXt1Cp9zZieGhKwSRMoNQg9z3ErqtnVxAxw5LAheAC09Z5OHr1Ln0kLjRDhC+r6GiNzOKzFVuTH+ERyQ=="], + "@solid-primitives/context": ["@solid-primitives/context@0.3.0", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-hVLWfZbXuz23gpJ+flCllbKidlIDCcSTOtlhSo9AhQ/vNiwvenLK8J4B2Hw2WCa4ETGPpQcNfgQHSNffEV5YeQ=="], + "@solid-primitives/cursor": ["@solid-primitives/cursor@0.0.115", "", { "dependencies": { "@solid-primitives/utils": "^6.2.3" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-8nEmUN/sacXPChwuJOAi6Yi6VnxthW/Jk8VGvvcF38AenjUvOA6FHI6AkJILuFXjQw1PGxia1YbH/Mn77dPiOA=="], "@solid-primitives/destructure": ["@solid-primitives/destructure@0.2.0", "", { "dependencies": { "@solid-primitives/utils": "^6.3.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-nfE6nSkyLle+hIQzvxGwzyt3TvFwgBjhFiQ7y2Cq+amBiwpvVVm+1qncE8tKaKk6JNn/CilgXZgJ/KMb/p3csA=="], @@ -397,6 +426,8 @@ "@types/braces": ["@types/braces@3.0.5", "", {}, "sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w=="], + "@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="], + "@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="], "@types/estree": ["@types/estree@1.0.6", "", {}, "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw=="], @@ -421,6 +452,10 @@ "@types/sinonjs__fake-timers": ["@types/sinonjs__fake-timers@8.1.5", "", {}, "sha512-mQkU2jY8jJEF7YHjHvsQO8+3ughTL1mcnn96igfhONmR+fUPSKIkefQYpSe8bsly2Ep7oQbn/6VG5/9/0qcArQ=="], + "@types/statuses": ["@types/statuses@2.0.5", "", {}, "sha512-jmIUGWrAiwu3dZpxntxieC+1n/5c3mjrImkmOSQ2NC5uP6cYO4aAZDdSmRcI5C1oiTmqlZGHC+/NmJrKogbP5A=="], + + "@types/tough-cookie": ["@types/tough-cookie@4.0.5", "", {}, "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA=="], + "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], @@ -439,23 +474,25 @@ "@vinxi/server-components": ["@vinxi/server-components@0.5.0", "", { "dependencies": { "@vinxi/plugin-directives": "0.5.0", "acorn": "^8.10.0", "acorn-loose": "^8.3.0", "acorn-typescript": "^1.4.3", "astring": "^1.8.6", "magicast": "^0.2.10", "recast": "^0.23.4" }, "peerDependencies": { "vinxi": "^0.5.0" } }, "sha512-2p6ZYzoqF7ZAriU0rC9KJWSX/n5qHhUBs7x04SLYzmy9lFxQNw3YHsmsA4b3aHDU+Mxw26wyFwvIbrL6eU3Gyw=="], - "@vitest/coverage-istanbul": ["@vitest/coverage-istanbul@3.0.6", "", { "dependencies": { "@istanbuljs/schema": "^0.1.3", "debug": "^4.4.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-instrument": "^6.0.3", "istanbul-lib-report": "^3.0.1", "istanbul-lib-source-maps": "^5.0.6", "istanbul-reports": "^3.1.7", "magicast": "^0.3.5", "test-exclude": "^7.0.1", "tinyrainbow": "^2.0.0" }, "peerDependencies": { "vitest": "3.0.6" } }, "sha512-e+8HkmVlPpqOZXIWGE8opxex3trTMCeCMHax7yG0JbWOtGRVKBjuNS/GGA/eta89LuXUrCIcQrRfJHLUrWl7Wg=="], + "@vitest/browser": ["@vitest/browser@3.0.7", "", { "dependencies": { "@testing-library/dom": "^10.4.0", "@testing-library/user-event": "^14.6.1", "@vitest/mocker": "3.0.7", "@vitest/utils": "3.0.7", "magic-string": "^0.30.17", "msw": "^2.7.3", "sirv": "^3.0.1", "tinyrainbow": "^2.0.0", "ws": "^8.18.1" }, "peerDependencies": { "playwright": "*", "vitest": "3.0.7", "webdriverio": "^7.0.0 || ^8.0.0 || ^9.0.0" }, "optionalPeers": ["playwright", "webdriverio"] }, "sha512-TDzZtnbe37KZLSLhvlO1pUkeRSRzW3rOhPLsshX8agGoPELMlG7EvS4z9GfsdaCxsP7oWLBJpFjNJwLS458Bzg=="], - "@vitest/coverage-v8": ["@vitest/coverage-v8@3.0.6", "", { "dependencies": { "@ampproject/remapping": "^2.3.0", "@bcoe/v8-coverage": "^1.0.2", "debug": "^4.4.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-lib-source-maps": "^5.0.6", "istanbul-reports": "^3.1.7", "magic-string": "^0.30.17", "magicast": "^0.3.5", "std-env": "^3.8.0", "test-exclude": "^7.0.1", "tinyrainbow": "^2.0.0" }, "peerDependencies": { "@vitest/browser": "3.0.6", "vitest": "3.0.6" }, "optionalPeers": ["@vitest/browser"] }, "sha512-JRTlR8Bw+4BcmVTICa7tJsxqphAktakiLsAmibVLAWbu1lauFddY/tXeM6sAyl1cgkPuXtpnUgaCPhTdz1Qapg=="], + "@vitest/coverage-istanbul": ["@vitest/coverage-istanbul@3.0.7", "", { "dependencies": { "@istanbuljs/schema": "^0.1.3", "debug": "^4.4.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-instrument": "^6.0.3", "istanbul-lib-report": "^3.0.1", "istanbul-lib-source-maps": "^5.0.6", "istanbul-reports": "^3.1.7", "magicast": "^0.3.5", "test-exclude": "^7.0.1", "tinyrainbow": "^2.0.0" }, "peerDependencies": { "vitest": "3.0.7" } }, "sha512-hkd7rlfnqQJFlg6IPv9aFNaxJNkWLasdfaMJR3MBsBkxddSYy5ax9sW6Vv1/3tmmyT9m/b0lHDNknybKJ33cXw=="], - "@vitest/expect": ["@vitest/expect@3.0.6", "", { "dependencies": { "@vitest/spy": "3.0.6", "@vitest/utils": "3.0.6", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-zBduHf/ja7/QRX4HdP1DSq5XrPgdN+jzLOwaTq/0qZjYfgETNFCKf9nOAp2j3hmom3oTbczuUzrzg9Hafh7hNg=="], + "@vitest/coverage-v8": ["@vitest/coverage-v8@3.0.7", "", { "dependencies": { "@ampproject/remapping": "^2.3.0", "@bcoe/v8-coverage": "^1.0.2", "debug": "^4.4.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-lib-source-maps": "^5.0.6", "istanbul-reports": "^3.1.7", "magic-string": "^0.30.17", "magicast": "^0.3.5", "std-env": "^3.8.0", "test-exclude": "^7.0.1", "tinyrainbow": "^2.0.0" }, "peerDependencies": { "@vitest/browser": "3.0.7", "vitest": "3.0.7" }, "optionalPeers": ["@vitest/browser"] }, "sha512-Av8WgBJLTrfLOer0uy3CxjlVuWK4CzcLBndW1Nm2vI+3hZ2ozHututkfc7Blu1u6waeQ7J8gzPK/AsBRnWA5mQ=="], - "@vitest/mocker": ["@vitest/mocker@3.0.6", "", { "dependencies": { "@vitest/spy": "3.0.6", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0 || ^6.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-KPztr4/tn7qDGZfqlSPQoF2VgJcKxnDNhmfR3VgZ6Fy1bO8T9Fc1stUiTXtqz0yG24VpD00pZP5f8EOFknjNuQ=="], + "@vitest/expect": ["@vitest/expect@3.0.7", "", { "dependencies": { "@vitest/spy": "3.0.7", "@vitest/utils": "3.0.7", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-QP25f+YJhzPfHrHfYHtvRn+uvkCFCqFtW9CktfBxmB+25QqWsx7VB2As6f4GmwllHLDhXNHvqedwhvMmSnNmjw=="], - "@vitest/pretty-format": ["@vitest/pretty-format@3.0.6", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-Zyctv3dbNL+67qtHfRnUE/k8qxduOamRfAL1BurEIQSyOEFffoMvx2pnDSSbKAAVxY0Ej2J/GH2dQKI0W2JyVg=="], + "@vitest/mocker": ["@vitest/mocker@3.0.7", "", { "dependencies": { "@vitest/spy": "3.0.7", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0 || ^6.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-qui+3BLz9Eonx4EAuR/i+QlCX6AUZ35taDQgwGkK/Tw6/WgwodSrjN1X2xf69IA/643ZX5zNKIn2svvtZDrs4w=="], - "@vitest/runner": ["@vitest/runner@3.0.6", "", { "dependencies": { "@vitest/utils": "3.0.6", "pathe": "^2.0.3" } }, "sha512-JopP4m/jGoaG1+CBqubV/5VMbi7L+NQCJTu1J1Pf6YaUbk7bZtaq5CX7p+8sY64Sjn1UQ1XJparHfcvTTdu9cA=="], + "@vitest/pretty-format": ["@vitest/pretty-format@3.0.7", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-CiRY0BViD/V8uwuEzz9Yapyao+M9M008/9oMOSQydwbwb+CMokEq3XVaF3XK/VWaOK0Jm9z7ENhybg70Gtxsmg=="], - "@vitest/snapshot": ["@vitest/snapshot@3.0.6", "", { "dependencies": { "@vitest/pretty-format": "3.0.6", "magic-string": "^0.30.17", "pathe": "^2.0.3" } }, "sha512-qKSmxNQwT60kNwwJHMVwavvZsMGXWmngD023OHSgn873pV0lylK7dwBTfYP7e4URy5NiBCHHiQGA9DHkYkqRqg=="], + "@vitest/runner": ["@vitest/runner@3.0.7", "", { "dependencies": { "@vitest/utils": "3.0.7", "pathe": "^2.0.3" } }, "sha512-WeEl38Z0S2ZcuRTeyYqaZtm4e26tq6ZFqh5y8YD9YxfWuu0OFiGFUbnxNynwLjNRHPsXyee2M9tV7YxOTPZl2g=="], - "@vitest/spy": ["@vitest/spy@3.0.6", "", { "dependencies": { "tinyspy": "^3.0.2" } }, "sha512-HfOGx/bXtjy24fDlTOpgiAEJbRfFxoX3zIGagCqACkFKKZ/TTOE6gYMKXlqecvxEndKFuNHcHqP081ggZ2yM0Q=="], + "@vitest/snapshot": ["@vitest/snapshot@3.0.7", "", { "dependencies": { "@vitest/pretty-format": "3.0.7", "magic-string": "^0.30.17", "pathe": "^2.0.3" } }, "sha512-eqTUryJWQN0Rtf5yqCGTQWsCFOQe4eNz5Twsu21xYEcnFJtMU5XvmG0vgebhdLlrHQTSq5p8vWHJIeJQV8ovsA=="], - "@vitest/utils": ["@vitest/utils@3.0.6", "", { "dependencies": { "@vitest/pretty-format": "3.0.6", "loupe": "^3.1.3", "tinyrainbow": "^2.0.0" } }, "sha512-18ktZpf4GQFTbf9jK543uspU03Q2qya7ZGya5yiZ0Gx0nnnalBvd5ZBislbl2EhLjM8A8rt4OilqKG7QwcGkvQ=="], + "@vitest/spy": ["@vitest/spy@3.0.7", "", { "dependencies": { "tinyspy": "^3.0.2" } }, "sha512-4T4WcsibB0B6hrKdAZTM37ekuyFZt2cGbEGd2+L0P8ov15J1/HUsUaqkXEQPNAWr4BtPPe1gI+FYfMHhEKfR8w=="], + + "@vitest/utils": ["@vitest/utils@3.0.7", "", { "dependencies": { "@vitest/pretty-format": "3.0.7", "loupe": "^3.1.3", "tinyrainbow": "^2.0.0" } }, "sha512-xePVpCRfooFX3rANQjwoditoXgWb1MaFbzmGuPP59MK6i13mrnDw/yEIyJudLeW6/38mCNcwCiJIGmpDPibAIg=="], "abbrev": ["abbrev@3.0.0", "", {}, "sha512-+/kfrslGQ7TNV2ecmQwMJj/B65g5KVq1/L3SGVZ3tCYGqlzFuFCGBZJtMP99wH3NpEUyAjn0zPdPUg0D+DwrOA=="], @@ -477,6 +514,8 @@ "ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="], + "ansi-escapes": ["ansi-escapes@4.3.2", "", { "dependencies": { "type-fest": "^0.21.3" } }, "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ=="], + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], @@ -543,7 +582,7 @@ "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], - "bun-types": ["bun-types@1.2.2", "", { "dependencies": { "@types/node": "*", "@types/ws": "~8.5.10" } }, "sha512-RCbMH5elr9gjgDGDhkTTugA21XtJAy/9jkKe/G3WR2q17VPGhcquf9Sir6uay9iW+7P/BV0CAHA1XlHXMAVKHg=="], + "bun-types": ["bun-types@1.2.3", "", { "dependencies": { "@types/node": "*", "@types/ws": "~8.5.10" } }, "sha512-P7AeyTseLKAvgaZqQrvp3RqFM3yN9PlcLuSTe7SoJOfZkER73mLdT2vEQi8U64S1YvM/ldcNiQjn0Sn7H9lGgg=="], "c12": ["c12@2.0.1", "", { "dependencies": { "chokidar": "^4.0.1", "confbox": "^0.1.7", "defu": "^6.1.4", "dotenv": "^16.4.5", "giget": "^1.2.3", "jiti": "^2.3.0", "mlly": "^1.7.1", "ohash": "^1.1.4", "pathe": "^1.1.2", "perfect-debounce": "^1.0.0", "pkg-types": "^1.2.0", "rc9": "^2.1.2" }, "peerDependencies": { "magicast": "^0.3.5" }, "optionalPeers": ["magicast"] }, "sha512-Z4JgsKXHG37C6PYUtIxCfLJZvo6FyhHJoClwwb9ftUkLpPSkuYqn6Tr+vnaN8hymm0kIbcg6Ey3kv/Q71k5w/A=="], @@ -577,6 +616,8 @@ "cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="], + "cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="], + "clipboardy": ["clipboardy@4.0.0", "", { "dependencies": { "execa": "^8.0.1", "is-wsl": "^3.1.0", "is64bit": "^2.0.0" } }, "sha512-5mOlNS0mhX0707P2I0aZ2V/cmHUEO/fL7VFLqszkhUsxt7RwnmrInf/eEQKlf5GzvYeHIjT+Ov1HRfNmymlG0w=="], "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], @@ -609,6 +650,8 @@ "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + "cookie-es": ["cookie-es@1.2.2", "", {}, "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg=="], "core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="], @@ -769,7 +812,7 @@ "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="], - "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], @@ -793,11 +836,13 @@ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "graphql": ["graphql@16.10.0", "", {}, "sha512-AjqGKbDGUFRKIRCP9tCKiIGHyriz2oHEbPIbEtcSLSs4YjReZOIPQQWek4+6hjw62H9QShXHyaGivGiYVLeYFQ=="], + "gzip-size": ["gzip-size@7.0.0", "", { "dependencies": { "duplexer": "^0.1.2" } }, "sha512-O1Ld7Dr+nqPnmGpdhzLmMTQ4vAsD+rHwMm1NLUmoUFFymBOMKxCCrtDxqdBRYXdeEPEi3SyoR4TizJLQrnKBNA=="], "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.1.1", "", { "dependencies": { "webidl-conversions": "^7.0.0", "whatwg-mimetype": "^3.0.0" } }, "sha512-OSTkBlmD/6Do7gCd7nZB5iFq1bF9VQg/iFmjHmxvVX2S1UiOpo6sT+aFNnu3XUsB8hCZb9+GZ0G1g1TaMiAggw=="], + "happy-dom": ["happy-dom@17.1.8", "", { "dependencies": { "webidl-conversions": "^7.0.0", "whatwg-mimetype": "^3.0.0" } }, "sha512-Yxbq/FG79z1rhAf/iB6YM8wO2JB/JDQBy99RiLSs+2siEAi5J05x9eW1nnASHZJbpldjJE2KuFLsLZ+AzX/IxA=="], "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], @@ -833,6 +878,8 @@ "hastscript": ["hastscript@9.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^6.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-jzaLBGavEDKHrc5EfFImKN7nZKKBdSLIdGvCwDZ9TfzbF2ffXiov8CKE445L2Z1Ek2t/m4SKQ2j6Ipv7NyUolw=="], + "headers-polyfill": ["headers-polyfill@4.0.3", "", {}, "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ=="], + "hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="], "html-encoding-sniffer": ["html-encoding-sniffer@4.0.0", "", { "dependencies": { "whatwg-encoding": "^3.1.1" } }, "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ=="], @@ -893,6 +940,8 @@ "is-module": ["is-module@1.0.0", "", {}, "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g=="], + "is-node-process": ["is-node-process@1.2.0", "", {}, "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw=="], + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], @@ -1059,8 +1108,14 @@ "mlly": ["mlly@1.7.4", "", { "dependencies": { "acorn": "^8.14.0", "pathe": "^2.0.1", "pkg-types": "^1.3.0", "ufo": "^1.5.4" } }, "sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw=="], + "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "msw": ["msw@2.7.3", "", { "dependencies": { "@bundled-es-modules/cookie": "^2.0.1", "@bundled-es-modules/statuses": "^1.0.1", "@bundled-es-modules/tough-cookie": "^0.1.6", "@inquirer/confirm": "^5.0.0", "@mswjs/interceptors": "^0.37.0", "@open-draft/deferred-promise": "^2.2.0", "@open-draft/until": "^2.1.0", "@types/cookie": "^0.6.0", "@types/statuses": "^2.0.4", "graphql": "^16.8.1", "headers-polyfill": "^4.0.2", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "path-to-regexp": "^6.3.0", "picocolors": "^1.1.1", "strict-event-emitter": "^0.5.1", "type-fest": "^4.26.1", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": ">= 4.8.x" }, "optionalPeers": ["typescript"], "bin": { "msw": "cli/index.js" } }, "sha512-+mycXv8l2fEAjFZ5sjrtjJDmm2ceKGjrNbBr1durRg6VkU9fNUE/gsmQ51hWbHqs+l35W1iM+ZsmOD9Fd6lspw=="], + + "mute-stream": ["mute-stream@2.0.0", "", {}, "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA=="], + "nanoid": ["nanoid@3.3.8", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w=="], "nitropack": ["nitropack@2.10.4", "", { "dependencies": { "@cloudflare/kv-asset-handler": "^0.3.4", "@netlify/functions": "^2.8.2", "@rollup/plugin-alias": "^5.1.1", "@rollup/plugin-commonjs": "^28.0.1", "@rollup/plugin-inject": "^5.0.5", "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^15.3.0", "@rollup/plugin-replace": "^6.0.1", "@rollup/plugin-terser": "^0.4.4", "@rollup/pluginutils": "^5.1.3", "@types/http-proxy": "^1.17.15", "@vercel/nft": "^0.27.5", "archiver": "^7.0.1", "c12": "2.0.1", "chokidar": "^3.6.0", "citty": "^0.1.6", "compatx": "^0.1.8", "confbox": "^0.1.8", "consola": "^3.2.3", "cookie-es": "^1.2.2", "croner": "^9.0.0", "crossws": "^0.3.1", "db0": "^0.2.1", "defu": "^6.1.4", "destr": "^2.0.3", "dot-prop": "^9.0.0", "esbuild": "^0.24.0", "escape-string-regexp": "^5.0.0", "etag": "^1.8.1", "fs-extra": "^11.2.0", "globby": "^14.0.2", "gzip-size": "^7.0.0", "h3": "^1.13.0", "hookable": "^5.5.3", "httpxy": "^0.1.5", "ioredis": "^5.4.1", "jiti": "^2.4.0", "klona": "^2.0.6", "knitwork": "^1.1.0", "listhen": "^1.9.0", "magic-string": "^0.30.12", "magicast": "^0.3.5", "mime": "^4.0.4", "mlly": "^1.7.2", "node-fetch-native": "^1.6.4", "ofetch": "^1.4.1", "ohash": "^1.1.4", "openapi-typescript": "^7.4.2", "pathe": "^1.1.2", "perfect-debounce": "^1.0.0", "pkg-types": "^1.2.1", "pretty-bytes": "^6.1.1", "radix3": "^1.1.2", "rollup": "^4.24.3", "rollup-plugin-visualizer": "^5.12.0", "scule": "^1.3.0", "semver": "^7.6.3", "serve-placeholder": "^2.0.2", "serve-static": "^1.16.2", "std-env": "^3.7.0", "ufo": "^1.5.4", "uncrypto": "^0.1.3", "unctx": "^2.3.1", "unenv": "^1.10.0", "unimport": "^3.13.1", "unstorage": "^1.13.1", "untyped": "^1.5.1", "unwasm": "^0.3.9" }, "peerDependencies": { "xml2js": "^0.6.2" }, "optionalPeers": ["xml2js"], "bin": { "nitro": "dist/cli/index.mjs", "nitropack": "dist/cli/index.mjs" } }, "sha512-sJiG/MIQlZCVSw2cQrFG1H6mLeSqHlYfFerRjLKz69vUfdu0EL2l0WdOxlQbzJr3mMv/l4cOlCCLzVRzjzzF/g=="], @@ -1105,6 +1160,8 @@ "openapi-typescript": ["openapi-typescript@7.6.1", "", { "dependencies": { "@redocly/openapi-core": "^1.28.0", "ansi-colors": "^4.1.3", "change-case": "^5.4.4", "parse-json": "^8.1.0", "supports-color": "^9.4.0", "yargs-parser": "^21.1.1" }, "peerDependencies": { "typescript": "^5.x" }, "bin": { "openapi-typescript": "bin/cli.js" } }, "sha512-F7RXEeo/heF3O9lOXo2bNjCOtfp7u+D6W3a3VNEH2xE6v+fxLtn5nq0uvUcA1F5aT+CMhNeC5Uqtg5tlXFX/ag=="], + "outvariant": ["outvariant@1.4.3", "", {}, "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA=="], + "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], "parse-json": ["parse-json@8.1.0", "", { "dependencies": { "@babel/code-frame": "^7.22.13", "index-to-position": "^0.1.2", "type-fest": "^4.7.1" } }, "sha512-rum1bPifK5SSar35Z6EKZuYPJx85pkNaFrxBK3mwdfSJ1/WKbYrjoW/zTPSjRRamfmVX1ACBIdFAO0VRErW/EA=="], @@ -1137,6 +1194,10 @@ "pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="], + "playwright": ["playwright@1.50.1", "", { "dependencies": { "playwright-core": "1.50.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-G8rwsOQJ63XG6BbKj2w5rHeavFjy5zynBA9zsJMMtBoe/Uf757oG12NXz6e6OirF7RCrTVAKFXbLmn1RbL7Qaw=="], + + "playwright-core": ["playwright-core@1.50.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-ra9fsNWayuYumt+NiM069M6OkcRb1FZSK8bgi66AtpFoWkg2+y0bJSNmkFrWhMbEBbVKC/EruAHH3g0zmtwGmQ=="], + "pluralize": ["pluralize@8.0.0", "", {}, "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA=="], "postcss": ["postcss@8.5.2", "", { "dependencies": { "nanoid": "^3.3.8", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-MjOadfU3Ys9KYoX0AdkBlFEF1Vx37uCCeN4ZHnmwm9FfpbsGWMZeBLMmmpY+6Ocqod7mkdZ0DT31OlbsFrLlkA=="], @@ -1151,8 +1212,12 @@ "property-information": ["property-information@6.5.0", "", {}, "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig=="], + "psl": ["psl@1.15.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w=="], + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "querystringify": ["querystringify@2.2.0", "", {}, "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ=="], + "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], "radix3": ["radix3@1.1.2", "", {}, "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA=="], @@ -1261,6 +1326,8 @@ "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + "sirv": ["sirv@3.0.1", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-FoqMu0NCGBLCcAkS1qA+XJIQTR6/JHfQXl+uGteNCQ76T91DMUjPa9xfmeqMY3z80nLSg9yQmNjK0Px6RWsH/A=="], + "sitemap": ["sitemap@8.0.0", "", { "dependencies": { "@types/node": "^17.0.5", "@types/sax": "^1.2.1", "arg": "^5.0.0", "sax": "^1.2.4" }, "bin": { "sitemap": "dist/cli.js" } }, "sha512-+AbdxhM9kJsHtruUF39bwS/B0Fytw6Fr1o4ZAIAEqA6cke2xcoO2GleBw9Zw7nRzILVEgz7zBM5GiTJjie1G9A=="], "slash": ["slash@5.1.0", "", {}, "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg=="], @@ -1271,7 +1338,7 @@ "solid-icons": ["solid-icons@1.1.0", "", { "peerDependencies": { "solid-js": "*" } }, "sha512-IesTfr/F1ElVwH2E1110s2RPXH4pujKfSs+koT8rwuTAdleO5s26lNSpqJV7D1+QHooJj18mcOiz2PIKs0ic+A=="], - "solid-js": ["solid-js@1.9.4", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "^1.1.0", "seroval-plugins": "^1.1.0" } }, "sha512-ipQl8FJ31bFUoBNScDQTG3BjN6+9Rg+Q+f10bUbnO6EOTTf5NGerJeHc7wyu5I4RMHEl/WwZwUmy/PTRgxxZ8g=="], + "solid-js": ["solid-js@1.9.5", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "^1.1.0", "seroval-plugins": "^1.1.0" } }, "sha512-ogI3DaFcyn6UhYhrgcyRAMbu/buBJitYQASZz5WzfQVPP10RD2AbCoRZ517psnezrasyCbWzIxZ6kVqet768xw=="], "solid-refresh": ["solid-refresh@0.6.3", "", { "dependencies": { "@babel/generator": "^7.23.6", "@babel/helper-module-imports": "^7.22.15", "@babel/types": "^7.23.6" }, "peerDependencies": { "solid-js": "^1.3" } }, "sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA=="], @@ -1297,6 +1364,8 @@ "streamx": ["streamx@2.22.0", "", { "dependencies": { "fast-fifo": "^1.3.2", "text-decoder": "^1.1.0" }, "optionalDependencies": { "bare-events": "^2.2.0" } }, "sha512-sLh1evHOzBy/iWRiR6d1zRcLao4gGZr3C1kzNz4fopCOKJb6xD9ub8Mpi9Mr1R6id5o43S+d93fI48UC5uM9aw=="], + "strict-event-emitter": ["strict-event-emitter@0.5.1", "", {}, "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ=="], + "string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], "string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -1359,6 +1428,8 @@ "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="], + "tough-cookie": ["tough-cookie@5.1.1", "", { "dependencies": { "tldts": "^6.1.32" } }, "sha512-Ek7HndSVkp10hmHP9V4qZO1u+pn1RU5sI0Fw+jCU3lyvuMZcgqsNgc6CmJJZyByK4Vm/qotGRJlfgAX8q+4JiA=="], "tr46": ["tr46@5.0.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g=="], @@ -1375,7 +1446,7 @@ "type-detect": ["type-detect@4.0.8", "", {}, "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g=="], - "type-fest": ["type-fest@2.19.0", "", {}, "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA=="], + "type-fest": ["type-fest@4.34.1", "", {}, "sha512-6kSc32kT0rbwxD6QL1CYe8IqdzN/J/ILMrNK+HMQCKH3insCDRY/3ITb0vcBss0a3t72fzh2YSzj8ko1HgwT3g=="], "typescript": ["typescript@5.7.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw=="], @@ -1427,6 +1498,8 @@ "uri-js-replace": ["uri-js-replace@1.0.1", "", {}, "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g=="], + "url-parse": ["url-parse@1.5.10", "", { "dependencies": { "querystringify": "^2.1.1", "requires-port": "^1.0.0" } }, "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ=="], + "urlpattern-polyfill": ["urlpattern-polyfill@8.0.2", "", {}, "sha512-Qp95D4TPJl1kC9SKigDcqgyM2VDVO4RiJc2d4qe5GrYm+zbIQCWWKAFaJNQ4BhdFeDGwBmAxqJBwWSJDb9T3BQ=="], "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], @@ -1443,7 +1516,7 @@ "vite": ["vite@6.1.0", "", { "dependencies": { "esbuild": "^0.24.2", "postcss": "^8.5.1", "rollup": "^4.30.1" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-RjjMipCKVoR4hVfPY6GQTgveinjNuyLw+qruksLDvA5ktI1150VmcMBKmQaEWJhg/j6Uaf6dNCNA0AfdzUb/hQ=="], - "vite-node": ["vite-node@3.0.6", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.0", "es-module-lexer": "^1.6.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-s51RzrTkXKJrhNbUzQRsarjmAae7VmMPAsRT7lppVpIg6mK3zGthP9Hgz0YQQKuNcF+Ii7DfYk3Fxz40jRmePw=="], + "vite-node": ["vite-node@3.0.7", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.0", "es-module-lexer": "^1.6.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-2fX0QwX4GkkkpULXdT1Pf4q0tC1i1lFOyseKoonavXUNlQ77KpW2XqBGGNIm/J4Ows4KxgGJzDguYVPKwG/n5A=="], "vite-plugin-solid": ["vite-plugin-solid@2.11.2", "", { "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-/OXVasW5OIRSFXnqzMgm8X3hPvf+JTbGecjQhmk7QnbDFq4hqdLssuYAWw9GsJGfzUPiMHM3ME2Y2XHPsTWmkw=="], @@ -1451,7 +1524,7 @@ "vitefu": ["vitefu@1.0.5", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0" }, "optionalPeers": ["vite"] }, "sha512-h4Vflt9gxODPFNGPwp4zAMZRpZR7eslzwH2c5hn5kNZ5rhnKyRJ50U+yGCdc2IRaBs8O4haIgLNGrV5CrpMsCA=="], - "vitest": ["vitest@3.0.6", "", { "dependencies": { "@vitest/expect": "3.0.6", "@vitest/mocker": "3.0.6", "@vitest/pretty-format": "^3.0.6", "@vitest/runner": "3.0.6", "@vitest/snapshot": "3.0.6", "@vitest/spy": "3.0.6", "@vitest/utils": "3.0.6", "chai": "^5.2.0", "debug": "^4.4.0", "expect-type": "^1.1.0", "magic-string": "^0.30.17", "pathe": "^2.0.3", "std-env": "^3.8.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinypool": "^1.0.2", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0", "vite-node": "3.0.6", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.0.6", "@vitest/ui": "3.0.6", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-/iL1Sc5VeDZKPDe58oGK4HUFLhw6b5XdY1MYawjuSaDA4sEfYlY9HnS6aCEG26fX+MgUi7MwlduTBHHAI/OvMA=="], + "vitest": ["vitest@3.0.7", "", { "dependencies": { "@vitest/expect": "3.0.7", "@vitest/mocker": "3.0.7", "@vitest/pretty-format": "^3.0.7", "@vitest/runner": "3.0.7", "@vitest/snapshot": "3.0.7", "@vitest/spy": "3.0.7", "@vitest/utils": "3.0.7", "chai": "^5.2.0", "debug": "^4.4.0", "expect-type": "^1.1.0", "magic-string": "^0.30.17", "pathe": "^2.0.3", "std-env": "^3.8.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinypool": "^1.0.2", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0", "vite-node": "3.0.7", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.0.7", "@vitest/ui": "3.0.7", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-IP7gPK3LS3Fvn44x30X1dM9vtawm0aesAa2yBIZ9vQf+qB69NXC5776+Qmcr7ohUXIQuLhk7xQR0aSUIDPqavg=="], "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], @@ -1483,7 +1556,7 @@ "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], - "ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], + "ws": ["ws@8.18.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w=="], "xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="], @@ -1499,6 +1572,8 @@ "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], + "yoctocolors-cjs": ["yoctocolors-cjs@2.1.2", "", {}, "sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA=="], + "zip-stream": ["zip-stream@6.0.1", "", { "dependencies": { "archiver-utils": "^5.0.0", "compress-commons": "^6.0.2", "readable-stream": "^4.0.0" } }, "sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA=="], "zod": ["zod@3.24.1", "", {}, "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A=="], @@ -1511,8 +1586,12 @@ "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@bundled-es-modules/tough-cookie/tough-cookie": ["tough-cookie@4.1.4", "", { "dependencies": { "psl": "^1.1.33", "punycode": "^2.1.1", "universalify": "^0.2.0", "url-parse": "^1.5.3" } }, "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag=="], + "@cloudflare/kv-asset-handler/mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], + "@inquirer/core/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], + "@mapbox/node-pre-gyp/detect-libc": ["detect-libc@2.0.3", "", {}, "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw=="], "@parcel/watcher-wasm/napi-wasm": ["napi-wasm@1.1.3", "", { "bundled": true }, "sha512-h/4nMGsHjZDCYmQVNODIrYACVJ+I9KItbG+0si6W/jSjdA9JbWDoU4LLeMXVcEQGHjttI2tuXqDrbGF7qkUHHg=="], @@ -1547,6 +1626,8 @@ "ansi-align/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="], + "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "archiver-utils/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], @@ -1555,12 +1636,16 @@ "boxen/chalk": ["chalk@5.4.1", "", {}, "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w=="], + "boxen/type-fest": ["type-fest@2.19.0", "", {}, "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA=="], + "c12/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], "c12/jiti": ["jiti@2.4.2", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A=="], "c12/pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="], + "chokidar/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], @@ -1575,8 +1660,6 @@ "dax-sh/undici-types": ["undici-types@5.28.4", "", {}, "sha512-3OeMF5Lyowe8VW0skf5qaIE7Or3yS9LS7fvMUI0gg4YxpIBVg0L8BxCmROw2CcYhSkpR68Epz7CGc8MPj94Uww=="], - "dot-prop/type-fest": ["type-fest@4.34.1", "", {}, "sha512-6kSc32kT0rbwxD6QL1CYe8IqdzN/J/ILMrNK+HMQCKH3insCDRY/3ITb0vcBss0a3t72fzh2YSzj8ko1HgwT3g=="], - "fs-minipass/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], "giget/pathe": ["pathe@2.0.2", "", {}, "sha512-15Ztpk+nov8DR524R4BF7uEuzESgzUEAV4Ah7CUMNGXdE5ELuvxElxGXndBl32vMSsWa1jpNf22Z+Er3sKwq+w=="], @@ -1589,6 +1672,8 @@ "is-inside-container/is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], + "jsdom/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], + "lazystream/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], "listhen/@parcel/watcher-wasm": ["@parcel/watcher-wasm@2.5.1", "", { "dependencies": { "is-glob": "^4.0.3", "micromatch": "^4.0.5", "napi-wasm": "^1.1.0" } }, "sha512-RJxlQQLkaMMIuWRozy+z2vEqbaQlCuaCgVZIUCzQLYggY22LZbP5Y1+ia+FD724Ids9e+XIyOLXLrLgQSHIthw=="], @@ -1617,8 +1702,6 @@ "openapi-typescript/supports-color": ["supports-color@9.4.0", "", {}, "sha512-VL+lNrEoIXww1coLPOmiEmK/0sGigko5COxI09KzHc2VJXJsQ37UaQ+8quuxjDeA7+KnLGTWRyOXSLLR2Wb4jw=="], - "parse-json/type-fest": ["type-fest@4.34.1", "", {}, "sha512-6kSc32kT0rbwxD6QL1CYe8IqdzN/J/ILMrNK+HMQCKH3insCDRY/3ITb0vcBss0a3t72fzh2YSzj8ko1HgwT3g=="], - "pkg-types/pathe": ["pathe@2.0.2", "", {}, "sha512-15Ztpk+nov8DR524R4BF7uEuzESgzUEAV4Ah7CUMNGXdE5ELuvxElxGXndBl32vMSsWa1jpNf22Z+Er3sKwq+w=="], "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], @@ -1627,6 +1710,8 @@ "readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "rollup/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "rollup-plugin-visualizer/source-map": ["source-map@0.7.4", "", {}, "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA=="], "send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], @@ -1669,6 +1754,8 @@ "vite/esbuild": ["esbuild@0.24.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.24.2", "@esbuild/android-arm": "0.24.2", "@esbuild/android-arm64": "0.24.2", "@esbuild/android-x64": "0.24.2", "@esbuild/darwin-arm64": "0.24.2", "@esbuild/darwin-x64": "0.24.2", "@esbuild/freebsd-arm64": "0.24.2", "@esbuild/freebsd-x64": "0.24.2", "@esbuild/linux-arm": "0.24.2", "@esbuild/linux-arm64": "0.24.2", "@esbuild/linux-ia32": "0.24.2", "@esbuild/linux-loong64": "0.24.2", "@esbuild/linux-mips64el": "0.24.2", "@esbuild/linux-ppc64": "0.24.2", "@esbuild/linux-riscv64": "0.24.2", "@esbuild/linux-s390x": "0.24.2", "@esbuild/linux-x64": "0.24.2", "@esbuild/netbsd-arm64": "0.24.2", "@esbuild/netbsd-x64": "0.24.2", "@esbuild/openbsd-arm64": "0.24.2", "@esbuild/openbsd-x64": "0.24.2", "@esbuild/sunos-x64": "0.24.2", "@esbuild/win32-arm64": "0.24.2", "@esbuild/win32-ia32": "0.24.2", "@esbuild/win32-x64": "0.24.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA=="], + "vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "wrap-ansi/ansi-styles": ["ansi-styles@6.2.1", "", {}, "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="], "wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -1677,6 +1764,12 @@ "yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "@bundled-es-modules/tough-cookie/tough-cookie/universalify": ["universalify@0.2.0", "", {}, "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg=="], + + "@inquirer/core/wrap-ansi/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "@inquirer/core/wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "@vercel/nft/glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], "ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], @@ -1813,6 +1906,8 @@ "yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "@inquirer/core/wrap-ansi/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "@vercel/nft/glob/minimatch/brace-expansion": ["brace-expansion@1.1.11", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="], "giget/tar/minizlib/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], diff --git a/package.json b/package.json index d8ac624..ae5ab32 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ }, "dependencies": { "@solid-primitives/clipboard": "^1.6.0", + "@solid-primitives/context": "^0.3.0", "@solid-primitives/destructure": "^0.2.0", "@solid-primitives/event-listener": "^2.4.0", "@solid-primitives/i18n": "^2.2.0", @@ -29,7 +30,7 @@ "remark-stringify": "^11.0.0", "sitemap": "^8.0.0", "solid-icons": "^1.1.0", - "solid-js": "^1.9.4", + "solid-js": "^1.9.5", "ts-pattern": "^5.6.2", "unified": "^11.0.5", "unist-util-find": "^3.0.0", @@ -37,21 +38,23 @@ "vinxi": "^0.5.3" }, "devDependencies": { - "@happy-dom/global-registrator": "^17.1.1", + "@happy-dom/global-registrator": "^17.1.8", "@sinonjs/fake-timers": "^14.0.0", "@solidjs/testing-library": "^0.8.10", "@testing-library/jest-dom": "^6.6.3", "@testing-library/user-event": "^14.6.1", "@types/sinonjs__fake-timers": "^8.1.5", "@types/wicg-file-system-access": "^2023.10.5", - "@vitest/coverage-istanbul": "3.0.6", - "@vitest/coverage-v8": "3.0.6", - "bun-types": "^1.2.2", + "@vitest/browser": "^3.0.7", + "@vitest/coverage-istanbul": "3.0.7", + "@vitest/coverage-v8": "3.0.7", + "bun-types": "^1.2.3", "jsdom": "^26.0.0", + "playwright": "^1.50.1", "solid-devtools": "^0.33.0", "vite-plugin-solid": "^2.11.2", "vite-plugin-solid-svg": "^0.8.1", - "vitest": "^3.0.6", + "vitest": "^3.0.7", "workbox-window": "^7.3.0" }, "scripts": { @@ -59,7 +62,7 @@ "build": "vinxi build", "start": "vinxi start", "version": "vinxi version", - "test": "vitest --coverage", + "test": "vitest --coverage --browser=chromium", "test:ci": "vitest run" } } \ No newline at end of file diff --git a/src/components/textarea/textarea.tsx b/src/components/textarea/textarea.tsx index 9d987b1..39c20f8 100644 --- a/src/components/textarea/textarea.tsx +++ b/src/components/textarea/textarea.tsx @@ -1,12 +1,13 @@ import { Component, createEffect, createMemo, createSignal, For, on, untrack } from 'solid-js'; import { createSelection, getTextNodes } from '@solid-primitives/selection'; import { isServer } from 'solid-js/web'; -import { createEditContext } from '~/features/edit-context'; +import { createEditContext } from '~/features/editor'; import { createSource } from '~/features/source'; import css from './textarea.module.css'; interface TextareaProps { class?: string; + title?: string; value: string; lang: string; placeholder?: string; @@ -20,28 +21,33 @@ export function Textarea(props: TextareaProps) { const source = createSource(() => props.value); const [text] = createEditContext(editorRef, () => source.out); + createEffect(() => { + source.out = text(); + }); + createEffect(() => { props.oninput?.(source.in); }); - createEffect(on(() => [editorRef(), source.spellingErrors] as const, ([ref, errors]) => { + createEffect(on(() => [editorRef()!, source.spellingErrors] as const, ([ref, errors]) => { createHighlights(ref, 'spelling-error', errors); })); - createEffect(on(() => [editorRef(), source.grammarErrors] as const, ([ref, errors]) => { + createEffect(on(() => [editorRef()!, source.grammarErrors] as const, ([ref, errors]) => { createHighlights(ref, 'grammar-error', errors); })); - createEffect(on(() => [editorRef(), source.queryResults] as const, ([ref, errors]) => { + createEffect(on(() => [editorRef()!, source.queryResults] as const, ([ref, errors]) => { createHighlights(ref, 'search-results', errors); })); return <> - source.query = e.target.value} /> + source.query = e.target.value} />
" && 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/editor/__screenshots__/context.spec.tsx/createEditor-should-create-1.png b/src/features/editor/__screenshots__/context.spec.tsx/createEditor-should-create-1.png new file mode 100644 index 0000000000000000000000000000000000000000..d550b179e792a20f920f7d097106d1693bee6d8c GIT binary patch literal 21680 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 { + describe('create', () => { + it('should create', async () => { + // Arrange + const res = render(() =>

); + const ref = await res.findByTestId('editor'); + + // Act + const actual = createEditor( + () => ref, + () => '

this is a string

' + ); + + // Assert + expect(actual).toBeTruthy(); + }); + + it('should update after a change has taken place', async () => { + // Arrange + const [value, setValue] = createSignal('

this is a string

'); + + const res = render(() => { + const [ref, setRef] = createSignal(); + + const [text] = createEditor(ref, value); + + return
; + }); + const ref = await res.findByTestId('editor'); + + // Act + setValue('

this is another totally different string

'); + + // Assert + expect(ref.innerHTML).toBe('

this is another totally different string

'); + }); + }); + + describe('selection', () => { + it('should not fail if there are no selection ranges', async () => { + // Arrange + const res = render(() => { + const [ref, setRef] = createSignal(); + + const [text] = createEditor(ref, () => '

paragraph 1

\n

paragraph 2

\n

paragraph 3

'); + + return
; + }); + + const ref = await res.findByTestId('editor'); + + // Act + window.getSelection()!.removeAllRanges(); + + // Assert + expect(true).toBeTruthy(); + }); + + it('should react to changes in selection', async () => { + // Arrange + const res = render(() => { + const [ref, setRef] = createSignal(); + + const [text] = createEditor(ref, () => '

paragraph 1

\n

paragraph 2

\n

paragraph 3

'); + + return
; + }); + + const ref = await res.findByTestId('editor'); + + // Act + ref.focus(); + window.getSelection()!.setBaseAndExtent(ref.childNodes[0].childNodes[0], 0, ref.childNodes[0].childNodes[0], 10); + + console.log(window.getSelection()!.rangeCount); + + // Assert + expect(true).toBeTruthy(); + }); + }); +}); \ No newline at end of file diff --git a/src/features/edit-context/context.ts b/src/features/editor/context.ts similarity index 57% rename from src/features/edit-context/context.ts rename to src/features/editor/context.ts index cbebf4b..9a80f65 100644 --- a/src/features/edit-context/context.ts +++ b/src/features/editor/context.ts @@ -1,18 +1,17 @@ import { createEventListenerMap, DocumentEventListener, WindowEventListener } from "@solid-primitives/event-listener"; -import { Accessor, createEffect, createMemo, onMount } from "solid-js"; +import { Accessor, createEffect, createMemo, onMount, untrack } 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 { createMap } from './map'; +import { splice } from "~/utilities"; import rehypeParse from "rehype-parse"; -type EditContext = [Accessor]; +type Editor = [Accessor]; -export function createEditContext(ref: Accessor, value: Accessor): EditContext { +export function createEditor(ref: Accessor, value: Accessor): Editor { if (isServer) { - return [createMemo(() => value())]; + return [value]; } if (!("EditContext" in window)) { @@ -34,51 +33,15 @@ export function createEditContext(ref: Accessor, value: }); 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()); - }); + const indexMap = createMap(() => ref()!, ast); createEventListenerMap(context, { textupdate(e: TextUpdateEvent) { - const { updateRangeStart: start, updateRangeEnd: end } = e; + const { updateRangeStart: start, updateRangeEnd: end, text } = e; - setStore('text', `${store.text.slice(0, start)}${e.text}${store.text.slice(end)}`); + setStore('text', `${store.text.slice(0, start)}${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); + context.updateSelection(start + text.length, start + text.length); }, compositionstart() { @@ -102,37 +65,43 @@ export function createEditContext(ref: Accessor, value: }, }); + function updateText(start: number, end: number, text: string) { + context.updateText(start, end, text); + + setStore('text', splice(store.text, start, end, text)); + + context.updateSelection(start + text.length, start + text.length); + } + 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.updateSelection(...indexMap.toHtmlIndices(range)); context.updateSelectionBounds(range.getBoundingClientRect()); - setSelection([ref()!, start, end]); + queueMicrotask(() => { + const selection = window.getSelection(); + + if (selection === null) { + return; + } + + if (selection.rangeCount !== 0) { + const existingRange = selection.getRangeAt(0); + + if (equals(range, existingRange)) { + return; + } + + selection.removeAllRanges(); + } + + console.log('is it me?'); + + selection.addRange(range); + }); } WindowEventListener({ @@ -149,27 +118,28 @@ export function createEditContext(ref: Accessor, value: return; } - const start = context.selectionStart; - const end = context.selectionEnd; + const start = Math.min(context.selectionStart, context.selectionEnd); + let end = Math.max(context.selectionStart, context.selectionEnd); if (e.key === 'Tab') { e.preventDefault(); - context.updateText(start, end, '\t'); - // updateSelection(start + 1, start + 1); + updateText(start, end, '    '); } else if (e.key === 'Enter') { - context.updateText(start, end, '\n'); - - // updateSelection(start + 1, start + 1); + updateText(start, end, '\n'); } }, }); DocumentEventListener({ onSelectionchange(e) { - const selection = document.getSelection()!; + const selection = document.getSelection(); - if (selection.rangeCount < 1) { + if (selection === null) { + return; + } + + if (selection.rangeCount === 0) { return; } @@ -185,7 +155,7 @@ export function createEditContext(ref: Accessor, value: updateControlBounds(); }); - createEffect((last?: HTMLElement) => { + createEffect((last?: Element) => { if (last !== undefined) { last.editContext = undefined; } @@ -202,14 +172,31 @@ export function createEditContext(ref: Accessor, value: }); createEffect(() => { - context.updateText(0, 0, value()); + updateText(0, -0, value()); + }); + + createEffect(() => { + store.text; + + if (document.activeElement === untrack(ref)) { + queueMicrotask(() => { + console.log(); + + updateSelection(indexMap.toRange(context.selectionStart, context.selectionEnd)); + }); + } }); return [createMemo(() => store.text)]; } +const equals = (a: Range, b: Range): boolean => { + const keys: (keyof Range)[] = ['startOffset', 'endOffset', 'commonAncestorContainer', 'startContainer', 'endContainer']; + return keys.every(key => a[key] === b[key]); +} + declare global { - interface HTMLElement { + interface Element { editContext?: EditContext; } @@ -275,50 +262,4 @@ declare global { } 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 +} \ No newline at end of file diff --git a/src/features/editor/editor.tsx b/src/features/editor/editor.tsx new file mode 100644 index 0000000..870e72e --- /dev/null +++ b/src/features/editor/editor.tsx @@ -0,0 +1,20 @@ +import { createContextProvider } from "@solid-primitives/context"; +import { createEffect, ParentProps } from "solid-js"; +import { createEditor } from "./context"; + + +const [EditorProvider, useEditor] = createContextProvider((props: { ref: Element, value: string }) => { + const [text] = createEditor(() => props.ref, () => props.value); + + createEffect(() => { + console.log(text()); + }); + + return { text }; +}); + +export { useEditor }; + +export function Editor(props: ParentProps<{ ref: Element, value: string }>) { + return {props.children}; +} \ No newline at end of file diff --git a/src/features/editor/index.tsx b/src/features/editor/index.tsx new file mode 100644 index 0000000..dd45223 --- /dev/null +++ b/src/features/editor/index.tsx @@ -0,0 +1,4 @@ + + +export { createEditor as createEditContext } from './context'; +export { Editor, useEditor } from './editor'; \ No newline at end of file diff --git a/src/features/editor/map.ts b/src/features/editor/map.ts new file mode 100644 index 0000000..981fc9b --- /dev/null +++ b/src/features/editor/map.ts @@ -0,0 +1,114 @@ +import type { Root, Text } from 'hast'; +import { getTextNodes } from '@solid-primitives/selection'; +import { Accessor, createEffect, createMemo, createSignal, onCleanup } from "solid-js"; +import { visit } from 'unist-util-visit'; + +type IndexNode = { node: Node, text: { start: number, end: number }, html: { start: number, end: number } }; +type IndexMap = IndexNode[]; + +export function createMap(root: Accessor, ast: Accessor) { + // Observe the element so that the references to the nodes in the indices are updated if the DOM is changed + const latestMutations = observe(root); + + const indices = createMemo(() => { + latestMutations(); + + const node = root(); + + if (node === undefined) { + return []; + } + + return createIndices(node, ast()); + }); + + return { + atHtmlPosition(index: number) { + return indices().find(({ html }) => html.start <= index && html.end >= index); + }, + + toTextIndices(range: Range): [number, number] { + const startNode = indices().find(({ node }) => node === range.startContainer); + const endNode = indices().find(({ node }) => node === range.endContainer); + + return [ + startNode ? (startNode.text.start + range.startOffset) : -1, + endNode ? (endNode.text.start + range.endOffset) : -1 + ]; + }, + + toHtmlIndices(range: Range): [number, number] { + const startNode = indices().find(({ node }) => node === range.startContainer); + const endNode = indices().find(({ node }) => node === range.endContainer); + + return [ + startNode ? (startNode.html.start + range.startOffset) : -1, + endNode ? (endNode.html.start + range.endOffset) : -1 + ]; + }, + + toRange(start: number, end: number): Range { + const startNode = indices().find(({ html }) => html.start <= start && html.end >= start); + const endNode = indices().find(({ html }) => html.start <= end && html.end >= end); + + const range = new Range(); + + if (startNode) { + const offset = start - startNode.html.start; + + range.setStart(startNode.node, offset); + } + + if (endNode) { + const offset = end - endNode.html.start; + + range.setEnd(endNode.node, offset); + } + + return range; + }, + }; +} + +const createIndices = (root: Node, ast: Root): IndexMap => { + const nodes = getTextNodes(root); + const indices: IndexMap = []; + + 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 observe = (node: Accessor): Accessor => { + const [mutations, setMutations] = createSignal([]); + + const observer = new MutationObserver(records => { + setMutations(records); + }); + + createEffect(() => { + const n = node(); + + observer.disconnect(); + + if (n) { + observer.observe(n, { characterData: true, subtree: true, childList: true }); + } + }); + + onCleanup(() => { + observer.disconnect(); + }); + + return mutations; +}; \ No newline at end of file diff --git a/src/features/source/source.spec.ts b/src/features/source/source.spec.ts index 5d5a4a4..59a7cc4 100644 --- a/src/features/source/source.spec.ts +++ b/src/features/source/source.spec.ts @@ -10,7 +10,7 @@ describe('Source', () => { // Arrange // Act - const actual = createSource(''); + const actual = createSource(() => ''); // Assert expect(actual.out).toBe(''); @@ -22,7 +22,7 @@ describe('Source', () => { const expected = '

text

'; // Act - const actual = createSource(given); + const actual = createSource(() => given); // Assert expect(actual.out).toBe(expected); @@ -31,7 +31,7 @@ describe('Source', () => { it('should contain query results', () => { // Arrange const expected: [number, number][] = [[8, 9], [12, 13], [15, 16]]; - const source = createSource('this is a seachable string'); + const source = createSource(() => 'this is a seachable string'); // Act source.query = 'a'; diff --git a/src/routes/(editor)/experimental/formatter.tsx b/src/routes/(editor)/experimental/formatter.tsx index ce40a6d..a42441a 100644 --- a/src/routes/(editor)/experimental/formatter.tsx +++ b/src/routes/(editor)/experimental/formatter.tsx @@ -2,6 +2,7 @@ import { createSignal } from "solid-js"; import { debounce } from "@solid-primitives/scheduled"; import { Textarea } from "~/components/textarea"; import css from './formatter.module.css'; +import { Editor } from "~/features/editor"; const tempVal = ` # Header @@ -37,7 +38,7 @@ export default function Formatter(props: {}) { }, 300); return
- - + - - + {/* - + */} - {/* - {/* - - */} - + - - - +
+ + + + +
; } +function Toolbar() { + const { mutate, selection } = useEditor(); + + const bold = () => { + console.log('toggle text bold', selection()); + }; + + return
+ +
+} + function SearchAndReplace() { const { mutate, source } = useEditor(); const [replacement, setReplacement] = createSignal(''); + const [term, setTerm] = createSignal(''); + const [caseInsensitive, setCaseInsensitive] = createSignal(true); - const replace = () => { - mutate(text => text.replaceAll(source.query, replacement())); + const query = createMemo(() => new RegExp(term(), caseInsensitive() ? 'gi' : 'g')); + + createEffect(() => { + source.query = query(); + }); + + const replace = (e: SubmitEvent) => { + e.preventDefault(); + + const form = e.target as HTMLFormElement; + form.reset(); + + mutate(text => text.replaceAll(query(), replacement())); }; - return
- source.query = e.target.value} /> - setReplacement(e.target.value)} /> - + return + + + + +
; }; \ No newline at end of file From 97036272dd9491ff999a6f13e156b83db312ea32 Mon Sep 17 00:00:00 2001 From: Chris Kruining Date: Mon, 10 Mar 2025 16:48:37 +0100 Subject: [PATCH 12/16] made a start on mutating the AST --- bun.lock | 4 + package.json | 2 + src/components/textarea/textarea.tsx | 128 +++----------------- src/features/editor/ast.spec.ts | 0 src/features/editor/ast.ts | 93 ++++++++++++++ src/features/editor/context.ts | 44 ++++--- src/features/editor/editor.tsx | 11 +- src/features/editor/index.tsx | 3 +- src/features/editor/map.ts | 63 ++++++---- src/features/editor/state.ts | 43 +++++++ src/routes/(editor)/experimental/editor.tsx | 89 +++++++++++++- src/utilities.ts | 3 +- 12 files changed, 318 insertions(+), 165 deletions(-) create mode 100644 src/features/editor/ast.spec.ts create mode 100644 src/features/editor/ast.ts create mode 100644 src/features/editor/state.ts diff --git a/bun.lock b/bun.lock index 21f03d8..54675bb 100644 --- a/bun.lock +++ b/bun.lock @@ -32,8 +32,10 @@ "solid-js": "^1.9.5", "ts-pattern": "^5.6.2", "unified": "^11.0.5", + "unist-util-ancestor": "^1.4.3", "unist-util-find": "^3.0.0", "unist-util-visit": "^5.0.0", + "unist-util-visit-parents": "^6.0.1", "vinxi": "^0.5.3", }, "devDependencies": { @@ -1471,6 +1473,8 @@ "unimport": ["unimport@3.14.6", "", { "dependencies": { "@rollup/pluginutils": "^5.1.4", "acorn": "^8.14.0", "escape-string-regexp": "^5.0.0", "estree-walker": "^3.0.3", "fast-glob": "^3.3.3", "local-pkg": "^1.0.0", "magic-string": "^0.30.17", "mlly": "^1.7.4", "pathe": "^2.0.1", "picomatch": "^4.0.2", "pkg-types": "^1.3.0", "scule": "^1.3.0", "strip-literal": "^2.1.1", "unplugin": "^1.16.1" } }, "sha512-CYvbDaTT04Rh8bmD8jz3WPmHYZRG/NnvYVzwD6V1YAlvvKROlAeNDUBhkBGzNav2RKaeuXvlWYaa1V4Lfi/O0g=="], + "unist-util-ancestor": ["unist-util-ancestor@1.4.3", "", { "dependencies": { "unist-util-visit-parents": "^6.0.1" } }, "sha512-UUllGrozJ4w/zms9+sUMqmmHTEiCUnvoXu8AkEtrrUhfD9RCwUzEjubObNFpLasm+jW/JFFn3kZvVRS4xAtvtg=="], + "unist-util-find": ["unist-util-find@3.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "lodash.iteratee": "^4.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-T7ZqS7immLjYyC4FCp2hDo3ksZ1v+qcbb+e5+iWxc2jONgHOLXPCpms1L8VV4hVxCXgWTxmBHDztuEZFVwC+Gg=="], "unist-util-find-after": ["unist-util-find-after@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ=="], diff --git a/package.json b/package.json index d65e4b7..17b361d 100644 --- a/package.json +++ b/package.json @@ -34,8 +34,10 @@ "solid-js": "^1.9.5", "ts-pattern": "^5.6.2", "unified": "^11.0.5", + "unist-util-ancestor": "^1.4.3", "unist-util-find": "^3.0.0", "unist-util-visit": "^5.0.0", + "unist-util-visit-parents": "^6.0.1", "vinxi": "^0.5.3" }, "devDependencies": { diff --git a/src/components/textarea/textarea.tsx b/src/components/textarea/textarea.tsx index 81a2507..fbfc51e 100644 --- a/src/components/textarea/textarea.tsx +++ b/src/components/textarea/textarea.tsx @@ -1,9 +1,8 @@ -import { Component, createEffect, createMemo, createSignal, For, on, onMount, untrack } from 'solid-js'; +import { createEffect, createSignal, on, onMount } from 'solid-js'; import { debounce } from '@solid-primitives/scheduled'; import { createSelection, getTextNodes } from '@solid-primitives/selection'; import { createSource } from '~/features/source'; import css from './textarea.module.css'; -import { debounce } from '@solid-primitives/scheduled'; interface TextareaProps { class?: string; @@ -21,7 +20,7 @@ export function Textarea(props: TextareaProps) { const [editorRef, setEditorRef] = createSignal(); let mounted = false; - const source = createSource(props.value); + const source = createSource(() => props.value); createEffect(on(() => [props.oninput, source.in] as const, ([oninput, text]) => { if (!mounted) { @@ -44,6 +43,8 @@ export function Textarea(props: TextareaProps) { const ref = editorRef(); if (ref) { + console.log(ref.innerHTML); + source.out = ref.innerHTML; ref.style.height = `1px`; @@ -77,116 +78,17 @@ export function Textarea(props: TextareaProps) { createHighlights(ref, 'search-results', errors); })); - return <> - - source.query = e.target.value} /> -
e.stopPropagation()} - on:pointerdown={e => e.stopPropagation()} - /> - ; -} - -const Suggestions: Component = () => { - const [selection] = createSelection(); - const [suggestionRef, setSuggestionRef] = createSignal(); - const [suggestions, setSuggestions] = createSignal([]); - - const marker = createMemo(() => { - if (isServer) { - return; - } - - const [n] = selection(); - const s = window.getSelection(); - - if (n === null || s === null || s.rangeCount < 1) { - return; - } - - return (findMarkerNode(s.getRangeAt(0)?.commonAncestorContainer) ?? undefined) as HTMLElement | undefined; - }); - - createEffect((prev) => { - if (prev) { - prev.style.setProperty('anchor-name', null); - } - - const m = marker(); - const ref = untrack(() => suggestionRef()!); - - if (m === undefined) { - if (ref.matches(':popover-open')) { - ref.hidePopover(); - } - - return; - } - - m.style.setProperty('anchor-name', '--suggestions'); - - if (ref.matches(':not(:popover-open)')) { - ref.showPopover(); - } - - ref.focus() - - return m; - }); - - createEffect(() => { - marker(); - - setSuggestions(Array(Math.ceil(Math.random() * 5)).fill('').map((_, i) => `suggestion ${i}`)); - }); - - const onPointerDown = (e: PointerEvent) => { - marker()?.replaceWith(document.createTextNode(e.target.textContent)); - }; - - const onKeyDown = (e: KeyboardEvent) => { - console.log(e); - } - - return - { - suggestion =>
  • {suggestion}
  • - }
    -
    ; -}; - -const findMarkerNode = (node: Node | null) => { - while (node !== null) { - if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).hasAttribute('data-marker')) { - break; - } - - node = node.parentNode; - } - - 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; - }); - } + return
    e.stopPropagation()} + on:pointerdown={e => e.stopPropagation()} + />; } const createHighlights = (node: Node, type: string, ranges: [number, number][]) => { diff --git a/src/features/editor/ast.spec.ts b/src/features/editor/ast.spec.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/features/editor/ast.ts b/src/features/editor/ast.ts new file mode 100644 index 0000000..e909f24 --- /dev/null +++ b/src/features/editor/ast.ts @@ -0,0 +1,93 @@ +import type { Node, Text, Element, ElementContent, Parent, RootContent } from 'hast'; +import { find } from 'unist-util-find'; +import { visit } from 'unist-util-visit'; +import { deepCopy } from '~/utilities'; + +/** + * + * Given + * root + * |- element + * | |- text [0, 6] + * | |- element + * | | |- text [7, 18] + * | |- text [19, 25] + * |- element + * |- text [26, 40] + * |- element + * | |- text [41, 53] + * |- text [54, 60] + * + * split at 10 + * + * root + * |- element + * | |- text [0, 6] + * | |- element + * | | |- text [7, 9] + * + * root + * |- element + * | |- element + * | | |- text [10, 18] + * | |- text [19, 25] + * |- element + * |- text [26, 40] + * |- element + * | |- text [41, 53] + * |- text [54, 60] + */ + +export const splitAt = (tree: Parent, node: Text, offset: number): [RootContent[], RootContent[]] => { + const index = tree.children.findIndex(c => find(c, { ...node })); + + if (index === -1) { + throw new Error('The tree does not contain the given node'); + } + + const left = tree.children.slice(0, index); + const right = tree.children.slice(index + 1); + + if (offset === 0) { + right.unshift(tree.children[index]); + } + else if (offset === node.value.length) { + left.push(tree.children[index]); + } + else { + const targetLeft = deepCopy(tree.children[index]); + const targetRight = tree.children[index]; + + left.push(targetLeft); + right.unshift(targetRight); + + visit(targetLeft, (n): n is Text => equals(n, node), n => { + n.value = n.value.slice(0, offset); + }) + + visit(targetRight, (n): n is Text => equals(n, node), n => { + n.value = n.value.slice(offset); + }) + } + + return [left, right]; +}; + +const splitNode = (node: Node, offset: number) => { + +} + +const equals = (a: Node, b: Node): boolean => { + if (a === b) { + return true; + } + + if (a.type !== b.type) { + return false; + } + + // This is the nasty version of deep object checking, + // but I hope this is safe to do in this case because + // we are working with a html-ast and not just any type of object. + return JSON.stringify(a) === JSON.stringify(b); +}; \ No newline at end of file diff --git a/src/features/editor/context.ts b/src/features/editor/context.ts index 53af3dd..4868df1 100644 --- a/src/features/editor/context.ts +++ b/src/features/editor/context.ts @@ -2,15 +2,16 @@ import { createEventListenerMap, DocumentEventListener, WindowEventListener } fr import { Accessor, createEffect, createMemo, onMount, untrack } from "solid-js"; import { createStore } from "solid-js/store"; import { isServer } from "solid-js/web"; -import { unified } from "unified"; -import { createMap } from './map'; +import { createMap, IndexRange } from './map'; import { splice } from "~/utilities"; -import rehypeParse from "rehype-parse"; +import { createState } from "./state"; +import type { Root } from 'hast'; -type Editor = [Accessor, { select(range: Range): void, mutate(setter: (text: string) => string): void, readonly selection: Accessor }]; +export type SelectFunction = (range: Range) => void; +export type MutateFunction = (setter: (ast: Root) => Root) => void; +type Editor = [Accessor, { select: SelectFunction, mutate: MutateFunction, readonly selection: Accessor }]; interface EditorStoreType { - text: string; isComposing: boolean; selection: Range | undefined; characterBounds: DOMRect[]; @@ -23,7 +24,7 @@ export function createEditor(ref: Accessor, value: Accessor return [value, { select() { }, mutate() { }, - selection: () => undefined, + selection: () => [undefined, undefined], }]; } @@ -35,8 +36,9 @@ export function createEditor(ref: Accessor, value: Accessor text: value(), }); + const state = createState(value); + const indexMap = createMap(() => ref()!, () => state.ast); const [store, setStore] = createStore({ - text: value(), isComposing: false, selection: undefined, @@ -46,9 +48,6 @@ export function createEditor(ref: Accessor, value: Accessor selectionBounds: new DOMRect(), }); - const ast = createMemo(() => unified().use(rehypeParse).parse(store.text)); - const indexMap = createMap(() => ref()!, ast); - createEventListenerMap(context, { textupdate(e: TextUpdateEvent) { const { updateRangeStart: start, updateRangeEnd: end, text } = e; @@ -82,7 +81,7 @@ export function createEditor(ref: Accessor, value: Accessor function updateText(start: number, end: number, text: string) { context.updateText(start, end, text); - setStore('text', splice(store.text, start, end, text)); + state.text = splice(state.text, start, end, text); context.updateSelection(start + text.length, start + text.length); } @@ -167,6 +166,8 @@ export function createEditor(ref: Accessor, value: Accessor onMount(() => { updateControlBounds(); + + updateSelection(indexMap.toRange(40, 60)) }); createEffect((last?: Element) => { @@ -185,34 +186,43 @@ export function createEditor(ref: Accessor, value: Accessor return el; }); + createEffect(() => { + }); + createEffect(() => { updateText(0, -0, value()); }); createEffect(() => { - store.text; + state.text; if (document.activeElement === untrack(ref)) { queueMicrotask(() => { - console.log(); - updateSelection(indexMap.toRange(context.selectionStart, context.selectionEnd)); }); } }); return [ - createMemo(() => store.text), + createMemo(() => state.text), { select(range: Range) { updateSelection(range); }, mutate(setter) { - setStore('text', setter); + state.ast = setter(state.ast); }, - selection: createMemo(() => store.selection), + selection: createMemo(() => { + const selection = store.selection; + + if (!selection) { + return [undefined, undefined]; + } + + return indexMap.atHtmlPosition(selection); + }), }]; } diff --git a/src/features/editor/editor.tsx b/src/features/editor/editor.tsx index c14be9f..24ce40b 100644 --- a/src/features/editor/editor.tsx +++ b/src/features/editor/editor.tsx @@ -1,16 +1,17 @@ import { createContextProvider } from "@solid-primitives/context"; import { Accessor, createEffect, createMemo, createSignal, on, ParentProps, Setter } from "solid-js"; -import { createEditor } from "./context"; +import { createEditor, MutateFunction, SelectFunction } from "./context"; import { createSource, Source } from "../source"; import { getTextNodes } from "@solid-primitives/selection"; import { isServer } from "solid-js/web"; +import { IndexRange } from "./map"; interface EditorContextType { readonly text: Accessor; - readonly selection: Accessor; + readonly selection: Accessor; readonly source: Source; - select(range: Range): void; - mutate(setter: (prev: string) => string): void; + select: SelectFunction; + mutate: MutateFunction; } interface EditorContextProps extends Record { @@ -52,7 +53,7 @@ const [EditorProvider, useEditor] = createContextProvider '', - selection: () => undefined, + selection: () => [undefined, undefined], source: {} as Source, select() { }, mutate() { }, diff --git a/src/features/editor/index.tsx b/src/features/editor/index.tsx index dd45223..2036329 100644 --- a/src/features/editor/index.tsx +++ b/src/features/editor/index.tsx @@ -1,4 +1,5 @@ export { createEditor as createEditContext } from './context'; -export { Editor, useEditor } from './editor'; \ No newline at end of file +export { Editor, useEditor } from './editor'; +export { splitAt } from './ast'; \ No newline at end of file diff --git a/src/features/editor/map.ts b/src/features/editor/map.ts index 981fc9b..36726ca 100644 --- a/src/features/editor/map.ts +++ b/src/features/editor/map.ts @@ -3,17 +3,16 @@ import { getTextNodes } from '@solid-primitives/selection'; import { Accessor, createEffect, createMemo, createSignal, onCleanup } from "solid-js"; import { visit } from 'unist-util-visit'; -type IndexNode = { node: Node, text: { start: number, end: number }, html: { start: number, end: number } }; -type IndexMap = IndexNode[]; +export type IndexNode = { node: Text, dom: Node, text: { start: number, end: number }, html: { start: number, end: number }, offset: number }; +export type IndexMap = IndexNode[]; +export type IndexRange = [IndexNode, IndexNode] | [undefined, undefined]; export function createMap(root: Accessor, ast: Accessor) { // Observe the element so that the references to the nodes in the indices are updated if the DOM is changed const latestMutations = observe(root); const indices = createMemo(() => { - latestMutations(); - - const node = root(); + const [node] = latestMutations(); if (node === undefined) { return []; @@ -23,13 +22,36 @@ export function createMap(root: Accessor, ast: Accessor html.start <= index && html.end >= index); + atHtmlPosition(range: Range): IndexRange { + const start = { ...(indices().find(({ dom }) => dom === range.startContainer)!) }; + const end = indices().find(({ dom }) => dom === range.endContainer); + + if (!start || !end) { + return [undefined, undefined]; + } + + start.offset = range.startOffset; + end.offset = range.endOffset; + + return [start, end]; + }, + + atTextPosition(start: number, end: number): IndexRange { + const startNode = { ...(indices().find(({ html }) => html.start <= start && html.end >= start)!) }; + const endNode = indices().find(({ html }) => html.start <= end && html.end >= end); + + if (!startNode || !endNode) { + return [undefined, undefined]; + } + + startNode.offset = start - startNode.html.start; + endNode.offset = end - endNode.html.start; + + return [startNode, endNode]; }, toTextIndices(range: Range): [number, number] { - const startNode = indices().find(({ node }) => node === range.startContainer); - const endNode = indices().find(({ node }) => node === range.endContainer); + const [startNode, endNode] = this.atHtmlPosition(range); return [ startNode ? (startNode.text.start + range.startOffset) : -1, @@ -38,8 +60,7 @@ export function createMap(root: Accessor, ast: Accessor node === range.startContainer); - const endNode = indices().find(({ node }) => node === range.endContainer); + const [startNode, endNode] = this.atHtmlPosition(range); return [ startNode ? (startNode.html.start + range.startOffset) : -1, @@ -48,21 +69,15 @@ export function createMap(root: Accessor, ast: Accessor html.start <= start && html.end >= start); - const endNode = indices().find(({ html }) => html.start <= end && html.end >= end); - + const [startNode, endNode] = this.atTextPosition(start, end); const range = new Range(); if (startNode) { - const offset = start - startNode.html.start; - - range.setStart(startNode.node, offset); + range.setStart(startNode.dom, startNode.offset); } if (endNode) { - const offset = end - endNode.html.start; - - range.setEnd(endNode.node, offset); + range.setEnd(endNode.dom, endNode.offset); } return range; @@ -75,12 +90,12 @@ const createIndices = (root: Node, ast: Root): IndexMap => { const indices: IndexMap = []; let index = 0; - visit(ast, n => n.type === 'text', (node) => { + visit(ast, (n): n is Text => 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! } }); + indices.push({ node, dom: nodes.shift()!, text: { start: index, end }, html: { start: position.start.offset!, end: position.end.offset! }, offset: 0 }); } index = end; @@ -89,7 +104,7 @@ const createIndices = (root: Node, ast: Root): IndexMap => { return indices; }; -const observe = (node: Accessor): Accessor => { +const observe = (node: Accessor): Accessor => { const [mutations, setMutations] = createSignal([]); const observer = new MutationObserver(records => { @@ -110,5 +125,5 @@ const observe = (node: Accessor): Accessor = observer.disconnect(); }); - return mutations; + return createMemo(() => [node(), mutations()] as const); }; \ No newline at end of file diff --git a/src/features/editor/state.ts b/src/features/editor/state.ts new file mode 100644 index 0000000..21cb26b --- /dev/null +++ b/src/features/editor/state.ts @@ -0,0 +1,43 @@ +import rehypeParse from "rehype-parse"; +import rehypeStringify from "rehype-stringify"; +import { Accessor, createSignal } from "solid-js"; +import { unified } from "unified"; +import type { Root } from 'hast'; + +export interface State { + text: string; + ast: Root; +} + +export const createState = (value: Accessor): State => { + const [text, setText] = createSignal(value()); + const [ast, setAst] = createSignal(parse(value())); + + return { + get text() { + return text(); + }, + + set text(next: string) { + setText(next); + setAst(parse(next)); + }, + + get ast() { + return ast(); + }, + + set ast(next: Root) { + console.log(stringify(next)); + + setText(stringify(next)); + setAst(next); + }, + }; +}; + +const stringifyProcessor = unified().use(rehypeStringify) +const parseProcessor = unified().use(rehypeParse) + +const stringify = (root: Root) => stringifyProcessor.stringify(root); +const parse = (text: string) => parseProcessor.parse(text); \ No newline at end of file diff --git a/src/routes/(editor)/experimental/editor.tsx b/src/routes/(editor)/experimental/editor.tsx index 28d2a33..baf687d 100644 --- a/src/routes/(editor)/experimental/editor.tsx +++ b/src/routes/(editor)/experimental/editor.tsx @@ -1,6 +1,9 @@ -import { createEffect, createMemo, createSignal, onMount } from "solid-js"; +import { createEffect, createMemo, createSignal } from "solid-js"; import { debounce } from "@solid-primitives/scheduled"; -import { Editor, useEditor } from "~/features/editor"; +import { Editor, splitAt, useEditor } from "~/features/editor"; +import { visitParents } from "unist-util-visit-parents"; +import findAncestor from 'unist-util-ancestor'; +import type * as hast from 'hast'; import css from './editor.module.css'; const tempVal = ` @@ -49,13 +52,91 @@ export default function Formatter(props: {}) { function Toolbar() { const { mutate, selection } = useEditor(); + const matchesAncestor = (tree: hast.Node, node: hast.Text, predicate: (node: hast.Node) => boolean) => { + let matches = false; + + visitParents(tree, n => n === node, (_, ancestors) => { + matches = ancestors.some(predicate); + }); + + return matches; + } + const bold = () => { - console.log('toggle text bold', selection()); + const [start, end] = selection(); + + if (!start || !end) { + return + } + + mutate((ast) => { + console.log(end.node.value.slice(0, end.offset)); + + // Trim whitespace from selection + const matchStart = start.node.value.slice(start.offset).match(/^(\s+).*?$/); + if (matchStart !== null) { + start.offset += matchStart[1].length; + } + + const matchEnd = end.node.value.slice(0, end.offset).match(/^.*?(\s+)$/); + if (matchEnd !== null) { + end.offset -= matchEnd[1].length; + } + + // Edge case Unbold the selected characters + if (start.node === end.node) { + visitParents(ast, (n): n is hast.Text => n === start.node, (n, ancestors) => { + const [strong, parent] = ancestors.toReversed(); + + if (strong.type === 'element' && strong.tagName === 'strong') { + parent.children.splice(parent.children.indexOf(strong as hast.ElementContent), 1, + { type: 'element', tagName: 'strong', properties: {}, children: [{ type: 'text', value: n.value.slice(0, start.offset) }] }, + { type: 'text', value: n.value.slice(start.offset, end.offset) }, + { type: 'element', tagName: 'strong', properties: {}, children: [{ type: 'text', value: n.value.slice(end.offset) }] }, + ); + } + else { + strong.children.splice(strong.children.indexOf(n), 1, + { type: 'text', value: n.value.slice(0, start.offset) }, + { type: 'element', tagName: 'strong', properties: {}, children: [{ type: 'text', value: n.value.slice(start.offset, end.offset) }] }, + { type: 'text', value: n.value.slice(end.offset) }, + ); + } + }); + + return ast; + } + + const common = findAncestor(ast, [start.node, end.node] as const) as hast.Element; + const startIsBold = matchesAncestor(common, start.node, (node) => node.type === 'element' && node.tagName === 'strong'); + const endIsBold = matchesAncestor(common, end.node, (node) => node.type === 'element' && node.tagName === 'strong'); + + // Extend to left + if (startIsBold) { + start.offset = 0; + } + + // Extend to right + if (endIsBold) { + end.offset = end.node.value.length; + } + + const [a, b] = splitAt(common, start.node, start.offset); + const [c, d] = splitAt({ type: 'root', children: b }, end.node, end.offset); + const boldedElement = { type: 'element', tagName: 'strong', children: c } as hast.RootContent; + + common.children = [...a, boldedElement, ...d] as hast.ElementContent[]; + + console.log(c, d, common.children); + + return ast; + }); + }; return
    -
    +
    ; } function SearchAndReplace() { diff --git a/src/utilities.ts b/src/utilities.ts index 6b3f1f6..ec283aa 100644 --- a/src/utilities.ts +++ b/src/utilities.ts @@ -129,7 +129,8 @@ export function* deepDiff(a: T1, b: T2, pa } }; -const isIterable = (subject: object): subject is Iterable => ['boolean', 'undefined', 'null', 'number'].includes(typeof subject) === false; +const nonIterableTypes = ['boolean', 'undefined', 'null', 'number']; +const isIterable = (subject: object): subject is Iterable => nonIterableTypes.includes(typeof subject) === false; const entriesOf = (subject: object): Iterable => { if (subject instanceof Array) { return subject.entries(); From b1e617e74a68d94724bb5188ecb90b9ed341ae16 Mon Sep 17 00:00:00 2001 From: Chris Kruining Date: Wed, 12 Mar 2025 16:50:49 +0100 Subject: [PATCH 13/16] stash --- src/features/dataset/index.ts | 8 +- src/features/editor/ast.ts | 133 +++++---- src/features/editor/context.ts | 57 +++- src/features/editor/editor.tsx | 10 +- src/features/editor/index.tsx | 3 +- src/features/editor/map.ts | 84 ++++-- src/features/editor/state.ts | 2 - src/features/editor/temp.ts | 253 ++++++++++++++++++ src/routes/(editor)/experimental/editor.tsx | 96 ++----- src/routes/(editor)/experimental/textarea.tsx | 2 +- src/utilities.ts | 14 + 11 files changed, 476 insertions(+), 186 deletions(-) create mode 100644 src/features/editor/temp.ts diff --git a/src/features/dataset/index.ts b/src/features/dataset/index.ts index 6c948ee..1e1ff99 100644 --- a/src/features/dataset/index.ts +++ b/src/features/dataset/index.ts @@ -1,6 +1,6 @@ import { trackStore } from "@solid-primitives/deep"; import { Accessor, createEffect, createMemo, untrack } from "solid-js"; -import { createStore } from "solid-js/store"; +import { createStore, unwrap } from "solid-js/store"; import { CustomPartial } from "solid-js/store/types/store.js"; import { deepCopy, deepDiff, MutarionKind, Mutation } from "~/utilities"; @@ -60,7 +60,7 @@ function defaultGroupingFunction(groupBy: keyof T): GroupingFunction>(data: Accessor, initialOptions?: { sort?: SortOptions, group?: GroupOptions }): DataSet => { const [state, setState] = createStore>({ - value: deepCopy(data()), + value: structuredClone(data()), snapshot: data(), sorting: initialOptions?.sort, grouping: initialOptions?.group, @@ -99,6 +99,10 @@ export const createDataSet = >(data: Accessor return deepDiff(state.snapshot, state.value).toArray(); }); + createEffect(() => { + console.log('muts', mutations()); + }); + const apply = (data: T[], mutations: Mutation[]) => { for (const mutation of mutations) { const path = mutation.key.split('.'); diff --git a/src/features/editor/ast.ts b/src/features/editor/ast.ts index e909f24..6ec041f 100644 --- a/src/features/editor/ast.ts +++ b/src/features/editor/ast.ts @@ -1,82 +1,78 @@ -import type { Node, Text, Element, ElementContent, Parent, RootContent } from 'hast'; +import type { Node, Text, Parent, RootContent } from 'hast'; import { find } from 'unist-util-find'; import { visit } from 'unist-util-visit'; -import { deepCopy } from '~/utilities'; +import { hash } from './temp'; -/** - * - * Given - * root - * |- element - * | |- text [0, 6] - * | |- element - * | | |- text [7, 18] - * | |- text [19, 25] - * |- element - * |- text [26, 40] - * |- element - * | |- text [41, 53] - * |- text [54, 60] - * - * split at 10 - * - * root - * |- element - * | |- text [0, 6] - * | |- element - * | | |- text [7, 9] - * - * root - * |- element - * | |- element - * | | |- text [10, 18] - * | |- text [19, 25] - * |- element - * |- text [26, 40] - * |- element - * | |- text [41, 53] - * |- text [54, 60] - */ +export const createElement = (tagName: string, children: any[], properties: object = {}) => ({ type: 'element', tagName, children, properties }); -export const splitAt = (tree: Parent, node: Text, offset: number): [RootContent[], RootContent[]] => { - const index = tree.children.findIndex(c => find(c, { ...node })); +interface SplitPoint { + node: Text; + offset: number; +} - if (index === -1) { - throw new Error('The tree does not contain the given node'); +export const splitBy = (tree: Parent, splitPoints: SplitPoint[]): RootContent[][] => { + const result: RootContent[][] = []; + let remaining: RootContent[] = Object.hasOwn(tree, 'children') ? (tree as Parent).children : []; + + console.log('kaas'); + // console.log(Object.groupBy(splitPoints, p => hash(p.node))); + + for (const { node, offset } of splitPoints) { + const index = remaining.findIndex(c => find(c, n => equals(n, node))); + + if (index === -1) { + throw new Error('The tree does not contain the given node'); + } + + const [targetLeft, targetRight] = splitNode(remaining[index], node, offset); + + const left = remaining.slice(0, index); + const right = remaining.slice(index + 1); + + if (targetLeft) { + left.push(targetLeft); + } + + if (targetRight) { + right.unshift(targetRight); + } + + remaining = right; + result.push(left); } - const left = tree.children.slice(0, index); - const right = tree.children.slice(index + 1); + result.push(remaining); - if (offset === 0) { - right.unshift(tree.children[index]); - } - else if (offset === node.value.length) { - left.push(tree.children[index]); - } - else { - const targetLeft = deepCopy(tree.children[index]); - const targetRight = tree.children[index]; - - left.push(targetLeft); - right.unshift(targetRight); - - visit(targetLeft, (n): n is Text => equals(n, node), n => { - n.value = n.value.slice(0, offset); - }) - - visit(targetRight, (n): n is Text => equals(n, node), n => { - n.value = n.value.slice(offset); - }) - } - - return [left, right]; + return result; }; -const splitNode = (node: Node, offset: number) => { +const splitNode = (node: Node, text: Text, offset: number): [RootContent | undefined, RootContent | undefined] => { + if (offset === 0) { + return [undefined, node as RootContent]; + } + if (offset === text.value.length) { + return [node as RootContent, undefined]; + } + + const left = structuredClone(node) as RootContent; + const right = node as RootContent; + + visit(left, (n): n is Text => equals(n, text), n => { + n.value = n.value.slice(0, offset); + }) + + visit(right, (n): n is Text => equals(n, text), n => { + n.value = n.value.slice(offset); + }) + + return [left, right]; } +export const mergeNodes = (...nodes: Text[]): Text => { + return { type: 'text', value: nodes.map(n => n.value).join() }; +}; + const equals = (a: Node, b: Node): boolean => { if (a === b) { return true; @@ -86,8 +82,5 @@ const equals = (a: Node, b: Node): boolean => { return false; } - // This is the nasty version of deep object checking, - // but I hope this is safe to do in this case because - // we are working with a html-ast and not just any type of object. - return JSON.stringify(a) === JSON.stringify(b); + return hash(a) === hash(b); }; \ No newline at end of file diff --git a/src/features/editor/context.ts b/src/features/editor/context.ts index 4868df1..a612280 100644 --- a/src/features/editor/context.ts +++ b/src/features/editor/context.ts @@ -2,14 +2,15 @@ import { createEventListenerMap, DocumentEventListener, WindowEventListener } fr import { Accessor, createEffect, createMemo, onMount, untrack } from "solid-js"; import { createStore } from "solid-js/store"; import { isServer } from "solid-js/web"; -import { createMap, IndexRange } from './map'; -import { splice } from "~/utilities"; +import { createMap } from './map'; +import { lazy, splice } from "~/utilities"; import { createState } from "./state"; -import type { Root } from 'hast'; +import type { Parent, Root, Text } from 'hast'; +import findAncestor from "unist-util-ancestor"; export type SelectFunction = (range: Range) => void; export type MutateFunction = (setter: (ast: Root) => Root) => void; -type Editor = [Accessor, { select: SelectFunction, mutate: MutateFunction, readonly selection: Accessor }]; +type Editor = [Accessor, { select: SelectFunction, mutate: MutateFunction, readonly selection: Accessor }]; interface EditorStoreType { isComposing: boolean; @@ -19,12 +20,21 @@ interface EditorStoreType { selectionBounds: DOMRect; } +export interface Index_Range { + startNode: Text; + startOffset: number; + endNode: Text; + endOffset: number; + + commonAncestor: () => Parent; +} + export function createEditor(ref: Accessor, value: Accessor): Editor { if (isServer) { return [value, { select() { }, mutate() { }, - selection: () => [undefined, undefined], + selection: () => undefined, }]; } @@ -83,7 +93,7 @@ export function createEditor(ref: Accessor, value: Accessor state.text = splice(state.text, start, end, text); - context.updateSelection(start + text.length, start + text.length); + // context.updateSelection(start + text.length, start + text.length); } function updateControlBounds() { @@ -167,7 +177,7 @@ export function createEditor(ref: Accessor, value: Accessor onMount(() => { updateControlBounds(); - updateSelection(indexMap.toRange(40, 60)) + updateSelection(indexMap.fromHtmlIndices(40, 60)) }); createEffect((last?: Element) => { @@ -198,7 +208,7 @@ export function createEditor(ref: Accessor, value: Accessor if (document.activeElement === untrack(ref)) { queueMicrotask(() => { - updateSelection(indexMap.toRange(context.selectionStart, context.selectionEnd)); + updateSelection(indexMap.fromHtmlIndices(context.selectionStart, context.selectionEnd)); }); } }); @@ -211,17 +221,42 @@ export function createEditor(ref: Accessor, value: Accessor }, mutate(setter) { + const [start, end] = indexMap.toTextIndices(store.selection!); + state.ast = setter(state.ast); + + setTimeout(() => { + console.log('RESTORING SELECTION') + const range = indexMap.fromTextIndices(start, end); + + console.log(start, end, range); + + updateSelection(range); + }, 100); }, - selection: createMemo(() => { + selection: createMemo(() => { const selection = store.selection; if (!selection) { - return [undefined, undefined]; + return undefined; } - return indexMap.atHtmlPosition(selection); + const [start, end] = indexMap.query(selection); + + if (!start || !end) { + return undefined; + } + + return { + startNode: start.node, + startOffset: selection.startOffset, + + endNode: end.node, + endOffset: selection.endOffset, + + commonAncestor: lazy(() => findAncestor(untrack(() => state.ast), [start.node, end.node]) as Parent), + } }), }]; } diff --git a/src/features/editor/editor.tsx b/src/features/editor/editor.tsx index 24ce40b..4f0d6d3 100644 --- a/src/features/editor/editor.tsx +++ b/src/features/editor/editor.tsx @@ -1,14 +1,12 @@ import { createContextProvider } from "@solid-primitives/context"; -import { Accessor, createEffect, createMemo, createSignal, on, ParentProps, Setter } from "solid-js"; -import { createEditor, MutateFunction, SelectFunction } from "./context"; +import { Accessor, createEffect, createSignal, on, ParentProps, Setter } from "solid-js"; +import { createEditor, Index_Range, MutateFunction, SelectFunction } from "./context"; import { createSource, Source } from "../source"; import { getTextNodes } from "@solid-primitives/selection"; -import { isServer } from "solid-js/web"; -import { IndexRange } from "./map"; interface EditorContextType { readonly text: Accessor; - readonly selection: Accessor; + readonly selection: Accessor; readonly source: Source; select: SelectFunction; mutate: MutateFunction; @@ -53,7 +51,7 @@ const [EditorProvider, useEditor] = createContextProvider '', - selection: () => [undefined, undefined], + selection: () => undefined, source: {} as Source, select() { }, mutate() { }, diff --git a/src/features/editor/index.tsx b/src/features/editor/index.tsx index 2036329..6a7741c 100644 --- a/src/features/editor/index.tsx +++ b/src/features/editor/index.tsx @@ -1,5 +1,6 @@ +export type { Index_Range } from './context'; export { createEditor as createEditContext } from './context'; export { Editor, useEditor } from './editor'; -export { splitAt } from './ast'; \ No newline at end of file +export { splitBy, createElement, mergeNodes } from './ast'; \ No newline at end of file diff --git a/src/features/editor/map.ts b/src/features/editor/map.ts index 36726ca..c9725cb 100644 --- a/src/features/editor/map.ts +++ b/src/features/editor/map.ts @@ -1,12 +1,13 @@ import type { Root, Text } from 'hast'; import { getTextNodes } from '@solid-primitives/selection'; -import { Accessor, createEffect, createMemo, createSignal, onCleanup } from "solid-js"; +import { Accessor, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"; import { visit } from 'unist-util-visit'; -export type IndexNode = { node: Text, dom: Node, text: { start: number, end: number }, html: { start: number, end: number }, offset: number }; +export type IndexNode = { node: Text, dom: Node, text: { start: number, end: number }, html: { start: number, end: number } }; export type IndexMap = IndexNode[]; export type IndexRange = [IndexNode, IndexNode] | [undefined, undefined]; + export function createMap(root: Accessor, ast: Accessor) { // Observe the element so that the references to the nodes in the indices are updated if the DOM is changed const latestMutations = observe(root); @@ -22,36 +23,37 @@ export function createMap(root: Accessor, ast: Accessor dom === range.startContainer)!) }; - const end = indices().find(({ dom }) => dom === range.endContainer); - - if (!start || !end) { - return [undefined, undefined]; - } - - start.offset = range.startOffset; - end.offset = range.endOffset; - - return [start, end]; + query(range: Range): [IndexNode | undefined, IndexNode | undefined] { + return [ + indices().find(({ dom }) => dom === range.startContainer), + indices().find(({ dom }) => dom === range.endContainer), + ]; }, - atTextPosition(start: number, end: number): IndexRange { - const startNode = { ...(indices().find(({ html }) => html.start <= start && html.end >= start)!) }; + atHtmlPosition(start: number, end: number): IndexRange { + const startNode = indices().find(({ html }) => html.start <= start && html.end >= start); const endNode = indices().find(({ html }) => html.start <= end && html.end >= end); if (!startNode || !endNode) { return [undefined, undefined]; } - startNode.offset = start - startNode.html.start; - endNode.offset = end - endNode.html.start; + return [startNode, endNode]; + }, + + atTextPosition(start: number, end: number): IndexRange { + const startNode = indices().find(({ text }) => text.start <= start && text.end >= start); + const endNode = indices().find(({ text }) => text.start <= end && text.end >= end); + + if (!startNode || !endNode) { + return [undefined, undefined]; + } return [startNode, endNode]; }, toTextIndices(range: Range): [number, number] { - const [startNode, endNode] = this.atHtmlPosition(range); + const [startNode, endNode] = this.query(range); return [ startNode ? (startNode.text.start + range.startOffset) : -1, @@ -60,7 +62,7 @@ export function createMap(root: Accessor, ast: Accessor, ast: Accessor { const nodes = getTextNodes(root); const indices: IndexMap = []; + console.log(ast); + let index = 0; visit(ast, (n): n is Text => n.type === 'text', (node) => { const { position, value } = node as Text; const end = index + value.length; + const dom = nodes.shift()!; + + console.log({ value, text: dom?.textContent, dom }); + + // if (value.includes('ntains bolded text')) { + // console.log(value, dom.textContent, { node, dom }); + // } if (position) { - indices.push({ node, dom: nodes.shift()!, text: { start: index, end }, html: { start: position.start.offset!, end: position.end.offset! }, offset: 0 }); + indices.push({ node, dom, text: { start: index, end }, html: { start: position.start.offset!, end: position.end.offset! } }); } index = end; diff --git a/src/features/editor/state.ts b/src/features/editor/state.ts index 21cb26b..d5172b4 100644 --- a/src/features/editor/state.ts +++ b/src/features/editor/state.ts @@ -28,8 +28,6 @@ export const createState = (value: Accessor): State => { }, set ast(next: Root) { - console.log(stringify(next)); - setText(stringify(next)); setAst(next); }, diff --git a/src/features/editor/temp.ts b/src/features/editor/temp.ts new file mode 100644 index 0000000..fccd7be --- /dev/null +++ b/src/features/editor/temp.ts @@ -0,0 +1,253 @@ +const bit = { + get(subject: number, index: number) { + return Boolean((subject >> index) & 1); + }, + + set(subject: number, index: number, value?: boolean) { + if (value !== undefined) { + return this.clear(subject, index) | ((value ? 1 : 0) << index); + } + + return subject | (1 << index) + }, + + clear(subject: number, index: number) { + return subject & ~(1 << index); + }, + + toggle(subject: number, index: number) { + return subject ^ (1 << index); + }, +}; + +interface BitArray { + [index: number]: boolean; + length: number; +} + +const ITEM_BIT_SIZE = 64; +const createBitArray = (data: boolean[] = []) => { + const store: number[] = []; + const populated: number[] = []; + let length = 0; + + const parseIndex = (key: string) => { + const value = Number.parseInt(key); + + if (Number.isNaN(value) || !Number.isFinite(value)) { + return undefined; + } + + return value; + }; + + const convert = (index: number) => [ + Math.floor(index / ITEM_BIT_SIZE), + index % ITEM_BIT_SIZE, + ] as const; + + const get = (index: number) => { + if (index >= length) { + return undefined; + } + + const [arrayIndex, bitIndex] = convert(index); + + if (bit.get(populated[arrayIndex], bitIndex) === false) { + return undefined; + } + + return bit.get(store[arrayIndex], bitIndex); + } + + const set = (index: number, value: boolean) => { + const [arrayIndex, bitIndex] = convert(index); + + store[arrayIndex] = bit.set((store[arrayIndex] ?? 0), bitIndex, value); + populated[arrayIndex] = bit.set((populated[arrayIndex] ?? 0), bitIndex); + length = Math.max(length, index + 1); + }; + + const clear = (index: number) => { + const [arrayIndex, bitIndex] = convert(index); + + // I think I can skip the store because it is covered by the populated list + // store[arrayIndex] = bit.set((store[arrayIndex] ?? 0), bitIndex, false); + populated[arrayIndex] = bit.set((populated[arrayIndex] ?? 0), bitIndex, false); + length = Math.max(length, index); + } + + // initial population of array + for (const [i, v] of data.entries()) { + set(i, v); + } + + return new Proxy([], { + get(target, property, receiver) { + if (property === Symbol.species) { + return 'BitArray' + } + + if (typeof property === 'symbol') { + return undefined; + } + + const index = parseIndex(property); + + if (index) { + console.log(store.map(i => i.toString(2)), populated.map(i => i.toString(2))); + + return get(index); + } + + console.log(property, index); + }, + + set(target, property, value, receiver) { + if (typeof property === 'symbol') { + return false; + } + + const index = parseIndex(property); + + if (index) { + if (typeof value !== 'boolean') { + throw new Error(`Only able to set boolean values on indices, received '${typeof value}' instead`) + } + + set(index, value); + + return true; + } + + return false; + }, + + deleteProperty(target, property) { + if (typeof property === 'symbol') { + return false; + } + + const index = parseIndex(property); + + if (index) { + clear(index); + + return true; + } + + return false; + }, + }); +}; + +const BLOCK_SIZE = 512; +const CHUNK_SIZE = 16; +const UINT32_BYTE_SIZE = 4; +const HASH_NUMBER_OF_UINT32 = 5; +const HASH_SIZE = HASH_NUMBER_OF_UINT32 * UINT32_BYTE_SIZE; +const initalizationVector /* 20 bytes */ = [0x67452301, 0xEFCDAB89, 0x98BADCFE, 0x10325476, 0xC3D2E1F0] as const; +const hashKey /* 16 bytes */ = [0x5A827999, 0x6ED9EBA1, 0x8F1BBCDC, 0xCA62C1D6] as const; + +type Word = number & {}; // union with empty object so typescript show this as 'Word' and not as 'number' +type Chunk = Iterable & { length: typeof HASH_NUMBER_OF_UINT32 }; +type HashBytes = Uint32Array & { length: typeof HASH_NUMBER_OF_UINT32 }; + +const _hash = (data: string | Uint8Array | Uint32Array) => { + // Normalize data to byte array + if (typeof data === 'string') { + data = new TextEncoder().encode(data); + } + + // Normalize to Uint32Array + if (data instanceof Uint8Array) { + data = new Uint32Array(data.buffer, data.byteOffset, data.byteLength / 4); + } + + if (!Number.isSafeInteger(data.length)) { + throw new Error('Cannot hash more than 2^53 - 1 bits'); + } + + // prepare blocks + const output = new Uint32Array(initalizationVector) as HashBytes; + const blocks = range(0, data.length, CHUNK_SIZE, true).map(i => { + const view = data.subarray(i, i + 16); + const words = Array(80); + + words[0] = view[0]; + words[1] = view[1]; + words[2] = view[2]; + words[3] = view[3]; + words[4] = view[4]; + + return words; + }); + + // apply blocks + for (const words of blocks) { + let [a, b, c, d, e] = output; + + for (const index of range(0, 80)) { + if (index >= 16) { + words[index] = circularShiftLeft(1, words[index - 3] ^ words[index - 8] ^ words[index - 14] ^ words[index - 16]); + } + + const tmp = ( + circularShiftLeft(a, HASH_NUMBER_OF_UINT32) + + logicalHashFunctions(index, b, c, d) + + e + + words[index] + + hashKey[Math.floor(index / HASH_SIZE)] + ); + + e = d; + d = c; + c = circularShiftLeft(b, 30); + b = a; + a = tmp; + } + + output[0] = (output[0] + a) | 0; + output[1] = (output[1] + b) | 0; + output[2] = (output[2] + c) | 0; + output[3] = (output[3] + d) | 0; + output[4] = (output[4] + e) | 0; + } + + return output.values().map(word => (word >>> 0).toString(16)).join(''); +}; + +const circularShiftLeft = (subject: number, offset: number): number => { + return ((subject << offset) | (subject >>> 32 - offset)) & (0xFFFFFFFF); +}; + +const logicalHashFunctions = (index: number, b: Word, c: Word, d: Word): Word => { + if (index < HASH_SIZE) { + return (b & c) | (~b & d); + } + else if (index < (2 * HASH_SIZE)) { + return b ^ c ^ d; + } + else if (index < (3 * HASH_SIZE)) { + return (b & c) | (b & d) | (c & d); + } + else if (index < (4 * HASH_SIZE)) { + return b ^ c ^ d; + } + + throw new Error('Unreachable code'); +}; + +const range = function* (start: number, end: number, step: number = 1, inclusive: boolean = false): Iterator { + for (let i = start; inclusive ? (i <= end) : (i < end); i += (step ?? 1)) { + yield i; + } +}; + +export const hash = (data: any): string => { + if (typeof data === 'string' || (typeof data === 'object' && (data instanceof Uint8Array || data instanceof Uint32Array))) { + return _hash(data); + } + + return _hash(JSON.stringify(data)); +}; \ No newline at end of file diff --git a/src/routes/(editor)/experimental/editor.tsx b/src/routes/(editor)/experimental/editor.tsx index baf687d..3b18802 100644 --- a/src/routes/(editor)/experimental/editor.tsx +++ b/src/routes/(editor)/experimental/editor.tsx @@ -1,8 +1,7 @@ -import { createEffect, createMemo, createSignal } from "solid-js"; +import { createEffect, createMemo, createSignal, untrack } from "solid-js"; import { debounce } from "@solid-primitives/scheduled"; -import { Editor, splitAt, useEditor } from "~/features/editor"; +import { Editor, Index_Range, splitBy, createElement, useEditor, mergeNodes } from "~/features/editor"; import { visitParents } from "unist-util-visit-parents"; -import findAncestor from 'unist-util-ancestor'; import type * as hast from 'hast'; import css from './editor.module.css'; @@ -41,7 +40,7 @@ export default function Formatter(props: {}) {
    - + @@ -52,82 +51,41 @@ export default function Formatter(props: {}) { function Toolbar() { const { mutate, selection } = useEditor(); - const matchesAncestor = (tree: hast.Node, node: hast.Text, predicate: (node: hast.Node) => boolean) => { - let matches = false; + const trimWhitespaceOn = ({ startNode: startContainer, endNode: endContainer, startOffset, endOffset, ...rest }: Index_Range): Index_Range => { + const matchStart = startContainer.value.slice(startOffset).match(/^(\s+).*?$/); + const matchEnd = endContainer.value.slice(0, endOffset).match(/^.*?(\s+)$/); - visitParents(tree, n => n === node, (_, ancestors) => { - matches = ancestors.some(predicate); - }); - - return matches; - } + return { + startNode: startContainer, + startOffset: startOffset + (matchStart?.[1].length ?? 0), + endNode: endContainer, + endOffset: endOffset - (matchEnd?.[1].length ?? 0), + ...rest + }; + }; const bold = () => { - const [start, end] = selection(); + const range = selection(); - if (!start || !end) { - return + if (!range) { + return; } mutate((ast) => { - console.log(end.node.value.slice(0, end.offset)); + const { startNode, endNode, startOffset, endOffset, commonAncestor } = trimWhitespaceOn(range); - // Trim whitespace from selection - const matchStart = start.node.value.slice(start.offset).match(/^(\s+).*?$/); - if (matchStart !== null) { - start.offset += matchStart[1].length; - } + const [left, toBold, right] = splitBy(commonAncestor(), [ + { node: startNode, offset: startOffset }, + { node: endNode, offset: endOffset }, + ]); - const matchEnd = end.node.value.slice(0, end.offset).match(/^.*?(\s+)$/); - if (matchEnd !== null) { - end.offset -= matchEnd[1].length; - } + console.log(left, toBold, right); + const boldedElement = createElement('strong', toBold.flatMap(child => child.tagName === 'strong' ? mergeNodes(child.children) : child)) as hast.RootContent; - // Edge case Unbold the selected characters - if (start.node === end.node) { - visitParents(ast, (n): n is hast.Text => n === start.node, (n, ancestors) => { - const [strong, parent] = ancestors.toReversed(); + // THIS IS WHERE I LEFT OFF + // AST needs to be clean!!!! - if (strong.type === 'element' && strong.tagName === 'strong') { - parent.children.splice(parent.children.indexOf(strong as hast.ElementContent), 1, - { type: 'element', tagName: 'strong', properties: {}, children: [{ type: 'text', value: n.value.slice(0, start.offset) }] }, - { type: 'text', value: n.value.slice(start.offset, end.offset) }, - { type: 'element', tagName: 'strong', properties: {}, children: [{ type: 'text', value: n.value.slice(end.offset) }] }, - ); - } - else { - strong.children.splice(strong.children.indexOf(n), 1, - { type: 'text', value: n.value.slice(0, start.offset) }, - { type: 'element', tagName: 'strong', properties: {}, children: [{ type: 'text', value: n.value.slice(start.offset, end.offset) }] }, - { type: 'text', value: n.value.slice(end.offset) }, - ); - } - }); - - return ast; - } - - const common = findAncestor(ast, [start.node, end.node] as const) as hast.Element; - const startIsBold = matchesAncestor(common, start.node, (node) => node.type === 'element' && node.tagName === 'strong'); - const endIsBold = matchesAncestor(common, end.node, (node) => node.type === 'element' && node.tagName === 'strong'); - - // Extend to left - if (startIsBold) { - start.offset = 0; - } - - // Extend to right - if (endIsBold) { - end.offset = end.node.value.length; - } - - const [a, b] = splitAt(common, start.node, start.offset); - const [c, d] = splitAt({ type: 'root', children: b }, end.node, end.offset); - const boldedElement = { type: 'element', tagName: 'strong', children: c } as hast.RootContent; - - common.children = [...a, boldedElement, ...d] as hast.ElementContent[]; - - console.log(c, d, common.children); + commonAncestor().children = [...left, boldedElement, ...right]; return ast; }); diff --git a/src/routes/(editor)/experimental/textarea.tsx b/src/routes/(editor)/experimental/textarea.tsx index e638b27..38d73d5 100644 --- a/src/routes/(editor)/experimental/textarea.tsx +++ b/src/routes/(editor)/experimental/textarea.tsx @@ -1,4 +1,4 @@ -import { createSignal } from "solid-js"; +import { createSignal, untrack } from "solid-js"; import { debounce } from "@solid-primitives/scheduled"; import { Textarea } from "~/components/textarea"; import css from './textarea.module.css'; diff --git a/src/utilities.ts b/src/utilities.ts index ec283aa..5af38dc 100644 --- a/src/utilities.ts +++ b/src/utilities.ts @@ -46,6 +46,20 @@ const decodeReplacer = (_: any, char: EncodedChar) => ({ }[char.charAt(0) as ('t' | 'b' | 'n' | 'r' | 'f' | '\'' | '"' | 'u')]); export const decode = (subject: string): string => subject.replace(decodeRegex, decodeReplacer); +const LAZY_SYMBOL = Symbol('not loaded'); +export const lazy = (fn: () => T): (() => T) => { + let value: T | symbol = LAZY_SYMBOL; + + return () => { + if (value === LAZY_SYMBOL) { + value = fn(); + } + + return value as T; + } +}; + +/** @deprecated just use structuredClone instead */ export const deepCopy = (original: T): T => { if (typeof original !== 'object' || original === null || original === undefined) { return original; From e88d727d8e89fa3f6b492b80a7381ab76dd62836 Mon Sep 17 00:00:00 2001 From: Chris Kruining Date: Thu, 13 Mar 2025 10:41:22 +0100 Subject: [PATCH 14/16] buttoned up hash and ast --- src/features/editor/ast.spec.ts | 38 +++++ src/features/editor/ast.ts | 3 +- src/features/editor/hash.spec.ts | 54 +++++++ src/features/editor/hash.ts | 168 ++++++++++++++++++++ src/features/editor/temp.ts | 253 ------------------------------- 5 files changed, 261 insertions(+), 255 deletions(-) create mode 100644 src/features/editor/hash.spec.ts create mode 100644 src/features/editor/hash.ts delete mode 100644 src/features/editor/temp.ts diff --git a/src/features/editor/ast.spec.ts b/src/features/editor/ast.spec.ts index e69de29..ef4a9ad 100644 --- a/src/features/editor/ast.spec.ts +++ b/src/features/editor/ast.spec.ts @@ -0,0 +1,38 @@ +import { describe, expect } from "vitest"; +import { it } from "~/test-helpers"; +import { createElement, splitBy, mergeNodes } from './ast'; + +describe('ast', () => { + describe('createElement', () => { + it('should ____', () => { + // Arrange + + // Act + + // Assert + expect(true).toEqual(true); + }); + }); + + describe('splitBy', () => { + it('should ____', () => { + // Arrange + + // Act + + // Assert + expect(true).toEqual(true); + }); + }); + + describe('mergeNodes', () => { + it('should ____', () => { + // Arrange + + // Act + + // Assert + expect(true).toEqual(true); + }); + }); +}); \ No newline at end of file diff --git a/src/features/editor/ast.ts b/src/features/editor/ast.ts index 6ec041f..ea08fb9 100644 --- a/src/features/editor/ast.ts +++ b/src/features/editor/ast.ts @@ -1,7 +1,7 @@ import type { Node, Text, Parent, RootContent } from 'hast'; import { find } from 'unist-util-find'; import { visit } from 'unist-util-visit'; -import { hash } from './temp'; +import { hash } from './hash'; export const createElement = (tagName: string, children: any[], properties: object = {}) => ({ type: 'element', tagName, children, properties }); @@ -14,7 +14,6 @@ export const splitBy = (tree: Parent, splitPoints: SplitPoint[]): RootContent[][ const result: RootContent[][] = []; let remaining: RootContent[] = Object.hasOwn(tree, 'children') ? (tree as Parent).children : []; - console.log('kaas'); // console.log(Object.groupBy(splitPoints, p => hash(p.node))); for (const { node, offset } of splitPoints) { diff --git a/src/features/editor/hash.spec.ts b/src/features/editor/hash.spec.ts new file mode 100644 index 0000000..a79bb58 --- /dev/null +++ b/src/features/editor/hash.spec.ts @@ -0,0 +1,54 @@ +import { describe, expect } from "vitest"; +import { it } from "~/test-helpers"; +import { hash } from "./hash"; + +const DEFAULT_DATA = { + prop_object: { + is: 'some prop', + }, + + prop_boolean: false, + prop_bigint: 1_000_000_000_000n, + prop_null: null, + prop_undefined: undefined, + prop_function: () => { }, + prop_symbol: Symbol('symbol'), + + uint8array: new Uint8Array([0xff, 0x00, 0xff, 0x00]), + uint32array: new Uint32Array([0xff00ff00]), +}; + +describe('hash', () => { + it('should hash a value with sha-1 algorithm', () => { + // Arrange + const expected = '6fe383b712ec74177f7714a3f5db5416accef8b'; + + // Act + const actual = hash(DEFAULT_DATA); + + // Assert + expect(actual).toEqual(expected); + }); + + it('should be stable over multiple runs', () => { + // Arrange + + // Act + const run1 = hash(DEFAULT_DATA); + const run2 = hash(DEFAULT_DATA); + + // Assert + expect(run1).toEqual(run2); + }); + + // I can't seem to actually create a dataset that is large enough in order to test this. + // So, for now, I will consider this unreachable code. + it('should error if the input is too large', () => { + // Arrange + + // Act + + // Assert + expect(true).toEqual(true); + }); +}); \ No newline at end of file diff --git a/src/features/editor/hash.ts b/src/features/editor/hash.ts new file mode 100644 index 0000000..91e43c2 --- /dev/null +++ b/src/features/editor/hash.ts @@ -0,0 +1,168 @@ +import { installIntoGlobal } from "iterator-helpers-polyfill"; + +installIntoGlobal(); + +const CHUNK_SIZE = 16; +const UINT32_BYTE_SIZE = 4; +const HASH_NUMBER_OF_UINT32 = 5; +const HASH_SIZE = HASH_NUMBER_OF_UINT32 * UINT32_BYTE_SIZE; +const initalizationVector /* 20 bytes */ = Object.freeze([0x67452301, 0xEFCDAB89, 0x98BADCFE, 0x10325476, 0xC3D2E1F0] as const); +const hashKey /* 16 bytes */ = Object.freeze([0x5A827999, 0x6ED9EBA1, 0x8F1BBCDC, 0xCA62C1D6] as const); + +type Word = number & {}; // union with empty object so typescript show this as 'Word' and not as 'number' +type HashBytes = Uint32Array & { length: typeof HASH_NUMBER_OF_UINT32 }; + +export const hash = (data: any) => { + const buffer = typeof data === 'object' && data instanceof Uint32Array ? data : new Uint32Array(toBinary(data)); + + if (!Number.isSafeInteger(buffer.length)) { + throw new Error('Cannot hash more than 2^53 - 1 bits'); + } + + // prepare blocks + const output = new Uint32Array(initalizationVector) as HashBytes; + const blocks = range(0, buffer.length, CHUNK_SIZE).map(i => { + const view = buffer.subarray(i, i + 16); + const words = Array(80); + + words[0] = view[0]; + words[1] = view[1]; + words[2] = view[2]; + words[3] = view[3]; + words[4] = view[4]; + + return words; + }); + + // apply blocks + for (const words of blocks) { + let [a, b, c, d, e] = output; + + for (let i = 0; i < 80; i++) { + if (i >= 16) { + words[i] = circularShiftLeft(1, words[i - 3] ^ words[i - 8] ^ words[i - 14] ^ words[i - 16]); + } + + const tmp = ( + circularShiftLeft(a, HASH_NUMBER_OF_UINT32) + + logicalHashFunctions(i, b, c, d) + + e + + words[i] + + hashKey[Math.floor(i / HASH_SIZE)] + ); + + e = d; + d = c; + c = circularShiftLeft(b, 30); + b = a; + a = tmp; + } + + output[0] = (output[0] + a) | 0; + output[1] = (output[1] + b) | 0; + output[2] = (output[2] + c) | 0; + output[3] = (output[3] + d) | 0; + output[4] = (output[4] + e) | 0; + } + + return output.values().map(word => (word >>> 0).toString(16)).join(''); +}; + +const circularShiftLeft = (subject: number, offset: number): number => { + return ((subject << offset) | (subject >>> 32 - offset)) & (0xFFFFFFFF); +}; + +const logicalHashFunctions = (index: number, b: Word, c: Word, d: Word): Word => { + if (index < HASH_SIZE) { + return (b & c) | (~b & d); + } + else if (index < (2 * HASH_SIZE)) { + return b ^ c ^ d; + } + else if (index < (3 * HASH_SIZE)) { + return (b & c) | (b & d) | (c & d); + } + else if (index < (4 * HASH_SIZE)) { + return b ^ c ^ d; + } + + throw new Error('Unreachable code'); +}; + +const range = function* (start: number, end: number, step: number): Iterator { + for (let i = start; i <= end; i += step) { + yield i; + } +}; + +const toBinary = function*(data: T): Generator { + switch (typeof data) { + case 'function': + case 'symbol': + case 'undefined': + break; + + case 'string': + yield* compact(new TextEncoder().encode(data)); + break; + + case 'number': + yield data; + break; + + case 'boolean': + yield Number(data); + break; + + case 'bigint': + let value: bigint = data; + // limit the iteration to 10 cycles. + // This covers 10*32 bits, which in al honesty should be enough no? + const ITERATION_LIMIT = 10; + + for (let i = 0; i < ITERATION_LIMIT && value > 0; i++) { + yield Number((value & 0xffffffffn)); + value >>= 32n; + + if (i === 10) { + throw new Error('Iteration limit in bigint serialization reached'); + } + } + break; + + case 'object': + if (data === null) { + break; + } + + if (data instanceof Uint8Array) { + yield* compact(data); + } + + if (data instanceof Uint32Array) { + yield* data; + } + + for (const item of Object.values(data)) { + yield* toBinary(item); + } + break; + } +}; + +const compact = function* (source: Iterable): Generator { + let i = 0; + let buffer = 0; + + for (const value of source) { + buffer |= (value & 0xff) << (8 * i); + + if (i === 3) { + yield buffer; + buffer = 0; + } + + i = (i + 1) % 4; + } +}; + diff --git a/src/features/editor/temp.ts b/src/features/editor/temp.ts deleted file mode 100644 index fccd7be..0000000 --- a/src/features/editor/temp.ts +++ /dev/null @@ -1,253 +0,0 @@ -const bit = { - get(subject: number, index: number) { - return Boolean((subject >> index) & 1); - }, - - set(subject: number, index: number, value?: boolean) { - if (value !== undefined) { - return this.clear(subject, index) | ((value ? 1 : 0) << index); - } - - return subject | (1 << index) - }, - - clear(subject: number, index: number) { - return subject & ~(1 << index); - }, - - toggle(subject: number, index: number) { - return subject ^ (1 << index); - }, -}; - -interface BitArray { - [index: number]: boolean; - length: number; -} - -const ITEM_BIT_SIZE = 64; -const createBitArray = (data: boolean[] = []) => { - const store: number[] = []; - const populated: number[] = []; - let length = 0; - - const parseIndex = (key: string) => { - const value = Number.parseInt(key); - - if (Number.isNaN(value) || !Number.isFinite(value)) { - return undefined; - } - - return value; - }; - - const convert = (index: number) => [ - Math.floor(index / ITEM_BIT_SIZE), - index % ITEM_BIT_SIZE, - ] as const; - - const get = (index: number) => { - if (index >= length) { - return undefined; - } - - const [arrayIndex, bitIndex] = convert(index); - - if (bit.get(populated[arrayIndex], bitIndex) === false) { - return undefined; - } - - return bit.get(store[arrayIndex], bitIndex); - } - - const set = (index: number, value: boolean) => { - const [arrayIndex, bitIndex] = convert(index); - - store[arrayIndex] = bit.set((store[arrayIndex] ?? 0), bitIndex, value); - populated[arrayIndex] = bit.set((populated[arrayIndex] ?? 0), bitIndex); - length = Math.max(length, index + 1); - }; - - const clear = (index: number) => { - const [arrayIndex, bitIndex] = convert(index); - - // I think I can skip the store because it is covered by the populated list - // store[arrayIndex] = bit.set((store[arrayIndex] ?? 0), bitIndex, false); - populated[arrayIndex] = bit.set((populated[arrayIndex] ?? 0), bitIndex, false); - length = Math.max(length, index); - } - - // initial population of array - for (const [i, v] of data.entries()) { - set(i, v); - } - - return new Proxy([], { - get(target, property, receiver) { - if (property === Symbol.species) { - return 'BitArray' - } - - if (typeof property === 'symbol') { - return undefined; - } - - const index = parseIndex(property); - - if (index) { - console.log(store.map(i => i.toString(2)), populated.map(i => i.toString(2))); - - return get(index); - } - - console.log(property, index); - }, - - set(target, property, value, receiver) { - if (typeof property === 'symbol') { - return false; - } - - const index = parseIndex(property); - - if (index) { - if (typeof value !== 'boolean') { - throw new Error(`Only able to set boolean values on indices, received '${typeof value}' instead`) - } - - set(index, value); - - return true; - } - - return false; - }, - - deleteProperty(target, property) { - if (typeof property === 'symbol') { - return false; - } - - const index = parseIndex(property); - - if (index) { - clear(index); - - return true; - } - - return false; - }, - }); -}; - -const BLOCK_SIZE = 512; -const CHUNK_SIZE = 16; -const UINT32_BYTE_SIZE = 4; -const HASH_NUMBER_OF_UINT32 = 5; -const HASH_SIZE = HASH_NUMBER_OF_UINT32 * UINT32_BYTE_SIZE; -const initalizationVector /* 20 bytes */ = [0x67452301, 0xEFCDAB89, 0x98BADCFE, 0x10325476, 0xC3D2E1F0] as const; -const hashKey /* 16 bytes */ = [0x5A827999, 0x6ED9EBA1, 0x8F1BBCDC, 0xCA62C1D6] as const; - -type Word = number & {}; // union with empty object so typescript show this as 'Word' and not as 'number' -type Chunk = Iterable & { length: typeof HASH_NUMBER_OF_UINT32 }; -type HashBytes = Uint32Array & { length: typeof HASH_NUMBER_OF_UINT32 }; - -const _hash = (data: string | Uint8Array | Uint32Array) => { - // Normalize data to byte array - if (typeof data === 'string') { - data = new TextEncoder().encode(data); - } - - // Normalize to Uint32Array - if (data instanceof Uint8Array) { - data = new Uint32Array(data.buffer, data.byteOffset, data.byteLength / 4); - } - - if (!Number.isSafeInteger(data.length)) { - throw new Error('Cannot hash more than 2^53 - 1 bits'); - } - - // prepare blocks - const output = new Uint32Array(initalizationVector) as HashBytes; - const blocks = range(0, data.length, CHUNK_SIZE, true).map(i => { - const view = data.subarray(i, i + 16); - const words = Array(80); - - words[0] = view[0]; - words[1] = view[1]; - words[2] = view[2]; - words[3] = view[3]; - words[4] = view[4]; - - return words; - }); - - // apply blocks - for (const words of blocks) { - let [a, b, c, d, e] = output; - - for (const index of range(0, 80)) { - if (index >= 16) { - words[index] = circularShiftLeft(1, words[index - 3] ^ words[index - 8] ^ words[index - 14] ^ words[index - 16]); - } - - const tmp = ( - circularShiftLeft(a, HASH_NUMBER_OF_UINT32) + - logicalHashFunctions(index, b, c, d) + - e + - words[index] + - hashKey[Math.floor(index / HASH_SIZE)] - ); - - e = d; - d = c; - c = circularShiftLeft(b, 30); - b = a; - a = tmp; - } - - output[0] = (output[0] + a) | 0; - output[1] = (output[1] + b) | 0; - output[2] = (output[2] + c) | 0; - output[3] = (output[3] + d) | 0; - output[4] = (output[4] + e) | 0; - } - - return output.values().map(word => (word >>> 0).toString(16)).join(''); -}; - -const circularShiftLeft = (subject: number, offset: number): number => { - return ((subject << offset) | (subject >>> 32 - offset)) & (0xFFFFFFFF); -}; - -const logicalHashFunctions = (index: number, b: Word, c: Word, d: Word): Word => { - if (index < HASH_SIZE) { - return (b & c) | (~b & d); - } - else if (index < (2 * HASH_SIZE)) { - return b ^ c ^ d; - } - else if (index < (3 * HASH_SIZE)) { - return (b & c) | (b & d) | (c & d); - } - else if (index < (4 * HASH_SIZE)) { - return b ^ c ^ d; - } - - throw new Error('Unreachable code'); -}; - -const range = function* (start: number, end: number, step: number = 1, inclusive: boolean = false): Iterator { - for (let i = start; inclusive ? (i <= end) : (i < end); i += (step ?? 1)) { - yield i; - } -}; - -export const hash = (data: any): string => { - if (typeof data === 'string' || (typeof data === 'object' && (data instanceof Uint8Array || data instanceof Uint32Array))) { - return _hash(data); - } - - return _hash(JSON.stringify(data)); -}; \ No newline at end of file From 5a813627ea403763ded82c392ca7397c90459f2c Mon Sep 17 00:00:00 2001 From: Chris Kruining Date: Thu, 13 Mar 2025 16:19:48 +0100 Subject: [PATCH 15/16] oooh wow, I overcomplicated this sooooooooo much. just stick to dom manipulations. --- src/entry-client.tsx | 3 + src/features/editor/ast.ts | 13 +- src/features/editor/context.ts | 161 ++++++++---------- src/features/editor/editor.tsx | 23 ++- src/features/editor/index.tsx | 3 - src/features/editor/map.ts | 150 ++-------------- src/features/editor/state.ts | 41 ----- src/features/file/helpers.ts | 3 - .../(editor)/experimental/editor.module.css | 5 + src/routes/(editor)/experimental/editor.tsx | 63 +++---- src/utilities.ts | 6 + 11 files changed, 146 insertions(+), 325 deletions(-) delete mode 100644 src/features/editor/state.ts diff --git a/src/entry-client.tsx b/src/entry-client.tsx index b613fae..1639f34 100644 --- a/src/entry-client.tsx +++ b/src/entry-client.tsx @@ -1,5 +1,8 @@ // @refresh reload import { mount, StartClient } from "@solidjs/start/client"; +import { installIntoGlobal } from "iterator-helpers-polyfill"; import 'solid-devtools'; +installIntoGlobal(); + mount(() => , document.body); diff --git a/src/features/editor/ast.ts b/src/features/editor/ast.ts index ea08fb9..1ff0afa 100644 --- a/src/features/editor/ast.ts +++ b/src/features/editor/ast.ts @@ -13,17 +13,21 @@ interface SplitPoint { export const splitBy = (tree: Parent, splitPoints: SplitPoint[]): RootContent[][] => { const result: RootContent[][] = []; let remaining: RootContent[] = Object.hasOwn(tree, 'children') ? (tree as Parent).children : []; - - // console.log(Object.groupBy(splitPoints, p => hash(p.node))); + let lastNode; + let accumulatedOffset = 0; for (const { node, offset } of splitPoints) { + if (lastNode !== node) { + accumulatedOffset = 0; + } + const index = remaining.findIndex(c => find(c, n => equals(n, node))); if (index === -1) { throw new Error('The tree does not contain the given node'); } - const [targetLeft, targetRight] = splitNode(remaining[index], node, offset); + const [targetLeft, targetRight] = splitNode(remaining[index], node, offset - accumulatedOffset); const left = remaining.slice(0, index); const right = remaining.slice(index + 1); @@ -38,6 +42,9 @@ export const splitBy = (tree: Parent, splitPoints: SplitPoint[]): RootContent[][ remaining = right; result.push(left); + + lastNode = node; + accumulatedOffset += offset; } result.push(remaining); diff --git a/src/features/editor/context.ts b/src/features/editor/context.ts index a612280..1fcbfa9 100644 --- a/src/features/editor/context.ts +++ b/src/features/editor/context.ts @@ -1,16 +1,13 @@ import { createEventListenerMap, DocumentEventListener, WindowEventListener } from "@solid-primitives/event-listener"; -import { Accessor, createEffect, createMemo, onMount, untrack } from "solid-js"; +import { Accessor, createEffect, createMemo, createSignal, on, onCleanup, onMount } from "solid-js"; import { createStore } from "solid-js/store"; import { isServer } from "solid-js/web"; import { createMap } from './map'; -import { lazy, splice } from "~/utilities"; -import { createState } from "./state"; -import type { Parent, Root, Text } from 'hast'; -import findAncestor from "unist-util-ancestor"; +import { unified } from "unified"; +import rehypeParse from "rehype-parse"; export type SelectFunction = (range: Range) => void; -export type MutateFunction = (setter: (ast: Root) => Root) => void; -type Editor = [Accessor, { select: SelectFunction, mutate: MutateFunction, readonly selection: Accessor }]; +type Editor = { select: SelectFunction, readonly selection: Accessor }; interface EditorStoreType { isComposing: boolean; @@ -20,22 +17,12 @@ interface EditorStoreType { selectionBounds: DOMRect; } -export interface Index_Range { - startNode: Text; - startOffset: number; - endNode: Text; - endOffset: number; - - commonAncestor: () => Parent; -} - export function createEditor(ref: Accessor, value: Accessor): Editor { if (isServer) { - return [value, { + return { select() { }, - mutate() { }, selection: () => undefined, - }]; + }; } if (!("EditContext" in window)) { @@ -46,8 +33,9 @@ export function createEditor(ref: Accessor, value: Accessor text: value(), }); - const state = createState(value); - const indexMap = createMap(() => ref()!, () => state.ast); + const mutations = observe(ref); + const ast = createMemo(() => parse(value())); + const indexMap = createMap(ref, ast); const [store, setStore] = createStore({ isComposing: false, selection: undefined, @@ -58,13 +46,30 @@ export function createEditor(ref: Accessor, value: Accessor selectionBounds: new DOMRect(), }); + createEffect(on(mutations, () => { + const selection = store.selection; + + if (selection === undefined) { + return + } + + queueMicrotask(() => { + console.log(selection); + + updateSelection(selection); + }); + })); + createEventListenerMap(context, { textupdate(e: TextUpdateEvent) { - const { updateRangeStart: start, updateRangeEnd: end, text } = e; + const selection = store.selection; - setStore('text', `${store.text.slice(0, start)}${text}${store.text.slice(end)}`); + if (!selection) { + return; + } - context.updateSelection(start + text.length, start + text.length); + selection.insertNode(document.createTextNode(e.text)); + selection.collapse(); }, compositionstart() { @@ -88,20 +93,20 @@ export function createEditor(ref: Accessor, value: Accessor }, }); - function updateText(start: number, end: number, text: string) { - context.updateText(start, end, text); - - state.text = splice(state.text, start, end, text); - - // context.updateSelection(start + text.length, start + text.length); - } - function updateControlBounds() { context.updateControlBounds(ref()!.getBoundingClientRect()); } function updateSelection(range: Range) { - context.updateSelection(...indexMap.toHtmlIndices(range)); + const [start, end] = indexMap.query(range); + + console.log(start, end, range); + + if (!start || !end) { + return; + } + + context.updateSelection(start.start + range.startOffset, end.start + range.endOffset); context.updateSelectionBounds(range.getBoundingClientRect()); setStore('selection', range); @@ -158,6 +163,8 @@ export function createEditor(ref: Accessor, value: Accessor // 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) { + console.log(e); + return; } @@ -167,9 +174,9 @@ export function createEditor(ref: Accessor, value: Accessor if (e.key === 'Tab') { e.preventDefault(); - updateText(start, end, '    '); + context.updateText(start, end, '    '); } else if (e.key === 'Enter') { - updateText(start, end, '

     '); + context.updateText(start, end, '

     '); } }, }); @@ -177,7 +184,8 @@ export function createEditor(ref: Accessor, value: Accessor onMount(() => { updateControlBounds(); - updateSelection(indexMap.fromHtmlIndices(40, 60)) + // updateSelection(indexMap.fromHtmlIndices(40, 60)) + // updateSelection(indexMap.fromHtmlIndices(599, 603)) }); createEffect((last?: Element) => { @@ -196,70 +204,43 @@ export function createEditor(ref: Accessor, value: Accessor return el; }); - createEffect(() => { + return { + select(range: Range) { + updateSelection(range); + }, + + selection: createMemo(() => { + return store.selection; + }), + }; +} + +const observe = (node: Accessor): Accessor => { + const [mutations, setMutations] = createSignal([]); + + const observer = new MutationObserver(records => { + setMutations(records); }); createEffect(() => { - updateText(0, -0, value()); - }); + const n = node(); - createEffect(() => { - state.text; + observer.disconnect(); - if (document.activeElement === untrack(ref)) { - queueMicrotask(() => { - updateSelection(indexMap.fromHtmlIndices(context.selectionStart, context.selectionEnd)); - }); + if (n) { + observer.observe(n, { characterData: true, subtree: true, childList: true }); } }); - return [ - createMemo(() => state.text), - { - select(range: Range) { - updateSelection(range); - }, + onCleanup(() => { + observer.disconnect(); + }); - mutate(setter) { - const [start, end] = indexMap.toTextIndices(store.selection!); + return createMemo(() => [node(), mutations()] as const); +}; - state.ast = setter(state.ast); - - setTimeout(() => { - console.log('RESTORING SELECTION') - const range = indexMap.fromTextIndices(start, end); - - console.log(start, end, range); - - updateSelection(range); - }, 100); - }, - - selection: createMemo(() => { - const selection = store.selection; - - if (!selection) { - return undefined; - } - - const [start, end] = indexMap.query(selection); - - if (!start || !end) { - return undefined; - } - - return { - startNode: start.node, - startOffset: selection.startOffset, - - endNode: end.node, - endOffset: selection.endOffset, - - commonAncestor: lazy(() => findAncestor(untrack(() => state.ast), [start.node, end.node]) as Parent), - } - }), - }]; -} +const parseProcessor = unified().use(rehypeParse) +const parse = (text: string) => parseProcessor.parse(text); const equals = (a: Range, b: Range): boolean => { const keys: (keyof Range)[] = ['startOffset', 'endOffset', 'commonAncestorContainer', 'startContainer', 'endContainer']; diff --git a/src/features/editor/editor.tsx b/src/features/editor/editor.tsx index 4f0d6d3..67b3fab 100644 --- a/src/features/editor/editor.tsx +++ b/src/features/editor/editor.tsx @@ -1,15 +1,14 @@ import { createContextProvider } from "@solid-primitives/context"; -import { Accessor, createEffect, createSignal, on, ParentProps, Setter } from "solid-js"; -import { createEditor, Index_Range, MutateFunction, SelectFunction } from "./context"; +import { Accessor, createEffect, createMemo, createSignal, on, ParentProps, Setter } from "solid-js"; +import { createEditor, SelectFunction } from "./context"; import { createSource, Source } from "../source"; import { getTextNodes } from "@solid-primitives/selection"; interface EditorContextType { readonly text: Accessor; - readonly selection: Accessor; + readonly selection: Accessor; readonly source: Source; select: SelectFunction; - mutate: MutateFunction; } interface EditorContextProps extends Record { @@ -20,16 +19,12 @@ interface EditorContextProps extends Record { const [EditorProvider, useEditor] = createContextProvider((props) => { const source = createSource(() => props.value); - const [text, { select, mutate, selection }] = createEditor(props.ref, () => source.out); + const { select, selection } = createEditor(props.ref, () => source.out); createEffect(() => { props.oninput?.(source.in); }); - createEffect(() => { - source.out = text(); - }); - createEffect(on(() => [props.ref()!, source.spellingErrors] as const, ([ref, errors]) => { createHighlights(ref, 'spelling-error', errors); })); @@ -43,9 +38,8 @@ const [EditorProvider, useEditor] = createContextProvider source.out), select, - mutate, source, selection, }; @@ -54,7 +48,6 @@ const [EditorProvider, useEditor] = createContextProvider undefined, source: {} as Source, select() { }, - mutate() { }, }); export { useEditor }; @@ -72,6 +65,12 @@ export function Editor(props: ParentProps<{ value: string, oninput?: (value: str function Content(props: { ref: Setter }) { const { text } = useEditor(); + createEffect(() => { + text(); + + console.error('rerendering'); + }); + return

    ; } diff --git a/src/features/editor/index.tsx b/src/features/editor/index.tsx index 6a7741c..d0068de 100644 --- a/src/features/editor/index.tsx +++ b/src/features/editor/index.tsx @@ -1,6 +1,3 @@ - - -export type { Index_Range } from './context'; export { createEditor as createEditContext } from './context'; export { Editor, useEditor } from './editor'; export { splitBy, createElement, mergeNodes } from './ast'; \ No newline at end of file diff --git a/src/features/editor/map.ts b/src/features/editor/map.ts index c9725cb..9923233 100644 --- a/src/features/editor/map.ts +++ b/src/features/editor/map.ts @@ -7,159 +7,37 @@ export type IndexNode = { node: Text, dom: Node, text: { start: number, end: num export type IndexMap = IndexNode[]; export type IndexRange = [IndexNode, IndexNode] | [undefined, undefined]; - export function createMap(root: Accessor, ast: Accessor) { - // Observe the element so that the references to the nodes in the indices are updated if the DOM is changed - const latestMutations = observe(root); - - const indices = createMemo(() => { - const [node] = latestMutations(); + const mapping = createMemo(() => { + const node = root(); + const tree = ast(); if (node === undefined) { - return []; + return new WeakMap(); } - return createIndices(node, ast()); + console.warn('recalculating map'); + + return createMapping(node, tree); }); return { - query(range: Range): [IndexNode | undefined, IndexNode | undefined] { + query: (range: Range) => { return [ - indices().find(({ dom }) => dom === range.startContainer), - indices().find(({ dom }) => dom === range.endContainer), + mapping().get(range.startContainer), + mapping().get(range.endContainer), ]; }, - - atHtmlPosition(start: number, end: number): IndexRange { - const startNode = indices().find(({ html }) => html.start <= start && html.end >= start); - const endNode = indices().find(({ html }) => html.start <= end && html.end >= end); - - if (!startNode || !endNode) { - return [undefined, undefined]; - } - - return [startNode, endNode]; - }, - - atTextPosition(start: number, end: number): IndexRange { - const startNode = indices().find(({ text }) => text.start <= start && text.end >= start); - const endNode = indices().find(({ text }) => text.start <= end && text.end >= end); - - if (!startNode || !endNode) { - return [undefined, undefined]; - } - - return [startNode, endNode]; - }, - - toTextIndices(range: Range): [number, number] { - const [startNode, endNode] = this.query(range); - - return [ - startNode ? (startNode.text.start + range.startOffset) : -1, - endNode ? (endNode.text.start + range.endOffset) : -1 - ]; - }, - - toHtmlIndices(range: Range): [number, number] { - const [startNode, endNode] = this.query(range); - - return [ - startNode ? (startNode.html.start + range.startOffset) : -1, - endNode ? (endNode.html.start + range.endOffset) : -1 - ]; - }, - - fromTextIndices(start: number, end: number): Range { - const [startNode, endNode] = this.atTextPosition(start, end); - const range = new Range(); - - if (startNode) { - const offset = start - startNode.text.start; - - range.setStart(startNode.dom, offset); - } - - if (endNode) { - const offset = end - endNode.text.start; - - console.log('end offset', endNode); - - range.setEnd(endNode.dom, offset); - } - - return range; - }, - - fromHtmlIndices(start: number, end: number): Range { - const [startNode, endNode] = this.atHtmlPosition(start, end); - const range = new Range(); - - if (startNode) { - const offset = start - startNode.html.start; - - range.setStart(startNode.dom, offset); - } - - if (endNode) { - const offset = end - endNode.html.start; - - range.setEnd(endNode.dom, offset); - } - - return range; - }, }; } -const createIndices = (root: Node, ast: Root): IndexMap => { +const createMapping = (root: Node, ast: Root): WeakMap => { const nodes = getTextNodes(root); - const indices: IndexMap = []; + const map = new WeakMap(); - console.log(ast); - - let index = 0; visit(ast, (n): n is Text => n.type === 'text', (node) => { - const { position, value } = node as Text; - const end = index + value.length; - const dom = nodes.shift()!; - - console.log({ value, text: dom?.textContent, dom }); - - // if (value.includes('ntains bolded text')) { - // console.log(value, dom.textContent, { node, dom }); - // } - - if (position) { - indices.push({ node, dom, text: { start: index, end }, html: { start: position.start.offset!, end: position.end.offset! } }); - } - - index = end; + map.set(nodes.shift()!, { start: node.position!.start.offset, end: node.position!.end.offset, text: node.value }) }); - return indices; -}; - -const observe = (node: Accessor): Accessor => { - const [mutations, setMutations] = createSignal([]); - - const observer = new MutationObserver(records => { - setMutations(records); - }); - - createEffect(() => { - const n = node(); - - observer.disconnect(); - - if (n) { - observer.observe(n, { characterData: true, subtree: true, childList: true }); - } - }); - - onCleanup(() => { - observer.disconnect(); - }); - - return createMemo(() => [node(), mutations()] as const); + return map; }; \ No newline at end of file diff --git a/src/features/editor/state.ts b/src/features/editor/state.ts deleted file mode 100644 index d5172b4..0000000 --- a/src/features/editor/state.ts +++ /dev/null @@ -1,41 +0,0 @@ -import rehypeParse from "rehype-parse"; -import rehypeStringify from "rehype-stringify"; -import { Accessor, createSignal } from "solid-js"; -import { unified } from "unified"; -import type { Root } from 'hast'; - -export interface State { - text: string; - ast: Root; -} - -export const createState = (value: Accessor): State => { - const [text, setText] = createSignal(value()); - const [ast, setAst] = createSignal(parse(value())); - - return { - get text() { - return text(); - }, - - set text(next: string) { - setText(next); - setAst(parse(next)); - }, - - get ast() { - return ast(); - }, - - set ast(next: Root) { - setText(stringify(next)); - setAst(next); - }, - }; -}; - -const stringifyProcessor = unified().use(rehypeStringify) -const parseProcessor = unified().use(rehypeParse) - -const stringify = (root: Root) => stringifyProcessor.stringify(root); -const parse = (text: string) => parseProcessor.parse(text); \ No newline at end of file diff --git a/src/features/file/helpers.ts b/src/features/file/helpers.ts index a4b68bd..17a4801 100644 --- a/src/features/file/helpers.ts +++ b/src/features/file/helpers.ts @@ -2,11 +2,8 @@ import { Accessor, createEffect, from, createSignal } from "solid-js"; import { json } from "./parser"; import { filter } from "~/utilities"; import { isServer } from "solid-js/web"; -import { installIntoGlobal } from 'iterator-helpers-polyfill'; import { debounce } from "@solid-primitives/scheduled"; -installIntoGlobal(); - interface Files extends Record { } interface Contents extends Map> { } diff --git a/src/routes/(editor)/experimental/editor.module.css b/src/routes/(editor)/experimental/editor.module.css index f756d0b..6c4c873 100644 --- a/src/routes/(editor)/experimental/editor.module.css +++ b/src/routes/(editor)/experimental/editor.module.css @@ -20,6 +20,11 @@ background-color: transparent; } + & ::highlight(debug) { + text-decoration: double underline; + text-decoration-color: cornflowerblue; + } + & ::highlight(search-results) { background-color: var(--secondary-900); } diff --git a/src/routes/(editor)/experimental/editor.tsx b/src/routes/(editor)/experimental/editor.tsx index 3b18802..d7aa2a2 100644 --- a/src/routes/(editor)/experimental/editor.tsx +++ b/src/routes/(editor)/experimental/editor.tsx @@ -1,9 +1,8 @@ -import { createEffect, createMemo, createSignal, untrack } from "solid-js"; +import { createEffect, createMemo, createSignal, onMount, untrack } from "solid-js"; import { debounce } from "@solid-primitives/scheduled"; -import { Editor, Index_Range, splitBy, createElement, useEditor, mergeNodes } from "~/features/editor"; -import { visitParents } from "unist-util-visit-parents"; -import type * as hast from 'hast'; +import { Editor, useEditor } from "~/features/editor"; import css from './editor.module.css'; +import { assert } from "~/utilities"; const tempVal = ` # Header @@ -49,49 +48,39 @@ export default function Formatter(props: {}) { } function Toolbar() { - const { mutate, selection } = useEditor(); - - const trimWhitespaceOn = ({ startNode: startContainer, endNode: endContainer, startOffset, endOffset, ...rest }: Index_Range): Index_Range => { - const matchStart = startContainer.value.slice(startOffset).match(/^(\s+).*?$/); - const matchEnd = endContainer.value.slice(0, endOffset).match(/^.*?(\s+)$/); - - return { - startNode: startContainer, - startOffset: startOffset + (matchStart?.[1].length ?? 0), - endNode: endContainer, - endOffset: endOffset - (matchEnd?.[1].length ?? 0), - ...rest - }; - }; - const bold = () => { - const range = selection(); + const range = window.getSelection()!.getRangeAt(0); + // const { startContainer, startOffset, endContainer, endOffset, commonAncestorContainer } = range; + // console.log(startContainer, startOffset, endContainer, endOffset, commonAncestorContainer); - if (!range) { + if (range.startContainer.nodeType !== Node.TEXT_NODE) { return; } - mutate((ast) => { - const { startNode, endNode, startOffset, endOffset, commonAncestor } = trimWhitespaceOn(range); + if (range.endContainer.nodeType !== Node.TEXT_NODE) { + return; + } - const [left, toBold, right] = splitBy(commonAncestor(), [ - { node: startNode, offset: startOffset }, - { node: endNode, offset: endOffset }, - ]); + const fragment = range.extractContents(); - console.log(left, toBold, right); - const boldedElement = createElement('strong', toBold.flatMap(child => child.tagName === 'strong' ? mergeNodes(child.children) : child)) as hast.RootContent; - - // THIS IS WHERE I LEFT OFF - // AST needs to be clean!!!! - - commonAncestor().children = [...left, boldedElement, ...right]; - - return ast; - }); + if (range.startContainer === range.commonAncestorContainer && range.endContainer === range.commonAncestorContainer && range.commonAncestorContainer.parentElement?.tagName === 'STRONG') { + range.selectNode(range.commonAncestorContainer.parentElement); + range.insertNode(fragment); + } + else { + const strong = document.createElement('strong'); + strong.append(fragment); + range.insertNode(strong); + } }; + onMount(() => { + queueMicrotask(() => { + // bold(); + }); + }); + return
    ; diff --git a/src/utilities.ts b/src/utilities.ts index 5af38dc..bc36f57 100644 --- a/src/utilities.ts +++ b/src/utilities.ts @@ -1,3 +1,9 @@ +export const assert = (assertion: boolean, message: string) => { + if (assertion !== true) { + throw new Error(message); + } +} + export const splice = (subject: string, start: number, end: number, replacement: string) => { return `${subject.slice(0, start)}${replacement}${Object.is(end, -0) ? '' : subject.slice(end)}`; }; From 41a1ef0dbb53dbddf1e044ffd44ff28a728f8fd0 Mon Sep 17 00:00:00 2001 From: Chris Kruining Date: Mon, 17 Mar 2025 16:31:11 +0100 Subject: [PATCH 16/16] stabalized the index map, now the selection is lost on rerenders again :/ --- .vscode/launch.json | 46 +++---------- .vscode/settings.json | 8 +-- .../{context.spec.tsx => context.spec.ts} | 0 src/features/editor/context.ts | 68 +++++++++++++------ src/features/editor/editor.tsx | 12 ++-- src/features/editor/map.ts | 14 ++-- src/routes/(editor)/experimental/editor.tsx | 28 +++++--- tsconfig.json | 1 - 8 files changed, 91 insertions(+), 86 deletions(-) rename src/features/editor/{context.spec.tsx => context.spec.ts} (100%) diff --git a/.vscode/launch.json b/.vscode/launch.json index a6d750e..f6ec67b 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -6,7 +6,7 @@ "request": "launch", "name": "Start dev", // The path to a JavaScript or TypeScript file to run. - "program": "${file}", + "program": "entry-server.tsx", // The arguments to pass to the program, if any. "args": [], // The working directory of the program. @@ -15,40 +15,9 @@ "env": {}, // If the environment variables should not be inherited from the parent process. "strictEnv": false, - // If the program should be run in watch mode. - // This is equivalent to passing `--watch` to the `bun` executable. - // You can also set this to "hot" to enable hot reloading using `--hot`. "watchMode": false, // If the debugger should stop on the first line of the program. - "stopOnEntry": false, - // If the debugger should be disabled. (for example, breakpoints will not be hit) - "noDebug": false, - // The path to the `bun` executable, defaults to your `PATH` environment variable. - "runtime": "bun", - // The arguments to pass to the `bun` executable, if any. - // Unlike `args`, these are passed to the executable itself, not the program. - "runtimeArgs": [], - }, - { - "type": "bun", - "request": "launch", - "name": "Run tests", - // The path to a JavaScript or TypeScript file to run. - "program": "${file}", - // The arguments to pass to the program, if any. - "args": [], - // The working directory of the program. - "cwd": "${workspaceFolder}", - // The environment variables to pass to the program. - "env": {}, - // If the environment variables should not be inherited from the parent process. - "strictEnv": false, - // If the program should be run in watch mode. - // This is equivalent to passing `--watch` to the `bun` executable. - // You can also set this to "hot" to enable hot reloading using `--hot`. - "watchMode": false, - // If the debugger should stop on the first line of the program. - "stopOnEntry": false, + "stopOnEntry": true, // If the debugger should be disabled. (for example, breakpoints will not be hit) "noDebug": false, // The path to the `bun` executable, defaults to your `PATH` environment variable. @@ -56,17 +25,18 @@ // The arguments to pass to the `bun` executable, if any. // Unlike `args`, these are passed to the executable itself, not the program. "runtimeArgs": [ - "run", - "test" + "--bun", + "--inspect", + "dev" ], }, { "type": "bun", + "internalConsoleOptions": "neverOpen", "request": "attach", - "name": "Attach to Bun", - // The URL of the WebSocket inspector to attach to. - // This value can be retreived by using `bun --inspect`. + "name": "Attach Bun", "url": "ws://localhost:6499/", + "stopOnEntry": true } ] } \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index e57826f..be68592 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,10 +1,6 @@ { // The path to the `bun` executable. "bun.runtime": "/path/to/bun", - "bun.debugTerminal": { - // If support for Bun should be added to the default "JavaScript Debug Terminal". - "enabled": true, - // If the debugger should stop on the first line of the program. - "stopOnEntry": false, - } + "bun.debugTerminal.enabled": true, + "bun.debugTerminal.stopOnEntry": true } \ No newline at end of file diff --git a/src/features/editor/context.spec.tsx b/src/features/editor/context.spec.ts similarity index 100% rename from src/features/editor/context.spec.tsx rename to src/features/editor/context.spec.ts diff --git a/src/features/editor/context.ts b/src/features/editor/context.ts index 1fcbfa9..b725c0a 100644 --- a/src/features/editor/context.ts +++ b/src/features/editor/context.ts @@ -1,6 +1,6 @@ import { createEventListenerMap, DocumentEventListener, WindowEventListener } from "@solid-primitives/event-listener"; -import { Accessor, createEffect, createMemo, createSignal, on, onCleanup, onMount } from "solid-js"; -import { createStore } from "solid-js/store"; +import { Accessor, createEffect, createMemo, createSignal, on, onCleanup, onMount, Setter } from "solid-js"; +import { createStore, produce } from "solid-js/store"; import { isServer } from "solid-js/web"; import { createMap } from './map'; import { unified } from "unified"; @@ -10,6 +10,7 @@ export type SelectFunction = (range: Range) => void; type Editor = { select: SelectFunction, readonly selection: Accessor }; interface EditorStoreType { + text: string; isComposing: boolean; selection: Range | undefined; characterBounds: DOMRect[]; @@ -17,7 +18,7 @@ interface EditorStoreType { selectionBounds: DOMRect; } -export function createEditor(ref: Accessor, value: Accessor): Editor { +export function createEditor(ref: Accessor, value: Accessor, setValue: (next: string) => any): Editor { if (isServer) { return { select() { }, @@ -29,14 +30,8 @@ export function createEditor(ref: Accessor, value: Accessor throw new Error('`EditContext` is not implemented'); } - const context = new EditContext({ - text: value(), - }); - - const mutations = observe(ref); - const ast = createMemo(() => parse(value())); - const indexMap = createMap(ref, ast); const [store, setStore] = createStore({ + text: value(), isComposing: false, selection: undefined, @@ -46,20 +41,54 @@ export function createEditor(ref: Accessor, value: Accessor selectionBounds: new DOMRect(), }); - createEffect(on(mutations, () => { - const selection = store.selection; + const context = new EditContext({ + text: store.text, + }); - if (selection === undefined) { - return - } + const mutations = observe(ref); + const ast = createMemo(() => parse(store.text)); + const indexMap = createMap(ref, ast); + + createEffect(() => { + setValue(store.text); + }); + + // createEffect(() => { + // const selection = store.selection; + + // if (!selection) { + // return; + // } + + // console.log(indexMap.query(selection)); + // }); + + createEffect(on(() => [ref(), ast()], () => { + console.log('pre rerender?'); + const selection = store.selection; + const indices = selection ? indexMap.query(selection) : []; queueMicrotask(() => { - console.log(selection); - - updateSelection(selection); + console.log('post rerender?'); + console.log(indices); }); })); + createEffect(on(value, value => { + if (value !== store.text) { + setStore('text', value); + } + })); + + createEffect(on(mutations, ([root, mutations]) => { + const text = (root! as HTMLElement).innerHTML; + + if (text !== store.text) { + context.updateText(0, context.text.length, text); + setStore('text', context.text); + } + })); + createEventListenerMap(context, { textupdate(e: TextUpdateEvent) { const selection = store.selection; @@ -68,6 +97,7 @@ export function createEditor(ref: Accessor, value: Accessor return; } + selection.extractContents(); selection.insertNode(document.createTextNode(e.text)); selection.collapse(); }, @@ -100,8 +130,6 @@ export function createEditor(ref: Accessor, value: Accessor function updateSelection(range: Range) { const [start, end] = indexMap.query(range); - console.log(start, end, range); - if (!start || !end) { return; } diff --git a/src/features/editor/editor.tsx b/src/features/editor/editor.tsx index 67b3fab..eecc6dc 100644 --- a/src/features/editor/editor.tsx +++ b/src/features/editor/editor.tsx @@ -1,5 +1,5 @@ import { createContextProvider } from "@solid-primitives/context"; -import { Accessor, createEffect, createMemo, createSignal, on, ParentProps, Setter } from "solid-js"; +import { Accessor, createEffect, createMemo, createSignal, on, ParentProps, Setter, untrack } from "solid-js"; import { createEditor, SelectFunction } from "./context"; import { createSource, Source } from "../source"; import { getTextNodes } from "@solid-primitives/selection"; @@ -19,7 +19,7 @@ interface EditorContextProps extends Record { const [EditorProvider, useEditor] = createContextProvider((props) => { const source = createSource(() => props.value); - const { select, selection } = createEditor(props.ref, () => source.out); + const { select, selection } = createEditor(props.ref, () => source.out, next => source.out = next); createEffect(() => { props.oninput?.(source.in); @@ -38,7 +38,7 @@ const [EditorProvider, useEditor] = createContextProvider source.out), + text: () => source.out, select, source, selection, @@ -65,11 +65,7 @@ export function Editor(props: ParentProps<{ value: string, oninput?: (value: str function Content(props: { ref: Setter }) { const { text } = useEditor(); - createEffect(() => { - text(); - - console.error('rerendering'); - }); + createEffect(on(text, () => console.error('rerendering'))); return
    ; } diff --git a/src/features/editor/map.ts b/src/features/editor/map.ts index 9923233..8001e40 100644 --- a/src/features/editor/map.ts +++ b/src/features/editor/map.ts @@ -8,17 +8,21 @@ export type IndexMap = IndexNode[]; export type IndexRange = [IndexNode, IndexNode] | [undefined, undefined]; export function createMap(root: Accessor, ast: Accessor) { - const mapping = createMemo(() => { + const [mapping, setMapping] = createSignal(new WeakMap()); + + createEffect(() => { const node = root(); const tree = ast(); if (node === undefined) { - return new WeakMap(); + return; } - console.warn('recalculating map'); - - return createMapping(node, tree); + // Delay the recalculation a bit to give other code a chance to update the DOM. + // This -hopefully- prevents the map from getting out of sync + queueMicrotask(() => { + setMapping(createMapping(node, tree)); + }); }); return { diff --git a/src/routes/(editor)/experimental/editor.tsx b/src/routes/(editor)/experimental/editor.tsx index d7aa2a2..7553dfe 100644 --- a/src/routes/(editor)/experimental/editor.tsx +++ b/src/routes/(editor)/experimental/editor.tsx @@ -31,15 +31,15 @@ this is *a string* that contains italicized text export default function Formatter(props: {}) { const [value, setValue] = createSignal(tempVal); - const onInput = debounce((e: InputEvent) => { + const onInput = (e: InputEvent) => { setValue((e.target! as HTMLTextAreaElement).value); - }, 300); + }; return
    - + @@ -48,10 +48,12 @@ export default function Formatter(props: {}) { } function Toolbar() { + const { selection } = useEditor(); + const bold = () => { - const range = window.getSelection()!.getRangeAt(0); - // const { startContainer, startOffset, endContainer, endOffset, commonAncestorContainer } = range; - // console.log(startContainer, startOffset, endContainer, endOffset, commonAncestorContainer); + const range = untrack(selection)!; + + console.log(range); if (range.startContainer.nodeType !== Node.TEXT_NODE) { return; @@ -61,6 +63,13 @@ function Toolbar() { return; } + // Trim whitespace + { + const text = range.toString(); + range.setStart(range.startContainer, range.startOffset + (text.match(/^\s+/)?.[0].length ?? 0)); + range.setEnd(range.endContainer, range.endOffset - (text.match(/\s+$/)?.[0].length ?? 0)); + } + const fragment = range.extractContents(); if (range.startContainer === range.commonAncestorContainer && range.endContainer === range.commonAncestorContainer && range.commonAncestorContainer.parentElement?.tagName === 'STRONG') { @@ -72,6 +81,7 @@ function Toolbar() { strong.append(fragment); range.insertNode(strong); + range.selectNode(strong); } }; @@ -87,7 +97,7 @@ function Toolbar() { } function SearchAndReplace() { - const { mutate, source } = useEditor(); + const { source } = useEditor(); const [replacement, setReplacement] = createSignal(''); const [term, setTerm] = createSignal(''); const [caseInsensitive, setCaseInsensitive] = createSignal(true); @@ -104,7 +114,9 @@ function SearchAndReplace() { const form = e.target as HTMLFormElement; form.reset(); - mutate(text => text.replaceAll(query(), replacement())); + console.log(source.queryResults); + + // mutate(text => text.replaceAll(query(), replacement())); }; return
    diff --git a/tsconfig.json b/tsconfig.json index cec05f7..662f2f3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,7 +17,6 @@ "@vitest/browser/providers/playwright", "vinxi/types/client", "vite-plugin-solid-svg/types-component-solid", - "vite-plugin-pwa/solid", "bun-types" ], "isolatedModules": true,