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
32
.github/workflows/app.yml
vendored
32
.github/workflows/app.yml
vendored
|
@ -27,23 +27,25 @@ jobs:
|
|||
semver: ${{ steps.gitversion.outputs.SemVer }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install GitVersion
|
||||
uses: gittools/actions/gitversion/setup@v4.1.0
|
||||
uses: gittools/actions/gitversion/setup@v3.1.11
|
||||
with:
|
||||
versionSpec: "6.x"
|
||||
versionSpec: "5.x"
|
||||
- name: Determine Version
|
||||
id: gitversion
|
||||
uses: gittools/actions/gitversion/execute@v4.1.0
|
||||
uses: gittools/actions/gitversion/execute@v3.1.11
|
||||
with:
|
||||
useConfigFile: true
|
||||
|
||||
build_and_publish:
|
||||
name: Build & Publish
|
||||
runs-on: ubuntu-latest
|
||||
needs: versionize
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Build container images
|
||||
run: |
|
||||
|
@ -71,7 +73,7 @@ jobs:
|
|||
matrix:
|
||||
environment: [ 'prd' ]
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: |
|
||||
infrastructure
|
||||
|
@ -84,13 +86,13 @@ jobs:
|
|||
subscription-id: ${{ secrets.CALQUE_PRD_SUBSCRIPTION_ID }}
|
||||
|
||||
- name: Deploy bicep
|
||||
uses: azure/cli@v2
|
||||
uses: Azure/cli@v2
|
||||
with:
|
||||
azcliversion: 2.75.0
|
||||
inlineScript: >-
|
||||
az deployment sub create
|
||||
--location westeurope
|
||||
--template-file infrastructure/main.bicep
|
||||
--parameters infrastructure/params/${{ matrix.environment }}.bicepparam
|
||||
--parameters version=${{needs.versionize.outputs.semver}}
|
||||
--parameters registryUrl=${{ secrets.ACR_LOGIN_SERVER }}
|
||||
azcliversion: 2.66.0
|
||||
inlineScript: |
|
||||
az deployment sub create \
|
||||
--location westeurope \
|
||||
--template-file infrastructure/main.bicep \
|
||||
--parameters infrastructure/params/${{ matrix.environment }}.bicepparam \
|
||||
--parameters version=${{needs.versionize.outputs.semver}} \
|
||||
--parameters registryUrl=${{ secrets.ACR_LOGIN_SERVER }}
|
44
.vscode/launch.json
vendored
44
.vscode/launch.json
vendored
|
@ -6,7 +6,7 @@
|
|||
"request": "launch",
|
||||
"name": "Start dev",
|
||||
// The path to a JavaScript or TypeScript file to run.
|
||||
"program": "${file}",
|
||||
"program": "entry-server.tsx",
|
||||
// The arguments to pass to the program, if any.
|
||||
"args": [],
|
||||
// The working directory of the program.
|
||||
|
@ -15,40 +15,9 @@
|
|||
"env": {},
|
||||
// If the environment variables should not be inherited from the parent process.
|
||||
"strictEnv": false,
|
||||
// If the program should be run in watch mode.
|
||||
// This is equivalent to passing `--watch` to the `bun` executable.
|
||||
// You can also set this to "hot" to enable hot reloading using `--hot`.
|
||||
"watchMode": false,
|
||||
// If the debugger should stop on the first line of the program.
|
||||
"stopOnEntry": false,
|
||||
// If the debugger should be disabled. (for example, breakpoints will not be hit)
|
||||
"noDebug": false,
|
||||
// The path to the `bun` executable, defaults to your `PATH` environment variable.
|
||||
"runtime": "bun",
|
||||
// The arguments to pass to the `bun` executable, if any.
|
||||
// Unlike `args`, these are passed to the executable itself, not the program.
|
||||
"runtimeArgs": [],
|
||||
},
|
||||
{
|
||||
"type": "bun",
|
||||
"request": "launch",
|
||||
"name": "Run tests",
|
||||
// The path to a JavaScript or TypeScript file to run.
|
||||
"program": "${file}",
|
||||
// The arguments to pass to the program, if any.
|
||||
"args": [],
|
||||
// The working directory of the program.
|
||||
"cwd": "${workspaceFolder}",
|
||||
// The environment variables to pass to the program.
|
||||
"env": {},
|
||||
// If the environment variables should not be inherited from the parent process.
|
||||
"strictEnv": false,
|
||||
// If the program should be run in watch mode.
|
||||
// This is equivalent to passing `--watch` to the `bun` executable.
|
||||
// You can also set this to "hot" to enable hot reloading using `--hot`.
|
||||
"watchMode": false,
|
||||
// If the debugger should stop on the first line of the program.
|
||||
"stopOnEntry": false,
|
||||
"stopOnEntry": true,
|
||||
// If the debugger should be disabled. (for example, breakpoints will not be hit)
|
||||
"noDebug": false,
|
||||
// The path to the `bun` executable, defaults to your `PATH` environment variable.
|
||||
|
@ -57,16 +26,17 @@
|
|||
// Unlike `args`, these are passed to the executable itself, not the program.
|
||||
"runtimeArgs": [
|
||||
"--bun",
|
||||
"test"
|
||||
"--inspect",
|
||||
"dev"
|
||||
],
|
||||
},
|
||||
{
|
||||
"type": "bun",
|
||||
"internalConsoleOptions": "neverOpen",
|
||||
"request": "attach",
|
||||
"name": "Attach to Bun",
|
||||
// The URL of the WebSocket inspector to attach to.
|
||||
// This value can be retreived by using `bun --inspect`.
|
||||
"name": "Attach Bun",
|
||||
"url": "ws://localhost:6499/",
|
||||
"stopOnEntry": true
|
||||
}
|
||||
]
|
||||
}
|
8
.vscode/settings.json
vendored
8
.vscode/settings.json
vendored
|
@ -1,10 +1,6 @@
|
|||
{
|
||||
// The path to the `bun` executable.
|
||||
"bun.runtime": "/path/to/bun",
|
||||
"bun.debugTerminal": {
|
||||
// If support for Bun should be added to the default "JavaScript Debug Terminal".
|
||||
"enabled": true,
|
||||
// If the debugger should stop on the first line of the program.
|
||||
"stopOnEntry": false,
|
||||
}
|
||||
"bun.debugTerminal.enabled": true,
|
||||
"bun.debugTerminal.stopOnEntry": true
|
||||
}
|
|
@ -4,17 +4,16 @@ WORKDIR /usr/src/app
|
|||
FROM base AS install
|
||||
RUN mkdir -p /temp/dev
|
||||
COPY package.json bun.lock /temp/dev
|
||||
COPY patches/ /temp/dev/patches/
|
||||
RUN cd /temp/dev && bun install --frozen-lockfile
|
||||
|
||||
RUN mkdir -p /temp/prod
|
||||
COPY package.json bun.lock /temp/prod/
|
||||
COPY patches/ /temp/prod/patches/
|
||||
RUN cd /temp/prod && bun install --frozen-lockfile --production
|
||||
|
||||
FROM base AS prerelease
|
||||
COPY --from=install /temp/dev/node_modules node_modules
|
||||
COPY . .
|
||||
# RUN echo "SESSION_SECRET=$(head -c 64 /dev/random | base64)" > .env
|
||||
|
||||
ENV NODE_ENV=production
|
||||
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 solidSvg from 'vite-plugin-solid-svg';
|
||||
import devtools from 'solid-devtools/vite';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
export default defineConfig({
|
||||
vite: {
|
||||
resolve: {
|
||||
alias: [
|
||||
{ find: '@', replacement: 'F:\\Github\\calque\\node_modules\\' },
|
||||
],
|
||||
},
|
||||
html: {
|
||||
cspNonce: 'KAAS_IS_AWESOME',
|
||||
},
|
||||
// css: {
|
||||
// postcss: {
|
||||
// },
|
||||
// },
|
||||
plugins: [
|
||||
devtools({
|
||||
autoname: true,
|
||||
}),
|
||||
solidSvg(),
|
||||
{
|
||||
name: 'temp',
|
||||
configResolved(config) {
|
||||
console.log(config.resolve.alias);
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
solid: {
|
||||
|
|
|
@ -9,7 +9,7 @@ param registryUrl string
|
|||
|
||||
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}'
|
||||
location: context.location
|
||||
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'
|
||||
location: context.location
|
||||
identity: {
|
||||
|
|
|
@ -19,7 +19,7 @@ var context = {
|
|||
deployedAt: deployedAt
|
||||
}
|
||||
|
||||
resource calqueResourceGroup 'Microsoft.Resources/resourceGroups@2025-04-01' = {
|
||||
resource calqueResourceGroup 'Microsoft.Resources/resourceGroups@2024-11-01' = {
|
||||
name: 'rg-${locationAbbreviation}-${environment}-${projectName}'
|
||||
location: location
|
||||
}
|
||||
|
|
|
@ -4,7 +4,7 @@ targetScope = 'resourceGroup'
|
|||
|
||||
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}'
|
||||
location: context.location
|
||||
sku: {
|
||||
|
|
57
package.json
57
package.json
|
@ -6,50 +6,58 @@
|
|||
"bun": ">=1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@solid-primitives/clipboard": "^1.6.2",
|
||||
"@solid-primitives/destructure": "^0.2.2",
|
||||
"@solid-primitives/i18n": "^2.2.1",
|
||||
"@solid-primitives/scheduled": "^1.5.2",
|
||||
"@solid-primitives/selection": "^0.1.3",
|
||||
"@solid-primitives/storage": "^4.3.3",
|
||||
"@solid-primitives/timer": "^1.4.2",
|
||||
"@solid-primitives/clipboard": "^1.6.0",
|
||||
"@solid-primitives/context": "^0.3.0",
|
||||
"@solid-primitives/deep": "^0.3.0",
|
||||
"@solid-primitives/destructure": "^0.2.0",
|
||||
"@solid-primitives/event-listener": "^2.4.0",
|
||||
"@solid-primitives/i18n": "^2.2.0",
|
||||
"@solid-primitives/scheduled": "^1.5.0",
|
||||
"@solid-primitives/selection": "^0.1.1",
|
||||
"@solid-primitives/storage": "^4.3.1",
|
||||
"@solid-primitives/timer": "^1.4.0",
|
||||
"@solidjs/meta": "^0.29.4",
|
||||
"@solidjs/router": "^0.15.3",
|
||||
"@solidjs/start": "^1.1.7",
|
||||
"@solidjs/start": "^1.1.1",
|
||||
"dexie": "^4.0.11",
|
||||
"flag-icons": "^7.5.0",
|
||||
"flag-icons": "^7.3.2",
|
||||
"iterator-helpers-polyfill": "^3.0.1",
|
||||
"rehype-dom-parse": "^5.0.2",
|
||||
"rehype-parse": "^9.0.1",
|
||||
"rehype-remark": "^10.0.1",
|
||||
"rehype-remark": "^10.0.0",
|
||||
"rehype-stringify": "^10.0.1",
|
||||
"remark-parse": "^11.0.0",
|
||||
"remark-rehype": "^11.1.2",
|
||||
"remark-rehype": "^11.1.1",
|
||||
"remark-stringify": "^11.0.0",
|
||||
"sitemap": "^8.0.0",
|
||||
"solid-icons": "^1.1.0",
|
||||
"solid-js": "^1.9.7",
|
||||
"ts-pattern": "^5.7.1",
|
||||
"solid-js": "^1.9.5",
|
||||
"ts-pattern": "^5.6.2",
|
||||
"unified": "^11.0.5",
|
||||
"unist-util-ancestor": "^1.4.3",
|
||||
"unist-util-find": "^3.0.0",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"vinxi": "^0.5.8"
|
||||
"unist-util-visit-parents": "^6.0.1",
|
||||
"vinxi": "^0.5.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@happy-dom/global-registrator": "^18.0.1",
|
||||
"@happy-dom/global-registrator": "^17.1.8",
|
||||
"@sinonjs/fake-timers": "^14.0.0",
|
||||
"@solidjs/testing-library": "^0.8.10",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/sinonjs__fake-timers": "^8.1.5",
|
||||
"@types/wicg-file-system-access": "^2023.10.6",
|
||||
"@vitest/coverage-istanbul": "3.2.4",
|
||||
"@vitest/coverage-v8": "3.2.4",
|
||||
"bun-types": "^1.2.19",
|
||||
"jsdom": "^26.1.0",
|
||||
"solid-devtools": "^0.34.3",
|
||||
"vite-plugin-solid": "^2.11.7",
|
||||
"@types/wicg-file-system-access": "^2023.10.5",
|
||||
"@vitest/browser": "^3.0.7",
|
||||
"@vitest/coverage-istanbul": "3.0.7",
|
||||
"@vitest/coverage-v8": "3.0.7",
|
||||
"bun-types": "^1.2.3",
|
||||
"jsdom": "^26.0.0",
|
||||
"playwright": "^1.50.1",
|
||||
"solid-devtools": "^0.33.0",
|
||||
"vite-plugin-solid": "^2.11.2",
|
||||
"vite-plugin-solid-svg": "^0.8.1",
|
||||
"vitest": "^3.2.4",
|
||||
"vitest": "^3.0.7",
|
||||
"workbox-window": "^7.3.0"
|
||||
},
|
||||
"scripts": {
|
||||
|
@ -59,8 +67,5 @@
|
|||
"version": "vinxi version",
|
||||
"test": "vitest --coverage",
|
||||
"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 {
|
||||
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 { createSelection, getTextNodes } from '@solid-primitives/selection';
|
||||
import { createSource } from '~/features/source';
|
||||
import { isServer } from 'solid-js/web';
|
||||
import css from './textarea.module.css';
|
||||
|
||||
interface TextareaProps {
|
||||
class?: string;
|
||||
title?: string;
|
||||
value: string;
|
||||
lang: string;
|
||||
placeholder?: string;
|
||||
|
@ -20,7 +20,7 @@ export function Textarea(props: TextareaProps) {
|
|||
const [editorRef, setEditorRef] = createSignal<HTMLElement>();
|
||||
let mounted = false;
|
||||
|
||||
const source = createSource(props.value);
|
||||
const source = createSource(() => props.value);
|
||||
|
||||
createEffect(on(() => [props.oninput, source.in] as const, ([oninput, text]) => {
|
||||
if (!mounted) {
|
||||
|
@ -43,6 +43,8 @@ export function Textarea(props: TextareaProps) {
|
|||
const ref = editorRef();
|
||||
|
||||
if (ref) {
|
||||
console.log(ref.innerHTML);
|
||||
|
||||
source.out = ref.innerHTML;
|
||||
|
||||
ref.style.height = `1px`;
|
||||
|
@ -61,127 +63,32 @@ export function Textarea(props: TextareaProps) {
|
|||
});
|
||||
|
||||
createEffect(() => {
|
||||
createHighlights(editorRef()!, 'spelling-error', source.spellingErrors);
|
||||
props.oninput?.(source.in);
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
createHighlights(editorRef()!, 'grammar-error', source.grammarErrors);
|
||||
});
|
||||
createEffect(on(() => [editorRef()!, source.spellingErrors] as const, ([ref, errors]) => {
|
||||
createHighlights(ref, 'spelling-error', errors);
|
||||
}));
|
||||
|
||||
createEffect(() => {
|
||||
createHighlights(editorRef()!, 'search-results', source.queryResults);
|
||||
});
|
||||
createEffect(on(() => [editorRef()!, source.grammarErrors] as const, ([ref, errors]) => {
|
||||
createHighlights(ref, 'grammar-error', errors);
|
||||
}));
|
||||
|
||||
return <>
|
||||
<Suggestions />
|
||||
<input class={css.search} type="search" oninput={e => source.query = e.target.value} />
|
||||
<div
|
||||
ref={setEditorRef}
|
||||
class={`${css.textarea} ${props.class}`}
|
||||
contentEditable
|
||||
dir="auto"
|
||||
lang={props.lang}
|
||||
innerHTML={source.out}
|
||||
data-placeholder={props.placeholder ?? ''}
|
||||
on:keydown={e => e.stopPropagation()}
|
||||
on:pointerdown={e => e.stopPropagation()}
|
||||
/>
|
||||
</>;
|
||||
}
|
||||
createEffect(on(() => [editorRef()!, source.queryResults] as const, ([ref, errors]) => {
|
||||
createHighlights(ref, 'search-results', errors);
|
||||
}));
|
||||
|
||||
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;
|
||||
});
|
||||
}
|
||||
return <div
|
||||
ref={setEditorRef}
|
||||
class={`${css.textarea} ${props.class}`}
|
||||
contentEditable
|
||||
dir="auto"
|
||||
lang={props.lang}
|
||||
innerHTML={source.out}
|
||||
data-placeholder={props.placeholder ?? ''}
|
||||
on:keydown={e => e.stopPropagation()}
|
||||
on:pointerdown={e => e.stopPropagation()}
|
||||
/>;
|
||||
}
|
||||
|
||||
const createHighlights = (node: Node, type: string, ranges: [number, number][]) => {
|
||||
|
|
|
@ -1,5 +1,8 @@
|
|||
// @refresh reload
|
||||
import { mount, StartClient } from "@solidjs/start/client";
|
||||
import { installIntoGlobal } from "iterator-helpers-polyfill";
|
||||
import 'solid-devtools';
|
||||
|
||||
installIntoGlobal();
|
||||
|
||||
mount(() => <StartClient />, document.body);
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { trackStore } from "@solid-primitives/deep";
|
||||
import { Accessor, createEffect, createMemo, untrack } from "solid-js";
|
||||
import { createStore } from "solid-js/store";
|
||||
import { createStore, unwrap } from "solid-js/store";
|
||||
import { CustomPartial } from "solid-js/store/types/store.js";
|
||||
import { deepCopy, deepDiff, MutarionKind, Mutation } from "~/utilities";
|
||||
|
||||
|
@ -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> => {
|
||||
const [state, setState] = createStore<DataSetState<T>>({
|
||||
value: deepCopy(data()),
|
||||
value: structuredClone(data()),
|
||||
snapshot: data(),
|
||||
sorting: initialOptions?.sort,
|
||||
grouping: initialOptions?.group,
|
||||
|
@ -93,12 +94,15 @@ export const createDataSet = <T extends Record<string, any>>(data: Accessor<T[]>
|
|||
});
|
||||
|
||||
const mutations = createMemo(() => {
|
||||
// enumerate all values to make sure the memo is recalculated on any change
|
||||
Object.values(state.value).map(entry => Object.values(entry ?? {}));
|
||||
trackStore(state.value);
|
||||
|
||||
return deepDiff(state.snapshot, state.value).toArray();
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
console.log('muts', mutations());
|
||||
});
|
||||
|
||||
const apply = (data: T[], mutations: Mutation[]) => {
|
||||
for (const mutation of mutations) {
|
||||
const path = mutation.key.split('.');
|
||||
|
|
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 { decode, Mutation } from "~/utilities";
|
||||
import { Mutation } from "~/utilities";
|
||||
import { Column, GridApi as GridCompApi, Grid as GridComp } from "~/components/grid";
|
||||
import { createDataSet, DataSetNode, DataSetRowNode } from "~/features/dataset";
|
||||
import { SelectionItem } from "../selectable";
|
||||
import { useI18n } from "../i18n";
|
||||
import { debounce } from "@solid-primitives/scheduled";
|
||||
import css from "./grid.module.css"
|
||||
import { Textarea } from "~/components/textarea";
|
||||
|
||||
|
|
|
@ -2,17 +2,14 @@ import { Accessor, createEffect, from, createSignal } from "solid-js";
|
|||
import { json } from "./parser";
|
||||
import { filter } from "~/utilities";
|
||||
import { isServer } from "solid-js/web";
|
||||
import { installIntoGlobal } from 'iterator-helpers-polyfill';
|
||||
import { debounce } from "@solid-primitives/scheduled";
|
||||
|
||||
installIntoGlobal();
|
||||
|
||||
interface Files extends Record<string, { handle: FileSystemFileHandle, file: File }> { }
|
||||
interface Contents extends Map<string, Map<string, string>> { }
|
||||
|
||||
export const read = (file: File): Promise<Map<string, string> | undefined> => {
|
||||
switch (file.type) {
|
||||
case 'application/json': return json.load(file.text());
|
||||
case 'application/json': return json.load(file.stream());
|
||||
|
||||
default: return Promise.resolve(undefined);
|
||||
}
|
||||
|
|
|
@ -1,20 +1,200 @@
|
|||
import { decode } from "~/utilities";
|
||||
|
||||
export async function load(text: Promise<string>): Promise<Map<string, string>> {
|
||||
const source = JSON.parse(await text);
|
||||
const result = new Map();
|
||||
const candidates = Object.entries(source);
|
||||
export async function load(stream: ReadableStream<Uint8Array>): Promise<Map<string, string>> {
|
||||
return new Map(await Array.fromAsync(parse(stream), ({ key, value }) => [key, value]));
|
||||
}
|
||||
|
||||
while (candidates.length !== 0) {
|
||||
const [ key, value ] = candidates.shift()!;
|
||||
interface Entry {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
if (typeof value !== 'object' || value === null || value === undefined) {
|
||||
result.set(key, decode(value as 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 states.none();
|
||||
}
|
||||
else {
|
||||
candidates.unshift(...Object.entries(value).map<[string, any]>(([ k, v ]) => [`${key}.${k}`, v]));
|
||||
},
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
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 { SelectionProvider, selectable } from "~/features/selectable";
|
||||
import { debounce } from "@solid-primitives/scheduled";
|
||||
|
|
|
@ -10,7 +10,7 @@ describe('Source', () => {
|
|||
// Arrange
|
||||
|
||||
// Act
|
||||
const actual = createSource('');
|
||||
const actual = createSource(() => '');
|
||||
|
||||
// Assert
|
||||
expect(actual.out).toBe('');
|
||||
|
@ -22,7 +22,7 @@ describe('Source', () => {
|
|||
const expected = '<p><strong>text</strong></p>';
|
||||
|
||||
// Act
|
||||
const actual = createSource(given);
|
||||
const actual = createSource(() => given);
|
||||
|
||||
// Assert
|
||||
expect(actual.out).toBe(expected);
|
||||
|
@ -31,7 +31,7 @@ describe('Source', () => {
|
|||
it('should contain query results', () => {
|
||||
// Arrange
|
||||
const expected: [number, number][] = [[8, 9], [12, 13], [15, 16]];
|
||||
const source = createSource('this is a seachable string');
|
||||
const source = createSource(() => 'this is a seachable string');
|
||||
|
||||
// Act
|
||||
source.query = 'a';
|
||||
|
|
|
@ -1,21 +1,23 @@
|
|||
import { createEffect, onMount } from "solid-js";
|
||||
import { Accessor, createEffect, createMemo } from "solid-js";
|
||||
import { createStore } from "solid-js/store";
|
||||
import { unified } from 'unified'
|
||||
import { Text, Root } from 'hast'
|
||||
import { visit } from "unist-util-visit";
|
||||
import { decode } from "~/utilities";
|
||||
import remarkParse from 'remark-parse'
|
||||
import remarkRehype from 'remark-rehype'
|
||||
import remarkStringify from 'remark-stringify'
|
||||
import rehypeParse from 'rehype-parse'
|
||||
import rehypeDomParse from 'rehype-dom-parse'
|
||||
import rehypeRemark from 'rehype-remark'
|
||||
import rehypeStringify from 'rehype-stringify'
|
||||
import type { Text, Root } from 'hast'
|
||||
import { isServer } from "solid-js/web";
|
||||
|
||||
interface SourceStore {
|
||||
in: string;
|
||||
out: string;
|
||||
plain: string;
|
||||
query: string;
|
||||
query: RegExp;
|
||||
metadata: {
|
||||
spellingErrors: [number, number][];
|
||||
grammarErrors: [number, number][];
|
||||
|
@ -26,7 +28,7 @@ interface SourceStore {
|
|||
export interface Source {
|
||||
in: string;
|
||||
out: string;
|
||||
query: string;
|
||||
query: RegExp;
|
||||
readonly spellingErrors: [number, number][];
|
||||
readonly grammarErrors: [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.
|
||||
const inToOutProcessor = unified().use(remarkParse).use(remarkRehype).use(rehypeStringify);
|
||||
const outToInProcessor = unified().use(rehypeParse).use(rehypeRemark).use(remarkStringify, { bullet: '-' });
|
||||
const outToInProcessor = unified().use(isServer ? rehypeParse : rehypeDomParse).use(rehypeRemark).use(remarkStringify, { bullet: '-' });
|
||||
|
||||
export function createSource(initalValue: string): Source {
|
||||
const ast = inToOutProcessor.runSync(inToOutProcessor.parse(initalValue));
|
||||
const out = String(inToOutProcessor.stringify(ast));
|
||||
const plain = String(unified().use(plainTextStringify).stringify(ast));
|
||||
export function createSource(value: Accessor<string>): Source {
|
||||
const [store, setStore] = createStore<SourceStore>({ in: '', out: '', plain: '', query: new RegExp('', 'gi'), metadata: { spellingErrors: [], grammarErrors: [], queryResults: [] } });
|
||||
|
||||
const [store, setStore] = createStore<SourceStore>({ in: initalValue, out, plain, query: '', metadata: { spellingErrors: [], grammarErrors: [], queryResults: [] } });
|
||||
|
||||
createEffect(() => {
|
||||
const value = store.plain;
|
||||
|
||||
setStore('metadata', {
|
||||
spellingErrors: spellChecker(value, ''),
|
||||
grammarErrors: grammarChecker(value, ''),
|
||||
});
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
setStore('metadata', 'queryResults', findMatches(store.plain, store.query).toArray());
|
||||
});
|
||||
|
||||
return {
|
||||
const src: Source = {
|
||||
get in() {
|
||||
return store.in;
|
||||
},
|
||||
|
@ -102,6 +87,26 @@ export function createSource(initalValue: string): Source {
|
|||
return store.metadata.queryResults;
|
||||
},
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
src.in = value();
|
||||
});
|
||||
src.in = value();
|
||||
|
||||
createEffect(() => {
|
||||
const value = store.plain;
|
||||
|
||||
setStore('metadata', {
|
||||
spellingErrors: spellChecker(value, ''),
|
||||
grammarErrors: grammarChecker(value, ''),
|
||||
});
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
setStore('metadata', 'queryResults', findMatches(store.plain, store.query));
|
||||
});
|
||||
|
||||
return src;
|
||||
}
|
||||
|
||||
function plainTextStringify() {
|
||||
|
@ -116,26 +121,10 @@ function plainTextStringify() {
|
|||
};
|
||||
}
|
||||
|
||||
function* findMatches(text: string, query: string): Generator<[number, number], void, unknown> {
|
||||
if (query.length < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
function findMatches(text: string, query: RegExp): [number, number][] {
|
||||
return text.matchAll(query).map<[number, number]>(({ 0: match, index }) => {
|
||||
return [index, index + match.length];
|
||||
}).toArray();
|
||||
}
|
||||
|
||||
const spellChecker = checker(/\w+/gi);
|
||||
|
|
|
@ -35,7 +35,7 @@
|
|||
"newKey": {
|
||||
"title": "Which key do you want to create?",
|
||||
"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": {
|
||||
"title": "Which language do you want to add?",
|
||||
|
|
|
@ -35,7 +35,7 @@
|
|||
"newKey": {
|
||||
"title": "Welke sleutel wil je toevoegen?",
|
||||
"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": {
|
||||
"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('grid').with('grid')} />
|
||||
<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.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 { CellEditor, Column, DataSetGroupNode, DataSetNode, DataSetRowNode, Grid, GridApi } from '~/components/grid';
|
||||
import { people, Person } from './experimental.data';
|
||||
import { Component, createEffect, createMemo, createSignal, For, Match, Switch } from 'solid-js';
|
||||
import { Component, createEffect, createMemo, createSignal, For } from 'solid-js';
|
||||
import { MutarionKind, Mutation } from '~/utilities';
|
||||
import { Table } from '~/components/table';
|
||||
import { createDataSet } from '~/features/dataset';
|
||||
|
@ -71,6 +71,10 @@ export default function GridExperiment() {
|
|||
sort: { by: 'name', reversed: false },
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
console.log(rows);
|
||||
});
|
||||
|
||||
return <div class={css.root}>
|
||||
<Sidebar as="aside" label={'Grid options'} class={css.sidebar}>
|
||||
<fieldset>
|
||||
|
@ -107,13 +111,13 @@ type M = { kind: MutarionKind, key: string, original?: any, value?: any };
|
|||
const Mutations: Component<{ mutations: Mutation[] }> = (props) => {
|
||||
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(() => {
|
||||
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,
|
||||
value: ({ value }) => value ? <ins><pre>{JSON.stringify(value, null, 2)}</pre></ins> : null,
|
||||
}}</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 { Textarea } from "~/components/textarea";
|
||||
import css from './formatter.module.css';
|
||||
import css from './textarea.module.css';
|
||||
|
||||
const tempVal = `
|
||||
# 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.
|
||||
>
|
||||
> > 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!
|
||||
>
|
||||
> - 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
|
||||
|
@ -37,7 +35,7 @@ export default function Formatter(props: {}) {
|
|||
}, 300);
|
||||
|
||||
return <div class={css.root}>
|
||||
<textarea oninput={onInput}>{value()}</textarea>
|
||||
<Textarea class={css.textarea} value={value()} oninput={setValue} lang="en-GB" />
|
||||
<textarea oninput={onInput} title="markdown">{value()}</textarea>
|
||||
<Textarea class={css.textarea} title="html" value={value()} oninput={setValue} lang="en-GB" />
|
||||
</div>;
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
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';
|
||||
|
||||
const { spyOn } = vi;
|
||||
|
@ -11,6 +11,44 @@ const first = <T>(iterable: Iterable<T>): T | undefined => {
|
|||
}
|
||||
|
||||
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', () => {
|
||||
it('should split the given string at the given index', async () => {
|
||||
// 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] => {
|
||||
if (index < 0) {
|
||||
return [subject, ''];
|
||||
|
@ -43,6 +52,20 @@ const decodeReplacer = (_: any, char: EncodedChar) => ({
|
|||
}[char.charAt(0) as ('t' | 'b' | 'n' | 'r' | 'f' | '\'' | '"' | 'u')]);
|
||||
export const decode = (subject: string): string => subject.replace(decodeRegex, decodeReplacer);
|
||||
|
||||
const LAZY_SYMBOL = Symbol('not loaded');
|
||||
export const lazy = <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 => {
|
||||
if (typeof original !== 'object' || original === null || original === undefined) {
|
||||
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]> => {
|
||||
if (subject instanceof Array) {
|
||||
return subject.entries();
|
||||
|
|
|
@ -14,9 +14,9 @@
|
|||
"@solidjs/start/env",
|
||||
"@testing-library/jest-dom",
|
||||
"@types/wicg-file-system-access",
|
||||
"@vitest/browser/providers/playwright",
|
||||
"vinxi/types/client",
|
||||
"vite-plugin-solid-svg/types-component-solid",
|
||||
"vite-plugin-pwa/solid",
|
||||
"bun-types"
|
||||
],
|
||||
"isolatedModules": true,
|
||||
|
|
|
@ -28,7 +28,7 @@ export default defineConfig({
|
|||
provider: 'istanbul',
|
||||
reportsDirectory: './.coverage',
|
||||
all: false,
|
||||
},
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -39,6 +39,13 @@ function reportWith(...reporter: CoverageReporter[]): Plugin {
|
|||
config(userConf, env) {
|
||||
if (userConf.test) {
|
||||
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