Compare commits
18 commits
main
...
experiment
Author | SHA1 | Date | |
---|---|---|---|
|
41a1ef0dbb | ||
|
5a813627ea | ||
|
e88d727d8e | ||
|
b1e617e74a | ||
|
97036272dd | ||
|
603719de38 | ||
|
11aab1dc1a | ||
|
fa6bf5bbac | ||
|
8aab001e90 | ||
|
c6c7240fee | ||
|
925ea142fb | ||
|
44549c36be | ||
|
789d14330a | ||
|
5f6138d30b | ||
|
fc22ce6027 | ||
|
4fb7405466 | ||
|
213a1f7ae7 | ||
|
4041236b2d |
44 changed files with 2184 additions and 1025 deletions
30
.github/workflows/app.yml
vendored
30
.github/workflows/app.yml
vendored
|
@ -27,23 +27,25 @@ jobs:
|
||||||
semver: ${{ steps.gitversion.outputs.SemVer }}
|
semver: ${{ steps.gitversion.outputs.SemVer }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: Install GitVersion
|
- name: Install GitVersion
|
||||||
uses: gittools/actions/gitversion/setup@v4.1.0
|
uses: gittools/actions/gitversion/setup@v3.1.11
|
||||||
with:
|
with:
|
||||||
versionSpec: "6.x"
|
versionSpec: "5.x"
|
||||||
- name: Determine Version
|
- name: Determine Version
|
||||||
id: gitversion
|
id: gitversion
|
||||||
uses: gittools/actions/gitversion/execute@v4.1.0
|
uses: gittools/actions/gitversion/execute@v3.1.11
|
||||||
|
with:
|
||||||
|
useConfigFile: true
|
||||||
|
|
||||||
build_and_publish:
|
build_and_publish:
|
||||||
name: Build & Publish
|
name: Build & Publish
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: versionize
|
needs: versionize
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Build container images
|
- name: Build container images
|
||||||
run: |
|
run: |
|
||||||
|
@ -71,7 +73,7 @@ jobs:
|
||||||
matrix:
|
matrix:
|
||||||
environment: [ 'prd' ]
|
environment: [ 'prd' ]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
sparse-checkout: |
|
sparse-checkout: |
|
||||||
infrastructure
|
infrastructure
|
||||||
|
@ -84,13 +86,13 @@ jobs:
|
||||||
subscription-id: ${{ secrets.CALQUE_PRD_SUBSCRIPTION_ID }}
|
subscription-id: ${{ secrets.CALQUE_PRD_SUBSCRIPTION_ID }}
|
||||||
|
|
||||||
- name: Deploy bicep
|
- name: Deploy bicep
|
||||||
uses: azure/cli@v2
|
uses: Azure/cli@v2
|
||||||
with:
|
with:
|
||||||
azcliversion: 2.75.0
|
azcliversion: 2.66.0
|
||||||
inlineScript: >-
|
inlineScript: |
|
||||||
az deployment sub create
|
az deployment sub create \
|
||||||
--location westeurope
|
--location westeurope \
|
||||||
--template-file infrastructure/main.bicep
|
--template-file infrastructure/main.bicep \
|
||||||
--parameters infrastructure/params/${{ matrix.environment }}.bicepparam
|
--parameters infrastructure/params/${{ matrix.environment }}.bicepparam \
|
||||||
--parameters version=${{needs.versionize.outputs.semver}}
|
--parameters version=${{needs.versionize.outputs.semver}} \
|
||||||
--parameters registryUrl=${{ secrets.ACR_LOGIN_SERVER }}
|
--parameters registryUrl=${{ secrets.ACR_LOGIN_SERVER }}
|
44
.vscode/launch.json
vendored
44
.vscode/launch.json
vendored
|
@ -6,7 +6,7 @@
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"name": "Start dev",
|
"name": "Start dev",
|
||||||
// The path to a JavaScript or TypeScript file to run.
|
// 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.
|
// The arguments to pass to the program, if any.
|
||||||
"args": [],
|
"args": [],
|
||||||
// The working directory of the program.
|
// The working directory of the program.
|
||||||
|
@ -15,40 +15,9 @@
|
||||||
"env": {},
|
"env": {},
|
||||||
// If the environment variables should not be inherited from the parent process.
|
// If the environment variables should not be inherited from the parent process.
|
||||||
"strictEnv": false,
|
"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,
|
"watchMode": false,
|
||||||
// If the debugger should stop on the first line of the program.
|
// 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.
|
|
||||||
"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,
|
|
||||||
// If the debugger should be disabled. (for example, breakpoints will not be hit)
|
// If the debugger should be disabled. (for example, breakpoints will not be hit)
|
||||||
"noDebug": false,
|
"noDebug": false,
|
||||||
// The path to the `bun` executable, defaults to your `PATH` environment variable.
|
// The path to the `bun` executable, defaults to your `PATH` environment variable.
|
||||||
|
@ -57,16 +26,17 @@
|
||||||
// Unlike `args`, these are passed to the executable itself, not the program.
|
// Unlike `args`, these are passed to the executable itself, not the program.
|
||||||
"runtimeArgs": [
|
"runtimeArgs": [
|
||||||
"--bun",
|
"--bun",
|
||||||
"test"
|
"--inspect",
|
||||||
|
"dev"
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "bun",
|
"type": "bun",
|
||||||
|
"internalConsoleOptions": "neverOpen",
|
||||||
"request": "attach",
|
"request": "attach",
|
||||||
"name": "Attach to Bun",
|
"name": "Attach Bun",
|
||||||
// The URL of the WebSocket inspector to attach to.
|
|
||||||
// This value can be retreived by using `bun --inspect`.
|
|
||||||
"url": "ws://localhost:6499/",
|
"url": "ws://localhost:6499/",
|
||||||
|
"stopOnEntry": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
8
.vscode/settings.json
vendored
8
.vscode/settings.json
vendored
|
@ -1,10 +1,6 @@
|
||||||
{
|
{
|
||||||
// The path to the `bun` executable.
|
// The path to the `bun` executable.
|
||||||
"bun.runtime": "/path/to/bun",
|
"bun.runtime": "/path/to/bun",
|
||||||
"bun.debugTerminal": {
|
"bun.debugTerminal.enabled": true,
|
||||||
// If support for Bun should be added to the default "JavaScript Debug Terminal".
|
"bun.debugTerminal.stopOnEntry": true
|
||||||
"enabled": true,
|
|
||||||
// If the debugger should stop on the first line of the program.
|
|
||||||
"stopOnEntry": false,
|
|
||||||
}
|
|
||||||
}
|
}
|
|
@ -4,17 +4,16 @@ WORKDIR /usr/src/app
|
||||||
FROM base AS install
|
FROM base AS install
|
||||||
RUN mkdir -p /temp/dev
|
RUN mkdir -p /temp/dev
|
||||||
COPY package.json bun.lock /temp/dev
|
COPY package.json bun.lock /temp/dev
|
||||||
COPY patches/ /temp/dev/patches/
|
|
||||||
RUN cd /temp/dev && bun install --frozen-lockfile
|
RUN cd /temp/dev && bun install --frozen-lockfile
|
||||||
|
|
||||||
RUN mkdir -p /temp/prod
|
RUN mkdir -p /temp/prod
|
||||||
COPY package.json bun.lock /temp/prod/
|
COPY package.json bun.lock /temp/prod/
|
||||||
COPY patches/ /temp/prod/patches/
|
|
||||||
RUN cd /temp/prod && bun install --frozen-lockfile --production
|
RUN cd /temp/prod && bun install --frozen-lockfile --production
|
||||||
|
|
||||||
FROM base AS prerelease
|
FROM base AS prerelease
|
||||||
COPY --from=install /temp/dev/node_modules node_modules
|
COPY --from=install /temp/dev/node_modules node_modules
|
||||||
COPY . .
|
COPY . .
|
||||||
|
# RUN echo "SESSION_SECRET=$(head -c 64 /dev/random | base64)" > .env
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV SERVER_PRESET=bun
|
ENV SERVER_PRESET=bun
|
||||||
|
|
20
GitVersion.yml
Normal file
20
GitVersion.yml
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
assembly-versioning-scheme: MajorMinorPatch
|
||||||
|
assembly-file-versioning-scheme: MajorMinorPatchTag
|
||||||
|
assembly-informational-format: "{InformationalVersion}"
|
||||||
|
mode: Mainline
|
||||||
|
tag-prefix: "[vV]"
|
||||||
|
continuous-delivery-fallback-tag: ci
|
||||||
|
major-version-bump-message: '\+semver:\s?(breaking|major)'
|
||||||
|
minor-version-bump-message: '\+semver:\s?(feature|minor)'
|
||||||
|
patch-version-bump-message: '\+semver:\s?(fix|patch)'
|
||||||
|
no-bump-message: '\+semver:\s?(none|skip)'
|
||||||
|
legacy-semver-padding: 4
|
||||||
|
build-metadata-padding: 4
|
||||||
|
commits-since-version-source-padding: 4
|
||||||
|
commit-message-incrementing: Enabled
|
||||||
|
branches: {}
|
||||||
|
ignore:
|
||||||
|
sha: []
|
||||||
|
increment: Inherit
|
||||||
|
commit-date-format: yyyy-MM-dd
|
||||||
|
merge-message-formats: {}
|
|
@ -1,32 +1,18 @@
|
||||||
import { defineConfig } from '@solidjs/start/config';
|
import { defineConfig } from '@solidjs/start/config';
|
||||||
import solidSvg from 'vite-plugin-solid-svg';
|
import solidSvg from 'vite-plugin-solid-svg';
|
||||||
import devtools from 'solid-devtools/vite';
|
import devtools from 'solid-devtools/vite';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
vite: {
|
vite: {
|
||||||
resolve: {
|
|
||||||
alias: [
|
|
||||||
{ find: '@', replacement: 'F:\\Github\\calque\\node_modules\\' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
html: {
|
html: {
|
||||||
cspNonce: 'KAAS_IS_AWESOME',
|
cspNonce: 'KAAS_IS_AWESOME',
|
||||||
},
|
},
|
||||||
// css: {
|
|
||||||
// postcss: {
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
plugins: [
|
plugins: [
|
||||||
devtools({
|
devtools({
|
||||||
autoname: true,
|
autoname: true,
|
||||||
}),
|
}),
|
||||||
solidSvg(),
|
solidSvg(),
|
||||||
{
|
|
||||||
name: 'temp',
|
|
||||||
configResolved(config) {
|
|
||||||
console.log(config.resolve.alias);
|
|
||||||
},
|
|
||||||
}
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
solid: {
|
solid: {
|
||||||
|
|
|
@ -9,7 +9,7 @@ param registryUrl string
|
||||||
|
|
||||||
var appName = 'app'
|
var appName = 'app'
|
||||||
|
|
||||||
resource environment 'Microsoft.App/managedEnvironments@2025-01-01' = {
|
resource environment 'Microsoft.App/managedEnvironments@2024-03-01' = {
|
||||||
name: 'cea-${context.locationAbbreviation}-${context.environment}-${context.projectName}'
|
name: 'cea-${context.locationAbbreviation}-${context.environment}-${context.projectName}'
|
||||||
location: context.location
|
location: context.location
|
||||||
properties: {
|
properties: {
|
||||||
|
@ -29,7 +29,7 @@ resource environment 'Microsoft.App/managedEnvironments@2025-01-01' = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
resource app 'Microsoft.App/containerApps@2025-01-01' = {
|
resource app 'Microsoft.App/containerApps@2024-03-01' = {
|
||||||
name: 'ca-${context.locationAbbreviation}-${context.environment}-${context.projectName}-app'
|
name: 'ca-${context.locationAbbreviation}-${context.environment}-${context.projectName}-app'
|
||||||
location: context.location
|
location: context.location
|
||||||
identity: {
|
identity: {
|
||||||
|
|
|
@ -19,7 +19,7 @@ var context = {
|
||||||
deployedAt: deployedAt
|
deployedAt: deployedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
resource calqueResourceGroup 'Microsoft.Resources/resourceGroups@2025-04-01' = {
|
resource calqueResourceGroup 'Microsoft.Resources/resourceGroups@2024-11-01' = {
|
||||||
name: 'rg-${locationAbbreviation}-${environment}-${projectName}'
|
name: 'rg-${locationAbbreviation}-${environment}-${projectName}'
|
||||||
location: location
|
location: location
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,7 +4,7 @@ targetScope = 'resourceGroup'
|
||||||
|
|
||||||
param context Context
|
param context Context
|
||||||
|
|
||||||
resource registry 'Microsoft.ContainerRegistry/registries@2025-04-01' = {
|
resource registry 'Microsoft.ContainerRegistry/registries@2023-07-01' = {
|
||||||
name: 'acr${context.locationAbbreviation}${context.environment}${context.projectName}'
|
name: 'acr${context.locationAbbreviation}${context.environment}${context.projectName}'
|
||||||
location: context.location
|
location: context.location
|
||||||
sku: {
|
sku: {
|
||||||
|
|
57
package.json
57
package.json
|
@ -6,50 +6,58 @@
|
||||||
"bun": ">=1"
|
"bun": ">=1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@solid-primitives/clipboard": "^1.6.2",
|
"@solid-primitives/clipboard": "^1.6.0",
|
||||||
"@solid-primitives/destructure": "^0.2.2",
|
"@solid-primitives/context": "^0.3.0",
|
||||||
"@solid-primitives/i18n": "^2.2.1",
|
"@solid-primitives/deep": "^0.3.0",
|
||||||
"@solid-primitives/scheduled": "^1.5.2",
|
"@solid-primitives/destructure": "^0.2.0",
|
||||||
"@solid-primitives/selection": "^0.1.3",
|
"@solid-primitives/event-listener": "^2.4.0",
|
||||||
"@solid-primitives/storage": "^4.3.3",
|
"@solid-primitives/i18n": "^2.2.0",
|
||||||
"@solid-primitives/timer": "^1.4.2",
|
"@solid-primitives/scheduled": "^1.5.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/meta": "^0.29.4",
|
||||||
"@solidjs/router": "^0.15.3",
|
"@solidjs/router": "^0.15.3",
|
||||||
"@solidjs/start": "^1.1.7",
|
"@solidjs/start": "^1.1.1",
|
||||||
"dexie": "^4.0.11",
|
"dexie": "^4.0.11",
|
||||||
"flag-icons": "^7.5.0",
|
"flag-icons": "^7.3.2",
|
||||||
"iterator-helpers-polyfill": "^3.0.1",
|
"iterator-helpers-polyfill": "^3.0.1",
|
||||||
|
"rehype-dom-parse": "^5.0.2",
|
||||||
"rehype-parse": "^9.0.1",
|
"rehype-parse": "^9.0.1",
|
||||||
"rehype-remark": "^10.0.1",
|
"rehype-remark": "^10.0.0",
|
||||||
"rehype-stringify": "^10.0.1",
|
"rehype-stringify": "^10.0.1",
|
||||||
"remark-parse": "^11.0.0",
|
"remark-parse": "^11.0.0",
|
||||||
"remark-rehype": "^11.1.2",
|
"remark-rehype": "^11.1.1",
|
||||||
"remark-stringify": "^11.0.0",
|
"remark-stringify": "^11.0.0",
|
||||||
"sitemap": "^8.0.0",
|
"sitemap": "^8.0.0",
|
||||||
"solid-icons": "^1.1.0",
|
"solid-icons": "^1.1.0",
|
||||||
"solid-js": "^1.9.7",
|
"solid-js": "^1.9.5",
|
||||||
"ts-pattern": "^5.7.1",
|
"ts-pattern": "^5.6.2",
|
||||||
"unified": "^11.0.5",
|
"unified": "^11.0.5",
|
||||||
|
"unist-util-ancestor": "^1.4.3",
|
||||||
"unist-util-find": "^3.0.0",
|
"unist-util-find": "^3.0.0",
|
||||||
"unist-util-visit": "^5.0.0",
|
"unist-util-visit": "^5.0.0",
|
||||||
"vinxi": "^0.5.8"
|
"unist-util-visit-parents": "^6.0.1",
|
||||||
|
"vinxi": "^0.5.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@happy-dom/global-registrator": "^18.0.1",
|
"@happy-dom/global-registrator": "^17.1.8",
|
||||||
"@sinonjs/fake-timers": "^14.0.0",
|
"@sinonjs/fake-timers": "^14.0.0",
|
||||||
"@solidjs/testing-library": "^0.8.10",
|
"@solidjs/testing-library": "^0.8.10",
|
||||||
"@testing-library/jest-dom": "^6.6.3",
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/sinonjs__fake-timers": "^8.1.5",
|
"@types/sinonjs__fake-timers": "^8.1.5",
|
||||||
"@types/wicg-file-system-access": "^2023.10.6",
|
"@types/wicg-file-system-access": "^2023.10.5",
|
||||||
"@vitest/coverage-istanbul": "3.2.4",
|
"@vitest/browser": "^3.0.7",
|
||||||
"@vitest/coverage-v8": "3.2.4",
|
"@vitest/coverage-istanbul": "3.0.7",
|
||||||
"bun-types": "^1.2.19",
|
"@vitest/coverage-v8": "3.0.7",
|
||||||
"jsdom": "^26.1.0",
|
"bun-types": "^1.2.3",
|
||||||
"solid-devtools": "^0.34.3",
|
"jsdom": "^26.0.0",
|
||||||
"vite-plugin-solid": "^2.11.7",
|
"playwright": "^1.50.1",
|
||||||
|
"solid-devtools": "^0.33.0",
|
||||||
|
"vite-plugin-solid": "^2.11.2",
|
||||||
"vite-plugin-solid-svg": "^0.8.1",
|
"vite-plugin-solid-svg": "^0.8.1",
|
||||||
"vitest": "^3.2.4",
|
"vitest": "^3.0.7",
|
||||||
"workbox-window": "^7.3.0"
|
"workbox-window": "^7.3.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
@ -59,8 +67,5 @@
|
||||||
"version": "vinxi version",
|
"version": "vinxi version",
|
||||||
"test": "vitest --coverage",
|
"test": "vitest --coverage",
|
||||||
"test:ci": "vitest run"
|
"test:ci": "vitest run"
|
||||||
},
|
|
||||||
"patchedDependencies": {
|
|
||||||
"@tanstack/directive-functions-plugin@1.119.2": "patches/@tanstack%2Fdirective-functions-plugin@1.119.2.patch"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,14 +0,0 @@
|
||||||
diff --git a/dist/esm/index.js b/dist/esm/index.js
|
|
||||||
index 813fa63450583316c537cadd46db0c6fce055ac7..205b03d0ae77bfe3ee93f42b0e3d4b5f453502ec 100644
|
|
||||||
--- a/dist/esm/index.js
|
|
||||||
+++ b/dist/esm/index.js
|
|
||||||
@@ -13,6 +13,9 @@ function TanStackDirectiveFunctionsPlugin(opts) {
|
|
||||||
ROOT = config.root;
|
|
||||||
},
|
|
||||||
transform(code, id) {
|
|
||||||
+ if (id.startsWith('/@')) {
|
|
||||||
+ id = `@/${id.slice(2)}`;
|
|
||||||
+ }
|
|
||||||
var _a;
|
|
||||||
const url = pathToFileURL(id);
|
|
||||||
url.searchParams.delete("v");
|
|
|
@ -23,12 +23,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.search {
|
|
||||||
position: absolute;
|
|
||||||
inset-inline-end: 0;
|
|
||||||
inset-block-start: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.suggestions {
|
.suggestions {
|
||||||
position-anchor: --suggestions;
|
position-anchor: --suggestions;
|
||||||
|
|
||||||
|
|
|
@ -1,12 +1,12 @@
|
||||||
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 { debounce } from '@solid-primitives/scheduled';
|
||||||
import { createSelection, getTextNodes } from '@solid-primitives/selection';
|
import { createSelection, getTextNodes } from '@solid-primitives/selection';
|
||||||
import { createSource } from '~/features/source';
|
import { createSource } from '~/features/source';
|
||||||
import { isServer } from 'solid-js/web';
|
|
||||||
import css from './textarea.module.css';
|
import css from './textarea.module.css';
|
||||||
|
|
||||||
interface TextareaProps {
|
interface TextareaProps {
|
||||||
class?: string;
|
class?: string;
|
||||||
|
title?: string;
|
||||||
value: string;
|
value: string;
|
||||||
lang: string;
|
lang: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
@ -20,7 +20,7 @@ export function Textarea(props: TextareaProps) {
|
||||||
const [editorRef, setEditorRef] = createSignal<HTMLElement>();
|
const [editorRef, setEditorRef] = createSignal<HTMLElement>();
|
||||||
let mounted = false;
|
let mounted = false;
|
||||||
|
|
||||||
const source = createSource(props.value);
|
const source = createSource(() => props.value);
|
||||||
|
|
||||||
createEffect(on(() => [props.oninput, source.in] as const, ([oninput, text]) => {
|
createEffect(on(() => [props.oninput, source.in] as const, ([oninput, text]) => {
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
|
@ -43,6 +43,8 @@ export function Textarea(props: TextareaProps) {
|
||||||
const ref = editorRef();
|
const ref = editorRef();
|
||||||
|
|
||||||
if (ref) {
|
if (ref) {
|
||||||
|
console.log(ref.innerHTML);
|
||||||
|
|
||||||
source.out = ref.innerHTML;
|
source.out = ref.innerHTML;
|
||||||
|
|
||||||
ref.style.height = `1px`;
|
ref.style.height = `1px`;
|
||||||
|
@ -61,21 +63,22 @@ export function Textarea(props: TextareaProps) {
|
||||||
});
|
});
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
createHighlights(editorRef()!, 'spelling-error', source.spellingErrors);
|
props.oninput?.(source.in);
|
||||||
});
|
});
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(on(() => [editorRef()!, source.spellingErrors] as const, ([ref, errors]) => {
|
||||||
createHighlights(editorRef()!, 'grammar-error', source.grammarErrors);
|
createHighlights(ref, 'spelling-error', errors);
|
||||||
});
|
}));
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(on(() => [editorRef()!, source.grammarErrors] as const, ([ref, errors]) => {
|
||||||
createHighlights(editorRef()!, 'search-results', source.queryResults);
|
createHighlights(ref, 'grammar-error', errors);
|
||||||
});
|
}));
|
||||||
|
|
||||||
return <>
|
createEffect(on(() => [editorRef()!, source.queryResults] as const, ([ref, errors]) => {
|
||||||
<Suggestions />
|
createHighlights(ref, 'search-results', errors);
|
||||||
<input class={css.search} type="search" oninput={e => source.query = e.target.value} />
|
}));
|
||||||
<div
|
|
||||||
|
return <div
|
||||||
ref={setEditorRef}
|
ref={setEditorRef}
|
||||||
class={`${css.textarea} ${props.class}`}
|
class={`${css.textarea} ${props.class}`}
|
||||||
contentEditable
|
contentEditable
|
||||||
|
@ -85,103 +88,7 @@ export function Textarea(props: TextareaProps) {
|
||||||
data-placeholder={props.placeholder ?? ''}
|
data-placeholder={props.placeholder ?? ''}
|
||||||
on:keydown={e => e.stopPropagation()}
|
on:keydown={e => e.stopPropagation()}
|
||||||
on:pointerdown={e => e.stopPropagation()}
|
on:pointerdown={e => e.stopPropagation()}
|
||||||
/>
|
/>;
|
||||||
</>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Suggestions: Component = () => {
|
|
||||||
const [selection] = createSelection();
|
|
||||||
const [suggestionRef, setSuggestionRef] = createSignal<HTMLElement>();
|
|
||||||
const [suggestions, setSuggestions] = createSignal<string[]>([]);
|
|
||||||
|
|
||||||
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<HTMLElement | undefined>((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 <menu ref={setSuggestionRef} class={css.suggestions} popover="manual" onkeydown={onKeyDown}>
|
|
||||||
<For each={suggestions()}>{
|
|
||||||
suggestion => <li onpointerdown={onPointerDown}>{suggestion}</li>
|
|
||||||
}</For>
|
|
||||||
</menu>;
|
|
||||||
};
|
|
||||||
|
|
||||||
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<RegExpExecArray>(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][]) => {
|
const createHighlights = (node: Node, type: string, ranges: [number, number][]) => {
|
||||||
|
|
|
@ -1,5 +1,8 @@
|
||||||
// @refresh reload
|
// @refresh reload
|
||||||
import { mount, StartClient } from "@solidjs/start/client";
|
import { mount, StartClient } from "@solidjs/start/client";
|
||||||
|
import { installIntoGlobal } from "iterator-helpers-polyfill";
|
||||||
import 'solid-devtools';
|
import 'solid-devtools';
|
||||||
|
|
||||||
|
installIntoGlobal();
|
||||||
|
|
||||||
mount(() => <StartClient />, document.body);
|
mount(() => <StartClient />, document.body);
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
|
import { trackStore } from "@solid-primitives/deep";
|
||||||
import { Accessor, createEffect, createMemo, untrack } from "solid-js";
|
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 { CustomPartial } from "solid-js/store/types/store.js";
|
||||||
import { deepCopy, deepDiff, MutarionKind, Mutation } from "~/utilities";
|
import { deepCopy, deepDiff, MutarionKind, Mutation } from "~/utilities";
|
||||||
|
|
||||||
|
@ -59,7 +60,7 @@ function defaultGroupingFunction<T>(groupBy: keyof T): GroupingFunction<number,
|
||||||
|
|
||||||
export const createDataSet = <T extends Record<string, any>>(data: Accessor<T[]>, initialOptions?: { sort?: SortOptions<T>, group?: GroupOptions<T> }): DataSet<T> => {
|
export const createDataSet = <T extends Record<string, any>>(data: Accessor<T[]>, initialOptions?: { sort?: SortOptions<T>, group?: GroupOptions<T> }): DataSet<T> => {
|
||||||
const [state, setState] = createStore<DataSetState<T>>({
|
const [state, setState] = createStore<DataSetState<T>>({
|
||||||
value: deepCopy(data()),
|
value: structuredClone(data()),
|
||||||
snapshot: data(),
|
snapshot: data(),
|
||||||
sorting: initialOptions?.sort,
|
sorting: initialOptions?.sort,
|
||||||
grouping: initialOptions?.group,
|
grouping: initialOptions?.group,
|
||||||
|
@ -93,12 +94,15 @@ export const createDataSet = <T extends Record<string, any>>(data: Accessor<T[]>
|
||||||
});
|
});
|
||||||
|
|
||||||
const mutations = createMemo(() => {
|
const mutations = createMemo(() => {
|
||||||
// enumerate all values to make sure the memo is recalculated on any change
|
trackStore(state.value);
|
||||||
Object.values(state.value).map(entry => Object.values(entry ?? {}));
|
|
||||||
|
|
||||||
return deepDiff(state.snapshot, state.value).toArray();
|
return deepDiff(state.snapshot, state.value).toArray();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
console.log('muts', mutations());
|
||||||
|
});
|
||||||
|
|
||||||
const apply = (data: T[], mutations: Mutation[]) => {
|
const apply = (data: T[], mutations: Mutation[]) => {
|
||||||
for (const mutation of mutations) {
|
for (const mutation of mutations) {
|
||||||
const path = mutation.key.split('.');
|
const path = mutation.key.split('.');
|
||||||
|
|
38
src/features/editor/ast.spec.ts
Normal file
38
src/features/editor/ast.spec.ts
Normal file
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
92
src/features/editor/ast.ts
Normal file
92
src/features/editor/ast.ts
Normal file
|
@ -0,0 +1,92 @@
|
||||||
|
import type { Node, Text, Parent, RootContent } from 'hast';
|
||||||
|
import { find } from 'unist-util-find';
|
||||||
|
import { visit } from 'unist-util-visit';
|
||||||
|
import { hash } from './hash';
|
||||||
|
|
||||||
|
export const createElement = (tagName: string, children: any[], properties: object = {}) => ({ type: 'element', tagName, children, properties });
|
||||||
|
|
||||||
|
interface SplitPoint {
|
||||||
|
node: Text;
|
||||||
|
offset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const splitBy = (tree: Parent, splitPoints: SplitPoint[]): RootContent[][] => {
|
||||||
|
const result: RootContent[][] = [];
|
||||||
|
let remaining: RootContent[] = Object.hasOwn(tree, 'children') ? (tree as Parent).children : [];
|
||||||
|
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 - accumulatedOffset);
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
lastNode = node;
|
||||||
|
accumulatedOffset += offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push(remaining);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (a.type !== b.type) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return hash(a) === hash(b);
|
||||||
|
};
|
87
src/features/editor/context.spec.ts
Normal file
87
src/features/editor/context.spec.ts
Normal file
|
@ -0,0 +1,87 @@
|
||||||
|
import { describe, expect } from "vitest";
|
||||||
|
import { createEditor } from "./context";
|
||||||
|
import { render } from "@solidjs/testing-library";
|
||||||
|
import { it } from "~/test-helpers";
|
||||||
|
import { createSignal } from "solid-js";
|
||||||
|
|
||||||
|
describe('createEditor', () => {
|
||||||
|
describe('create', () => {
|
||||||
|
it('should create', async () => {
|
||||||
|
// Arrange
|
||||||
|
const res = render(() => <div data-testid="editor" />);
|
||||||
|
const ref = await res.findByTestId('editor');
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const actual = createEditor(
|
||||||
|
() => ref,
|
||||||
|
() => '<p>this is a string</p>'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(actual).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update after a change has taken place', async () => {
|
||||||
|
// Arrange
|
||||||
|
const [value, setValue] = createSignal('<p>this is a string</p>');
|
||||||
|
|
||||||
|
const res = render(() => {
|
||||||
|
const [ref, setRef] = createSignal<Element>();
|
||||||
|
|
||||||
|
const [text] = createEditor(ref, value);
|
||||||
|
|
||||||
|
return <div ref={setRef} innerHTML={text()} data-testid="editor" />;
|
||||||
|
});
|
||||||
|
const ref = await res.findByTestId('editor');
|
||||||
|
|
||||||
|
// Act
|
||||||
|
setValue('<p>this is another totally different string</p>');
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(ref.innerHTML).toBe('<p>this is another totally different string</p>');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('selection', () => {
|
||||||
|
it('should not fail if there are no selection ranges', async () => {
|
||||||
|
// Arrange
|
||||||
|
const res = render(() => {
|
||||||
|
const [ref, setRef] = createSignal<Element>();
|
||||||
|
|
||||||
|
const [text] = createEditor(ref, () => '<p>paragraph 1</p>\n<p>paragraph 2</p>\n<p>paragraph 3</p>');
|
||||||
|
|
||||||
|
return <div ref={setRef} innerHTML={text()} data-testid="editor" />;
|
||||||
|
});
|
||||||
|
|
||||||
|
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<Element>();
|
||||||
|
|
||||||
|
const [text] = createEditor(ref, () => '<p>paragraph 1</p>\n<p>paragraph 2</p>\n<p>paragraph 3</p>');
|
||||||
|
|
||||||
|
return <div ref={setRef} innerHTML={text()} data-testid="editor" />;
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
345
src/features/editor/context.ts
Normal file
345
src/features/editor/context.ts
Normal file
|
@ -0,0 +1,345 @@
|
||||||
|
import { createEventListenerMap, DocumentEventListener, WindowEventListener } from "@solid-primitives/event-listener";
|
||||||
|
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";
|
||||||
|
import rehypeParse from "rehype-parse";
|
||||||
|
|
||||||
|
export type SelectFunction = (range: Range) => void;
|
||||||
|
type Editor = { select: SelectFunction, readonly selection: Accessor<Range | undefined> };
|
||||||
|
|
||||||
|
interface EditorStoreType {
|
||||||
|
text: string;
|
||||||
|
isComposing: boolean;
|
||||||
|
selection: Range | undefined;
|
||||||
|
characterBounds: DOMRect[];
|
||||||
|
controlBounds: DOMRect;
|
||||||
|
selectionBounds: DOMRect;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createEditor(ref: Accessor<Element | undefined>, value: Accessor<string>, setValue: (next: string) => any): Editor {
|
||||||
|
if (isServer) {
|
||||||
|
return {
|
||||||
|
select() { },
|
||||||
|
selection: () => undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!("EditContext" in window)) {
|
||||||
|
throw new Error('`EditContext` is not implemented');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [store, setStore] = createStore<EditorStoreType>({
|
||||||
|
text: value(),
|
||||||
|
isComposing: false,
|
||||||
|
selection: undefined,
|
||||||
|
|
||||||
|
// Bounds
|
||||||
|
characterBounds: new Array<DOMRect>(),
|
||||||
|
controlBounds: new DOMRect(),
|
||||||
|
selectionBounds: new DOMRect(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const context = new EditContext({
|
||||||
|
text: store.text,
|
||||||
|
});
|
||||||
|
|
||||||
|
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('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<any>(context, {
|
||||||
|
textupdate(e: TextUpdateEvent) {
|
||||||
|
const selection = store.selection;
|
||||||
|
|
||||||
|
if (!selection) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
selection.extractContents();
|
||||||
|
selection.insertNode(document.createTextNode(e.text));
|
||||||
|
selection.collapse();
|
||||||
|
},
|
||||||
|
|
||||||
|
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] = indexMap.query(range);
|
||||||
|
|
||||||
|
if (!start || !end) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
context.updateSelection(start.start + range.startOffset, end.start + range.endOffset);
|
||||||
|
context.updateSelectionBounds(range.getBoundingClientRect());
|
||||||
|
|
||||||
|
setStore('selection', range);
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
selection.addRange(range);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
WindowEventListener({
|
||||||
|
onresize() {
|
||||||
|
updateControlBounds()
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
DocumentEventListener({
|
||||||
|
onSelectionchange(e) {
|
||||||
|
const selection = document.getSelection();
|
||||||
|
|
||||||
|
if (selection === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selection.rangeCount === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.activeElement !== ref()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSelection(selection.getRangeAt(0)!);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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) {
|
||||||
|
console.log(e);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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, ' ');
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
context.updateText(start, end, '</p><p> ');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
updateControlBounds();
|
||||||
|
|
||||||
|
// updateSelection(indexMap.fromHtmlIndices(40, 60))
|
||||||
|
// updateSelection(indexMap.fromHtmlIndices(599, 603))
|
||||||
|
});
|
||||||
|
|
||||||
|
createEffect((last?: Element) => {
|
||||||
|
if (last !== undefined) {
|
||||||
|
last.editContext = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const el = ref();
|
||||||
|
|
||||||
|
if (el === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
el.editContext = context;
|
||||||
|
|
||||||
|
return el;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
select(range: Range) {
|
||||||
|
updateSelection(range);
|
||||||
|
},
|
||||||
|
|
||||||
|
selection: createMemo<Range | undefined>(() => {
|
||||||
|
return store.selection;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const observe = (node: Accessor<Node | undefined>): Accessor<readonly [Node | undefined, MutationRecord[]]> => {
|
||||||
|
const [mutations, setMutations] = createSignal<MutationRecord[]>([]);
|
||||||
|
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
|
||||||
|
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'];
|
||||||
|
return keys.every(key => a[key] === b[key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Element {
|
||||||
|
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<K extends keyof EditContextEventMap>(type: K, listener: (this: EditContext, ev: EditContextEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EditContextConstructor {
|
||||||
|
new(): EditContext;
|
||||||
|
new(options: Partial<Pick<EditContext, 'text' | 'selectionStart' | 'selectionEnd'>>): EditContext;
|
||||||
|
readonly prototype: EditContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
var EditContext: EditContextConstructor;
|
||||||
|
}
|
104
src/features/editor/editor.tsx
Normal file
104
src/features/editor/editor.tsx
Normal file
|
@ -0,0 +1,104 @@
|
||||||
|
import { createContextProvider } from "@solid-primitives/context";
|
||||||
|
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";
|
||||||
|
|
||||||
|
interface EditorContextType {
|
||||||
|
readonly text: Accessor<string>;
|
||||||
|
readonly selection: Accessor<Range | undefined>;
|
||||||
|
readonly source: Source;
|
||||||
|
select: SelectFunction;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EditorContextProps extends Record<string, unknown> {
|
||||||
|
ref: Accessor<Element | undefined>;
|
||||||
|
value: string;
|
||||||
|
oninput?: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [EditorProvider, useEditor] = createContextProvider<EditorContextType, EditorContextProps>((props) => {
|
||||||
|
const source = createSource(() => props.value);
|
||||||
|
const { select, selection } = createEditor(props.ref, () => source.out, next => source.out = next);
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
props.oninput?.(source.in);
|
||||||
|
});
|
||||||
|
|
||||||
|
createEffect(on(() => [props.ref()!, source.spellingErrors] as const, ([ref, errors]) => {
|
||||||
|
createHighlights(ref, 'spelling-error', errors);
|
||||||
|
}));
|
||||||
|
|
||||||
|
createEffect(on(() => [props.ref()!, source.grammarErrors] as const, ([ref, errors]) => {
|
||||||
|
createHighlights(ref, 'grammar-error', errors);
|
||||||
|
}));
|
||||||
|
|
||||||
|
createEffect(on(() => [props.ref()!, source.queryResults] as const, ([ref, results]) => {
|
||||||
|
createHighlights(ref, 'search-results', results);
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: () => source.out,
|
||||||
|
select,
|
||||||
|
source,
|
||||||
|
selection,
|
||||||
|
};
|
||||||
|
}, {
|
||||||
|
text: () => '',
|
||||||
|
selection: () => undefined,
|
||||||
|
source: {} as Source,
|
||||||
|
select() { },
|
||||||
|
});
|
||||||
|
|
||||||
|
export { useEditor };
|
||||||
|
|
||||||
|
export function Editor(props: ParentProps<{ value: string, oninput?: (value: string) => void }>) {
|
||||||
|
const [ref, setRef] = createSignal<Element>();
|
||||||
|
|
||||||
|
return <EditorProvider ref={ref} value={props.value} oninput={props.oninput}>
|
||||||
|
{props.children}
|
||||||
|
|
||||||
|
<Content ref={setRef} />
|
||||||
|
</EditorProvider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Content(props: { ref: Setter<Element | undefined> }) {
|
||||||
|
const { text } = useEditor();
|
||||||
|
|
||||||
|
createEffect(on(text, () => console.error('rerendering')));
|
||||||
|
|
||||||
|
return <div ref={props.ref} innerHTML={text()} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createHighlights = (node: Node, type: string, indices: [number, number][]) => {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
const nodes = getTextNodes(node);
|
||||||
|
|
||||||
|
CSS.highlights.set(type, new Highlight(...indices.map(([start, end]) => indicesToRange(start, end, nodes))));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const indicesToRange = (start: number, end: number, textNodes: Node[]) => {
|
||||||
|
const [startNode, startPos] = getRangeArgs(start, textNodes);
|
||||||
|
const [endNode, endPos] = start === end ? [startNode, startPos] : getRangeArgs(end, textNodes);
|
||||||
|
|
||||||
|
const range = new Range();
|
||||||
|
|
||||||
|
if (startNode && endNode && startPos !== -1 && endPos !== -1) {
|
||||||
|
range.setStart(startNode, startPos);
|
||||||
|
range.setEnd(endNode, endPos);
|
||||||
|
}
|
||||||
|
|
||||||
|
return range;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getRangeArgs = (offset: number, texts: Node[]): [node: Node | null, offset: number] =>
|
||||||
|
texts.reduce(
|
||||||
|
([node, pos], text) =>
|
||||||
|
node
|
||||||
|
? [node, pos]
|
||||||
|
: pos <= (text as Text).data.length
|
||||||
|
? [text, pos]
|
||||||
|
: [null, pos - (text as Text).data.length],
|
||||||
|
[null, offset] as [node: Node | null, pos: number],
|
||||||
|
);
|
54
src/features/editor/hash.spec.ts
Normal file
54
src/features/editor/hash.spec.ts
Normal file
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
168
src/features/editor/hash.ts
Normal file
168
src/features/editor/hash.ts
Normal file
|
@ -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<Word>(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<number> {
|
||||||
|
for (let i = start; i <= end; i += step) {
|
||||||
|
yield i;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toBinary = function*<T>(data: T): Generator<number, void, unknown> {
|
||||||
|
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<number>): Generator<number, void, unknown> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
3
src/features/editor/index.tsx
Normal file
3
src/features/editor/index.tsx
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
export { createEditor as createEditContext } from './context';
|
||||||
|
export { Editor, useEditor } from './editor';
|
||||||
|
export { splitBy, createElement, mergeNodes } from './ast';
|
47
src/features/editor/map.ts
Normal file
47
src/features/editor/map.ts
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
import type { Root, Text } from 'hast';
|
||||||
|
import { getTextNodes } from '@solid-primitives/selection';
|
||||||
|
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 } };
|
||||||
|
export type IndexMap = IndexNode[];
|
||||||
|
export type IndexRange = [IndexNode, IndexNode] | [undefined, undefined];
|
||||||
|
|
||||||
|
export function createMap(root: Accessor<Element | undefined>, ast: Accessor<Root>) {
|
||||||
|
const [mapping, setMapping] = createSignal(new WeakMap());
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const node = root();
|
||||||
|
const tree = ast();
|
||||||
|
|
||||||
|
if (node === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
query: (range: Range) => {
|
||||||
|
return [
|
||||||
|
mapping().get(range.startContainer),
|
||||||
|
mapping().get(range.endContainer),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const createMapping = (root: Node, ast: Root): WeakMap<Node, { start: number, end: number }> => {
|
||||||
|
const nodes = getTextNodes(root);
|
||||||
|
const map = new WeakMap();
|
||||||
|
|
||||||
|
visit(ast, (n): n is Text => n.type === 'text', (node) => {
|
||||||
|
map.set(nodes.shift()!, { start: node.position!.start.offset, end: node.position!.end.offset, text: node.value })
|
||||||
|
});
|
||||||
|
|
||||||
|
return map;
|
||||||
|
};
|
|
@ -1,10 +1,9 @@
|
||||||
import { Accessor, Component, createEffect, createMemo, createSignal, For, JSX, Show, untrack } from "solid-js";
|
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 { Column, GridApi as GridCompApi, Grid as GridComp } from "~/components/grid";
|
||||||
import { createDataSet, DataSetNode, DataSetRowNode } from "~/features/dataset";
|
import { createDataSet, DataSetNode, DataSetRowNode } from "~/features/dataset";
|
||||||
import { SelectionItem } from "../selectable";
|
import { SelectionItem } from "../selectable";
|
||||||
import { useI18n } from "../i18n";
|
import { useI18n } from "../i18n";
|
||||||
import { debounce } from "@solid-primitives/scheduled";
|
|
||||||
import css from "./grid.module.css"
|
import css from "./grid.module.css"
|
||||||
import { Textarea } from "~/components/textarea";
|
import { Textarea } from "~/components/textarea";
|
||||||
|
|
||||||
|
|
|
@ -2,17 +2,14 @@ import { Accessor, createEffect, from, createSignal } from "solid-js";
|
||||||
import { json } from "./parser";
|
import { json } from "./parser";
|
||||||
import { filter } from "~/utilities";
|
import { filter } from "~/utilities";
|
||||||
import { isServer } from "solid-js/web";
|
import { isServer } from "solid-js/web";
|
||||||
import { installIntoGlobal } from 'iterator-helpers-polyfill';
|
|
||||||
import { debounce } from "@solid-primitives/scheduled";
|
import { debounce } from "@solid-primitives/scheduled";
|
||||||
|
|
||||||
installIntoGlobal();
|
|
||||||
|
|
||||||
interface Files extends Record<string, { handle: FileSystemFileHandle, file: File }> { }
|
interface Files extends Record<string, { handle: FileSystemFileHandle, file: File }> { }
|
||||||
interface Contents extends Map<string, Map<string, string>> { }
|
interface Contents extends Map<string, Map<string, string>> { }
|
||||||
|
|
||||||
export const read = (file: File): Promise<Map<string, string> | undefined> => {
|
export const read = (file: File): Promise<Map<string, string> | undefined> => {
|
||||||
switch (file.type) {
|
switch (file.type) {
|
||||||
case 'application/json': return json.load(file.text());
|
case 'application/json': return json.load(file.stream());
|
||||||
|
|
||||||
default: return Promise.resolve(undefined);
|
default: return Promise.resolve(undefined);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,20 +1,200 @@
|
||||||
import { decode } from "~/utilities";
|
import { decode } from "~/utilities";
|
||||||
|
|
||||||
export async function load(text: Promise<string>): Promise<Map<string, string>> {
|
export async function load(stream: ReadableStream<Uint8Array>): Promise<Map<string, string>> {
|
||||||
const source = JSON.parse(await text);
|
return new Map(await Array.fromAsync(parse(stream), ({ key, value }) => [key, value]));
|
||||||
const result = new Map();
|
|
||||||
const candidates = Object.entries(source);
|
|
||||||
|
|
||||||
while (candidates.length !== 0) {
|
|
||||||
const [ key, value ] = candidates.shift()!;
|
|
||||||
|
|
||||||
if (typeof value !== 'object' || value === null || value === undefined) {
|
|
||||||
result.set(key, decode(value as string));
|
|
||||||
}
|
}
|
||||||
else {
|
|
||||||
candidates.unshift(...Object.entries(value).map<[string, any]>(([ k, v ]) => [`${key}.${k}`, v]));
|
interface Entry {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
(token: Token): State;
|
||||||
|
entry?: Entry
|
||||||
|
}
|
||||||
|
|
||||||
|
const states = {
|
||||||
|
none(): State {
|
||||||
|
return (token: Token) => {
|
||||||
|
if (token.kind === 'braceOpen') {
|
||||||
|
return states.object();
|
||||||
|
}
|
||||||
|
|
||||||
|
return states.none;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
object({ path = [], expect = 'key' }: Partial<{ path: string[], expect: 'key' | 'colon' | 'value' }> = {}): State {
|
||||||
|
return (token: Token) => {
|
||||||
|
switch (expect) {
|
||||||
|
case 'key': {
|
||||||
|
if (token.kind === 'braceClose') {
|
||||||
|
return states.object({
|
||||||
|
path: path.slice(0, -1),
|
||||||
|
expect: 'key',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else if (token.kind === 'string') {
|
||||||
|
return states.object({
|
||||||
|
path: [...path, token.value],
|
||||||
|
expect: 'colon'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return states.error(`Expected a key, got ${token.kind} instead`);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'colon': {
|
||||||
|
if (token.kind !== 'colon') {
|
||||||
|
return states.error(`Expected a ':', got ${token.kind} instead`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return states.object({
|
||||||
|
path,
|
||||||
|
expect: 'value'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'value': {
|
||||||
|
if (token.kind === 'braceOpen') {
|
||||||
|
return states.object({
|
||||||
|
path,
|
||||||
|
expect: 'key',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else if (token.kind === 'string') {
|
||||||
|
const next = states.object({
|
||||||
|
path: path.slice(0, -1),
|
||||||
|
expect: 'key',
|
||||||
|
});
|
||||||
|
|
||||||
|
next.entry = { key: path.join('.'), value: decode(token.value) };
|
||||||
|
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
return states.error(`Invalid value type found '${token.kind}'`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return states.none();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error(message: string): State {
|
||||||
|
throw new Error(message);
|
||||||
|
|
||||||
|
return states.none();
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
async function* parse(stream: ReadableStream<Uint8Array>): AsyncGenerator<any, void, unknown> {
|
||||||
|
let state = states.none();
|
||||||
|
|
||||||
|
for await (const token of tokenize(read(toGenerator(stream)))) {
|
||||||
|
try {
|
||||||
|
state = state(token);
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.entry) {
|
||||||
|
yield state.entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function* take<T>(iterable: AsyncIterable<T>, numberToTake: number): AsyncGenerator<T, void, unknown> {
|
||||||
|
let i = 0;
|
||||||
|
for await (const entry of iterable) {
|
||||||
|
yield entry;
|
||||||
|
|
||||||
|
i++;
|
||||||
|
|
||||||
|
if (i === numberToTake) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Token = { start: number, length: number } & (
|
||||||
|
| { kind: 'braceOpen' }
|
||||||
|
| { kind: 'braceClose' }
|
||||||
|
| { kind: 'colon' }
|
||||||
|
| { kind: 'string', value: string }
|
||||||
|
);
|
||||||
|
|
||||||
|
async function* tokenize(characters: AsyncIterable<number>): AsyncGenerator<Token, void, unknown> {
|
||||||
|
let buffer: string = '';
|
||||||
|
let clearBuffer = false;
|
||||||
|
let start = 0;
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
for await (const character of characters) {
|
||||||
|
if (buffer.length === 0) {
|
||||||
|
start = i;
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer += String.fromCharCode(character);
|
||||||
|
const length = buffer.length;
|
||||||
|
|
||||||
|
if (buffer === '{') {
|
||||||
|
yield { kind: 'braceOpen', start, length };
|
||||||
|
clearBuffer = true;
|
||||||
|
}
|
||||||
|
else if (buffer === '}') {
|
||||||
|
yield { kind: 'braceClose', start, length };
|
||||||
|
clearBuffer = true;
|
||||||
|
}
|
||||||
|
else if (buffer === ':') {
|
||||||
|
yield { kind: 'colon', start, length };
|
||||||
|
clearBuffer = true;
|
||||||
|
}
|
||||||
|
else if (buffer.length > 1 && buffer.startsWith('"') && buffer.endsWith('"') && buffer.at(-2) !== '\\') {
|
||||||
|
yield { kind: 'string', start, length, value: buffer.slice(1, buffer.length - 1) };
|
||||||
|
clearBuffer = true;
|
||||||
|
}
|
||||||
|
else if (buffer === ',') {
|
||||||
|
clearBuffer = true;
|
||||||
|
}
|
||||||
|
else if (buffer.trim() === '') {
|
||||||
|
clearBuffer = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clearBuffer) {
|
||||||
|
buffer = '';
|
||||||
|
clearBuffer = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function* read(chunks: AsyncIterable<Uint8Array>): AsyncGenerator<number, void, unknown> {
|
||||||
|
for await (const chunk of chunks) {
|
||||||
|
for (const character of chunk) {
|
||||||
|
yield character;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function* toGenerator<T>(stream: ReadableStream<T>): AsyncGenerator<T, void, unknown> {
|
||||||
|
const reader = stream.getReader();
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
|
||||||
|
if (done) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
yield value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
reader.releaseLock();
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -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 { AiFillFile, AiFillFolder, AiFillFolderOpen } from "solid-icons/ai";
|
||||||
import { SelectionProvider, selectable } from "~/features/selectable";
|
import { SelectionProvider, selectable } from "~/features/selectable";
|
||||||
import { debounce } from "@solid-primitives/scheduled";
|
import { debounce } from "@solid-primitives/scheduled";
|
||||||
|
|
|
@ -10,7 +10,7 @@ describe('Source', () => {
|
||||||
// Arrange
|
// Arrange
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
const actual = createSource('');
|
const actual = createSource(() => '');
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
expect(actual.out).toBe('');
|
expect(actual.out).toBe('');
|
||||||
|
@ -22,7 +22,7 @@ describe('Source', () => {
|
||||||
const expected = '<p><strong>text</strong></p>';
|
const expected = '<p><strong>text</strong></p>';
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
const actual = createSource(given);
|
const actual = createSource(() => given);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
expect(actual.out).toBe(expected);
|
expect(actual.out).toBe(expected);
|
||||||
|
@ -31,7 +31,7 @@ describe('Source', () => {
|
||||||
it('should contain query results', () => {
|
it('should contain query results', () => {
|
||||||
// Arrange
|
// Arrange
|
||||||
const expected: [number, number][] = [[8, 9], [12, 13], [15, 16]];
|
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
|
// Act
|
||||||
source.query = 'a';
|
source.query = 'a';
|
||||||
|
|
|
@ -1,21 +1,23 @@
|
||||||
import { createEffect, onMount } from "solid-js";
|
import { Accessor, createEffect, createMemo } from "solid-js";
|
||||||
import { createStore } from "solid-js/store";
|
import { createStore } from "solid-js/store";
|
||||||
import { unified } from 'unified'
|
import { unified } from 'unified'
|
||||||
import { Text, Root } from 'hast'
|
|
||||||
import { visit } from "unist-util-visit";
|
import { visit } from "unist-util-visit";
|
||||||
import { decode } from "~/utilities";
|
import { decode } from "~/utilities";
|
||||||
import remarkParse from 'remark-parse'
|
import remarkParse from 'remark-parse'
|
||||||
import remarkRehype from 'remark-rehype'
|
import remarkRehype from 'remark-rehype'
|
||||||
import remarkStringify from 'remark-stringify'
|
import remarkStringify from 'remark-stringify'
|
||||||
import rehypeParse from 'rehype-parse'
|
import rehypeParse from 'rehype-parse'
|
||||||
|
import rehypeDomParse from 'rehype-dom-parse'
|
||||||
import rehypeRemark from 'rehype-remark'
|
import rehypeRemark from 'rehype-remark'
|
||||||
import rehypeStringify from 'rehype-stringify'
|
import rehypeStringify from 'rehype-stringify'
|
||||||
|
import type { Text, Root } from 'hast'
|
||||||
|
import { isServer } from "solid-js/web";
|
||||||
|
|
||||||
interface SourceStore {
|
interface SourceStore {
|
||||||
in: string;
|
in: string;
|
||||||
out: string;
|
out: string;
|
||||||
plain: string;
|
plain: string;
|
||||||
query: string;
|
query: RegExp;
|
||||||
metadata: {
|
metadata: {
|
||||||
spellingErrors: [number, number][];
|
spellingErrors: [number, number][];
|
||||||
grammarErrors: [number, number][];
|
grammarErrors: [number, number][];
|
||||||
|
@ -26,7 +28,7 @@ interface SourceStore {
|
||||||
export interface Source {
|
export interface Source {
|
||||||
in: string;
|
in: string;
|
||||||
out: string;
|
out: string;
|
||||||
query: string;
|
query: RegExp;
|
||||||
readonly spellingErrors: [number, number][];
|
readonly spellingErrors: [number, number][];
|
||||||
readonly grammarErrors: [number, number][];
|
readonly grammarErrors: [number, number][];
|
||||||
readonly queryResults: [number, number][];
|
readonly queryResults: [number, number][];
|
||||||
|
@ -34,29 +36,12 @@ export interface Source {
|
||||||
|
|
||||||
// TODO :: make this configurable, right now we can only do markdown <--> html.
|
// TODO :: make this configurable, right now we can only do markdown <--> html.
|
||||||
const inToOutProcessor = unified().use(remarkParse).use(remarkRehype).use(rehypeStringify);
|
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 {
|
export function createSource(value: Accessor<string>): Source {
|
||||||
const ast = inToOutProcessor.runSync(inToOutProcessor.parse(initalValue));
|
const [store, setStore] = createStore<SourceStore>({ in: '', out: '', plain: '', query: new RegExp('', 'gi'), metadata: { spellingErrors: [], grammarErrors: [], queryResults: [] } });
|
||||||
const out = String(inToOutProcessor.stringify(ast));
|
|
||||||
const plain = String(unified().use(plainTextStringify).stringify(ast));
|
|
||||||
|
|
||||||
const [store, setStore] = createStore<SourceStore>({ in: initalValue, out, plain, query: '', metadata: { spellingErrors: [], grammarErrors: [], queryResults: [] } });
|
const src: Source = {
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
const value = store.plain;
|
|
||||||
|
|
||||||
setStore('metadata', {
|
|
||||||
spellingErrors: spellChecker(value, ''),
|
|
||||||
grammarErrors: grammarChecker(value, ''),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
setStore('metadata', 'queryResults', findMatches(store.plain, store.query).toArray());
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
get in() {
|
get in() {
|
||||||
return store.in;
|
return store.in;
|
||||||
},
|
},
|
||||||
|
@ -102,6 +87,26 @@ export function createSource(initalValue: string): Source {
|
||||||
return store.metadata.queryResults;
|
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));
|
||||||
|
});
|
||||||
|
|
||||||
|
return src;
|
||||||
}
|
}
|
||||||
|
|
||||||
function plainTextStringify() {
|
function plainTextStringify() {
|
||||||
|
@ -116,26 +121,10 @@ function plainTextStringify() {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function* findMatches(text: string, query: string): Generator<[number, number], void, unknown> {
|
function findMatches(text: string, query: RegExp): [number, number][] {
|
||||||
if (query.length < 1) {
|
return text.matchAll(query).map<[number, number]>(({ 0: match, index }) => {
|
||||||
return;
|
return [index, index + match.length];
|
||||||
}
|
}).toArray();
|
||||||
|
|
||||||
let startIndex = 0;
|
|
||||||
|
|
||||||
while (startIndex < text.length) {
|
|
||||||
const index = text.indexOf(query, startIndex);
|
|
||||||
|
|
||||||
if (index === -1) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const end = index + query.length;
|
|
||||||
|
|
||||||
yield [index, end];
|
|
||||||
|
|
||||||
startIndex = end;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const spellChecker = checker(/\w+/gi);
|
const spellChecker = checker(/\w+/gi);
|
||||||
|
|
|
@ -35,7 +35,7 @@
|
||||||
"newKey": {
|
"newKey": {
|
||||||
"title": "Which key do you want to create?",
|
"title": "Which key do you want to create?",
|
||||||
"placeholder": "Name of the new key",
|
"placeholder": "Name of the new key",
|
||||||
"description": "Hint: use `.` to denote nested keys,\ni.e. `this.is.some.key` would be a key that is four levels deep."
|
"description": "Hint: use `.` to denote nested keys,\n\ni.e. `this.is.some.key` would be a key that is four levels deep."
|
||||||
},
|
},
|
||||||
"newLanguage": {
|
"newLanguage": {
|
||||||
"title": "Which language do you want to add?",
|
"title": "Which language do you want to add?",
|
||||||
|
|
|
@ -35,7 +35,7 @@
|
||||||
"newKey": {
|
"newKey": {
|
||||||
"title": "Welke sleutel wil je toevoegen?",
|
"title": "Welke sleutel wil je toevoegen?",
|
||||||
"placeholder": "Naam van de nieuwe sleutel",
|
"placeholder": "Naam van de nieuwe sleutel",
|
||||||
"description": "Hint: gebruik een `.` voor geneste sleutels,\nbijv. `this.is.some.key` is een sleutel die vier lagen diep is."
|
"description": "Hint: gebruik een `.` voor geneste sleutels,\n\nbijv. `this.is.some.key` is een sleutel die vier lagen diep is."
|
||||||
},
|
},
|
||||||
"newLanguage": {
|
"newLanguage": {
|
||||||
"title": "Welke taal wil je toevoegen?",
|
"title": "Welke taal wil je toevoegen?",
|
||||||
|
|
|
@ -17,7 +17,8 @@ export default function Experimental(props: ParentProps) {
|
||||||
<Menu.Item command={goTo.withLabel('table').with('table')} />
|
<Menu.Item command={goTo.withLabel('table').with('table')} />
|
||||||
<Menu.Item command={goTo.withLabel('grid').with('grid')} />
|
<Menu.Item command={goTo.withLabel('grid').with('grid')} />
|
||||||
<Menu.Item command={goTo.withLabel('context-menu').with('context-menu')} />
|
<Menu.Item command={goTo.withLabel('context-menu').with('context-menu')} />
|
||||||
<Menu.Item command={goTo.withLabel('formatter').with('formatter')} />
|
<Menu.Item command={goTo.withLabel('textarea').with('textarea')} />
|
||||||
|
<Menu.Item command={goTo.withLabel('editor').with('editor')} />
|
||||||
<Menu.Item command={goTo.withLabel('file-system-observer').with('file-system-observer')} />
|
<Menu.Item command={goTo.withLabel('file-system-observer').with('file-system-observer')} />
|
||||||
</Menu.Root>
|
</Menu.Root>
|
||||||
|
|
||||||
|
|
73
src/routes/(editor)/experimental/editor.module.css
Normal file
73
src/routes/(editor)/experimental/editor.module.css
Normal file
|
@ -0,0 +1,73 @@
|
||||||
|
.root {
|
||||||
|
position: relative;
|
||||||
|
margin: 1em;
|
||||||
|
padding: .5em;
|
||||||
|
gap: 1em;
|
||||||
|
display: grid;
|
||||||
|
|
||||||
|
grid: 100% / repeat(2, minmax(0, 1fr));
|
||||||
|
|
||||||
|
inline-size: calc(100% - 2em);
|
||||||
|
block-size: calc(100% - 2em);
|
||||||
|
|
||||||
|
place-content: start;
|
||||||
|
background-color: var(--surface-500);
|
||||||
|
border-radius: var(--radii-xl);
|
||||||
|
|
||||||
|
& > :is(textarea, .textarea) {
|
||||||
|
overflow: auto;
|
||||||
|
padding: .5em;
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
& ::highlight(debug) {
|
||||||
|
text-decoration: double underline;
|
||||||
|
text-decoration-color: cornflowerblue;
|
||||||
|
}
|
||||||
|
|
||||||
|
& ::highlight(search-results) {
|
||||||
|
background-color: var(--secondary-900);
|
||||||
|
}
|
||||||
|
|
||||||
|
& ::highlight(spelling-error) {
|
||||||
|
text-decoration-line: spelling-error;
|
||||||
|
}
|
||||||
|
|
||||||
|
& ::highlight(grammar-error) {
|
||||||
|
text-decoration-line: grammar-error;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor {
|
||||||
|
display: block grid;
|
||||||
|
grid: auto 1fr / 100%;
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: block grid;
|
||||||
|
grid-auto-flow: column;
|
||||||
|
place-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search {
|
||||||
|
position: absolute;
|
||||||
|
inset-inline-end: 0;
|
||||||
|
inset-block-start: 0;
|
||||||
|
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
|
||||||
|
padding: .5em;
|
||||||
|
gap: .5em;
|
||||||
|
|
||||||
|
background-color: var(--surface-700);
|
||||||
|
border-radius: var(--radii-m);
|
||||||
|
box-shadow: var(--shadow-2);
|
||||||
|
|
||||||
|
&:popover-open {
|
||||||
|
display: block grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > label {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
129
src/routes/(editor)/experimental/editor.tsx
Normal file
129
src/routes/(editor)/experimental/editor.tsx
Normal file
|
@ -0,0 +1,129 @@
|
||||||
|
import { createEffect, createMemo, createSignal, onMount, untrack } from "solid-js";
|
||||||
|
import { debounce } from "@solid-primitives/scheduled";
|
||||||
|
import { Editor, useEditor } from "~/features/editor";
|
||||||
|
import css from './editor.module.css';
|
||||||
|
import { assert } from "~/utilities";
|
||||||
|
|
||||||
|
const tempVal = `
|
||||||
|
# Header
|
||||||
|
|
||||||
|
this is **a string** that contains bolded text
|
||||||
|
|
||||||
|
this is *a string* that contains italicized text
|
||||||
|
|
||||||
|
> Dorothy followed her through many of the beautiful rooms in her castle.
|
||||||
|
|
||||||
|
> #### The quarterly results look great!
|
||||||
|
>
|
||||||
|
> - Revenue was off the chart.
|
||||||
|
> - Profits were higher than ever.
|
||||||
|
>
|
||||||
|
> > The Witch bade her clean the pots and kettles and sweep the floor and keep the fire fed with wood.
|
||||||
|
>
|
||||||
|
> *Everything* is going according to **plan**.
|
||||||
|
|
||||||
|
- First item
|
||||||
|
- Second item
|
||||||
|
- Third item
|
||||||
|
- Fourth item
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default function Formatter(props: {}) {
|
||||||
|
const [value, setValue] = createSignal(tempVal);
|
||||||
|
|
||||||
|
const onInput = (e: InputEvent) => {
|
||||||
|
setValue((e.target! as HTMLTextAreaElement).value);
|
||||||
|
};
|
||||||
|
|
||||||
|
return <div class={css.root}>
|
||||||
|
<textarea oninput={onInput} title="markdown">{value()}</textarea>
|
||||||
|
|
||||||
|
<div class={css.editor}>
|
||||||
|
<Editor value={value()} oninput={setValue}>
|
||||||
|
<Toolbar />
|
||||||
|
<SearchAndReplace />
|
||||||
|
</Editor>
|
||||||
|
</div>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Toolbar() {
|
||||||
|
const { selection } = useEditor();
|
||||||
|
|
||||||
|
const bold = () => {
|
||||||
|
const range = untrack(selection)!;
|
||||||
|
|
||||||
|
console.log(range);
|
||||||
|
|
||||||
|
if (range.startContainer.nodeType !== Node.TEXT_NODE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (range.endContainer.nodeType !== Node.TEXT_NODE) {
|
||||||
|
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') {
|
||||||
|
range.selectNode(range.commonAncestorContainer.parentElement);
|
||||||
|
range.insertNode(fragment);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
const strong = document.createElement('strong');
|
||||||
|
strong.append(fragment);
|
||||||
|
|
||||||
|
range.insertNode(strong);
|
||||||
|
range.selectNode(strong);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
// bold();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return <div class={css.toolbar}>
|
||||||
|
<button onclick={bold}>bold</button>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SearchAndReplace() {
|
||||||
|
const { source } = useEditor();
|
||||||
|
const [replacement, setReplacement] = createSignal('');
|
||||||
|
const [term, setTerm] = createSignal('');
|
||||||
|
const [caseInsensitive, setCaseInsensitive] = createSignal(true);
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
console.log(source.queryResults);
|
||||||
|
|
||||||
|
// mutate(text => text.replaceAll(query(), replacement()));
|
||||||
|
};
|
||||||
|
|
||||||
|
return <form on:submit={replace} class={css.search} popover="manual">
|
||||||
|
<label><span>Case insensitive</span><input type="checkbox" checked={caseInsensitive()} oninput={e => setCaseInsensitive(e.target.checked)} /></label>
|
||||||
|
<label><span>Search for</span><input type="search" title="editor-search" oninput={e => setTerm(e.target.value)} /></label>
|
||||||
|
<label><span>Replace with</span><input type="search" title="editor-replace" oninput={e => setReplacement(e.target.value)} /></label>
|
||||||
|
|
||||||
|
<button>replace</button>
|
||||||
|
</form>;
|
||||||
|
};
|
|
@ -1,22 +0,0 @@
|
||||||
.root {
|
|
||||||
position: relative;
|
|
||||||
margin: 1em;
|
|
||||||
padding: .5em;
|
|
||||||
gap: 1em;
|
|
||||||
display: grid;
|
|
||||||
|
|
||||||
grid: 100% / repeat(2, minmax(0, 1fr));
|
|
||||||
|
|
||||||
inline-size: calc(100% - 2em);
|
|
||||||
block-size: calc(100% - 2em);
|
|
||||||
|
|
||||||
place-content: start;
|
|
||||||
background-color: var(--surface-500);
|
|
||||||
border-radius: var(--radii-xl);
|
|
||||||
|
|
||||||
& > :is(textarea, .textarea) {
|
|
||||||
overflow: auto;
|
|
||||||
padding: .5em;
|
|
||||||
background-color: transparent;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { Sidebar } from '~/components/sidebar';
|
import { Sidebar } from '~/components/sidebar';
|
||||||
import { CellEditor, Column, DataSetGroupNode, DataSetNode, DataSetRowNode, Grid, GridApi } from '~/components/grid';
|
import { CellEditor, Column, DataSetGroupNode, DataSetNode, DataSetRowNode, Grid, GridApi } from '~/components/grid';
|
||||||
import { people, Person } from './experimental.data';
|
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 { MutarionKind, Mutation } from '~/utilities';
|
||||||
import { Table } from '~/components/table';
|
import { Table } from '~/components/table';
|
||||||
import { createDataSet } from '~/features/dataset';
|
import { createDataSet } from '~/features/dataset';
|
||||||
|
@ -71,6 +71,10 @@ export default function GridExperiment() {
|
||||||
sort: { by: 'name', reversed: false },
|
sort: { by: 'name', reversed: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
console.log(rows);
|
||||||
|
});
|
||||||
|
|
||||||
return <div class={css.root}>
|
return <div class={css.root}>
|
||||||
<Sidebar as="aside" label={'Grid options'} class={css.sidebar}>
|
<Sidebar as="aside" label={'Grid options'} class={css.sidebar}>
|
||||||
<fieldset>
|
<fieldset>
|
||||||
|
@ -107,13 +111,13 @@ type M = { kind: MutarionKind, key: string, original?: any, value?: any };
|
||||||
const Mutations: Component<{ mutations: Mutation[] }> = (props) => {
|
const Mutations: Component<{ mutations: Mutation[] }> = (props) => {
|
||||||
const columns: Column<M>[] = [{ id: 'key', label: 'Key' }, { id: 'original', label: 'Old' }, { id: 'value', label: 'New' }];
|
const columns: Column<M>[] = [{ id: 'key', label: 'Key' }, { id: 'original', label: 'Old' }, { id: 'value', label: 'New' }];
|
||||||
|
|
||||||
const rows = createMemo(() => createDataSet<M>(props.mutations));
|
const rows = createDataSet<M>(() => props.mutations);
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
rows().group({ by: 'kind' });
|
rows.group({ by: 'kind' });
|
||||||
});
|
});
|
||||||
|
|
||||||
return <Table rows={rows()} columns={columns}>{{
|
return <Table rows={rows} columns={columns}>{{
|
||||||
original: ({ value }) => value ? <del><pre>{JSON.stringify(value, null, 2)}</pre></del> : null,
|
original: ({ value }) => value ? <del><pre>{JSON.stringify(value, null, 2)}</pre></del> : null,
|
||||||
value: ({ value }) => value ? <ins><pre>{JSON.stringify(value, null, 2)}</pre></ins> : null,
|
value: ({ value }) => value ? <ins><pre>{JSON.stringify(value, null, 2)}</pre></ins> : null,
|
||||||
}}</Table>
|
}}</Table>
|
||||||
|
|
50
src/routes/(editor)/experimental/textarea.module.css
Normal file
50
src/routes/(editor)/experimental/textarea.module.css
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
.root {
|
||||||
|
position: relative;
|
||||||
|
margin: 1em;
|
||||||
|
padding: .5em;
|
||||||
|
gap: 1em;
|
||||||
|
display: grid;
|
||||||
|
|
||||||
|
grid: 100% / repeat(2, minmax(0, 1fr));
|
||||||
|
|
||||||
|
inline-size: calc(100% - 2em);
|
||||||
|
block-size: calc(100% - 2em);
|
||||||
|
|
||||||
|
place-content: start;
|
||||||
|
background-color: var(--surface-500);
|
||||||
|
border-radius: var(--radii-xl);
|
||||||
|
|
||||||
|
& > :is(textarea, .textarea) {
|
||||||
|
overflow: auto;
|
||||||
|
padding: .5em;
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
& ::highlight(search-results) {
|
||||||
|
background-color: var(--secondary-900);
|
||||||
|
}
|
||||||
|
|
||||||
|
& ::highlight(spelling-error) {
|
||||||
|
text-decoration-line: spelling-error;
|
||||||
|
}
|
||||||
|
|
||||||
|
& ::highlight(grammar-error) {
|
||||||
|
text-decoration-line: grammar-error;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search {
|
||||||
|
position: absolute;
|
||||||
|
inset-inline-end: 0;
|
||||||
|
inset-block-start: 0;
|
||||||
|
|
||||||
|
display: block grid;
|
||||||
|
grid-auto-flow: row;
|
||||||
|
|
||||||
|
padding: .5em;
|
||||||
|
gap: .5em;
|
||||||
|
|
||||||
|
background-color: var(--surface-700);
|
||||||
|
border-radius: var(--radii-m);
|
||||||
|
box-shadow: var(--shadow-2);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,7 +1,7 @@
|
||||||
import { createSignal } from "solid-js";
|
import { createSignal, untrack } from "solid-js";
|
||||||
import { debounce } from "@solid-primitives/scheduled";
|
import { debounce } from "@solid-primitives/scheduled";
|
||||||
import { Textarea } from "~/components/textarea";
|
import { Textarea } from "~/components/textarea";
|
||||||
import css from './formatter.module.css';
|
import css from './textarea.module.css';
|
||||||
|
|
||||||
const tempVal = `
|
const tempVal = `
|
||||||
# Header
|
# Header
|
||||||
|
@ -12,15 +12,13 @@ this is *a string* that contains italicized text
|
||||||
|
|
||||||
> Dorothy followed her through many of the beautiful rooms in her castle.
|
> Dorothy followed her through many of the beautiful rooms in her castle.
|
||||||
|
|
||||||
> Dorothy followed her through many of the beautiful rooms in her castle.
|
|
||||||
>
|
|
||||||
> > The Witch bade her clean the pots and kettles and sweep the floor and keep the fire fed with wood.
|
|
||||||
|
|
||||||
> #### The quarterly results look great!
|
> #### The quarterly results look great!
|
||||||
>
|
>
|
||||||
> - Revenue was off the chart.
|
> - Revenue was off the chart.
|
||||||
> - Profits were higher than ever.
|
> - Profits were higher than ever.
|
||||||
>
|
>
|
||||||
|
> > The Witch bade her clean the pots and kettles and sweep the floor and keep the fire fed with wood.
|
||||||
|
>
|
||||||
> *Everything* is going according to **plan**.
|
> *Everything* is going according to **plan**.
|
||||||
|
|
||||||
- First item
|
- First item
|
||||||
|
@ -37,7 +35,7 @@ export default function Formatter(props: {}) {
|
||||||
}, 300);
|
}, 300);
|
||||||
|
|
||||||
return <div class={css.root}>
|
return <div class={css.root}>
|
||||||
<textarea oninput={onInput}>{value()}</textarea>
|
<textarea oninput={onInput} title="markdown">{value()}</textarea>
|
||||||
<Textarea class={css.textarea} value={value()} oninput={setValue} lang="en-GB" />
|
<Textarea class={css.textarea} title="html" value={value()} oninput={setValue} lang="en-GB" />
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, expect, vi } from 'vitest';
|
import { describe, expect, vi } from 'vitest';
|
||||||
import { decode, deepCopy, deepDiff, filter, gen__split_by_filter, map, MutarionKind, split_by_filter, splitAt } from './utilities';
|
import { decode, deepCopy, deepDiff, filter, gen__split_by_filter, map, MutarionKind, splice, split_by_filter, splitAt } from './utilities';
|
||||||
import { it } from '~/test-helpers';
|
import { it } from '~/test-helpers';
|
||||||
|
|
||||||
const { spyOn } = vi;
|
const { spyOn } = vi;
|
||||||
|
@ -11,6 +11,44 @@ const first = <T>(iterable: Iterable<T>): T | undefined => {
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('utilities', () => {
|
describe('utilities', () => {
|
||||||
|
describe('splice', () => {
|
||||||
|
it('can replace part of string based on indices', async () => {
|
||||||
|
// Arrange
|
||||||
|
const given = 'this is a string';
|
||||||
|
const expected = 'this was a string';
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const actual = splice(given, 5, 7, 'was');
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(actual).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can replace from the start', async () => {
|
||||||
|
// Arrange
|
||||||
|
const given = 'this is a string';
|
||||||
|
const expected = 'was a string';
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const actual = splice(given, 0, 7, 'was');
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(actual).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can replace till the end', async () => {
|
||||||
|
// Arrange
|
||||||
|
const given = 'this is a string';
|
||||||
|
const expected = 'this was';
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const actual = splice(given, 5, -0, 'was');
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(actual).toBe(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('splitAt', () => {
|
describe('splitAt', () => {
|
||||||
it('should split the given string at the given index', async () => {
|
it('should split the given string at the given index', async () => {
|
||||||
// Arrange
|
// Arrange
|
||||||
|
|
|
@ -1,3 +1,12 @@
|
||||||
|
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)}`;
|
||||||
|
};
|
||||||
export const splitAt = (subject: string, index: number): readonly [string, string] => {
|
export const splitAt = (subject: string, index: number): readonly [string, string] => {
|
||||||
if (index < 0) {
|
if (index < 0) {
|
||||||
return [subject, ''];
|
return [subject, ''];
|
||||||
|
@ -43,6 +52,20 @@ const decodeReplacer = (_: any, char: EncodedChar) => ({
|
||||||
}[char.charAt(0) as ('t' | 'b' | 'n' | 'r' | 'f' | '\'' | '"' | 'u')]);
|
}[char.charAt(0) as ('t' | 'b' | 'n' | 'r' | 'f' | '\'' | '"' | 'u')]);
|
||||||
export const decode = (subject: string): string => subject.replace(decodeRegex, decodeReplacer);
|
export const decode = (subject: string): string => subject.replace(decodeRegex, decodeReplacer);
|
||||||
|
|
||||||
|
const LAZY_SYMBOL = Symbol('not loaded');
|
||||||
|
export const lazy = <T>(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 = <T>(original: T): T => {
|
export const deepCopy = <T>(original: T): T => {
|
||||||
if (typeof original !== 'object' || original === null || original === undefined) {
|
if (typeof original !== 'object' || original === null || original === undefined) {
|
||||||
return original;
|
return original;
|
||||||
|
@ -126,7 +149,8 @@ export function* deepDiff<T1 extends object, T2 extends object>(a: T1, b: T2, pa
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isIterable = (subject: object): subject is Iterable<any> => ['boolean', 'undefined', 'null', 'number'].includes(typeof subject) === false;
|
const nonIterableTypes = ['boolean', 'undefined', 'null', 'number'];
|
||||||
|
const isIterable = (subject: object): subject is Iterable<any> => nonIterableTypes.includes(typeof subject) === false;
|
||||||
const entriesOf = (subject: object): Iterable<readonly [string | number, any]> => {
|
const entriesOf = (subject: object): Iterable<readonly [string | number, any]> => {
|
||||||
if (subject instanceof Array) {
|
if (subject instanceof Array) {
|
||||||
return subject.entries();
|
return subject.entries();
|
||||||
|
|
|
@ -14,9 +14,9 @@
|
||||||
"@solidjs/start/env",
|
"@solidjs/start/env",
|
||||||
"@testing-library/jest-dom",
|
"@testing-library/jest-dom",
|
||||||
"@types/wicg-file-system-access",
|
"@types/wicg-file-system-access",
|
||||||
|
"@vitest/browser/providers/playwright",
|
||||||
"vinxi/types/client",
|
"vinxi/types/client",
|
||||||
"vite-plugin-solid-svg/types-component-solid",
|
"vite-plugin-solid-svg/types-component-solid",
|
||||||
"vite-plugin-pwa/solid",
|
|
||||||
"bun-types"
|
"bun-types"
|
||||||
],
|
],
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
|
|
|
@ -28,7 +28,7 @@ export default defineConfig({
|
||||||
provider: 'istanbul',
|
provider: 'istanbul',
|
||||||
reportsDirectory: './.coverage',
|
reportsDirectory: './.coverage',
|
||||||
all: false,
|
all: false,
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -39,6 +39,13 @@ function reportWith(...reporter: CoverageReporter[]): Plugin {
|
||||||
config(userConf, env) {
|
config(userConf, env) {
|
||||||
if (userConf.test) {
|
if (userConf.test) {
|
||||||
userConf.test.coverage = { ...userConf.test.coverage, reporter } as CoverageV8Options;
|
userConf.test.coverage = { ...userConf.test.coverage, reporter } as CoverageV8Options;
|
||||||
|
userConf.test.browser = {
|
||||||
|
provider: 'playwright',
|
||||||
|
enabled: true,
|
||||||
|
headless: true,
|
||||||
|
screenshotFailures: false,
|
||||||
|
instances: [{ browser: 'chromium' }]
|
||||||
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue