@openpresentation/opf-editor 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,123 @@
1
+ import {richTextContent, formatRichTextRange, replaceRichTextRange} from './rich-text.js';
2
+
3
+ /** Native SVG selection stays on the exact rendered glyphs; no HTML reflow. */
4
+ export function createRichTextToolbar(root, {editor, report, beforeChange, onProperties, validateChange, onChange}) {
5
+ const doc = root.ownerDocument, win = doc.defaultView;
6
+ const toolbar = doc.createElement('div');
7
+ toolbar.className = 'opf-rich-toolbar';
8
+ toolbar.setAttribute('role','toolbar'); toolbar.setAttribute('aria-label','Text formatting');
9
+ toolbar.style.cssText = 'position:absolute;left:8px;bottom:8px;max-width:calc(100% - 16px);display:flex;flex-wrap:wrap;gap:5px;align-items:center;padding:8px;border:1px solid #d5cde5;border-radius:8px;background:#fff;color:#332e40;box-shadow:0 4px 20px #28203922;z-index:5;font:12px/1.4 system-ui;pointer-events:auto';
10
+ root.append(toolbar);
11
+ let selected = null, restoring = false;
12
+ const buttons = new Map();
13
+ const hide = () => {selected = null; toolbar.style.display = 'none';};
14
+ const button = (label, action) => {
15
+ const node = doc.createElement('button'); node.type='button'; node.textContent=label;
16
+ node.style.cssText='padding:4px 7px;border:1px solid #ded8e8;border-radius:4px;background:#faf9fc;color:inherit;cursor:pointer';
17
+ node.addEventListener('mousedown',event=>event.preventDefault());
18
+ node.onclick=()=>{try {action();} catch(error) {report(error);}};
19
+ toolbar.append(node); return node;
20
+ };
21
+ function currentRuns() {
22
+ if (!selected) return [];
23
+ const value=editor.get(selected.path), result=[];let offset=0;
24
+ for (const run of typeof value==='string'?[value]:value) {
25
+ const text=typeof run==='string'?run:run.text;
26
+ if (offset<selected.end && offset+text.length>selected.start) result.push(typeof run==='string'?{}:run);
27
+ offset+=text.length;
28
+ }
29
+ return result;
30
+ }
31
+ function common(key) {
32
+ const values=currentRuns().map(run=>run[key]);
33
+ return values.length && values.every(value=>value===values[0]) ? values[0] : undefined;
34
+ }
35
+ function sync() {
36
+ if (!selected) return;
37
+ toolbar.style.display='flex';
38
+ for (const [key,node] of buttons) {
39
+ const values=currentRuns().map(run=>!!run[key]), mixed=values.some(Boolean)&&!values.every(Boolean);
40
+ node.setAttribute('aria-pressed',mixed?'mixed':String(values.every(Boolean)));
41
+ node.style.background=values.every(Boolean)?'#eae4fa':'#faf9fc';
42
+ }
43
+ color.value=common('color')??'';size.value=common('fontSize')??'';
44
+ family.value=common('fontFamily')??'';link.value=common('link')??'';
45
+ replacement.value=richTextContent(editor.get(selected.path)).slice(selected.start,selected.end);
46
+ status.textContent=`${selected.end-selected.start} characters selected`;
47
+ }
48
+ function restore() {
49
+ if (!selected) return;
50
+ const nodes=[...root.querySelectorAll('text[data-opf-text-start]')].filter(node=>node.closest('[data-opf-path]')?.getAttribute('data-opf-path')===selected.path);
51
+ const a=nodes.find(node=>+node.dataset.opfTextStart<=selected.start&&+node.dataset.opfTextEnd>selected.start);
52
+ const b=nodes.findLast(node=>+node.dataset.opfTextStart<selected.end&&+node.dataset.opfTextEnd>=selected.end);
53
+ if (!a?.firstChild || !b?.firstChild) return;
54
+ const range=doc.createRange(); range.setStart(a.firstChild,selected.start-Number(a.dataset.opfTextStart));range.setEnd(b.firstChild,selected.end-Number(b.dataset.opfTextStart));
55
+ const selection=win.getSelection(); selection.removeAllRanges();selection.addRange(range);
56
+ }
57
+ function apply(format, text) {
58
+ if (!selected) return;
59
+ const snapshot=selected;
60
+ if (JSON.stringify(editor.get(snapshot.path))!==snapshot.base) {hide();throw new Error('The text changed elsewhere. Select it again before formatting.');}
61
+ if (beforeChange && !beforeChange()) return;
62
+ if (JSON.stringify(editor.get(snapshot.path))!==snapshot.base) {hide();throw new Error("Finish the active edit, then select text again.");}
63
+ const value=editor.get(snapshot.path);
64
+ const next=text===undefined ? formatRichTextRange(value,snapshot.start,snapshot.end,format) : replaceRichTextRange(value,snapshot.start,snapshot.end,text);
65
+ validateChange?.(snapshot.path,next);
66
+ restoring=true;
67
+ try {
68
+ editor.set(snapshot.path,next,{source:'canvas-rich-text',rejectInvalid:true});
69
+ selected={...snapshot,base:JSON.stringify(editor.get(snapshot.path)),end:text===undefined?snapshot.end:snapshot.start+text.length};
70
+ if(selected.end===selected.start) hide();else {restore();sync();}
71
+ onChange?.(snapshot.path);
72
+ } finally {restoring=false;}
73
+ }
74
+ for(const [key,label] of [['bold','Bold'],['italic','Italic'],['underline','Underline'],['strikethrough','Strike'],['superscript','Superscript'],['subscript','Subscript']])
75
+ buttons.set(key,button(label,()=>apply({[key]:!currentRuns().every(run=>!!run[key])})));
76
+ function field(label,type='text') {
77
+ const input=doc.createElement('input');input.type=type;input.setAttribute('aria-label',label);input.placeholder=label;
78
+ input.style.cssText='width:90px;min-width:0;padding:4px 6px;border:1px solid #ded8e8;border-radius:4px;background:white;color:inherit;font:inherit';
79
+ input.onkeydown=event=>{if(event.key==='Enter') {event.preventDefault();input.dispatchEvent(new win.Event('change'));}};
80
+ toolbar.append(input);return input;
81
+ }
82
+ const color=field('Text color');
83
+ color.onchange=()=>{try {if(color.value&&!/^#(?:[\da-f]{3}|[\da-f]{6}|[\da-f]{8})$/i.test(color.value))throw new Error('Use a hex text color, such as #2563eb.');apply({color:color.value||null});}catch(error){report(error);}};
84
+ const size=field('Font size (pt)','number');size.min='0.1';size.step='0.5';size.style.width='80px';
85
+ size.onchange=()=>{try{apply({fontSize:size.value?Number(size.value):null});}catch(error){report(error);}};
86
+ const family=field('Font family');family.onchange=()=>{try{apply({fontFamily:family.value||null});}catch(error){report(error);}};
87
+ const link=field('Link URL');link.onchange=()=>{try{if(link.value&&!/^(https?:|mailto:)/i.test(link.value))throw new Error('Use an HTTP, HTTPS, or mailto link.');apply({link:link.value||null});}catch(error){report(error);}};
88
+ const replacement=field('Selected text');replacement.style.width='160px';
89
+ replacement.onkeydown=event=>{if(event.key==='Enter'){event.preventDefault();try{apply(null,replacement.value);}catch(error){report(error);}}};
90
+ button('Replace text',()=>apply(null,replacement.value));
91
+ button('Reset style',()=>apply(Object.fromEntries(['bold','italic','underline','strikethrough','superscript','subscript','color','fontSize','fontFamily','link'].map(key=>[key,null]))));
92
+ button('Edit runs',()=>{const path=selected?.path;hide();if(path)onProperties?.(path);});
93
+ button('Done',()=>{hide();win.getSelection()?.removeAllRanges();});
94
+ const status=doc.createElement('span');status.style.fontSize='11px';toolbar.append(status);
95
+ toolbar.addEventListener('keydown',event=>{if(event.key==='Escape'){event.preventDefault();event.stopPropagation();hide();}
96
+ if(event.target.tagName==='BUTTON'&&['ArrowLeft','ArrowRight','Home','End'].includes(event.key)){
97
+ const controls=[...toolbar.querySelectorAll('button,input')],index=controls.indexOf(event.target);
98
+ const next=event.key==='Home'?0:event.key==='End'?controls.length-1:(index+(event.key==='ArrowRight'?1:-1)+controls.length)%controls.length;
99
+ event.preventDefault();controls[next].focus();
100
+ }});
101
+ function endpoint(node,offset) {
102
+ if(node?.nodeType!==3) return null;
103
+ const element=node.parentElement?.closest('text[data-opf-text-start]');
104
+ if(!element||!root.contains(element))return null;
105
+ return {path:element.closest('[data-opf-path]')?.getAttribute('data-opf-path'),offset:Number(element.dataset.opfTextStart)+offset};
106
+ }
107
+ function selectionChanged() {
108
+ if(restoring||toolbar.contains(doc.activeElement))return;
109
+ const selection=win.getSelection();
110
+ if(!selection?.rangeCount||selection.isCollapsed){hide();return;}
111
+ const range=selection.getRangeAt(0),a=endpoint(range.startContainer,range.startOffset),b=endpoint(range.endContainer,range.endOffset);
112
+ if(!a||!b||a.path!==b.path||a.offset>=b.offset){hide();return;}
113
+ selected={path:a.path,start:a.offset,end:b.offset,base:JSON.stringify(editor.get(a.path))};sync();
114
+ }
115
+ doc.addEventListener('selectionchange',selectionChanged);
116
+ const unsubscribe=editor.subscribe(()=>{if(!restoring)hide();});
117
+ hide();
118
+ return {hide,selectAll(path) {
119
+ const value=editor.get(path);if(!Array.isArray(value))return false;
120
+ const end=richTextContent(value).length;if(!end)return false;
121
+ selected={path,start:0,end,base:JSON.stringify(value)};restore();sync();return true;
122
+ },destroy(){doc.removeEventListener('selectionchange',selectionChanged);unsubscribe();toolbar.remove();}};
123
+ }
@@ -0,0 +1,10 @@
1
+ export type TextRun = string | {text:string; bold?:boolean; italic?:boolean; underline?:boolean; strikethrough?:boolean; color?:string; fontFamily?:string; fontSize?:number; link?:string; superscript?:boolean; subscript?:boolean; [key:string]:unknown};
2
+ export interface TextRunFormat {
3
+ bold?:boolean|null; italic?:boolean|null; underline?:boolean|null; strikethrough?:boolean|null;
4
+ superscript?:boolean|null; subscript?:boolean|null; color?:string|null; fontFamily?:string|null;
5
+ fontSize?:number|null; link?:string|null;
6
+ }
7
+ export declare function richTextContent(value:string|TextRun[]):string;
8
+ /** Nonempty UTF-16 range on grapheme boundaries; null removes an override. */
9
+ export declare function formatRichTextRange(value:string|TextRun[],start:number,end:number,format:TextRunFormat):TextRun[];
10
+ export declare function replaceRichTextRange(value:string|TextRun[],start:number,end:number,replacement:string):TextRun[];
@@ -0,0 +1,71 @@
1
+ /** UTF-16 ranges match DOM Selection; boundaries must be whole graphemes. */
2
+ export function richTextContent(value) {
3
+ return runs(value).map(run => typeof run === 'string' ? run : run.text).join('');
4
+ }
5
+ function runs(value) {
6
+ const result = typeof value === 'string' ? [value] : value;
7
+ if (!Array.isArray(result) || result.some(run => typeof run !== 'string' && (!run || typeof run !== 'object' || typeof run.text !== 'string')))
8
+ throw new TypeError('Expected a string or OPF text runs.');
9
+ return result;
10
+ }
11
+ function range(value, start, end, allowEmpty) {
12
+ const text = richTextContent(value);
13
+ if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start || end > text.length || (!allowEmpty && start === end))
14
+ throw new RangeError('Select a nonempty text range within the content.');
15
+ const boundaries = new Set([0, text.length]);
16
+ for (const part of new Intl.Segmenter(undefined, {granularity:'grapheme'}).segment(text)) boundaries.add(part.index);
17
+ if (!boundaries.has(start) || !boundaries.has(end)) throw new RangeError('Text ranges must end at whole grapheme boundaries.');
18
+ return runs(value);
19
+ }
20
+ const booleans = ['bold','italic','underline','strikethrough','superscript','subscript'];
21
+ const strings = ['color','fontFamily','link'];
22
+ function checkedFormat(format) {
23
+ if (!format || typeof format !== 'object' || Array.isArray(format)) throw new TypeError('Expected a run formatting object.');
24
+ for (const [key,value] of Object.entries(format)) {
25
+ if (![...booleans,...strings,'fontSize'].includes(key)) throw new TypeError(`Unknown run style: ${key}`);
26
+ if (value === null) continue;
27
+ if (booleans.includes(key) && typeof value !== 'boolean' || strings.includes(key) && typeof value !== 'string' || key === 'fontSize' && (!Number.isFinite(value) || value <= 0))
28
+ throw new TypeError(`Invalid run style: ${key}`);
29
+ }
30
+ if (format.superscript && format.subscript) throw new TypeError('Choose superscript or subscript, not both.');
31
+ return {...format, ...(format.superscript ? {subscript:false} : {}), ...(format.subscript ? {superscript:false} : {})};
32
+ }
33
+ function piece(run, text) { return typeof run === 'string' ? text : {...structuredClone(run),text}; }
34
+ export function formatRichTextRange(value, start, end, format) {
35
+ const source = range(value,start,end,false), patch = checkedFormat(format), result = [];
36
+ let offset = 0;
37
+ for (const run of source) {
38
+ const text = typeof run === 'string' ? run : run.text, next = offset + text.length;
39
+ if (next <= start || offset >= end || !text.length) result.push(structuredClone(run));
40
+ else {
41
+ const a = Math.max(0,start-offset), b = Math.min(text.length,end-offset);
42
+ if (a) result.push(piece(run,text.slice(0,a)));
43
+ const changed = typeof run === 'string' ? {text:text.slice(a,b)} : piece(run,text.slice(a,b));
44
+ for (const [key,value] of Object.entries(patch)) { if (value === null) delete changed[key]; else changed[key] = value; }
45
+ result.push(changed);
46
+ if (b < text.length) result.push(piece(run,text.slice(b)));
47
+ }
48
+ offset = next;
49
+ }
50
+ return result;
51
+ }
52
+ /** Replacement inherits the first selected run (the preceding run for insertion). */
53
+ export function replaceRichTextRange(value, start, end, replacement) {
54
+ const source = range(value,start,end,true);
55
+ if (typeof replacement !== 'string') throw new TypeError('Replacement text must be a string.');
56
+ const result = []; let offset = 0, inserted = false;
57
+ for (const run of source) {
58
+ const text = typeof run === 'string' ? run : run.text, next = offset + text.length;
59
+ const insertion = start === end && start >= offset && start <= next;
60
+ if (!inserted && (insertion || next > start && offset < end)) {
61
+ if (start > offset) result.push(piece(run,text.slice(0,start-offset)));
62
+ if (replacement) result.push(piece(run,replacement));
63
+ inserted = true;
64
+ if (end < next) result.push(piece(run,text.slice(Math.max(0,end-offset))));
65
+ } else if (next <= start || offset >= end) result.push(structuredClone(run));
66
+ else if (end < next) result.push(piece(run,text.slice(end-offset)));
67
+ offset = next;
68
+ }
69
+ if (!inserted && replacement) result.push(replacement);
70
+ return result.length ? result : [''];
71
+ }
@@ -0,0 +1,3 @@
1
+ import type { EditorSession } from './index.js';
2
+ export interface SchemaInspector { navigate(path:string|string[]):void; commit():boolean; reset():void; readonly document:any; readonly dirty:boolean; destroy():void; }
3
+ export declare function createSchemaInspector(container:HTMLElement, options:{editor:EditorSession;path?:string;onDraft?:(event:{document:any;path:string;dirty:boolean})=>void;onCommit?:(event:{editor:EditorSession;path:string})=>void;onCancel?:()=>void;onError?:(error:Error)=>void}):SchemaInspector;
@@ -0,0 +1,100 @@
1
+ import { applyJsonPatch, createValuePatch, getValueAtPath, opfPathToJsonPointer, splitOpfPath, validateOpfDocument } from './index.js';
2
+ import { schemaAtPath, schemaVariants, activeSchema, schemaType, schemaLabel, createSchemaValue } from './schema.js';
3
+ /** A staged form editor; valid drafts preview immediately and Apply creates one undo step. */
4
+ export function createSchemaInspector(container, {editor,path='',onDraft,onCommit,onCancel,onError}={}) {
5
+ if(!editor)throw new Error('An editor session is required.');
6
+ let base=JSON.stringify(editor.document), draft=structuredClone(editor.document), selected=opfPathToJsonPointer(path), dirty=false, destroyed=false, suppress=false;
7
+ const doc=container.ownerDocument;
8
+ const el=(tag,text,cls)=>{const node=doc.createElement(tag);if(text!==undefined)node.textContent=text;if(cls)node.className=cls;return node;};
9
+ const button=(text,action)=>{const node=el('button',text);node.type='button';node.onclick=()=>{try{action();}catch(error){showError(error.message);onError?.(error);}};return node;};
10
+ const input=(label,value,change,type='text')=>{const wrap=el('label',undefined,'opf-schema-field'),caption=el('span',label);const control=el(type==='textarea'?'textarea':'input');if(type!=='textarea')control.type=type;control.value=String(value??'');control.setAttribute('aria-label',label);control.oninput=()=>change(control.value,control);wrap.append(caption,control);return wrap;};
11
+ const errorBox=el('p',undefined,'opf-schema-error');errorBox.setAttribute('role','alert');
12
+ const status=el('p',undefined,'opf-schema-status');status.setAttribute('role','status');
13
+ const body=el('div',undefined,'opf-schema-body'),nav=el('nav',undefined,'opf-schema-breadcrumbs');nav.setAttribute('aria-label','OPF field location');
14
+ const actions=el('div',undefined,'opf-schema-actions');const apply=button('Apply changes',commit),cancel=button('Discard draft',reset);actions.append(status,apply,cancel);
15
+ container.replaceChildren(nav,body,errorBox,actions);container.classList.add('opf-schema-inspector');
16
+ function showError(message){errorBox.textContent=message;}
17
+ function check(){
18
+ const validation=validateOpfDocument(draft);
19
+ apply.disabled=!dirty||!validation.valid;
20
+ status.textContent=dirty?'Draft changes · apply to save':'No pending changes';
21
+ showError(validation.valid?'':validation.errors.slice(0,3).map(error=>`${error.path||'/'}: ${error.message}`).join('\n'));
22
+ if(validation.valid)try{onDraft?.({document:structuredClone(draft),path:selected,dirty});}catch(error){showError(`Preview: ${error.message}`);onError?.(error);}
23
+ }
24
+ function mutate(operations,redraw=true){draft=applyJsonPatch(draft,operations);dirty=JSON.stringify(draft)!==base;if(redraw)render();check();}
25
+ function set(path,value,redraw=true){mutate(createValuePatch(draft,path,value),redraw);}
26
+ function navigate(path){selected=opfPathToJsonPointer(path);render();check();}
27
+ function reset(){base=JSON.stringify(editor.document);draft=structuredClone(editor.document);dirty=false;render();check();onCancel?.();}
28
+ function commit(){
29
+ if(!dirty)return true;
30
+ if(JSON.stringify(editor.document)!==base){showError('The document changed elsewhere. Discard this draft and reopen the field to avoid overwriting newer changes.');return false;}
31
+ const result=validateOpfDocument(draft);if(!result.valid){check();return false;}
32
+ suppress=true;
33
+ try{editor.applyPatch([{op:'replace',path:'',value:draft}],{source:'schema-inspector',rejectInvalid:true});base=JSON.stringify(editor.document);draft=structuredClone(editor.document);dirty=false;render();check();onCommit?.({editor,path:selected});return true;}finally{suppress=false;}
34
+ }
35
+ function summary(value){return Array.isArray(value)?`${value.length} items`:value&&typeof value==='object'?`${Object.keys(value).length} fields`:String(value??'').slice(0,80);}
36
+ function render(){
37
+ if(destroyed)return;
38
+ if(getValueAtPath(draft,selected)===undefined) selected='';
39
+ const parts=splitOpfPath(selected), value=getValueAtPath(draft,selected), raw=schemaAtPath(draft,selected), variants=schemaVariants(raw), schema=activeSchema(raw,value), type=schemaType(schema,value);
40
+ nav.replaceChildren(button('Presentation',()=>navigate('')));
41
+ parts.forEach((part,index)=>nav.append(el('span','/'),button(/^\d+$/.test(part)?String(Number(part)+1):part,()=>navigate(parts.slice(0,index+1)))));
42
+ body.replaceChildren(el('h3',parts[0]==='slides'&&parts.length===2?`Slide ${Number(parts[1])+1}`:parts.at(-1)??'Presentation'));
43
+ if(schema.description)body.append(el('p',schema.description,'opf-schema-description'));
44
+ if(variants.length>1 || Object.keys(raw).length===0){
45
+ const choices=variants.length>1?variants:[{type:'string'},{type:'number'},{type:'boolean'},{type:'object'},{type:'array'},{type:'null'}];
46
+ const label=el('label','Value form'), select=el('select');select.setAttribute('aria-label','Value form');
47
+ choices.forEach((variant,index)=>{const option=el('option',schemaLabel(variant,index));option.value=String(index);select.append(option);});
48
+ const current=choices.findIndex(item=>item===schema||JSON.stringify(item)===JSON.stringify(schema));select.value=String(Math.max(0,current>=0?current:choices.findIndex(item=>schemaType(item,value)===(Array.isArray(value)?'array':value===null?'null':typeof value))));
49
+ select.onchange=()=>{try{set(selected,createSchemaValue(choices[Number(select.value)]));}catch(error){showError(error.message);}};label.append(select);body.append(label);
50
+ }
51
+ if(type==='object' && value!==null && !Array.isArray(value)){
52
+ const search=input('Find a field','',text=>{for(const row of rows.children)row.hidden=!row.textContent.toLowerCase().includes(text.toLowerCase());},'search');body.append(search);
53
+ const rows=el('div',undefined,'opf-schema-rows');
54
+ const properties={...schema.properties};for(const key of Object.keys(value))properties[key]??={};
55
+ for(const [key,fieldSchema]of Object.entries(properties)){
56
+ const present=Object.prototype.hasOwnProperty.call(value,key), required=(schema.required??[]).includes(key), fieldPath=opfPathToJsonPointer([...parts,key]);
57
+ const row=el('div',undefined,'opf-schema-row'),name=el('div');name.append(el('strong',key+(required?' *':'')),el('small',present?summary(value[key]):'Not set'));
58
+ const help=fieldSchema.description??'';row.title=help;
59
+ const controls=el('div');controls.append(button(present?'Edit':'Add',()=>{if(!present)set(fieldPath,createSchemaValue(fieldSchema));navigate(fieldPath);}));
60
+ if(present&&!required)controls.append(button('Remove',()=>mutate([{op:'remove',path:fieldPath}])));
61
+ for(const control of controls.children)control.setAttribute('aria-label',`${control.textContent} ${key}`);
62
+ row.append(name,controls);rows.append(row);
63
+ }
64
+ body.append(rows);
65
+ if(schema.additionalProperties!==false){
66
+ let newKey='';body.append(input('New field name','',value=>{newKey=value;}),button('Add named field',()=>{
67
+ if(!newKey.trim()||['__proto__','constructor','prototype'].includes(newKey))throw new Error('Enter a safe, nonempty field name.');
68
+ if(Object.prototype.hasOwnProperty.call(value,newKey))throw new Error('That field already exists.');
69
+ const keySchema=Object.entries(schema.patternProperties??{}).find(([pattern])=>new RegExp(pattern).test(newKey))?.[1]??schema.additionalProperties;
70
+ const next=opfPathToJsonPointer([...parts,newKey]);set(next,createSchemaValue(typeof keySchema==='object'?keySchema:{}));navigate(next);
71
+ }));
72
+ }
73
+ }else if(type==='array' && Array.isArray(value)){
74
+ value.forEach((item,index)=>{
75
+ const row=el('div',undefined,'opf-schema-row'), itemPath=opfPathToJsonPointer([...parts,String(index)]),controls=el('div');
76
+ row.append(el('span',`${index+1}. ${summary(item)}`));controls.append(button('Edit',()=>navigate(itemPath)),button('Duplicate',()=>mutate([{op:'add',path:opfPathToJsonPointer([...parts,String(index+1)]),value:item}])));
77
+ if(index>0)controls.append(button('Move up',()=>{const next=[...value];[next[index-1],next[index]]=[next[index],next[index-1]];set(selected,next);}));
78
+ if(index<value.length-1)controls.append(button('Move down',()=>{const next=[...value];[next[index+1],next[index]]=[next[index],next[index+1]];set(selected,next);}));
79
+ if(value.length>(schema.minItems??0))controls.append(button('Remove',()=>mutate([{op:'remove',path:itemPath}])));
80
+ row.append(controls);body.append(row);
81
+ });
82
+ const add=button('Add item',()=>{const itemPath=opfPathToJsonPointer([...parts,String(value.length)]), itemSchema=schemaAtPath(draft,itemPath);set(itemPath,createSchemaValue(itemSchema));navigate(itemPath);});add.disabled=value.length>=(schema.maxItems??10000);body.append(add);
83
+ }else if(schema.enum || Object.prototype.hasOwnProperty.call(schema,'const')){
84
+ const label=el('label','Value'),select=el('select');select.setAttribute('aria-label','Value');
85
+ (schema.enum??[schema.const]).forEach(item=>{const option=el('option',String(item));option.value=JSON.stringify(item);select.append(option);});select.value=JSON.stringify(value);select.disabled=schema.const!==undefined;select.onchange=()=>set(selected,JSON.parse(select.value),false);label.append(select);body.append(label);
86
+ }else if(type==='boolean'){
87
+ const label=el('label'),control=el('input');control.type='checkbox';control.checked=value;control.setAttribute('aria-label',parts.at(-1)??'Value');control.onchange=()=>set(selected,control.checked,false);label.append(control,el('span','Enabled'));body.append(label);
88
+ }else if(type==='null')body.append(el('p','Null value'));
89
+ else {
90
+ const numeric=type==='number'||type==='integer';
91
+ body.append(input('Value',value,(text,control)=>{
92
+ if(numeric && (!text.trim()||!Number.isFinite(Number(text)))){control.setAttribute('aria-invalid','true');apply.disabled=true;showError('Enter a finite number.');return;}
93
+ control.removeAttribute('aria-invalid');set(selected,numeric?Number(text):text,false);
94
+ },numeric?'number':'textarea'));
95
+ }
96
+ }
97
+ const unsubscribe=editor.subscribe(()=>{if(suppress)return;if(dirty){showError('The document changed elsewhere. Discard the draft to load the latest version.');apply.disabled=true;}else reset();});
98
+ render();check();
99
+ return {navigate,commit,reset,get document(){return structuredClone(draft);},get dirty(){return dirty;},destroy(){destroyed=true;unsubscribe();container.replaceChildren();}};
100
+ }
@@ -0,0 +1,10 @@
1
+ export type Schema = Record<string, any>;
2
+ export declare const opfSchemas: Record<string, Schema>;
3
+ export declare function resolveSchema(schema: Schema, root?: Schema): Schema;
4
+ export declare function schemaVariants(schema: Schema, root?: Schema): Schema[];
5
+ export declare function schemaType(schema: Schema, value?: unknown): string;
6
+ export declare function activeSchema(schema: Schema, value: unknown, root?: Schema): Schema;
7
+ export declare function schemaAtPath(document: unknown, path: string | string[], root?: Schema): Schema;
8
+ export declare function schemaLabel(schema: Schema, index?: number): string;
9
+ export declare function createSchemaValue(schema: Schema, root?: Schema): any;
10
+ export declare function listSchemaFields(): Array<{schema:string;path:string;name:string;description:string;reference:string|null;type:string|null;values:unknown[]|null}>;
package/dist/schema.js ADDED
@@ -0,0 +1,102 @@
1
+ import { schemas, catalogSchemaNames } from '@openpresentation/opf';
2
+ import { getValueAtPath, splitOpfPath, opfPathToJsonPointer } from './index.js';
3
+ export const opfSchemas = schemas;
4
+ const documents = new Map(Object.values(schemas).map(schema => [schema.$id, schema]));
5
+ const own = (object, key) => Object.prototype.hasOwnProperty.call(object, key);
6
+ export function resolveSchema(schema, root = schemas.presentation, seen = new Set()) {
7
+ if (!schema || typeof schema !== 'object') return {};
8
+ if (!schema.$ref) return schema;
9
+ const ref = schema.$ref;
10
+ if (seen.has(ref)) return {};
11
+ seen.add(ref);
12
+ const [id, fragment = ''] = ref.split('#');
13
+ const target = id ? documents.get(id) : root;
14
+ if (!target) throw new Error(`Unresolved OPF schema: ${ref}`);
15
+ const resolved = fragment ? getValueAtPath(target, fragment) : target;
16
+ if (!resolved) throw new Error(`Unresolved OPF schema: ${ref}`);
17
+ const { $ref, ...rest } = schema;
18
+ return {...resolveSchema(resolved, target, seen), ...rest};
19
+ }
20
+ export function schemaVariants(schema, root = schemas.presentation) {
21
+ const resolved = resolveSchema(schema, root);
22
+ if (Array.isArray(resolved.type)) return resolved.type.flatMap(type=>schemaVariants({...resolved,type},root));
23
+ const variants = resolved.oneOf ?? resolved.anyOf;
24
+ if (!variants) return [resolved];
25
+ const {$ref, oneOf, anyOf, ...base} = resolved;
26
+ return variants.flatMap(child => schemaVariants(child, root).map(variant => ({...base, ...variant, properties: base.properties || variant.properties ? {...base.properties,...variant.properties} : undefined})));
27
+ }
28
+ export function schemaType(schema, value) {
29
+ return schema.type ?? (schema.properties || schema.additionalProperties ? 'object' : schema.items ? 'array' : schema.const !== undefined ? typeof schema.const : value === null ? 'null' : Array.isArray(value) ? 'array' : typeof value === 'undefined' ? 'string' : typeof value);
30
+ }
31
+ function score(schema, value) {
32
+ const type=schemaType(schema,value);
33
+ if (type === 'integer' ? !Number.isInteger(value) : type === 'array' ? !Array.isArray(value) : type === 'null' ? value !== null : typeof value !== type || (type === 'object' && (value === null || Array.isArray(value)))) return -100;
34
+ if (own(schema,'const')) return JSON.stringify(schema.const)===JSON.stringify(value)?100:-100;
35
+ if(schema.enum && !schema.enum.includes(value))return -100;
36
+ let result=1;
37
+ for(const [key,child] of Object.entries(schema.properties??{})) {
38
+ if(!value || !own(value,key))continue;
39
+ if(own(child,'const'))result+=value[key]===child.const?100:-1000;
40
+ else result+=1;
41
+ }
42
+ for(const key of schema.required??[])if(!value || !own(value,key))result-=2;
43
+ return result;
44
+ }
45
+ export function activeSchema(schema, value, root=schemas.presentation) {
46
+ return schemaVariants(schema,root).sort((a,b)=>score(b,value)-score(a,value))[0] ?? {};
47
+ }
48
+ export function schemaAtPath(document, path, root = schemas.presentation) {
49
+ const parts=splitOpfPath(path);let current=root,value=document;
50
+ for(let index=0; index<parts.length;index++) {
51
+ const key=parts[index], resolved=activeSchema(current,value,root);
52
+ if(parts[0]==='catalogs' && parts[2]==='records' && index===3) current=schemas[catalogSchemaNames[parts[1]]]??{};
53
+ else current=Array.isArray(value)||resolved.type==='array' ? resolved.items??{} : resolved.properties?.[key] ?? Object.entries(resolved.patternProperties??{}).find(([pattern])=>new RegExp(pattern).test(key))?.[1] ?? (typeof resolved.additionalProperties==='object'?resolved.additionalProperties:{});
54
+ value=value?.[key];
55
+ }
56
+ return resolveSchema(current,root);
57
+ }
58
+ export function schemaLabel(schema, index=0) {
59
+ const type=schemaType(schema);
60
+ return schema.title ?? schema.properties?.type?.const ?? (schema.const!==undefined?String(schema.const):`${type}${schema.properties ? ` · ${Object.keys(schema.properties).slice(0,3).join(', ')}` : ''}`) ?? `Option ${index+1}`;
61
+ }
62
+ export function createSchemaValue(schema, root=schemas.presentation, depth=0) {
63
+ if(depth>20)throw new Error('Choose a shallower initial value for this recursive structure.');
64
+ const variants=schemaVariants(schema,root), s=variants[0]??{};
65
+ if(own(s,'const'))return structuredClone(s.const);
66
+ if(own(s,'default'))return structuredClone(s.default);
67
+ if(s.enum?.length)return structuredClone(s.enum[0]);
68
+ const type=schemaType(s);
69
+ if(type==='object'){
70
+ const value={};
71
+ for(const key of new Set([...(s.required??[]),...Object.keys(s.properties??{}).filter(key=>own(s.properties[key],'const'))])) value[key]=createSchemaValue(s.properties?.[key]??{},root,depth+1);
72
+ if(s.anyOf || s.oneOf){ /* Variants have already been expanded. */ }
73
+ return value;
74
+ }
75
+ if(type==='array')return Array.from({length:Math.min(s.minItems??0,100)},()=>createSchemaValue(s.items??{},root,depth+1));
76
+ if(type==='boolean')return false;
77
+ if(type==='null')return null;
78
+ if(type==='number'||type==='integer')return s.minimum??(s.exclusiveMinimum!==undefined?s.exclusiveMinimum+1:0);
79
+ if(s.examples?.length && typeof s.examples[0]==='string' && !s.examples[0].includes('...'))return s.examples[0];
80
+ if(s.format==='uri')return 'https://example.com';
81
+ if(s.pattern?.includes('a-z'))return 'example';
82
+ return s.minLength?'Text':'';
83
+ }
84
+ export function listSchemaFields() {
85
+ const result=[];
86
+ for(const [name,root] of Object.entries(schemas)) {
87
+ const walk=(schema,path)=>{
88
+ if(!schema || typeof schema!=='object')return;
89
+ for(const [key,child]of Object.entries(schema.properties??{})){
90
+ result.push({schema:name,path:opfPathToJsonPointer([...path,'properties',key]),name:key,description:child.description??'',reference:child.$ref??null,type:child.type??null,values:child.enum??(own(child,'const')?[child.const]:null)});
91
+ walk(child,[...path,'properties',key]);
92
+ }
93
+ for(const [key,child]of Object.entries(schema.$defs??{}))walk(child,[...path,'$defs',key]);
94
+ for(const keyword of ['oneOf','anyOf','allOf']) (schema[keyword]??[]).forEach((child,index)=>walk(child,[...path,keyword,String(index)]));
95
+ if(schema.items)walk(schema.items,[...path,'items']);
96
+ if(typeof schema.additionalProperties==='object')walk(schema.additionalProperties,[...path,'additionalProperties']);
97
+ for(const [key,child]of Object.entries(schema.patternProperties??{}))walk(child,[...path,'patternProperties',key]);
98
+ };
99
+ walk(root,[]);
100
+ }
101
+ return result;
102
+ }
@@ -0,0 +1,27 @@
1
+ export declare const MAX_OPF_BYTES: number;
2
+ export interface OpfTransfer {
3
+ kind: "presentation" | "slides" | "selection";
4
+ value: unknown;
5
+ document?: any;
6
+ }
7
+ export declare function assertOpf(document: any): any;
8
+ export declare function unwrapOpf(value: any): any;
9
+ export declare function parseOpfTransfer(text: string): OpfTransfer;
10
+ export declare function serializeOpfTransfer(
11
+ document: any,
12
+ options?: {
13
+ scope?: "presentation" | "slide" | "selection";
14
+ slideIndex?: number;
15
+ path?: string;
16
+ format?: "pretty" | "compact" | "markdown";
17
+ },
18
+ ): string;
19
+ export declare function prepareOpfImport(
20
+ current: any,
21
+ transfer: OpfTransfer,
22
+ options?: {
23
+ mode?: "insert" | "replace" | "selection";
24
+ slideIndex?: number;
25
+ path?: string;
26
+ },
27
+ ): { document: any; slideIndex: number };