diff --git a/README.md b/README.md index 8aea1923..3b94a1b0 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,4 @@ # solid-panes - A set of core solid-compatible applets based on solid-ui These are a set of interlinked applications, or parts of applications, @@ -115,8 +114,23 @@ I want the menu to have a tiny button on the bottom margin left with an arrow to * Raprot mini: instead of this code (index.ts of footer), I want to make use of a new footer web component with the readme: # solid-ui-footer component -* Raptor mini: The footer created shoudl actually be part of the left side menu only. Should be displayed inside it and should collaps and expand as the menu. +* Raptor mini: The footer created should actually be part of the left side menu only. Should be displayed inside it and should collaps and expand as the menu. * Raptor mini: the footer should completely dissapear when menu folded up. * Raptor mini: Please always keep the footer at the bottom of the menu + +* GPT-5.3-Codex in GitHub Copilot: This is the comment I got from copilot, but suggested fix remove the dark class as well The nested table shading logic checks UI.utils.ancestor(newTable, 'TABLE') immediately after creating newTable, but at that moment newTable typically has no +parent in the DOM yet. This makes parentTable null and will +always apply dataContentPaneNestedLight, preventing the +intended alternating light/dark nested backgrounds. +Consider assigning the class after appending newTable into +its parent table, or pass the parent table (or current nesting depth) into objectTree() so the decision can be made reliably. can you help me fix this without removing the dark altering + +* GPT-5.4 Model: Make this (n3Pane) look good on mobile by indenting the lines that wrap. + +* GPT-5.4 Model: Generate TypeScript types for the default pane. + +* GPT-5.4 Model: Add a compatibility shim in the form pane for mixed `ui:Group` plus field typing. + +* AI GPT-5.4 Model: After friend is added when mutual checkbox is checked refresh mutual and header sections */ diff --git a/dev/dev-mash.css b/dev/dev-mash.css index 0169a71e..5da8bc02 100644 --- a/dev/dev-mash.css +++ b/dev/dev-mash.css @@ -682,10 +682,6 @@ div.exceptionPane pre { display: none; } -.active { - /* display: visible; */ -} - .submitRow { clear: both; height: 5em; @@ -704,119 +700,17 @@ div.exceptionPane pre { display: inline; } -/******************* CV Pane *****************/ - -.CVclass { - background-color: var(--color-cv-pane-bg); -} - -/******************* Data Content Pane *****************/ - -div.dataContentPane { - border-top: solid 1px var(--color-data-pane-border-top); - border-left: solid 1px var(--color-data-pane-border-top); - border-bottom: solid 1px var(--color-data-pane-border-side); - border-right: solid 1px var(--color-data-pane-border-side); - padding: 0.5em; /* color: #404; */ - margin-top: 0.5em; - margin-bottom: 0.5em; -} - -.nestedFormula { - border-top: solid 1px var(--color-data-pane-border-top); - border-left: solid 1px var(--color-data-pane-border-top); - border-bottom: solid 1px var(--color-data-pane-border-side); - border-right: solid 1px var(--color-data-pane-border-side); - padding: 0.5em; - border-radius: 0.5em; -} - -div.dataContentPane td { - padding-left: 0.2em; - padding-top: 0.1em; - padding-right: 0.2em; - padding-bottom: 0.05em; - /* vertical-align: middle; /*@@ Lalana's request*/ - vertical-align: top; /*@@ Tims's request*/ - /* With middel, you can't tell what is with what */ - /* background-color: white; */ -} - -div.dataContentPane tr { - margin-bottom: 0.6em; - padding-top: 1em; - padding-bottom: 1em; -} - -.dataContentPane a { - color: var(--color-text-link); - text-decoration: none; - font-weight: bold; -} -.dataContentPane a:link { - color: var(--color-text-link); - text-decoration: none; - font-weight: bold; -} -.dataContentPane a:visited { - color: var(--color-text-link-visited); - text-decoration: none; - font-weight: bold; -} -.dataContentPane a:hover { - color: var(--color-text-link-hover); - text-decoration: underline; - font-weight: bold; -} -.dataContentPane a:active { - color: var(--color-text-link-active); - text-decoration: none; -} - -.dataContentPane.embeddedText { - white-space: pre-wrap; -} - -/* div.dataContentPane a { text-decoration: none; color: #006} /* Only very slightly blue */ -div.dataContentPane td.pred { - min-width: 12em; -} /* Keep aligned with others better */ div.dataContentPane td.pred a { color: var(--color-text-muted); } /* Greyish as form field names have less info value */ /* .collectionAsTables {border-right: green 1px; margin: 0.2em;} */ -div.n3Pane { - padding: 1em; - border-top: solid 1px var(--color-data-pane-border-top); - border-left: solid 1px var(--color-data-pane-border-top); - border-bottom: solid 1px var(--color-data-pane-border-side); - border-right: solid 1px var(--color-data-pane-border-side); - color: var(--color-text-blue); -} - .imageView { border: 1em var(--color-background); margin: 1em; } -.n3Pane pre { - font-size: 120%; -} - -.RDFXMLPane pre { - font-size: 120%; -} - -div.RDFXMLPane { - padding: 1em; - border-top: solid 2px var(--color-data-pane-border-top); - border-left: solid 2px var(--color-data-pane-border-top); - border-bottom: solid 2px var(--color-data-pane-border-side); - border-right: solid 2px var(--color-data-pane-border-side); - color: var(--color-text-brown); -} /* Generic things useful anywhere */ @@ -1685,28 +1579,6 @@ button:disabled, [role="button"][aria-disabled="true"] { cursor: not-allowed; pointer-events: none; } - -/* Loading indicator accessibility */ -.loading-spinner { - width: 40px; - height: 40px; - border: 3px solid var(--color-border-pale); - border-top: 3px solid var(--color-primary); - border-radius: 50%; - animation: spin 1s linear infinite; -} - -@keyframes spin { - 0% { transform: rotate(0deg); } - 100% { transform: rotate(360deg); } -} - -@media (prefers-reduced-motion: reduce) { - .loading-spinner { - animation: none; - border-top-color: var(--color-primary); - } -} /* copied from profile-pane */ @media (prefers-reduced-motion: reduce) { *, *::before, *::after { diff --git a/dev/loader.ts b/dev/loader.ts index 71984bfc..6636a2f5 100644 --- a/dev/loader.ts +++ b/dev/loader.ts @@ -128,7 +128,8 @@ function createIconElement (Pane: { icon: string }) { window.onload = async () => { console.log('document ready') // registerPanes((cjsOrEsModule: any) => paneRegistry.register(cjsOrEsModule.default || cjsOrEsModule)) - paneRegistry.register(require('contacts-pane')) + const contactsPane = await import('contacts-pane') + paneRegistry.register((contactsPane as any).default || contactsPane) await authSession.handleIncomingRedirect({ restorePreviousSession: true }) diff --git a/package-lock.json b/package-lock.json index 3bdf58b2..ffc36540 100644 --- a/package-lock.json +++ b/package-lock.json @@ -67,6 +67,58 @@ "webpack-dev-server": "^5.2.3" } }, + "../profile-pane": { + "version": "3.1.5", + "extraneous": true, + "license": "MIT", + "dependencies": { + "lit-html": "^3.3.2", + "pane-registry": "^3.1.0", + "qrcode": "^1.5.4", + "validate-color": "^2.2.4" + }, + "devDependencies": { + "@babel/cli": "^7.28.6", + "@babel/core": "^7.29.0", + "@babel/preset-env": "^7.29.0", + "@babel/preset-typescript": "^7.28.5", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6.9.1", + "@types/jest": "^30.0.0", + "@typescript-eslint/eslint-plugin": "^8.55.0", + "@typescript-eslint/parser": "^8.55.0", + "axe-core": "^4.11.1", + "babel-jest": "^30.2.0", + "babel-loader": "^10.0.0", + "babel-plugin-inline-import": "^3.0.0", + "chat-pane": "^3.0.3", + "contacts-pane": "^3.1.0", + "copy-webpack-plugin": "^14.0.0", + "css-loader": "^7.1.4", + "eslint": "^9.39.2", + "html-webpack-plugin": "^5.6.6", + "jest": "^30.2.0", + "jest-environment-jsdom": "^30.2.0", + "jest-fetch-mock": "^3.0.3", + "jsdom": "^29.0.0", + "node-polyfill-webpack-plugin": "^4.1.0", + "rdflib": "^2.3.6", + "solid-logic": "^4.0.6", + "solid-ui": "^3.0.6", + "style-loader": "^4.0.0", + "terser-webpack-plugin": "^5.3.16", + "ts-loader": "^9.5.4", + "typescript": "^5.9.3", + "webpack": "^5.105.2", + "webpack-cli": "^7.0.2", + "webpack-dev-server": "^5.2.3" + }, + "peerDependencies": { + "rdflib": "^2.3.6", + "solid-logic": "^4.0.6", + "solid-ui": "^3.0.5" + } + }, "node_modules/@adobe/css-tools": { "version": "4.4.4", "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", @@ -15699,9 +15751,9 @@ } }, "node_modules/solid-ui/node_modules/uuid": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", - "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.2.tgz", + "integrity": "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" diff --git a/src/RDFXMLPane.css b/src/RDFXMLPane.css new file mode 100644 index 00000000..eb60c4e6 --- /dev/null +++ b/src/RDFXMLPane.css @@ -0,0 +1,70 @@ +.rdfxml-pane { + padding: 1rem; + border-top: solid 2px var(--color-data-pane-border-top, black); + border-left: solid 2px var(--color-data-pane-border-top, black); + border-bottom: solid 2px var(--color-data-pane-border-side, #777); + border-right: solid 2px var(--color-data-pane-border-side, #777); + color: var(--color-text-brown, #440); + box-sizing: border-box; +} + +.rdfxml-pane__source { + overflow-x: auto; + max-width: 100%; + box-sizing: border-box; + font-family: monospace; + font-size: 120%; + margin: 0; + white-space: pre; +} + +.rdfxml-pane__line { + display: grid; + grid-template-columns: var(--rdfxml-indent, 0) minmax(max-content, auto); + align-items: start; +} + +.rdfxml-pane__line-indent { + display: block; + width: var(--rdfxml-indent, 0); +} + +.rdfxml-pane__line-content { + white-space: pre; +} + +.rdfxml-pane[data-layout='mobile'] .rdfxml-pane__source { + overflow-wrap: anywhere; + word-break: break-word; + white-space: normal; +} + +.rdfxml-pane[data-layout='mobile'] .rdfxml-pane__line { + grid-template-columns: var(--rdfxml-indent, 0) minmax(0, 1fr); +} + +.rdfxml-pane[data-layout='mobile'] .rdfxml-pane__line-content { + white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: break-word; + min-width: 0; +} + +@media (max-width: 576px) { + .rdfxml-pane__source { + overflow-wrap: anywhere; + word-break: break-word; + white-space: normal; + } + + .rdfxml-pane__line { + grid-template-columns: var(--rdfxml-indent, 0) minmax(0, 1fr); + } + + .rdfxml-pane__line-content { + white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: break-word; + min-width: 0; + } +} diff --git a/src/RDFXMLPane.js b/src/RDFXMLPane.js deleted file mode 100644 index 877d2ec2..00000000 --- a/src/RDFXMLPane.js +++ /dev/null @@ -1,63 +0,0 @@ -/* RDF/XML content Pane - ** - ** This pane shows the content of a particular RDF resource - ** or at least the RDF semantics we attribute to that resource, - ** in generated N3 syntax. - */ - -import * as UI from 'solid-ui' -import * as $rdf from 'rdflib' - -const ns = UI.ns - -export const RDFXMLPane = { - icon: UI.icons.originalIconBase + '22-text-xml4.png', - - name: 'RDFXML', - - audience: [ns.solid('Developer')], - - label: function (subject, context) { - const store = context.session.store - if ( - 'http://www.w3.org/2007/ont/link#ProtocolEvent' in - store.findTypeURIs(subject) - ) { - return null - } - - const n = store.statementsMatching(undefined, undefined, undefined, subject) - .length - if (n === 0) return null - return 'As RDF/XML (' + n + ')' - }, - - render: function (subject, context) { - const myDocument = context.dom - const kb = context.session.store - const div = myDocument.createElement('div') - div.setAttribute('class', 'RDFXMLPane') - // Because of smushing etc, this will not be a copy of the original source - // We could instead either fetch and re-parse the source, - // or we could keep all the pre-smushed triples. - const sts = kb.statementsMatching(undefined, undefined, undefined, subject) // @@ slow with current store! - /* - var kludge = kb.formula([]) // No features - for (var i=0; i< sts.length; i++) { - s = sts[i] - kludge.add(s.subject, s.predicate, s.object) - } - */ - const sz = $rdf.Serializer(kb) - sz.suggestNamespaces(kb.namespaces) - sz.setBase(subject.uri) - const str = sz.statementsToXML(sts) - const pre = myDocument.createElement('PRE') - pre.setAttribute('style', 'overflow-x: auto; max-width: 100%; box-sizing: border-box;') - pre.appendChild(myDocument.createTextNode(str)) - div.appendChild(pre) - return div - } -} - -// ends diff --git a/src/RDFXMLPane.ts b/src/RDFXMLPane.ts new file mode 100644 index 00000000..cee48c77 --- /dev/null +++ b/src/RDFXMLPane.ts @@ -0,0 +1,133 @@ +/* RDF/XML content Pane + ** + ** This pane shows the content of a particular RDF resource + ** or at least the RDF semantics we attribute to that resource, + ** in generated N3 syntax. + */ + +import * as UI from 'solid-ui' +import * as $rdf from 'rdflib' +import type { DataBrowserContext, RenderEnvironment } from 'pane-registry' +import type { NamedNode, Statement } from 'rdflib' +import './RDFXMLPane.css' + +const ns = UI.ns + +type RDFXMLPaneDefinition = { + icon: string + name: string + audience: NamedNode[] + label: (subject: NamedNode, context: DataBrowserContext) => string | null + render: (subject: NamedNode, context: DataBrowserContext) => HTMLDivElement +} + +function leadingIndentWidth (line: string): number { + if (line.trim().length === 0) { + return 0 + } + + let width = 0 + for (const character of line) { + if (character === ' ') { + width += 1 + continue + } + if (character === '\t') { + width += 2 + continue + } + break + } + return Math.max(width, 2) +} + +function trimLeadingIndent (line: string): string { + return line.replace(/^[ \t]+/, '') +} + +export const RDFXMLPane: RDFXMLPaneDefinition = { + icon: UI.icons.originalIconBase + '22-text-xml4.png', + + name: 'RDFXML', + + audience: [ns.solid('Developer')], + + label: function (subject: NamedNode, context: DataBrowserContext): string | null { + const store = context.session.store + if ( + 'http://www.w3.org/2007/ont/link#ProtocolEvent' in + store.findTypeURIs(subject) + ) { + return null + } + + const n = store.statementsMatching(undefined, undefined, undefined, subject) + .length + if (n === 0) return null + return 'As RDF/XML (' + n + ')' + }, + + render: function ( + subject: NamedNode, + context: DataBrowserContext + ): HTMLDivElement { + const myDocument = context.dom + const kb = context.session.store + + function applyEnvironmentAttributes (element: HTMLDivElement): void { + const environment = (context.environment ?? {}) as Partial + element.dataset.layout = environment.layout ?? 'desktop' + } + + const div = myDocument.createElement('div') + div.setAttribute('class', 'rdfxml-pane') + applyEnvironmentAttributes(div) + // Because of smushing etc, this will not be a copy of the original source + // We could instead either fetch and re-parse the source, + // or we could keep all the pre-smushed triples. + const sts = kb.statementsMatching( + undefined, + undefined, + undefined, + subject + ) as Statement[] // @@ slow with current store! + /* + var kludge = kb.formula([]) // No features + for (var i=0; i< sts.length; i++) { + s = sts[i] + kludge.add(s.subject, s.predicate, s.object) + } + */ + const sz = $rdf.Serializer(kb) + sz.suggestNamespaces(kb.namespaces) + sz.setBase(subject.uri) + const str = sz.statementsToXML(sts) + const source = myDocument.createElement('div') + source.classList.add('rdfxml-pane__source') + + str.split('\n').forEach(line => { + const lineElement = myDocument.createElement('div') + const indentElement = myDocument.createElement('span') + const contentElement = myDocument.createElement('span') + const indentWidth = leadingIndentWidth(line) + + lineElement.classList.add('rdfxml-pane__line') + lineElement.style.setProperty('--rdfxml-indent', `${indentWidth}ch`) + + indentElement.classList.add('rdfxml-pane__line-indent') + indentElement.setAttribute('aria-hidden', 'true') + + contentElement.classList.add('rdfxml-pane__line-content') + contentElement.textContent = line.length > 0 ? trimLeadingIndent(line) : ' ' + + lineElement.appendChild(indentElement) + lineElement.appendChild(contentElement) + source.appendChild(lineElement) + }) + + div.appendChild(source) + return div + } +} + +// ends diff --git a/src/dataContentPane.css b/src/dataContentPane.css new file mode 100644 index 00000000..928d66fe --- /dev/null +++ b/src/dataContentPane.css @@ -0,0 +1,271 @@ +.data-content-pane__literal { + white-space: pre-wrap; +} + +div.data-content-pane { + border-top: solid 1px var(--color-data-pane-border-top, black); + border-left: solid 1px var(--color-data-pane-border-top, black); + border-bottom: solid 1px var(--color-data-pane-border-side, #777); + border-right: solid 1px var(--color-data-pane-border-side, #777); + padding: var(--spacing-base, 0.5rem); + margin-top: var(--spacing-base, 0.5rem); + margin-bottom: var(--spacing-base, 0.5rem); +} + +div.data-content-pane table.data-content-pane__table { + width: 100%; + border-collapse: separate; + border-spacing: 0; +} + +div.data-content-pane table.data-content-pane__table--root { + table-layout: auto; +} + +div.data-content-pane .data-content-pane__row--root { + display: flex; + align-items: flex-start; +} + +div.data-content-pane .data-content-pane__row--root > td { + vertical-align: top; +} + +div.data-content-pane .data-content-pane__subject-cell { + flex: 0 0 auto; + width: clamp(7rem, 12%, 10rem); + padding-right: 0.8rem; +} + +div.data-content-pane .data-content-pane__details-cell { + flex: 0 1 auto; + min-width: 0; +} + +div.data-content-pane .data-content-pane__subject-cell, +div.data-content-pane td.data-content-pane__predicate-cell, +div.data-content-pane .data-content-pane__row--top-aligned > td:not(.data-content-pane__predicate-cell), +div.data-content-pane .data-content-pane__subject-cell a, +div.data-content-pane td.data-content-pane__predicate-cell a, +div.data-content-pane .data-content-pane__row--top-aligned > td:not(.data-content-pane__predicate-cell) a { + white-space: normal; + overflow-wrap: anywhere; + word-break: break-word; +} + +div.data-content-pane table.data-content-pane__table--property { + table-layout: auto; + width: auto; + max-width: 100%; +} + +.data-content-pane__nested-formula { + border-top: solid 1px var(--color-data-pane-border-top, black); + border-left: solid 1px var(--color-data-pane-border-top, black); + border-bottom: solid 1px var(--color-data-pane-border-side, #777); + border-right: solid 1px var(--color-data-pane-border-side, #777); + padding: var(--spacing-base, 0.5rem); + border-radius: var(--border-radius-md, 0.5rem); +} + +div.data-content-pane td { + padding-left: var(--spacing-xxxs, 0.2rem); + padding-top: var(--spacing-small, 0.1rem); + padding-right: var(--spacing-xxxs, 0.2rem); + padding-bottom: 0.05rem; + vertical-align: top; +} + +div.data-content-pane table.data-content-pane__table--property td { + padding-top: 0.3rem; + padding-bottom: 0.3rem; +} + +div.data-content-pane tr { + margin-bottom: var(--spacing-2xs, 0.625rem); + padding-top: var(--spacing-small, 0.1rem); + padding-bottom: var(--spacing-small, 0.1rem); +} + +.data-content-pane a { + color: var(--color-text-link, #3b5998); + text-decoration: none; + font-weight: bold; +} + +.data-content-pane a:link { + color: var(--color-text-link, #3b5998); + text-decoration: none; + font-weight: bold; +} + +.data-content-pane a:visited { + color: var(--color-text-link-visited, #3b5998); + text-decoration: none; + font-weight: bold; +} + +.data-content-pane a:hover { + color: var(--color-text-link-hover, #3b5998); + text-decoration: underline; + font-weight: bold; +} + +.data-content-pane a:active { + color: var(--color-text-link-active, #888); + text-decoration: none; +} + +.data-content-pane.embeddedText { + white-space: pre-wrap; +} + +div.data-content-pane td.data-content-pane__predicate-cell { + min-width: 8.5rem; + width: clamp(8.5rem, 18vw, 12rem); + max-width: 12rem; + padding-right: var(--spacing-base, 0.5rem); +} + +div.data-content-pane .data-content-pane__row--top-aligned > td:not(.data-content-pane__predicate-cell) { + padding-left: var(--spacing-xxs, 0.3125rem); +} + +div.data-content-pane td.data-content-pane__predicate-cell a { + color: var(--color-text-muted, #444); +} + +.data-content-pane__row--even { + background-color: var(--color-background-row-alternate, #f0f0f0); +} + +.data-content-pane__row--odd { + background-color: var(--color-background-row-default, white); +} + +.data-content-pane__row--top-aligned { + vertical-align: top; + margin-top: 0; + margin-bottom: 0; + padding-top: 0; + padding-bottom: 0; +} + +.data-content-pane__nested-table--light { + background-color: var(--color-background-row-default, white); +} + +.data-content-pane__nested-table--dark { + background-color: var(--color-background-row-alternate, #f0f0f0); +} + +div.data-content-pane[data-layout='mobile'] .data-content-pane__row--root { + display: flex; + align-items: flex-start; + padding-bottom: var(--spacing-xs, 0.75rem); +} + +div.data-content-pane[data-layout='mobile'] .data-content-pane__subject-cell { + flex: 0 0 40%; + width: 40%; + max-width: 40%; + box-sizing: border-box; + padding-right: var(--spacing-xs, 0.75rem); + padding-bottom: 0; +} + +div.data-content-pane[data-layout='mobile'] .data-content-pane__details-cell { + flex: 0 0 60%; + width: 60%; + max-width: 60%; + min-width: 0; + box-sizing: border-box; +} + +div.data-content-pane[data-layout='mobile'] table.data-content-pane__table--property > tbody > tr, +div.data-content-pane[data-layout='mobile'] table.data-content-pane__table--property > tr { + margin-bottom: 0; +} + +div.data-content-pane[data-layout='mobile'] .data-content-pane__row--top-aligned + .data-content-pane__row--top-aligned { + margin-top: 0; +} + +div.data-content-pane[data-layout='mobile'] .data-content-pane__row--top-aligned { + display: block; + margin-bottom: 0; + padding-top: 0; + padding-bottom: 0; +} + +div.data-content-pane[data-layout='mobile'] table.data-content-pane__table--property, +div.data-content-pane[data-layout='mobile'] table.data-content-pane__table--property > tbody { + display: block; + width: 100%; +} + +div.data-content-pane[data-layout='mobile'] table.data-content-pane__table--property td { + display: block; + width: 100%; + box-sizing: border-box; +} + +div.data-content-pane[data-layout='mobile'] table.data-content-pane__table--property td.data-content-pane__predicate-cell { + width: 100%; + min-width: 0; + padding-bottom: 0.1rem; +} + +div.data-content-pane[data-layout='mobile'] table.data-content-pane__table--property td:not(.data-content-pane__predicate-cell) { + padding-top: 0; + padding-left: 0.85rem; +} + +@media (max-width: 700px) { + div.data-content-pane .data-content-pane__row--root { + display: flex; + align-items: flex-start; + } + + div.data-content-pane .data-content-pane__subject-cell { + flex: 0 0 40%; + width: 40%; + max-width: 40%; + padding-right: var(--spacing-xs, 0.75rem); + padding-bottom: 0; + box-sizing: border-box; + } + + div.data-content-pane .data-content-pane__details-cell { + flex: 0 0 60%; + width: 60%; + max-width: 60%; + box-sizing: border-box; + } + + div.data-content-pane .data-content-pane__row--top-aligned { + display: block; + margin-top: 0; + margin-bottom: 0; + padding-top: 0; + padding-bottom: 0; + } + + div.data-content-pane .data-content-pane__row--top-aligned > td { + display: block; + width: 100%; + box-sizing: border-box; + } + + div.data-content-pane .data-content-pane__row--top-aligned > td.data-content-pane__predicate-cell { + min-width: 0; + max-width: none; + width: 100%; + padding-bottom: var(--spacing-small, 0.1rem); + } + + div.data-content-pane .data-content-pane__row--top-aligned > td:not(.data-content-pane__predicate-cell) { + padding-top: 0; + padding-left: 0.85rem; + } +} diff --git a/src/dataContentPane.js b/src/dataContentPane.js deleted file mode 100644 index 749ebc75..00000000 --- a/src/dataContentPane.js +++ /dev/null @@ -1,293 +0,0 @@ -/* Data content Pane - ** - ** This pane shows the content of a particular RDF resource - ** or at least the RDF semantics we attribute to that resource. - */ - -// To do: - Only take data from one graph -// - Only do forwards not backward? -// - Expand automatically all the way down -// - original source view? Use ffox view source - -import * as UI from 'solid-ui' -import * as $rdf from 'rdflib' - -const ns = UI.ns - -export const dataContentPane = { - icon: UI.icons.originalIconBase + 'rdf_flyer.24.gif', - - name: 'dataContents', - - audience: [ns.solid('Developer')], - - label: function (subject, context) { - if ( - 'http://www.w3.org/2007/ont/link#ProtocolEvent' in - context.session.store.findTypeURIs(subject) - ) { - return null - } - const n = context.session.store.statementsMatching( - undefined, - undefined, - undefined, - subject - ).length - if (n === 0) return null - return 'Data (' + n + ')' - }, - /* - shouldGetFocus: function(subject) { - return store.whether(subject, UI.ns.rdf('type'), UI.ns.link('RDFDocument')) - }, -*/ - statementsAsTables: function statementsAsTables (sts, context, initialRoots) { - const myDocument = context.dom - // const outliner = context.getOutliner(myDocument) - const rep = myDocument.createElement('table') - const sz = $rdf.Serializer(context.session.store) - const res = sz.rootSubjects(sts) - let roots = res.roots - const subjects = res.subjects - const loopBreakers = res.loopBreakers - for (const x in loopBreakers) { - console.log('\tdataContentPane: loopbreaker:' + x) - } - const doneBnodes = {} // For preventing looping - const referencedBnodes = {} // Bnodes which need to be named alas - - // The property tree for a single subject or anonymous node - function propertyTree (subject) { - // print('Proprty tree for '+subject) - const rep = myDocument.createElement('table') - let lastPred = null - const sts = subjects[sz.toStr(subject)] // relevant statements - if (!sts) { - // No statements in tree - rep.appendChild(myDocument.createTextNode('...')) // just empty bnode as object - return rep - } - sts.sort() - let same = 0 - let predicateTD // The cell which holds the predicate - for (let i = 0; i < sts.length; i++) { - const st = sts[i] - const tr = myDocument.createElement('tr') - if (st.predicate.uri !== lastPred) { - if (lastPred && same > 1) { - predicateTD.setAttribute('rowspan', '' + same) - } - predicateTD = myDocument.createElement('td') - predicateTD.setAttribute('class', 'pred') - const anchor = myDocument.createElement('a') - anchor.setAttribute('href', st.predicate.uri) - anchor.addEventListener( - 'click', - UI.widgets.openHrefInOutlineMode, - true - ) - anchor.appendChild( - myDocument.createTextNode( - UI.utils.predicateLabelForXML(st.predicate) - ) - ) - predicateTD.appendChild(anchor) - tr.appendChild(predicateTD) - lastPred = st.predicate.uri - same = 0 - } - same++ - const objectTD = myDocument.createElement('td') - objectTD.appendChild(objectTree(st.object)) - tr.appendChild(objectTD) - rep.appendChild(tr) - } - if (lastPred && same > 1) predicateTD.setAttribute('rowspan', '' + same) - return rep - } - - // Convert a set of statements into a nested tree of tables - function objectTree (obj) { - let res, anchor - switch (obj.termType) { - case 'NamedNode': - anchor = myDocument.createElement('a') - anchor.setAttribute('href', obj.uri) - anchor.addEventListener( - 'click', - UI.widgets.openHrefInOutlineMode, - true - ) - anchor.appendChild(myDocument.createTextNode(UI.utils.label(obj))) - return anchor - - case 'Literal': - if (!obj.datatype || !obj.datatype.uri) { - res = myDocument.createElement('div') - res.setAttribute('style', 'white-space: pre-wrap;') - res.textContent = obj.value - return res - } else if ( - obj.datatype.uri === - 'http://www.w3.org/1999/02/22-rdf-syntax-ns#XMLLiteral' - ) { - res = myDocument.createElement('div') - res.setAttribute('class', 'embeddedXHTML') - res.innerHTML = obj.value // Try that @@@ beware embedded dangerous code - return res - } - return myDocument.createTextNode(obj.value) // placeholder - could be smarter, - - case 'BlankNode': { - if (obj.toNT() in doneBnodes) { - // Break infinite recursion - referencedBnodes[obj.toNT()] = true - const anchor = myDocument.createElement('a') - anchor.setAttribute('href', '#' + obj.toNT().slice(2)) - anchor.setAttribute('class', 'bnodeRef') - anchor.textContent = '*' + obj.toNT().slice(3) - return anchor - } - doneBnodes[obj.toNT()] = true // Flag to prevent infinite recursion in propertyTree - const newTable = propertyTree(obj) - doneBnodes[obj.toNT()] = newTable // Track where we mentioned it first - if ( - UI.utils.ancestor(newTable, 'TABLE') && - UI.utils.ancestor(newTable, 'TABLE').style.backgroundColor === - 'white' - ) { - newTable.style.backgroundColor = '#eee' - } else { - newTable.style.backgroundColor = 'white' - } - return newTable - } - case 'Collection': - res = myDocument.createElement('table') - res.setAttribute('class', 'collectionAsTables') - for (let i = 0; i < obj.elements.length; i++) { - const tr = myDocument.createElement('tr') - res.appendChild(tr) - tr.appendChild(objectTree(obj.elements[i])) - } - return res - case 'Graph': - res = context.session.paneRegistry - .byName('dataContents') - .statementsAsTables(obj.statements, context) - res.setAttribute('class', 'nestedFormula') - return res - case 'Variable': - res = myDocument.createTextNode('?' + obj.uri) - return res - } - throw new Error('Unhandled node type: ' + obj.termType) - } - - // roots.sort() - - if (initialRoots) { - roots = initialRoots.concat( - roots.filter(function (x) { - for (let i = 0; i < initialRoots.length; i++) { - // Max 2 - if (x.sameTerm(initialRoots[i])) return false - } - return true - }) - ) - } - for (let i = 0; i < roots.length; i++) { - const tr = myDocument.createElement('tr') - tr.setAttribute('style', `background-color: ${i % 2 === 0 ? '#f0f0f0' : 'white'};`) - rep.appendChild(tr) - const subjectTD = myDocument.createElement('td') - tr.appendChild(subjectTD) - const TDTree = myDocument.createElement('td') - tr.appendChild(TDTree) - const root = roots[i] - if (root.termType === 'BlankNode') { - subjectTD.appendChild(myDocument.createTextNode(UI.utils.label(root))) // Don't recurse! - } else { - subjectTD.appendChild(objectTree(root)) // won't have tree - } - TDTree.appendChild(propertyTree(root)) - } - for (const bNT in referencedBnodes) { - // Add number to refer to - const table = doneBnodes[bNT] - // let tr = myDocument.createElement('tr') - const anchor = myDocument.createElement('a') - anchor.setAttribute('id', bNT.slice(2)) - anchor.setAttribute('class', 'bnodeDef') - anchor.textContent = bNT.slice(3) + ')' - table.insertBefore(anchor, table.firstChild) - } - return rep - }, // statementsAsTables - // View the data in a file in user-friendly way - render: function (subject, context) { - const myDocument = context.dom - - function alternativeRendering () { - const sz = $rdf.Serializer(context.session.store) - const res = sz.rootSubjects(sts) - const roots = res.roots - const p = {} - p.render = function (s2) { - const div = myDocument.createElement('div') - div.setAttribute('class', 'withinDocumentPane') - const plist = kb.statementsMatching(s2, undefined, undefined, subject) - outliner.appendPropertyTRs(div, plist, false, function ( - _pred, - _inverse - ) { - return true - }) - return div - } - for (let i = 0; i < roots.length; i++) { - const tr = myDocument.createElement('TR') - const root = roots[i] - tr.style.verticalAlign = 'top' - const td = outliner.outlineObjectTD(root, undefined, tr) - tr.appendChild(td) - div.appendChild(tr) - outliner.outlineExpand(td, root, { pane: p }) - } - } - - function mainRendering () { - const initialRoots = [] // Ordering: start with stuff about this doc - if (kb.holds(subject, undefined, undefined, subject)) { - initialRoots.push(subject) - } - // Then about the primary topic of the document if any - const ps = kb.any(subject, UI.ns.foaf('primaryTopic'), undefined, subject) - if (ps) initialRoots.push(ps) - div.appendChild( - context.session.paneRegistry - .byName('dataContents') - .statementsAsTables(sts, context, initialRoots) - ) - } - - const outliner = context.getOutliner(myDocument) - const kb = context.session.store - const div = myDocument.createElement('div') - div.setAttribute('class', 'dataContentPane') - // Because of smushing etc, this will not be a copy of the original source - // We could instead either fetch and re-parse the source, - // or we could keep all the pre-smushed triples. - const sts = kb.statementsMatching(undefined, undefined, undefined, subject) // @@ slow with current store! - - // eslint-disable-next-line no-constant-condition - if (false) { // keep code - alternativeRendering() - } else { - mainRendering() - } - return div - } -} diff --git a/src/dataContentPane.ts b/src/dataContentPane.ts new file mode 100644 index 00000000..b5d3282b --- /dev/null +++ b/src/dataContentPane.ts @@ -0,0 +1,319 @@ +/* Data content Pane + ** + ** This pane shows the content of a particular RDF resource + ** or at least the RDF semantics we attribute to that resource. + */ + +// To do: - Only take data from one graph +// - Only do forwards not backward? +// - Expand automatically all the way down +// - original source view? Use ffox view source + +import * as UI from 'solid-ui' +import * as $rdf from 'rdflib' +import type { DataBrowserContext, RenderEnvironment } from 'pane-registry' +import type { + BlankNode, + Formula, + NamedNode, + Statement +} from 'rdflib' +import './dataContentPane.css' + +const ns = UI.ns + +type SubjectTerm = NamedNode | BlankNode +type ObjectTerm = Statement['object'] | Formula + +type RootSubjectsResult = { + roots: SubjectTerm[] + subjects: Record + loopBreakers?: Record +} + +type DataContentPaneLike = { + statementsAsTables: ( + sts: Statement[], + context: DataBrowserContext, + initialRoots?: SubjectTerm[] + ) => HTMLTableElement +} + +export const dataContentPane = { + icon: UI.icons.originalIconBase + 'rdf_flyer.24.gif', + + name: 'dataContents', + + audience: [ns.solid('Developer')], + + label: function (subject: NamedNode, context: DataBrowserContext) { + if ( + 'http://www.w3.org/2007/ont/link#ProtocolEvent' in + context.session.store.findTypeURIs(subject) + ) { + return null + } + const n = context.session.store.statementsMatching( + undefined, + undefined, + undefined, + subject + ).length + if (n === 0) return null + return 'Data (' + n + ')' + }, + /* + shouldGetFocus: function(subject) { + return store.whether(subject, UI.ns.rdf('type'), UI.ns.link('RDFDocument')) + }, +*/ + /* This code was generated by Generative AI (GPT-5.3-Codex in GitHub Copilot) based on the following prompt: + Only the class assignments of data-content-pane__nested-table--light and + data-content-pane__nested-table--dark + are the only things added by AI. + + This is the comment I got from copilot, but suggested fix remove the dark + class as well The nested table shading logic checks + UI.utils.ancestor(newTable, 'TABLE') immediately after + creating newTable, but at that moment newTable typically has no + parent in the DOM yet. This makes parentTable null and will + always apply data-content-pane__nested-table--light, preventing the + intended alternating light/dark nested backgrounds. + Consider assigning the class after appending newTable into + its parent table, or pass the parent table (or current nesting + depth) into objectTree() so the decision can be made reliably. + can you help me fix this without removing the dark altering */ + statementsAsTables: function statementsAsTables ( + sts: Statement[], + context: DataBrowserContext, + initialRoots?: SubjectTerm[] + ): HTMLTableElement { + const myDocument = context.dom + // const outliner = context.getOutliner(myDocument) + const rep = myDocument.createElement('table') + rep.classList.add('data-content-pane__table', 'data-content-pane__table--root') + const isMobileLayout = context.environment?.layout === 'mobile' + const sz = $rdf.Serializer(context.session.store) + const res = sz.rootSubjects(sts) as RootSubjectsResult + let roots = res.roots + const subjects = res.subjects + const loopBreakers = res.loopBreakers ?? {} + for (const x in loopBreakers) { + console.log('\tdataContentPane: loopbreaker:' + x) + } + const doneBnodes: Record = {} + const referencedBnodes: Record = {} + + function propertyTree ( + subject: SubjectTerm, + nestingLevel = 0 + ): HTMLTableElement { + const rep = myDocument.createElement('table') + rep.classList.add('data-content-pane__table', 'data-content-pane__table--property') + let lastPred: string | null = null + const subjectStatements = subjects[sz.toStr(subject)] + if (!subjectStatements) { + rep.appendChild(myDocument.createTextNode('...')) + return rep + } + subjectStatements.sort() + let same = 0 + let predicateTD: HTMLTableCellElement | undefined + for (let i = 0; i < subjectStatements.length; i++) { + const st = subjectStatements[i] + const tr = myDocument.createElement('tr') + tr.classList.add('data-content-pane__row--top-aligned', 'data-content-pane__row--property') + if (st.predicate.uri !== lastPred || isMobileLayout) { + if (!isMobileLayout && lastPred && same > 1) { + predicateTD?.setAttribute('rowspan', '' + same) + } + predicateTD = myDocument.createElement('td') + predicateTD.setAttribute('class', 'data-content-pane__predicate-cell') + const anchor = myDocument.createElement('a') + anchor.setAttribute('href', st.predicate.uri) + anchor.addEventListener( + 'click', + UI.widgets.openHrefInOutlineMode, + true + ) + anchor.appendChild( + myDocument.createTextNode( + UI.utils.predicateLabelForXML(st.predicate) + ) + ) + predicateTD.appendChild(anchor) + tr.appendChild(predicateTD) + lastPred = st.predicate.uri + same = 0 + } + same++ + const objectTD = myDocument.createElement('td') + objectTD.classList.add('data-content-pane__value-cell') + objectTD.appendChild(objectTree(st.object, nestingLevel + 1)) + tr.appendChild(objectTD) + rep.appendChild(tr) + } + if (!isMobileLayout && lastPred && same > 1) { + predicateTD?.setAttribute('rowspan', '' + same) + } + return rep + } + + function objectTree (obj: ObjectTerm, nestingLevel = 0): Node { + let res: HTMLElement | HTMLTableElement | Text + let anchor: HTMLAnchorElement + switch (obj.termType) { + case 'NamedNode': + anchor = myDocument.createElement('a') + anchor.setAttribute('href', obj.uri) + anchor.addEventListener( + 'click', + UI.widgets.openHrefInOutlineMode, + true + ) + anchor.appendChild(myDocument.createTextNode(UI.utils.label(obj))) + return anchor + + case 'Literal': + if (!obj.datatype || !obj.datatype.uri) { + res = myDocument.createElement('div') + res.classList.add('data-content-pane__literal') + res.textContent = obj.value + return res + } else if ( + obj.datatype.uri === + 'http://www.w3.org/1999/02/22-rdf-syntax-ns#XMLLiteral' + ) { + res = myDocument.createElement('div') + res.classList.add('embeddedXHTML') + res.innerHTML = obj.value + return res + } + return myDocument.createTextNode(obj.value) + + case 'BlankNode': { + if (obj.toNT() in doneBnodes) { + referencedBnodes[obj.toNT()] = true + const referenceAnchor = myDocument.createElement('a') + referenceAnchor.setAttribute('href', '#' + obj.toNT().slice(2)) + referenceAnchor.setAttribute('class', 'bnodeRef') + referenceAnchor.textContent = '*' + obj.toNT().slice(3) + return referenceAnchor + } + doneBnodes[obj.toNT()] = true + const newTable = propertyTree(obj, nestingLevel) + doneBnodes[obj.toNT()] = newTable + if (nestingLevel % 2 === 1) { + newTable.classList.add('data-content-pane__nested-table--light') + } else { + newTable.classList.add('data-content-pane__nested-table--dark') + } + return newTable + } + + case 'Collection': + res = myDocument.createElement('table') + res.setAttribute('class', 'collectionAsTables') + for (let i = 0; i < obj.elements.length; i++) { + const tr = myDocument.createElement('tr') + res.appendChild(tr) + tr.appendChild(objectTree(obj.elements[i] as ObjectTerm, nestingLevel + 1)) + } + return res + + case 'Graph': + res = (context.session.paneRegistry + .byName('dataContents') as DataContentPaneLike) + .statementsAsTables(obj.statements, context) + res.setAttribute('class', 'data-content-pane__nested-formula') + return res + + case 'Variable': + return myDocument.createTextNode('?' + obj.uri) + } + throw new Error('Unhandled node type: ' + obj.termType) + } + + if (initialRoots) { + roots = initialRoots.concat( + roots.filter(function (x: SubjectTerm) { + for (let i = 0; i < initialRoots.length; i++) { + if (x.sameTerm(initialRoots[i])) return false + } + return true + }) + ) + } + for (let i = 0; i < roots.length; i++) { + const tr = myDocument.createElement('tr') + tr.classList.add( + i % 2 === 0 ? 'data-content-pane__row--even' : 'data-content-pane__row--odd', + 'data-content-pane__row--root' + ) + rep.appendChild(tr) + const subjectTD = myDocument.createElement('td') + subjectTD.classList.add('data-content-pane__subject-cell') + tr.appendChild(subjectTD) + const TDTree = myDocument.createElement('td') + TDTree.classList.add('data-content-pane__details-cell') + tr.appendChild(TDTree) + const root = roots[i] + if (root.termType === 'BlankNode') { + subjectTD.appendChild(myDocument.createTextNode(UI.utils.label(root))) + } else { + subjectTD.appendChild(objectTree(root, 0)) + } + TDTree.appendChild(propertyTree(root, 0)) + } + for (const bNT in referencedBnodes) { + const table = doneBnodes[bNT] + if (table === true) continue + const anchor = myDocument.createElement('a') + anchor.setAttribute('id', bNT.slice(2)) + anchor.setAttribute('class', 'bnodeDef') + anchor.textContent = bNT.slice(3) + ')' + table.insertBefore(anchor, table.firstChild) + } + return rep + }, + + render: function ( + subject: NamedNode, + context: DataBrowserContext + ): HTMLDivElement { + const myDocument = context.dom + + function applyEnvironmentAttributes (element: HTMLDivElement): void { + const environment = (context.environment ?? {}) as Partial + element.dataset.layout = environment.layout ?? 'desktop' + element.dataset.theme = environment.theme ?? 'light' + element.dataset.inputMode = environment.inputMode ?? 'pointer' + } + + function mainRendering () { + const kb = context.session.store + const sts = kb.statementsMatching(undefined, undefined, undefined, subject) + const initialRoots: SubjectTerm[] = [] + if (kb.holds(subject, undefined, undefined, subject)) { + initialRoots.push(subject) + } + const ps = kb.any(subject, UI.ns.foaf('primaryTopic'), undefined, subject) + if (ps && (ps.termType === 'NamedNode' || ps.termType === 'BlankNode')) { + initialRoots.push(ps as SubjectTerm) + } + + div.appendChild( + context.session.paneRegistry + .byName('dataContents') + .statementsAsTables(sts, context, initialRoots) + ) + } + + const div = myDocument.createElement('div') + div.classList.add('dataContentPane', 'data-content-pane') + applyEnvironmentAttributes(div) + + mainRendering() + return div + } +} diff --git a/src/defaultPane.css b/src/defaultPane.css new file mode 100644 index 00000000..2b9a8762 --- /dev/null +++ b/src/defaultPane.css @@ -0,0 +1,97 @@ +.defaultPane .bottom-border { + border: 0.2rem solid transparent; + width: 100%; +} + +.defaultPane .bottom-border-active { + cursor: copy; + border: 0.2rem solid; + border-color: var(--color-bottom-border-highlight, rgb(100%, 65%, 0%)); +} + +.defaultPane { + --default-pane-predicate-column: 16rem; + --default-pane-object-indent: 1.75rem; +} + +.defaultPane > tr { + display: grid; + grid-template-columns: minmax(0, var(--default-pane-predicate-column)) minmax(0, 1fr); + align-items: flex-start; +} + +.defaultPane > tr > td.pred { + grid-column: 1; + display: flex; + align-items: flex-start; + padding-left: 0 !important; + box-sizing: border-box; + min-width: 0; +} + +.defaultPane > tr > td.pred > .labelTD { + flex: 1 1 auto; + min-width: 0; + margin: 0; + padding-top: 0; + padding-bottom: 0; +} + +.defaultPane > tr > td.pred > .iconTD { + flex: 0 0 auto; + width: auto; + margin: 0; +} + +.defaultPane > tr > td.obj { + grid-column: 2; + min-width: 0; + box-sizing: border-box; + align-self: start; +} + +.defaultPane > tr > td[colspan='2'] { + grid-column: 1 / -1; +} + +.defaultPane[data-layout='mobile'] > tr { + grid-template-columns: minmax(0, 1fr); +} + +.defaultPane[data-layout='mobile'] > tr > td.pred, +.defaultPane[data-layout='mobile'] > tr > td.obj { + display: block; + grid-column: 1; + width: 100%; + box-sizing: border-box; +} + +.defaultPane[data-layout='mobile'] > tr > td.pred { + padding-bottom: var(--spacing-small, 0.1rem); +} + +.defaultPane[data-layout='mobile'] > tr > td.obj { + padding-left: var(--default-pane-object-indent) !important; +} + +@media (max-width: 700px) { + .defaultPane > tr { + grid-template-columns: minmax(0, 1fr); + } + + .defaultPane > tr > td.pred, + .defaultPane > tr > td.obj { + display: block; + grid-column: 1; + width: 100%; + box-sizing: border-box; + } + + .defaultPane > tr > td.pred { + padding-bottom: var(--spacing-small, 0.1rem); + } + + .defaultPane > tr > td.obj { + padding-left: var(--default-pane-object-indent) !important; + } +} diff --git a/src/defaultPane.js b/src/defaultPane.ts similarity index 57% rename from src/defaultPane.js rename to src/defaultPane.ts index e243f262..98a5a466 100644 --- a/src/defaultPane.js +++ b/src/defaultPane.ts @@ -7,24 +7,58 @@ import * as UI from 'solid-ui' import * as $rdf from 'rdflib' +import type { DataBrowserContext, RenderEnvironment } from 'pane-registry' +import type { BlankNode, Literal, NamedNode, Statement } from 'rdflib' +import './defaultPane.css' const ns = UI.ns +/* Types were generated by Generative AI (GPT-5.4 in GitHub Copilot) based on the following prompt: + Generate TypeScript types for the default pane. */ +type DefaultPaneSubject = NamedNode | BlankNode | Literal -export const defaultPane = { +type DefaultPaneDefinition = { + icon: string + name: string + audience: NamedNode[] + label: (subject: DefaultPaneSubject) => string + render: (subject: DefaultPaneSubject, context: DataBrowserContext) => HTMLDivElement +} + +type DefaultPaneOutliner = { + appendPropertyTRs: ( + parent: HTMLElement, + statements: Statement[], + inverse: boolean, + filter: (pred: NamedNode, inverse: boolean) => boolean + ) => void + UserInput: { + addNewPredicateObject: (event: Event) => void + } +} + +export const defaultPane: DefaultPaneDefinition = { icon: UI.icons.originalIconBase + 'about.png', name: 'default', audience: [ns.solid('Developer')], - label: function (_subject) { + label: function (_subject: DefaultPaneSubject): string { return 'about ' }, - render: function (subject, context) { + render: function ( + subject: DefaultPaneSubject, + context: DataBrowserContext + ): HTMLDivElement { const dom = context.dom - const filter = function (pred, inverse) { + function applyEnvironmentAttributes (element: HTMLDivElement): void { + const environment = (context.environment ?? {}) as Partial + element.dataset.layout = environment.layout ?? 'desktop' + } + + const filter = function (pred: NamedNode, inverse: boolean): boolean { if ( typeof context.session.paneRegistry.byName('internal').predicates[ pred.uri @@ -41,19 +75,23 @@ export const defaultPane = { return true } - const outliner = context.getOutliner(dom) + const outliner = context.getOutliner(dom) as DefaultPaneOutliner const kb = context.session.store // var outline = outliner; // @@ UI.log.info('@defaultPane.render, dom is now ' + dom.location) - subject = kb.canon(subject) + subject = kb.canon(subject) as DefaultPaneSubject const div = dom.createElement('div') div.setAttribute('class', 'defaultPane') + applyEnvironmentAttributes(div) // appendRemoveIcon(div, subject, div) - let plist = kb.statementsMatching(subject) + let plist = subject.termType === 'Literal' ? [] : kb.statementsMatching(subject) outliner.appendPropertyTRs(div, plist, false, filter) plist = kb.statementsMatching(undefined, undefined, subject) outliner.appendPropertyTRs(div, plist, true, filter) + const subjectStatement = subject.termType === 'BlankNode' + ? kb.anyStatementMatching(subject) + : undefined if ( subject.termType === 'Literal' && subject.value.slice(0, 7) === 'http://' @@ -69,10 +107,11 @@ export const defaultPane = { (subject.termType === 'NamedNode' && kb.updater.editable($rdf.Util.uri.docpart(subject.uri), kb)) || (subject.termType === 'BlankNode' && - kb.anyStatementMatching(subject) && - kb.anyStatementMatching(subject).why && - kb.anyStatementMatching(subject).why.uri && - kb.updater.editable(kb.anyStatementMatching(subject).why.uri)) + subjectStatement && + subjectStatement.why && + 'uri' in subjectStatement.why && + typeof subjectStatement.why.uri === 'string' && + kb.updater.editable(subjectStatement.why.uri)) // check the document containing something about of the bnode @@ what about as object? /* ! && HCIoptions["bottom insert highlights"].enabled */ ) { diff --git a/src/form/formPane.css b/src/form/formPane.css new file mode 100644 index 00000000..fe372ed4 --- /dev/null +++ b/src/form/formPane.css @@ -0,0 +1,120 @@ +.formPane a { + color: var(--color-text-link, #3b5998); + text-decoration: none; +} + +.formPane a:link { + color: var(--color-text-link, #3b5998); + text-decoration: none; +} + +.formPane a:visited { + color: var(--color-text-link-visited, #3b5998); + text-decoration: none; +} + +.formPane a:hover { + color: var(--color-text-link-hover, #3b5998); + font-weight: bold; +} + +.formPane a:active { + color: var(--color-text-link-active, #888); + text-decoration: none; +} + +.formPane__message, +.formPaneMessage { + color: #666; + margin: var(--spacing-xs, 0.5em) 0; + padding: var(--spacing-xs, 0.35em) var(--spacing-xs, 0.5em); +} + +.formPane__message--info { + background-color: var(--color-main-block-bg, #eee); +} + +.formPane__message--error { + background-color: var(--color-log-error-bg, #fee); +} + +.formPane__editButton { + margin-left: auto; + align-self: center; + padding: var(--spacing-xs, 0.5rem); + border: .5rem solid white; + font-size: 100%; + float: none; +} + +.formPane .formPane__mobileTextareaValue > div { + display: block; + min-width: 0; + position: relative; +} + +.formPane .formPane__mobileTextareaValue > div > textarea { + box-sizing: border-box; + max-width: 100%; + min-width: 0; + width: 100%; +} + +.formPane .formPane__mobileTextareaValue > div > button[type='button'] { + float: none !important; + position: absolute; + right: 0; + top: 0; +} + +.formPane[data-layout='mobile'] .formPane__mobileTextareaRow { + flex-direction: column !important; + align-items: stretch; +} + +.formPane[data-layout='mobile'] .formPane__mobileTextareaLabel { + width: auto !important; + padding-bottom: 0; +} + +.formPane[data-layout='mobile'] .formPane__mobileTextareaValue { + box-sizing: border-box; + min-width: 0; + padding-right: var(--spacing-sm, 0.75rem); + width: 100%; +} + +.formPane[data-layout='mobile'] .formPane__mobileTextareaValue textarea { + box-sizing: border-box; + margin-left: 0 !important; + margin-right: 0 !important; + max-width: 100%; + width: 100%; +} + +@media (max-width: 960px) { + .formPane .formPane__mobileTextareaRow { + flex-direction: column !important; + align-items: stretch; + } + + .formPane .formPane__mobileTextareaLabel { + width: auto !important; + padding-bottom: 0; + } + + .formPane .formPane__mobileTextareaValue { + box-sizing: border-box; + min-width: 0; + padding-right: var(--spacing-sm, 0.75rem); + width: 100%; + } + + .formPane .formPane__mobileTextareaValue textarea { + box-sizing: border-box; + margin-left: 0 !important; + margin-right: 0 !important; + max-width: 100%; + width: 100%; + } +} diff --git a/src/form/pane.js b/src/form/pane.js deleted file mode 100644 index 9c655615..00000000 --- a/src/form/pane.js +++ /dev/null @@ -1,217 +0,0 @@ -/* - ** Pane for running existing forms for any object - ** - */ - -import * as UI from 'solid-ui' -import { authn } from 'solid-logic' -import * as $rdf from 'rdflib' -const ns = UI.ns - -export const formPane = { - icon: UI.icons.iconBase + 'noun_122196.svg', - - name: 'form', - - audience: [ns.solid('PowerUser')], - - // Does the subject deserve this pane? - label: function (subject) { - const n = UI.widgets.formsFor(subject).length - UI.log.debug('Form pane: forms for ' + subject + ': ' + n) - if (!n) return null - return '' + n + ' forms' - }, - - render: function (subject, context) { - const kb = context.session.store - const dom = context.dom - - const mention = function complain (message, style) { - const pre = dom.createElement('p') - pre.setAttribute('style', style || 'color: grey; background-color: white') - box.appendChild(pre).textContent = message - return pre - } - - const complain = function complain (message, style) { - mention(message, 'style', style || 'color: grey; background-color: #fdd;') - } - - const complainIfBad = function (ok, body) { - if (ok) { - // setModifiedDate(store, kb, store); - // rerender(box); // Deleted forms at the moment - } else complain('Sorry, failed to save your change:\n' + body) - } - - // The question of where to store this data about subject - // This in general needs a whole lot more thought - // and it connects to the discoverbility through links - - // const t = kb.findTypeURIs(subject) - - const me = authn.currentUser() - - const box = dom.createElement('div') - box.setAttribute('class', 'formPane') - - if (!me) { - mention( - 'You are not logged in. If you log in and have ' + - 'workspaces then you would be able to select workspace in which ' + - 'to put this new information' - ) - } else { - const ws = kb.each(me, ns.ui('workspace')) - if (ws.length === 0) { - mention( - 'You don\'t seem to have any workspaces defined. ' + - 'A workspace is a place on the web (http://..) or in ' + - 'the file system (file:///) to store application data.\n' - ) - } else { - // @@ - } - } - - // Render forms using a given store - - const renderFormsFor = function (store, subject) { - kb.fetcher.nowOrWhenFetched(store.uri, subject, function (ok, body) { - if (!ok) return complain('Cannot load store ' + store.uri + ': ' + body) - - // Render the forms - - const forms = UI.widgets.formsFor(subject) - - // complain('Form for editing this form:'); - for (let i = 0; i < forms.length; i++) { - const form = forms[i] - const heading = dom.createElement('h4') - box.appendChild(heading) - if (form.uri) { - const formStore = $rdf.Util.uri.document(form.uri) - if (formStore.uri !== form.uri) { - // The form is a hash-type URI - const e = box.appendChild( - UI.widgets.editFormButton( - dom, - box, - form, - formStore, - complainIfBad - ) - ) - e.setAttribute('style', 'margin-left: auto; display: block;') - } - } - const anchor = dom.createElement('a') - anchor.setAttribute('href', form.uri) - heading.appendChild(anchor) - anchor.textContent = UI.utils.label(form, true) - - /* Keep tis as a reminder to let a New one have its URI given by user - mention("Where will this information be stored?") - const ele = dom.createElement('input'); - box.appendChild(ele); - ele.setAttribute('type', 'text'); - ele.setAttribute('size', '72'); - ele.setAttribute('maxlength', '1024'); - ele.setAttribute('style', 'font-size: 80%; color:#222;'); - ele.value = store.uri - */ - - UI.widgets.appendForm( - dom, - box, - {}, - subject, - form, - store, - complainIfBad - ) - } - }) // end: when store loded - } // renderFormsFor - - // Figure out what store - - // Which places are editable and have stuff about the subject? - - let store = null - - // 1. The document URI of the subject itself - const docuri = $rdf.Util.uri.docpart(subject.uri) - if (subject.uri !== docuri && kb.updater.editable(docuri, kb)) { - store = subject.doc() - } // an editable data file with hash - - store = store || kb.any(kb.sym(docuri), ns.link('annotationStore')) - - // 2. where stuff is already stored - if (!store) { - const docs = {} - const docList = [] - store.statementsMatching(subject).forEach(function (st) { - docs[st.why.uri] = 1 - }) - store - .statementsMatching(undefined, undefined, subject) - .forEach(function (st) { - docs[st.why.uri] = 2 - }) - for (const d in docs) docList.push(docs[d], d) - docList.sort() - for (let i = 0; i < docList.length; i++) { - const uri = docList[i][1] - if (uri && store.updater.editable(uri)) { - store = store.sym(uri) - break - } - } - } - - // 3. In a workspace store - // @@ TODO: Can probably remove _followeach (not done this time because the commit is a very safe refactor) - const _followeach = function (kb, subject, path) { - if (path.length === 0) return [subject] - const oo = kb.each(subject, path[0]) - let res = [] - for (let i = 0; i < oo.length; i++) { - res = res.concat(_followeach(kb, oo[i], path.slice(1))) - } - return res - } - - const date = '2014' // @@@@@@@@@@@@ pass as parameter - - if (store) { - // mention("@@ Ok, we have a store <" + store.uri + ">."); - renderFormsFor(store, subject) - } else { - complain('No suitable store is known, to edit <' + subject.uri + '>.') - const foobarbaz = UI.login.selectWorkspace(dom, function (ws) { - mention('Workspace selected OK: ' + ws) - - const activities = store.each(undefined, ns.space('workspace'), ws) - for (let j = 0; j < activities.length; j++) { - const act = activities[j] - - const subjectDoc2 = store.any(ws, ns.space('store')) - const start = store.any(ws, ns.cal('dtstart')).value() - const end = store.any(ws, ns.cal('dtend')).value() - if (subjectDoc2 && start && end && start <= date && end > date) { - renderFormsFor(subjectDoc2, subject) - break - } else { - complain('Note no suitable annotation store in activity: ' + act) - } - } - }) - box.appendChild(foobarbaz) - } - - return box - } -} diff --git a/src/form/pane.ts b/src/form/pane.ts new file mode 100644 index 00000000..d79d67a1 --- /dev/null +++ b/src/form/pane.ts @@ -0,0 +1,311 @@ +/* + ** Pane for running existing forms for any object + ** + */ + +import * as UI from 'solid-ui' +import { authn } from 'solid-logic' +import * as $rdf from 'rdflib' +import type { DataBrowserContext, PaneDefinition } from 'pane-registry' +import type { RenderEnvironment } from 'pane-registry' +import type { NamedNode, Statement } from 'rdflib' +import './formPane.css' +const ns = UI.ns + +type WorkspaceSelectionDetails = { + noun: string + appPathSegment: string +} + +function isNamedNode (term: { termType?: string } | null | undefined): term is NamedNode { + return term?.termType === 'NamedNode' +} + +/* The following helper was generated by AI GPT-5.4 Model */ +/* Prompt: Add a compatibility shim in the form pane for mixed `ui:Group` plus field typing. */ +function normalizeAmbiguousFieldTypes (store: typeof $rdf.graph extends () => infer T ? T : never, form: NamedNode): void { + const formDoc = form.doc ? form.doc() : undefined + const types = store.each(form, ns.rdf('type'), undefined, formDoc).filter(isNamedNode) + const hasGroupType = types.some(type => type.sameTerm(ns.ui('Group'))) + const hasOtherFieldType = types.some(type => !type.sameTerm(ns.ui('Group')) && !type.sameTerm(ns.ui('Form'))) + const hasProperty = !!store.any(form, ns.ui('property'), undefined, formDoc) + const partsList = store.any(form, ns.ui('parts'), undefined, formDoc) as { elements?: unknown[] } | null + const hasParts = !!partsList?.elements?.length || store.each(form, ns.ui('part'), undefined, formDoc).length > 0 + + // Some legacy forms mark a leaf field as both ui:Group and a concrete input type. + // solid-ui may then choose the Group renderer and produce an empty nested box. + if (hasGroupType && hasOtherFieldType && hasProperty && !hasParts) { + store.removeMany(form, ns.rdf('type'), ns.ui('Group'), formDoc) + } + + const listParts = partsList?.elements ?? [] + for (const part of listParts) { + if (isNamedNode(part as { termType?: string })) { + normalizeAmbiguousFieldTypes(store, part as NamedNode) + } + } + + const unorderedParts = store.each(form, ns.ui('part'), undefined, formDoc) + for (const part of unorderedParts) { + if (isNamedNode(part)) { + normalizeAmbiguousFieldTypes(store, part) + } + } +} + +function tagMobileTextareaRows (renderedForm: HTMLElement): void { + const textareas = Array.from(renderedForm.querySelectorAll('textarea')) + + for (const textarea of textareas) { + let row: HTMLDivElement | null = textarea.parentElement as HTMLDivElement | null + + while (row) { + const firstChild = row.firstElementChild as HTMLElement | null + const hasLabelColumn = !!firstChild && ( + firstChild.classList.contains('formFieldName') || + firstChild.querySelector('a') !== null + ) + + if (hasLabelColumn && row.children.length >= 2) { + row.classList.add('formPane__mobileTextareaRow') + firstChild.classList.add('formPane__mobileTextareaLabel') + const valueColumn = row.children[1] as HTMLElement + valueColumn.classList.add('formPane__mobileTextareaValue') + break + } + + row = row.parentElement as HTMLDivElement | null + } + } +} + +export const formPane: PaneDefinition = { + icon: UI.icons.iconBase + 'noun_122196.svg', + + name: 'form', + + audience: [ns.solid('PowerUser')], + + // Does the subject deserve this pane? + label: function (subject: NamedNode, _context: DataBrowserContext): string | null { + const n = UI.widgets.formsFor(subject).length + UI.log.debug('Form pane: forms for ' + subject + ': ' + n) + if (!n) return null + return '' + n + ' forms' + }, + + render: function (subject: NamedNode, context: DataBrowserContext): HTMLDivElement { + const kb = context.session.store + const dom = context.dom + const box = dom.createElement('div') + box.setAttribute('class', 'formPane') + + function applyEnvironmentAttributes (element: HTMLDivElement): void { + const environment = (context.environment ?? {}) as Partial + element.dataset.layout = environment.layout ?? 'desktop' + } + + applyEnvironmentAttributes(box) + + const mention = function ( + message: string, + modifier: 'info' | 'error' = 'info' + ): HTMLParagraphElement { + const pre = dom.createElement('p') + pre.className = `formPane__message formPane__message--${modifier}` + box.appendChild(pre).textContent = message + return pre + } + + const complain = function (message: string): HTMLParagraphElement { + return mention(message, 'error') + } + + const complainIfBad = function (ok: boolean, body: string): void { + if (ok) { + // setModifiedDate(store, kb, store); + // rerender(box); // Deleted forms at the moment + } else complain('Sorry, failed to save your change:\n' + body) + } + + // The question of where to store this data about subject + // This in general needs a whole lot more thought + // and it connects to the discoverbility through links + + // const t = kb.findTypeURIs(subject) + + const me = authn.currentUser() + + if (!me) { + mention( + 'You are not logged in. If you log in and have ' + + 'workspaces then you would be able to select workspace in which ' + + 'to put this new information' + ) + } else { + const ws = kb.each(me, ns.ui('workspace')) + if (ws.length === 0) { + mention( + 'You don\'t seem to have any workspaces defined. ' + + 'A workspace is a place on the web (http://..) or in ' + + 'the file system (file:///) to store application data.\n' + ) + } else { + // @@ + } + } + + // Render forms using a given store + + const renderFormsFor = function (storeNode: NamedNode, targetSubject: NamedNode): void { + kb.fetcher.nowOrWhenFetched( + storeNode.uri, + targetSubject as unknown as Parameters[1], + function (ok, body) { + if (!ok) return complain('Cannot load store ' + storeNode.uri + ': ' + body) + + // Render the forms + + const forms = UI.widgets.formsFor(targetSubject) as NamedNode[] + + // complain('Form for editing this form:'); + for (const form of forms) { + normalizeAmbiguousFieldTypes(kb as never, form) + + const heading = dom.createElement('h4') + heading.classList.add('formPane__heading') + box.appendChild(heading) + /* The edit Form is not working in the local environment. it does not find + the ui FormForm ontology. Need to research further and check in production. */ + if (form.uri) { + const formStore = $rdf.Util.uri.document(form.uri) + if (formStore.uri !== form.uri) { + const editButton = box.appendChild( + UI.widgets.editFormButton( + dom, + box, + form, + formStore, + complainIfBad + ) + ) + editButton.classList.add('formPane__editButton') + } + } + + const anchor = dom.createElement('a') + anchor.classList.add('formPane__headingLink') + anchor.setAttribute('href', form.uri) + heading.insertBefore(anchor, heading.firstChild) + anchor.textContent = UI.utils.label(form, true) + + /* Keep tis as a reminder to let a New one have its URI given by user + mention("Where will this information be stored?") + const ele = dom.createElement('input'); + box.appendChild(ele); + ele.setAttribute('type', 'text'); + ele.setAttribute('size', '72'); + ele.setAttribute('maxlength', '1024'); + ele.setAttribute('style', 'font-size: 80%; color:#222;'); + ele.value = store.uri + */ + + UI.widgets.appendForm( + dom, + box, + {}, + targetSubject, + form, + storeNode, + complainIfBad + ) + tagMobileTextareaRows(box) + } + } + ) // end: when store loded + } // renderFormsFor + + // Figure out what store + + // Which places are editable and have stuff about the subject? + + let targetStore: NamedNode | null = null + + // 1. The document URI of the subject itself + const docuri = $rdf.Util.uri.docpart(subject.uri) + if (kb.updater.editable(docuri, kb)) { + targetStore = subject.doc() + } // an editable data file with hash + + const annotationStore = kb.any(kb.sym(docuri), ns.link('annotationStore')) + if (!targetStore && isNamedNode(annotationStore)) { + targetStore = annotationStore + } + + // 2. where stuff is already stored + if (!targetStore) { + const docs = new Map() + kb.statementsMatching(subject).forEach(function (st: Statement) { + if (st.why.value) { + docs.set(st.why.value, 1) + } + }) + kb + .statementsMatching(undefined, undefined, subject) + .forEach(function (st: Statement) { + if (st.why.value) { + docs.set(st.why.value, 2) + } + }) + const docList = Array.from(docs.entries()).sort(function ([uriA, scoreA], [uriB, scoreB]) { + return scoreA - scoreB || uriA.localeCompare(uriB) + }) + for (const [uri] of docList) { + if (uri && kb.updater.editable(uri, kb)) { + targetStore = kb.sym(uri) + break + } + } + } + + // 3. In a workspace store + const date = '2014' // @@@@@@@@@@@@ pass as parameter + + if (targetStore) { + // mention("@@ Ok, we have a store <" + store.uri + ">."); + renderFormsFor(targetStore, subject) + } else { + complain('No suitable store is known, to edit <' + subject.uri + '>.') + const workspaceDetails: WorkspaceSelectionDetails = { + noun: 'form', + appPathSegment: 'form' + } + const foobarbaz = UI.login.selectWorkspace(dom, workspaceDetails, function (workspaceUri: string | null) { + const workspace = workspaceUri ? kb.sym(workspaceUri) : null + if (!workspace) { + complain('Workspace selection was cancelled.') + return + } + mention('Workspace selected OK: ' + workspace.uri) + + const activities = kb.each(undefined, ns.space('workspace'), workspace).filter(isNamedNode) + for (let j = 0; j < activities.length; j++) { + const act = activities[j] + + const subjectDoc2 = kb.any(act, ns.space('store')) + const start = kb.any(act, ns.cal('dtstart'))?.value + const end = kb.any(act, ns.cal('dtend'))?.value + if (isNamedNode(subjectDoc2) && start && end && start <= date && end > date) { + renderFormsFor(subjectDoc2, subject) + break + } else { + complain('Note no suitable annotation store in activity: ' + act) + } + } + }) + box.appendChild(foobarbaz) + } + + return box + } +} diff --git a/src/humanReadablePane.css b/src/humanReadablePane.css new file mode 100644 index 00000000..888b85ed --- /dev/null +++ b/src/humanReadablePane.css @@ -0,0 +1,129 @@ +.human-readable-pane { + display: block; + min-width: 0; + width: 100%; + max-width: 100%; + box-sizing: border-box; +} + +.human-readable-pane__container { + display: block; + min-width: 0; + width: 100%; + max-width: 100%; + box-sizing: border-box; + overflow-x: hidden; +} + +.human-readable-pane__frame { + display: block; + border: 1px solid; + padding: 1rem; + height: var(--human-readable-pane-height, 30rem); + min-width: 0; + max-width: 100%; + width: 100%; + box-sizing: border-box; + resize: both; + overflow: auto; +} + +.human-readable-pane__frame--iframe { + padding: 0; + overflow: hidden; + width: 100%; + max-width: 100%; + min-height: 18rem; + background: var(--color-background, white); +} + +.human-readable-pane__frame--markdown { + overflow-wrap: anywhere; + word-break: break-word; +} + +.human-readable-pane__frame--plain-text { + font-family: monospace; + white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: break-word; +} + +.human-readable-pane__frame--markdown > * { + max-width: 100%; + box-sizing: border-box; +} + +.human-readable-pane__frame--markdown img, +.human-readable-pane__frame--markdown video, +.human-readable-pane__frame--markdown canvas, +.human-readable-pane__frame--markdown iframe { + max-width: 100%; + height: auto; +} + +.human-readable-pane__frame--markdown pre, +.human-readable-pane__frame--markdown code { + max-width: 100%; + overflow-wrap: anywhere; + word-break: break-word; +} + +.human-readable-pane__frame--markdown pre { + white-space: pre-wrap; +} + +.human-readable-pane__frame--markdown table { + display: block; + width: 100%; + max-width: 100%; + overflow-x: auto; + box-sizing: border-box; +} + +.human-readable-pane__frame--markdown th, +.human-readable-pane__frame--markdown td { + white-space: normal; + overflow-wrap: anywhere; + word-break: break-word; +} + +.human-readable-pane[data-layout='mobile'] .human-readable-pane__frame { + padding: 0.75rem; + resize: none; + min-width: 0; +} + +.human-readable-pane[data-layout='mobile'] .human-readable-pane__frame--markdown, +.human-readable-pane[data-layout='mobile'] .human-readable-pane__frame--plain-text { + overflow-wrap: anywhere; + word-break: break-word; +} + +.human-readable-pane[data-layout='mobile'] .human-readable-pane__frame--iframe { + width: 100%; + max-width: 100%; + min-height: 16rem; + height: min(var(--human-readable-pane-height, 30rem), 75vh); +} + +@media (max-width: 576px) { + .human-readable-pane__frame { + padding: 0.75rem; + resize: none; + min-width: 0; + } + + .human-readable-pane__frame--markdown, + .human-readable-pane__frame--plain-text { + overflow-wrap: anywhere; + word-break: break-word; + } + + .human-readable-pane__frame--iframe { + width: 100%; + max-width: 100%; + min-height: 16rem; + height: min(var(--human-readable-pane-height, 30rem), 75vh); + } +} diff --git a/src/humanReadablePane.js b/src/humanReadablePane.ts similarity index 74% rename from src/humanReadablePane.js rename to src/humanReadablePane.ts index 76109a75..2e4bbd23 100644 --- a/src/humanReadablePane.js +++ b/src/humanReadablePane.ts @@ -7,19 +7,44 @@ import { icons, ns } from 'solid-ui' import { Util } from 'rdflib' import { marked } from 'marked' import DOMPurify from 'dompurify' +import type { DataBrowserContext, RenderEnvironment } from 'pane-registry' +import type { NamedNode } from 'rdflib' +import './humanReadablePane.css' // Helper function to check if a URI has a markdown file extension -const isMarkdownFile = (uri) => { +type DokieliCacheValue = 'dokieli' | 'html' + +type HumanReadableIcon = string | Promise + +type HumanReadablePaneDefinition = { + icon: (subject: NamedNode, context: DataBrowserContext) => HumanReadableIcon + name: string + label: (subject: NamedNode, context: DataBrowserContext) => 'view' | 'View' | null + render: (subject: NamedNode, context: DataBrowserContext) => HTMLDivElement +} + +const isMarkdownFile = (uri?: string | null): boolean => { if (!uri) return false const path = uri.split('?')[0].split('#')[0] // Remove query string and fragment return /\.(md|markdown|mdown|mkd|mkdn)$/i.test(path) } // Cache for dokieli detection results (keyed by subject URI) -const dokieliCache = new Map() +const dokieliCache = new Map() + +function applyFrameClasses ( + frame: HTMLElement, + modifier: 'markdown' | 'plain-text' | 'iframe', + lines: number +): void { + frame.className = '' + frame.classList.add('human-readable-pane__frame') + frame.classList.add(`human-readable-pane__frame--${modifier}`) + frame.style.setProperty('--human-readable-pane-height', `${lines}em`) +} -const humanReadablePane = { - icon: function (subject, context) { +const humanReadablePane: HumanReadablePaneDefinition = { + icon: function (subject: NamedNode, context: DataBrowserContext): HumanReadableIcon { // Markdown files detected by extension if (subject && isMarkdownFile(subject.uri)) { return icons.iconBase + 'markdown.svg' @@ -77,7 +102,10 @@ const humanReadablePane = { name: 'humanReadable', - label: function (subject, context) { + label: function ( + subject: NamedNode, + context: DataBrowserContext + ): 'view' | 'View' | null { const kb = context.session.store // See also the source pane, which has lower precedence. @@ -93,7 +121,11 @@ const humanReadablePane = { 'video/mp4' ] - const hasContentTypeIn = function (kb, x, displayables) { + const hasContentTypeIn = function ( + kb: typeof context.session.store, + x: NamedNode, + displayables: string[] + ): boolean { const cts = kb.fetcher.getHeader(x, 'content-type') if (cts) { for (let j = 0; j < cts.length; j++) { @@ -108,7 +140,11 @@ const humanReadablePane = { } // This data could come from a fetch OR from ldp container - const hasContentTypeIn2 = function (kb, x, displayables) { + const hasContentTypeIn2 = function ( + kb: typeof context.session.store, + x: NamedNode, + displayables: string[] + ): boolean { const t = kb.findTypeURIs(x) for (let k = 0; k < displayables.length; k++) { if (Util.mediaTypeClass(displayables[k]).uri in t) { @@ -156,11 +192,19 @@ const humanReadablePane = { return null }, - render: function (subject, context) { + render: function ( + subject: NamedNode, + context: DataBrowserContext + ): HTMLDivElement { const myDocument = context.dom const div = myDocument.createElement('div') const kb = context.session.store + function applyEnvironmentAttributes (element: HTMLDivElement): void { + const environment = (context.environment ?? {}) as Partial + element.dataset.layout = environment.layout ?? 'desktop' + } + const cts = kb.fetcher.getHeader(subject.doc(), 'content-type') const ct = cts ? cts[0].split(';', 1)[0].trim() : null // remove content-type parameters @@ -175,19 +219,18 @@ const humanReadablePane = { } // @@ When we can, use CSP to turn off scripts within the iframe - div.setAttribute('class', 'docView') - div.setAttribute('style', 'display: block; width: 100%; max-width: 100%; box-sizing: border-box;') + div.classList.add('human-readable-pane') + applyEnvironmentAttributes(div) // render markdown to html in a DIV element - const renderMarkdownContent = function (frame) { + const renderMarkdownContent = function (frame: HTMLDivElement) { kb.fetcher.webOperation('GET', subject.uri).then(response => { - const markdownText = response.responseText + const markdownText = response.responseText ?? '' const lines = Math.min(30, markdownText.split(/\n/).length + 5) - const res = marked.parse(markdownText) + const res = marked.parse(markdownText, { async: false }) const clean = DOMPurify.sanitize(res) frame.innerHTML = clean - frame.setAttribute('class', 'doc') - frame.setAttribute('style', `display: block; border: 1px solid; padding: 1em; height: ${lines}em; max-width: 100%; width: 100%; box-sizing: border-box; resize: both; overflow: auto;`) + applyFrameClasses(frame, 'markdown', lines) }).catch(error => { console.error('Error fetching markdown content:', error) frame.innerHTML = '

Error loading content

' @@ -195,44 +238,42 @@ const humanReadablePane = { } // render plain text in a PRE element - const renderPlainTextContent = function (frame) { + const renderPlainTextContent = function (frame: HTMLPreElement) { kb.fetcher.webOperation('GET', subject.uri).then(response => { - const plainText = response.responseText + const plainText = response.responseText ?? '' const lines = Math.min(30, plainText.split(/\n/).length + 5) frame.textContent = plainText - frame.setAttribute('class', 'doc') - frame.setAttribute('style', `display: block; border: 1px solid; padding: 1em; height: ${lines}em; max-width: 100%; width: 100%; box-sizing: border-box; resize: both; overflow: auto; font-family: monospace; white-space: pre-wrap; word-wrap: break-word;`) + applyFrameClasses(frame, 'plain-text', lines) }).catch(error => { console.error('Error fetching plain text content:', error) frame.textContent = 'Error loading content' }) } - const setIframeAttributes = (frame, lines) => { + const setIframeAttributes = (frame: HTMLIFrameElement, lines: number) => { frame.setAttribute('src', subject.uri) - frame.setAttribute('class', 'doc') - frame.setAttribute('style', `display: block; border: 1px solid; padding: 1em; height: ${lines}em; max-width: 100%; width: 100%; box-sizing: border-box; resize: both; overflow: auto;`) + applyFrameClasses(frame, 'iframe', lines) } if (isMarkdown) { // For markdown, use a DIV element and render the content - const frame = myDocument.createElement('DIV') + const frame = myDocument.createElement('div') renderMarkdownContent(frame) const frameContainer = myDocument.createElement('div') - frameContainer.setAttribute('style', 'display: block; width: 100%; max-width: 100%; box-sizing: border-box;') + frameContainer.classList.add('human-readable-pane__container') frameContainer.appendChild(frame) div.appendChild(frameContainer) } else if (isPlainText) { // For plain text, use a PRE element and render the content - const frame = myDocument.createElement('PRE') + const frame = myDocument.createElement('pre') renderPlainTextContent(frame) const frameContainer = myDocument.createElement('div') - frameContainer.setAttribute('style', 'display: block; width: 100%; max-width: 100%; box-sizing: border-box;') + frameContainer.classList.add('human-readable-pane__container') frameContainer.appendChild(frame) div.appendChild(frameContainer) } else { // For other content types, use IFRAME - const frame = myDocument.createElement('IFRAME') + const frame = myDocument.createElement('iframe') // Apply sandbox for HTML/XHTML if (ct === 'text/html' || ct === 'application/xhtml+xml') { @@ -241,7 +282,7 @@ const humanReadablePane = { // Fetch content to calculate lines dynamically kb.fetcher.webOperation('GET', subject.uri).then(response => { - const blobText = response.responseText + const blobText = response.responseText ?? '' const newLines = blobText.includes('