diff --git a/README.md b/README.md index f52f316..a9776af 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,9 @@ Also in the editor: a `.clas.abap` file, or link it with `?src=` (the playground's parameter; raw.githubusercontent.com, a github.com file page, or a gist) or `?class=&repo=[&ref=][&path=]`. + The playground's **Open in Layout Editor** hands its app class over as + `#playground=`, the format **Open in Playground** + writes, read the other way; the first file is the class. The `z2ui5_cl_ui5_view_builder` chain becomes the design, the public attributes the data, `client->_event( )` the event names. Export writes the chain back into that class and leaves every other line as it was; diff --git a/src/app.js b/src/app.js index 01cf42c..9261ab7 100644 --- a/src/app.js +++ b/src/app.js @@ -22,7 +22,7 @@ import { renderCode, highlightCode, setCode, setView, copyText, codeText } from import { loadPreview, postPreview, sendPreview, renderEventLog } from './ui/preview.js'; import { handleDrop, copySelection, pasteSelection, addControlByName, addControl } from './ui/edit.js'; import { openPalette } from './ui/palette.js'; -import { newDialog, importDialog, openClassDialog, openClassFromUrl, recentDialog, shareLink, openFile, saveDesign, saveToBrowser, exportZip, openInPlayground, openInVscode } from './ui/files.js'; +import { newDialog, importDialog, openClassDialog, openClassFromUrl, openClassFromPlayground, recentDialog, shareLink, openFile, saveDesign, saveToBrowser, exportZip, openInPlayground, openInVscode } from './ui/files.js'; import { onKey } from './ui/keys.js'; @@ -298,5 +298,9 @@ function openLinkedDesign() { openLinkedDesign(); window.addEventListener('hashchange', openLinkedDesign); +// a class the playground handed over: #playground= +openClassFromPlayground(); +window.addEventListener('hashchange', openClassFromPlayground); + // for the browser tests and the curious: the state and the generators window.designer = { state, generateClass, generateClassSource, toXml, validate, select, commit, handleDrop, openPalette, copySelection, pasteSelection, addControlByName, addControl }; diff --git a/src/handoff.js b/src/handoff.js index 050680a..f323134 100644 --- a/src/handoff.js +++ b/src/handoff.js @@ -28,6 +28,27 @@ export async function deflate(text) { return new Uint8Array(await new Response(new Blob([bytes]).stream().pipeThrough(new CompressionStream('deflate-raw'))).arrayBuffer()); } +export function fromBase64Url(text) { + const bin = atob(text.replace(/-/g, '+').replace(/_/g, '/')); + return Uint8Array.from(bin, (c) => c.charCodeAt(0)); +} + +export async function inflate(bytes) { + return new TextDecoder().decode(await new Response(new Blob([bytes]).stream().pipeThrough(new DecompressionStream('deflate-raw'))).arrayBuffer()); +} + +// The way back: the playground's "Open in Layout Editor" hands its app class +// over as #playground=, the format below read the other +// way. Refuses what is not that format rather than guessing at it. +export async function filesFromPlaygroundFragment(fragment) { + if (!fragment || fragment[0] !== '2') throw new Error('not a playground link of a version this editor reads'); + const files = JSON.parse(await inflate(fromBase64Url(fragment.slice(1)))); + if (!Array.isArray(files) || !files.length || files.some((f) => typeof f?.name !== 'string' || typeof f?.source !== 'string')) { + throw new Error('the link holds no files'); + } + return files; +} + // the playground's fragment for one or more files: version 2 export async function playgroundFragment(files) { const payload = JSON.stringify(files.map(({ name, source }) => ({ name, source }))); diff --git a/src/ui/files.js b/src/ui/files.js index 028646f..1bb7ebc 100644 --- a/src/ui/files.js +++ b/src/ui/files.js @@ -5,7 +5,7 @@ import { clone, walk, normalizeDesign, validate, checkAttributeName } from '../m import { copyText } from './code.js'; import { fileName } from '../abap.js'; import { fromClass, classReport, classFiles, generateClassSource, sourceUrl, repoClassUrl } from '../clas.js'; -import { playgroundLink, vscodeLink, PLAYGROUND_URL } from '../handoff.js'; +import { playgroundLink, vscodeLink, filesFromPlaygroundFragment, PLAYGROUND_URL } from '../handoff.js'; import { zip } from '../zip.js'; import { state, commit, replaceDesign, showPanel, recentDesigns, designToLink, libraryDesigns, saveToLibrary, removeFromLibrary } from './store.js'; import { $, h, toast, toastError, download, dialog, choose } from './dom.js'; @@ -220,6 +220,21 @@ export function openClassFromUrl(params) { fetchClass(url); } +// #playground=: a class the playground handed over (its "Open in +// Layout Editor"). The first file is the app; it replaces the design the way +// Open ABAP class does, and the address is cleaned either way. +export async function openClassFromPlayground() { + if (!location.hash.startsWith('#playground=')) return; + const fragment = location.hash.slice('#playground='.length); + history.replaceState(null, '', location.pathname + location.search); + try { + const [app] = await filesFromPlaygroundFragment(fragment); + importClassText(app.source); + } catch (err) { + toast(`Cannot open the class from the playground: ${err.message}`); + } +} + async function fetchClass(raw) { try { const href = sourceUrl(raw, location.href); diff --git a/test/handoff.test.mjs b/test/handoff.test.mjs index 8238308..4885573 100644 --- a/test/handoff.test.mjs +++ b/test/handoff.test.mjs @@ -8,7 +8,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { allDesigns } from './fixtures.mjs'; import { generateClass } from '../src/abap.js'; -import { playgroundLink, playgroundFragment, vscodeLink, PLAYGROUND_URL } from '../src/handoff.js'; +import { playgroundLink, playgroundFragment, filesFromPlaygroundFragment, vscodeLink, PLAYGROUND_URL } from '../src/handoff.js'; const fromBase64Url = (text) => Uint8Array.from(atob(text.replaceAll('-', '+').replaceAll('_', '/')), (c) => c.charCodeAt(0)); const inflate = async (bytes) => new TextDecoder().decode(await new Response(new Blob([bytes]).stream().pipeThrough(new DecompressionStream('deflate-raw'))).arrayBuffer()); @@ -39,3 +39,12 @@ test('the VS Code link carries the class for the extension\'s URI handler', asyn assert.equal(params.get('name'), 'zcl_my_app.clas.abap'); assert.equal(await inflate(fromBase64Url(params.get('code'))), source); }); + +test('a class the playground hands over (#playground=) reads back as the files it wrote', async () => { + const files = [{ name: 'zcl_my_app.clas.abap', source }, { name: 'zcl_helper.clas.abap', source: 'CLASS zcl_helper DEFINITION.' }]; + assert.deepEqual(await filesFromPlaygroundFragment(await playgroundFragment(files)), files); + await assert.rejects(filesFromPlaygroundFragment('1abc'), /version/); + await assert.rejects(filesFromPlaygroundFragment(''), /version/); + const empty = await playgroundFragment([]); + await assert.rejects(filesFromPlaygroundFragment(empty), /no files/); +});