class ClassPage { static ModeCookieName = 'doczilla-js-docs-class-page-mode'; static Mode = { Tabs: 'mode-tabs', List: 'mode-list' }; static StyleClasses = { Selected: 'selected', Active: 'active', CtrlPressed: 'ctrl-pressed', ShiftPressed: 'shift-pressed', ParentsBranch: 'parents-branch', ClassItem: 'class-item', ClassName: 'class-name', ClassIcon: 'class-icon', Filler: 'filler', FullSourcePrompt: 'full-source-prompt', FullSourcePromptText: 'full-source-prompt-text', FullSourcePromptButton: 'full-source-prompt-button', Spacing: 'spacing', CmLink: 'cm-link', Z8Locale: 'z8-locale', Hidden: 'hidden', Collapsed: 'collapsed', Highlighted: 'highlighted', Readonly: 'readonly', Clickable: 'clickable', Empty: 'empty', White: 'white' }; static Attributes = { DataTab: 'data-tab', DataDisplayMode: 'data-display-mode', DataClassName: 'data-class-name', OnClick: 'onclick', DataPropertyName: 'data-property-name', DataPropertyType: 'data-property-type', DataPropertyParent: 'data-property-parent', DataPropertyInherited: 'data-property-inherited', DataPropertyDynamic: 'data-property-dynamic' }; static Messages = { NoMixins: 'This class has no mixins.', NoChildren: 'This class has no child classes.', NoMixedIn: 'This class is not mixed in any classes.', NoParents: 'This is a base class, which has no parent classes.', ShowFullSourceText: 'There were found another entities in the source file of this class. Would you like to see full source file?', HideFullSourceText: 'Full source file shown. Would you like to hide all entities except the target class?', PromptButtonText: 'OK', CmLinkTipPrefix: 'Ctrl+Click to go to class' }; static TabNames = { Editor: 'Editor', Methods: 'Methods', Parents: 'Parents', Properties: 'Properties', Mixins: 'Mixins', Children: 'Children', MixedIn: 'MixedIn', Contribution: 'Contribution' }; static PropertyType = { Statics: 'statics', Base: 'base', Overridden: 'overridden', Dynamic: 'dynamic', Inherited: 'inherited' }; static PropertyLabel = { Statics: 'Static properties', Base: 'Base properties', Overridden: 'Overridden properties', Dynamic: 'Dynamic properties', Inherited: 'Inherited properties' }; static MethodLabel = { Statics: 'Static methods', Base: 'Base methods', Overridden: 'Overridden methods', Dynamic: 'Dynamic methods', Inherited: 'Inherited methods' }; static ClassProperties = { Name: 'name', Methods: 'methods', Properties: 'properties', Children: 'children', Mixins: 'mixins', MixedIn: 'mixedIn', ParentsBranch: 'parentsBranch', Statics: 'statics', DynamicProperties: 'dynamicProperties', Root: 'root', ShortName: 'shortName' }; static ContextMenuType = { PropertyItem: 'property-item' }; static __static__ = '__static__'; start() { if(typeof Class === 'string') { return this; } this.tabElements = { [ClassPage.TabNames.Editor]: DOM.get('.tab.editor'), [ClassPage.TabNames.Methods]: DOM.get('.tab.methods'), [ClassPage.TabNames.Parents]: DOM.get('.tab.parents'), [ClassPage.TabNames.Properties]: DOM.get('.tab.properties'), [ClassPage.TabNames.Mixins]: DOM.get('.tab.mixins'), [ClassPage.TabNames.Children]: DOM.get('.tab.children'), [ClassPage.TabNames.MixedIn]: DOM.get('.tab.mixedin') }; this.contentElements = { [ClassPage.TabNames.Editor]: DOM.get('.content-tab#editor'), [ClassPage.TabNames.Methods]: DOM.get('.content-tab#methods'), [ClassPage.TabNames.Parents]: DOM.get('.content-tab#parents'), [ClassPage.TabNames.Properties]: DOM.get('.content-tab#properties'), [ClassPage.TabNames.Mixins]: DOM.get('.content-tab#mixins'), [ClassPage.TabNames.Children]: DOM.get('.content-tab#children'), [ClassPage.TabNames.MixedIn]: DOM.get('.content-tab#mixedin') }; if(isEditor) { this.tabElements[ClassPage.TabNames.Contribution] = DOM.get('.tab.contribution'); this.contentElements[ClassPage.TabNames.Contribution] = DOM.get('.content-tab#contribution'); } this.documented = Object.keys(Comments).filter((key) => { return Comments[key].text.length > 0; }).length; this.documentable = 0; this.inheritedCommentsQuery = {}; this.inheritedCommentsFields = {}; this.propertyItemElements = {}; this.documentedPercentage = DOM.get('.class-documented-percentage'); const rightContainer = this.rightContainer = DOM.get('.right'); const modeCookieValue = DOM.getCookieProperty(App.CookieName, ClassPage.ModeCookieName); if(!modeCookieValue) DOM.setCookieProperty(App.CookieName, ClassPage.ModeCookieName, ClassPage.Mode.Tabs); const mode = this.mode = modeCookieValue || ClassPage.Mode.Tabs; const tabsModeButton = this.tabsModeButton = DOM.get('.display-mode-button.mode-tabs'); const listModeButton = this.listModeButton = DOM.get('.display-mode-button.mode-list'); /* >>> Context menu */ this.contextMenu = DOM.get('.context-menu'); this.contextMenuItems = {}; /* <<< Context menu */ (mode === ClassPage.Mode.Tabs ? tabsModeButton : listModeButton).addClass(ClassPage.StyleClasses.Selected); rightContainer.addClass(mode); this.renderContent(); this.markContentInEditor(); this.registerEventListeners(); this.applyHash(); this.openSocket(); return this; } switchMode(mode) { this.rightContainer.removeClass(this.mode); this.mode = mode; DOM.setCookieProperty(App.CookieName, ClassPage.ModeCookieName, mode); this.rightContainer.addClass(mode); this.codeMirrorEditor.cmRefresh(); } selectTab(tab) { tab = typeof tab === 'string' ? this.tabElements[tab] : tab; const selectedTab = this.selectedTab; let filler = selectedTab ? selectedTab.getFirstChild('.filler') : null; if(!filler) filler = DOM.create({ tag: DOM.Tags.Div, cls: ClassPage.StyleClasses.Filler }); selectedTab && selectedTab.removeClass(ClassPage.StyleClasses.Selected); this.selectedTab = tab.addClass(ClassPage.StyleClasses.Selected); this.selectedTab.append(filler); } activateContent(content) { content = typeof content === 'string' ? this.contentElements[content] : content; if(this.activeContent) this.activeContent.removeClass(ClassPage.StyleClasses.Active); this.activeContent = content.addClass(ClassPage.StyleClasses.Active); if(content === this.contentElements[ClassPage.TabNames.Editor]) this.codeMirrorEditor.cmRefresh(); if(content === this.contentElements[ClassPage.TabNames.Properties] || content === this.contentElements[ClassPage.TabNames.Methods]) DOM.getAll('.property-item-comment-input').forEach((item) => { item.style('height', `${Math.min(422, item.get().scrollHeight + 2)}px`); }); } registerEventListeners() { this.registerTabsEventListeners(); this.registerModeButtonsEventListeners(); } registerTabsEventListeners() { const tabElements = this.tabElements; for(const tabName of Object.keys(tabElements)) { tabElements[tabName].on(DOM.Events.Click, this.onTabClick.bind(this)); } } registerModeButtonsEventListeners() { this.tabsModeButton.on(DOM.Events.Click, this.onModeButtonClick.bind(this)); this.listModeButton.on(DOM.Events.Click, this.onModeButtonClick.bind(this)); } prepareSource() { const className = Class[ClassPage.ClassProperties.Name].replaceAll('.', '\\.'); const classRx = new RegExp(`Z8\\.define\\(\'${className}\',\\s*\\{(?:.|[\r\n])+?^\\}\\);?`, 'gm'); const classSourceMatch = ClassSource.match(classRx); if(!classSourceMatch) { // remove after `}\n);` issue fixed... this.sourceHasAnotherEntities = false; return this.classSource = ClassSource; } const classSource = this.classSource = classSourceMatch[0]; this.sourceHasAnotherEntities = ClassSource.trim() !== classSource; return classSource; } renderContent() { this.renderEditor(); this.renderParents(); this.renderMixins(); this.renderChildren(); this.renderMixedIn(); this.renderProperties(); this.renderMethods(); this.renderDocumentedPercentage(); this.loadInheritedComments(); if(isEditor) this.renderContribution(); } renderDocumentedPercentage() { this.documentedPercentage.setInnerHTML(`${Math.round(this.documented/this.documentable * 10000) / 100}%`); } renderEditor() { this.codeMirrorEditor = CodeMirror(DOM.get('#editor').get(), { [App.CodeMirrorProperties.Value]: this.prepareSource(ClassSource), [App.CodeMirrorProperties.Mode]: 'javascript', [App.CodeMirrorProperties.Theme]: 'darcula', [App.CodeMirrorProperties.Readonly]: true, [App.CodeMirrorProperties.LineNumbers]: true, [App.CodeMirrorProperties.MatchBrackets]: true, [App.CodeMirrorProperties.ScrollbarStyle]: 'overlay', [App.CodeMirrorProperties.ConfigureMouse]: (cm, repeat, ev) => { return { 'addNew': false }; }, }); if(this.sourceHasAnotherEntities) this.renderFullSourcePrompt(); this.codeMirrorEditorElement = DOM.get('.CodeMirror'); } renderProperties() { const propertiesElement = this.contentElements[ClassPage.TabNames.Properties]; const properties = this.getProperties(false); this.renderPropertiesType(properties, ClassPage.PropertyType.Statics, ClassPage.PropertyLabel.Statics, propertiesElement, false, false); this.renderPropertiesType(properties, ClassPage.PropertyType.Base, ClassPage.PropertyLabel.Base, propertiesElement, false, false); this.renderPropertiesType(properties, ClassPage.PropertyType.Overridden, ClassPage.PropertyLabel.Overridden, propertiesElement, false, false); this.renderPropertiesType(properties, ClassPage.PropertyType.Dynamic, ClassPage.PropertyLabel.Dynamic, propertiesElement, false, false); this.renderPropertiesType(properties, ClassPage.PropertyType.Inherited, ClassPage.PropertyLabel.Inherited, propertiesElement, true, false); } renderMethods() { const methodsElement = this.contentElements[ClassPage.TabNames.Methods]; const properties = this.getProperties(true); this.renderPropertiesType(properties, ClassPage.PropertyType.Statics, ClassPage.MethodLabel.Statics, methodsElement, false, true); this.renderPropertiesType(properties, ClassPage.PropertyType.Base, ClassPage.MethodLabel.Base, methodsElement, false, true); this.renderPropertiesType(properties, ClassPage.PropertyType.Overridden, ClassPage.MethodLabel.Overridden, methodsElement, false, true); this.renderPropertiesType(properties, ClassPage.PropertyType.Dynamic, ClassPage.MethodLabel.Dynamic, methodsElement, false, true); this.renderPropertiesType(properties, ClassPage.PropertyType.Inherited, ClassPage.MethodLabel.Inherited, methodsElement, true, true); } // TODO: refactor! Make PropertyItem class renderPropertiesType(properties, type, headerText, container, initiallyHidden, isMethods) { const isStatics = type === ClassPage.PropertyType.Statics; const isInherited = type === ClassPage.PropertyType.Inherited; properties = properties[type]; if(!properties || properties.length == 0) return; const propertyItemClickable = (element) => { let el = element; while(!el.hasClass('property-item-comment-static') && !el.hasClass('property-item')) el = el.getParent(); return element.hasClass('property-item-nearest-parent-span') || element.hasClass('property-item-comment-input') || element.hasClass('property-item-comment-button') || element.hasClass('property-item-saving-filler') || (el.hasClass('property-item-comment-static') && el.hasClass(ClassPage.StyleClasses.Clickable)) || element.getTag() === DOM.Tags.A; }; if(!isInherited) { this.documentable += properties.length; } else { const inheritedCommentsQuery = this.inheritedCommentsQuery; properties.forEach((prop) => { if(inheritedCommentsQuery[prop.nearestParent]) { inheritedCommentsQuery[prop.nearestParent].properties.push(prop.key); } else { inheritedCommentsQuery[prop.nearestParent] = { root: prop.nearestParentRoot, className: prop.nearestParent, properties: [prop.key] }; } }); } const propertiesHeaderText = DOM.create({ tag: DOM.Tags.Span, cls: 'properties-header-text', innerHTML: headerText }); const propertiesHeaderCollapsedIcon = DOM.create({ tag: DOM.Tags.Div, cls: 'properties-header-collapsed-icon' }); const propertiesHeader = DOM.create({ tag: DOM.Tags.Div, cls: 'properties-header', cn: [propertiesHeaderText, propertiesHeaderCollapsedIcon] }, container); const propertiesList = DOM.create({ tag: DOM.Tags.Div, cls: 'properties-list' }, container); if(initiallyHidden) { propertiesList.addClass(ClassPage.StyleClasses.Hidden); propertiesHeader.addClass(ClassPage.StyleClasses.Collapsed); } for(const property of properties) { const key = isStatics ? `${ClassPage.__static__}${property.key}` : property.key; const propertyItem = DOM.create({ tag: DOM.Tags.Div, cls: 'property-item', attr: { [ClassPage.Attributes.DataPropertyName]: key, [ClassPage.Attributes.DataPropertyType]: property.type }}, propertiesList).on(DOM.Events.MouseDown, (e) => { const element = CDElement.get(e.target); if(e.buttons === DOM.MouseButtons.Right) { if(element.hasClass('property-item-saving-filler')) return; setTimeout(() => { this.showContextMenu(ClassPage.ContextMenuType.PropertyItem, element, { x: e.pageX, y: e.pageY }); }, 10); } else if (e.buttons === DOM.MouseButtons.Left) { if(propertyItemClickable(element)) return; if(type === ClassPage.PropertyType.Inherited) { Url.goTo(`/class/${property.nearestParent}#${isMethods ? 'Methods' : 'Properties'}:${key}`); } else { this.searchPropertyInEditor(isMethods, property.dynamic, key); } } }); const itemNameText = DOM.create({ tag: DOM.Tags.Span, innerHTML: isMethods ? 'Signature: ' : 'Name: ' }); const itemNameValue = DOM.create({ tag: DOM.Tags.Span, cls: 'property-item-name-span', innerHTML: isMethods ? property.value : property.key }); DOM.create({ tag: DOM.Tags.Div, cls: 'property-item-name', cn: [itemNameText, itemNameValue] }, propertyItem); if(type !== ClassPage.PropertyType.Dynamic && !isMethods) { const itemTypeText = DOM.create({ tag: DOM.Tags.Span, innerHTML: 'Type: ' }); const itemTypeValue = DOM.create({ tag: DOM.Tags.Span, cls: 'property-item-type-span', innerHTML: property.type }); DOM.create({ tag: DOM.Tags.Div, cls: 'property-item-type', cn: [itemTypeText, itemTypeValue] }, propertyItem); } if(type !== ClassPage.PropertyType.Dynamic && !isMethods && property.type !== 'undefined') { const itemValueText = DOM.create({ tag: DOM.Tags.Span, innerHTML: 'Default value: ' }); const itemValueValue = DOM.create({ tag: DOM.Tags.Span, cls: 'property-item-default-value-span', innerHTML: property.type === 'string' ? `'${property.value}'` : property.value }); DOM.create({ tag: DOM.Tags.Div, cls: 'property-item-default-value', cn: [itemValueText, itemValueValue] }, propertyItem); } if(type !== ClassPage.PropertyType.Dynamic && type !== ClassPage.PropertyType.Statics && type !== ClassPage.PropertyType.Base) { const itemParentText = DOM.create({ tag: DOM.Tags.Span, innerHTML: 'Nearest parent: ' }); const itemParentValue = DOM.create({ tag: DOM.Tags.Span, cls: 'property-item-nearest-parent-span', innerHTML: property.nearestParent }).on('click', (e) => { Url.goTo(`/class/${property.nearestParent}`); }); DOM.create({ tag: DOM.Tags.Div, cls: 'property-item-nearest-parent', cn: [itemParentText, itemParentValue] }, propertyItem); propertyItem.setAttribute(ClassPage.Attributes.DataPropertyParent, property.nearestParent); } if(type === ClassPage.PropertyType.Dynamic) { propertyItem.setAttribute(ClassPage.Attributes.DataPropertyDynamic, 'true'); } const itemCommentText = DOM.create({ tag: DOM.Tags.Div, innerHTML: 'Comment:' }); const itemCommentCn = [itemCommentText]; const loadedComment = Comments[type === ClassPage.PropertyType.Statics ? `${ClassPage.__static__}${property.key}` : property.key]; const loadedCommentText = loadedComment && typeof loadedComment === 'object' ? loadedComment.text : ''; const hasComment = loadedComment && typeof loadedComment === 'object' && loadedCommentText.length > 0; const itemCommentStatic = DOM.create({ tag: DOM.Tags.Div, cls: `property-item-comment-static${!hasComment ? ' empty' : ''}`, innerHTML: hasComment ? CDUtils.nl2br(loadedCommentText) : 'Not commented yet...' }); itemCommentCn.push(itemCommentStatic); if(type === ClassPage.PropertyType.Inherited) { this.inheritedCommentsFields[`${property.nearestParent}:${property.key}`] = itemCommentStatic; propertyItem.setAttribute(ClassPage.Attributes.DataPropertyInherited, 'true'); } this.propertyItemElements[type === ClassPage.PropertyType.Statics ? `${ClassPage.__static__}${property.key}` : property.key] = propertyItem; if(isEditor) { const itemCommentInput = DOM.create({ tag: DOM.Tags.Textarea, cls: 'property-item-comment-input hidden', attr: { 'placeholder': 'Not commented yet...'} }).setValue(CDUtils.br2nl(loadedCommentText)); itemCommentCn.push(itemCommentInput); if(type === ClassPage.PropertyType.Inherited) { itemCommentInput.addClass(ClassPage.StyleClasses.Readonly).setAttribute('readonly', 'true'); } else { itemCommentStatic.addClass(ClassPage.StyleClasses.Clickable); itemCommentInput .on(DOM.Events.KeyDown, this.delayedAdjustCommentInputHeight.bind(this)) .on(DOM.Events.Change, this.adjustCommentInputHeight.bind(this)) .on(DOM.Events.Cut, this.delayedAdjustCommentInputHeight.bind(this)) .on(DOM.Events.Paste, this.delayedAdjustCommentInputHeight.bind(this)) .on(DOM.Events.Drop, this.delayedAdjustCommentInputHeight.bind(this)); const onCommentSave = (e) => { const commentContent = itemCommentInput.getValue(); if(commentContent === CDUtils.br2nl(itemCommentStatic.getValue()) || commentContent === '' && itemCommentStatic.hasClass('empty')) return; const propertyName = `${type === ClassPage.PropertyType.Statics ? ClassPage.__static__ : ''}${property.key}`; const className = Class[ClassPage.ClassProperties.Name]; const classRoot = Class[ClassPage.ClassProperties.Root]; propertyItem.addClass('saving'); itemCommentInput.blur(); fetch('/updateComment', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ 'root': classRoot, 'class': className, 'property': propertyName, 'comment': commentContent }) }).then((res) => { if(res.status !== 202) { propertyItem.removeClass('saving'); console.error(`Comment update failed (${res.status})`); } }).catch((e) => { propertyItem.removeClass('saving'); console.error(`Comment update failed`); }); }; const itemCommentOkButton = DOM.create({ tag: DOM.Tags.Div, cls: 'property-item-comment-button hidden', innerHTML: 'OK' }).on(DOM.Events.Click, onCommentSave); itemCommentCn.push(itemCommentOkButton); itemCommentInput.on(DOM.Events.KeyDown, (e) => { if(e.key === DOM.Keys.Escape) { const inputScrollTop = itemCommentInput.get().scrollTop; itemCommentInput.switchClass(ClassPage.StyleClasses.Hidden); itemCommentOkButton.switchClass(ClassPage.StyleClasses.Hidden); itemCommentStatic.switchClass(ClassPage.StyleClasses.Hidden); itemCommentStatic.get().scrollTop = inputScrollTop; if(!itemCommentStatic.hasClass('empty')) itemCommentInput.setValue(CDUtils.br2nl(itemCommentStatic.getValue())); } if(e.key === DOM.Keys.Enter && !e.shiftKey) { onCommentSave(); e.preventDefault(); } }); itemCommentStatic.on(DOM.Events.Click, (e) => { if(CDElement.get(e.target).getTag() === DOM.Tags.A) return; itemCommentInput.switchClass(ClassPage.StyleClasses.Hidden); itemCommentInput.focus(); itemCommentInput.style('height', `${Math.min(422, itemCommentStatic.get().scrollHeight + 2)}px`); itemCommentInput.get().scrollTop = itemCommentStatic.get().scrollTop; itemCommentOkButton.switchClass(ClassPage.StyleClasses.Hidden); itemCommentStatic.switchClass(ClassPage.StyleClasses.Hidden); }); } DOM.create({ tag: DOM.Tags.Div, cls: 'property-item-saving-filler' }, propertyItem); } if(hasComment) itemCommentCn.push(this.createCommentDateElement(loadedComment.timestamp, loadedComment.author)); DOM.create({ tag: DOM.Tags.Div, cls: 'property-item-comment', cn: itemCommentCn }, propertyItem); } propertiesHeader.on(DOM.Events.Click, (e) => { propertiesList.switchClass(ClassPage.StyleClasses.Hidden); propertiesHeader.switchClass(ClassPage.StyleClasses.Collapsed); }); } createCommentDateElement(date, author) { const commentDateText = DOM.create({ tag: DOM.Tags.Span, cls: 'property-item-comment-date-text', innerHTML: `Commented by ${author} on: ` }); const commentDateDate = DOM.create({ tag: DOM.Tags.Span, cls: 'property-item-comment-date-date', innerHTML: CDUtils.dateFormatUTC(date, 3, 'D.M.Y, H:I:S') }); return DOM.create({ tag: DOM.Tags.Div, cls: 'property-item-comment-date', cn: [commentDateText, commentDateDate] }); } loadInheritedComments() { if(Object.keys(this.inheritedCommentsQuery).length === 0 || Object.keys(this.inheritedCommentsFields).length === 0) return; fetch('/getInheritedComments', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ query: JSON.stringify(this.inheritedCommentsQuery) }) }).then(res => res.json()).then((inheritedComments) => { for(const cls of Object.keys(inheritedComments)) { const props = inheritedComments[cls]; for(const prop of Object.keys(props)) { const element = this.inheritedCommentsFields[`${cls}:${prop}`]; if(element) { element.setInnerHTML(props[prop].text); element.removeClass(ClassPage.StyleClasses.Empty); element.getParent().append(this.createCommentDateElement(props[prop].timestamp, props[prop].author)); } } } }); } getProperties(methods) { const filter = methods ? (item) => item.type === 'method' : (item) => item.type !== 'method'; const statics = Class[ClassPage.ClassProperties.Statics].filter(filter); const properties = Class[ClassPage.ClassProperties.Properties].sort((a, b) => a.key.toLowerCase().localeCompare(b.key.toLowerCase())).sort((a, b) => { return Class[ClassPage.ClassProperties.ParentsBranch].indexOf(a.nearestParent) > Class[ClassPage.ClassProperties.ParentsBranch].indexOf(b.nearestParent) ? -1 : 1; }).filter(filter); const dynamicProperties = methods ? [] : Class[ClassPage.ClassProperties.DynamicProperties]; const result = { [ClassPage.PropertyType.Statics]: statics, [ClassPage.PropertyType.Base]: properties.filter((item) => !item.inherited), [ClassPage.PropertyType.Overridden]: properties.filter((item) => item.overridden), [ClassPage.PropertyType.Inherited]: properties.filter((item) => item.inherited && !item.overridden), [ClassPage.PropertyType.Dynamic]: dynamicProperties }; return result; } renderMixins() { const mixinsElement = this.contentElements[ClassPage.TabNames.Mixins]; if(Class[ClassPage.ClassProperties.Mixins].length == 0) { DOM.create({ tag: DOM.Tags.Div, style: 'font-size: 24px;', innerHTML: ClassPage.Messages.NoMixins }, mixinsElement); mixinsElement.addClass(ClassPage.StyleClasses.Empty); return; } this.renderClassItems(Class[ClassPage.ClassProperties.Mixins], mixinsElement); } renderChildren() { const childrenElement = this.contentElements[ClassPage.TabNames.Children]; if(Class[ClassPage.ClassProperties.Children].length == 0) { DOM.create({ tag: DOM.Tags.Div, style: 'font-size: 24px;', innerHTML: ClassPage.Messages.NoChildren }, childrenElement); childrenElement.addClass(ClassPage.StyleClasses.Empty); return; } this.renderClassItems(Class[ClassPage.ClassProperties.Children], childrenElement); } renderMixedIn() { const mixedInElement = this.contentElements[ClassPage.TabNames.MixedIn]; if(Class[ClassPage.ClassProperties.MixedIn].length == 0) { DOM.create({ tag: DOM.Tags.Div, style: 'font-size: 24px;', innerHTML: ClassPage.Messages.NoMixedIn }, mixedInElement); mixedInElement.addClass(ClassPage.StyleClasses.Empty); return; } this.renderClassItems(Class[ClassPage.ClassProperties.MixedIn], mixedInElement); } renderFullSourcePrompt() { const editorContent = this.contentElements[ClassPage.TabNames.Editor]; const prompt = DOM.create({ tag: DOM.Tags.Div, cls: ClassPage.StyleClasses.FullSourcePrompt }, editorContent); const text = this.fullSourcePromptText = DOM.create({ tag: DOM.Tags.Div, cls: ClassPage.StyleClasses.FullSourcePromptText, innerHTML: ClassPage.Messages.ShowFullSourceText }, prompt); const button = DOM.create({ tag: DOM.Tags.Div, cls: ClassPage.StyleClasses.FullSourcePromptButton, innerHTML: ClassPage.Messages.PromptButtonText }, prompt); const onButtonClick = (e) => { this.switchFullSource(); }; button.on(DOM.Events.Click, onButtonClick.bind(this)); } switchFullSource(show) { const shown = this.fullSourceCodeShown = show !== undefined ? !show : !this.fullSourceCodeShown; this.codeMirrorEditor.cmSetValue(shown ? ClassSource : this.classSource); if(shown) this.findAndScrollToTargetClass(); this.codeMirrorEditor.cmRefresh(); this.markContentInEditor(); this.fullSourcePromptText && this.fullSourcePromptText.setInnerHTML(shown ? ClassPage.Messages.HideFullSourceText : ClassPage.Messages.ShowFullSourceText); return shown; } markContentInEditor() { const staticsRange = this.getStaticsRange(); this.codeMirrorEditor.cmEachLine((lineHandle) => { this.markExtend(lineHandle); this.markMixins(lineHandle); this.markZ8Locales(lineHandle); this.markNew(lineHandle); this.markThis(lineHandle); this.markProperties(lineHandle, staticsRange); }); } findAndScrollToTargetClass() { const className = Class[ClassPage.ClassProperties.Name].replaceAll('.', '\\.'); const editor = this.codeMirrorEditor; const defineRx = new RegExp(`Z8\\.define\\(\'${className}\',`); editor.cmEachLine((lineHandle) => { const text = lineHandle.text; const match = text.match(defineRx); if(match) { editor.scrollIntoView({ line: lineHandle.lineNo(), ch: 0 }, 100); return; } }); } renderParents() { const parentsContent = this.contentElements[ClassPage.TabNames.Parents]; const parentsContainer = DOM.create({ tag: DOM.Tags.Div, cls: ClassPage.StyleClasses.ParentsBranch }, parentsContent); if(Class[ClassPage.ClassProperties.ParentsBranch].length == 0) { DOM.create({ tag: DOM.Tags.Div, style: 'font-size: 24px;', innerHTML: ClassPage.Messages.NoParents }, parentsContent); parentsContent.addClass(ClassPage.StyleClasses.Empty); return; } this.renderClassItems(Class[ClassPage.ClassProperties.ParentsBranch], parentsContainer, true); } renderClassItems(itemsList, container, withIndent) { let indent = 0; if(!withIndent) itemsList = itemsList.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())); for(const cls of itemsList) { const icon = DOM.create({ tag: DOM.Tags.Div, cls: ClassPage.StyleClasses.ClassIcon }); const name = DOM.create({ tag: DOM.Tags.Div, cls: ClassPage.StyleClasses.ClassName, innerHTML: cls }); const cn = [icon, name]; if(indent > 0 && withIndent) cn.unshift(DOM.create({ tag: DOM.Tags.Div, cls: ClassPage.StyleClasses.Spacing, style: `width: ${10 * indent}px;` })); DOM.create({ tag: DOM.Tags.Div, cls: `${ClassPage.StyleClasses.ClassItem}${ withIndent ? ' indent' : '' }`, attr: { [ClassPage.Attributes.DataClassName]: cls }, cn: cn }, container).on(DOM.Events.Click, this.onClassClick.bind(this)); indent++; } } markExtend(lineHandle) { const editor = this.codeMirrorEditor; const text = lineHandle.text; const match = text.match(/extend:\s*['"]?([\w\.]+)['"]?/); if (match) { const className = match[1]; const from = { line: lineHandle.lineNo(), ch: match.index + 8 }; const to = { line: lineHandle.lineNo(), ch: match.index + match[0].length }; editor.markText(from, to, { className: ClassPage.StyleClasses.CmLink, title: `${ClassPage.Messages.CmLinkTipPrefix} ${className}`, attributes: { [ClassPage.Attributes.DataClassName]: className, [ClassPage.Attributes.OnClick]: 'window.page.onClassLinkClick(this);' } }); } } markMixins(lineHandle) { const editor = this.codeMirrorEditor; const text = lineHandle.text; const match = text.match(/mixins:\s*(\[.*?\]|\w+)/); if (match) { let mixins = match[1].replace(/\[|\]/g, "").split(/\s*,\s*/); const mixinsStr = match[1].replace(/\[|\]/g, ""); mixins = mixinsStr.split(/\s*,\s*/); const startIndex = match.index + match[0].indexOf(mixinsStr); for (var i = 0; i < mixins.length; i++) { const className = mixins[i].trim().replace(/^['"]|['"]$/g, ""); const classIndex = mixinsStr.indexOf(className); const from = { line: lineHandle.lineNo(), ch: startIndex + classIndex }; const to = { line: lineHandle.lineNo(), ch: startIndex + classIndex + className.length }; editor.markText(from, to, { className: ClassPage.StyleClasses.CmLink, title: `${ClassPage.Messages.CmLinkTipPrefix} ${className}`, attributes: { [ClassPage.Attributes.DataClassName]: className, [ClassPage.Attributes.OnClick]: 'window.page.onClassLinkClick(this);' } }); } } } markZ8Locales(lineHandle) { const editor = this.codeMirrorEditor; const text = lineHandle.text; const regexp = /Z8\.\$\('([\S]+)'(?:\s*,\s*.*)?\)/g; let match; while ((match = regexp.exec(text)) !== null) { const messageId = match[1]; const from = { line: lineHandle.lineNo(), ch: match.index }; const to = { line: lineHandle.lineNo(), ch: match.index + match[0].length }; editor.markText(from, to, { className: ClassPage.StyleClasses.Z8Locale, title: `RU: ${Z8Locales['ru'][messageId]}\nEN: ${Z8Locales['en'][messageId]}` }); } } markNew(lineHandle) { const editor = this.codeMirrorEditor; const text = lineHandle.text; const regexp = /new\s+([\w\.]+)/g; let match; while ((match = regexp.exec(text)) !== null) { const className = match[1]; const from = { line: lineHandle.lineNo(), ch: match.index + 4 }; const to = { line: lineHandle.lineNo(), ch: match.index + match[0].length }; if(!ClassList[className] && !this.shortNameExists(className)) continue; editor.markText(from, to, { className: ClassPage.StyleClasses.CmLink, title: `${ClassPage.Messages.CmLinkTipPrefix} ${className}`, attributes: { [ClassPage.Attributes.DataClassName]: className, [ClassPage.Attributes.OnClick]: 'window.page.onClassLinkClick(this);' } }); } } markThis(lineHandle) { const editor = this.codeMirrorEditor; const text = lineHandle.text; const regexp = /this\.([\w]+)/g; let match; while ((match = regexp.exec(text)) !== null) { const propertyName = match[1]; const from = { line: lineHandle.lineNo(), ch: match.index + 5 }; const to = { line: lineHandle.lineNo(), ch: match.index + match[0].length }; const foundProperty = this.findClassProperty(propertyName); if(!foundProperty) continue; editor.markText(from, to, { className: 'cm-this-prop', title: `Ctrl+Click to go to ${foundProperty.type === 'method' ? 'method' : 'property'} '${propertyName}'`, attributes: { [ClassPage.Attributes.DataPropertyName]: propertyName, [ClassPage.Attributes.DataPropertyType]: foundProperty.type === 'method' ? 'Methods' : 'Properties', [ClassPage.Attributes.DataPropertyParent]: foundProperty.inherited ? foundProperty.nearestParent : '', [ClassPage.Attributes.OnClick]: 'window.page.onPropertyClick(this);' } }); } } markProperties(lineHandle, staticsRange) { const editor = this.codeMirrorEditor; const text = lineHandle.text; const lineNo = lineHandle.lineNo(); const regexp = /\t([\w]+):/g; const isStatic = lineNo >= staticsRange.from && lineNo <= staticsRange.to; let match; while ((match = regexp.exec(text)) !== null) { const propertyName = isStatic ? `${ClassPage.__static__}${match[1]}` : match[1]; const from = { line: lineNo, ch: match.index + 1 }; const to = { line: lineNo, ch: match.index + match[0].length - 1 }; const foundProperty = this.findClassProperty(propertyName); if(!foundProperty) continue; const titlePropertyName = isStatic ? `${Class[ClassPage.ClassProperties.ShortName] || Class[ClassPage.ClassProperties.Name]}.${propertyName.replace(ClassPage.__static__, '')}` : propertyName; editor.markText(from, to, { className: 'cm-this-prop', title: `Ctrl+Click to go to ${foundProperty.type === 'method' ? 'method' : 'property'} '${titlePropertyName}'`, attributes: { [ClassPage.Attributes.DataPropertyName]: propertyName, [ClassPage.Attributes.DataPropertyType]: foundProperty.type === 'method' ? 'Methods' : 'Properties', [ClassPage.Attributes.DataPropertyParent]: foundProperty.inherited ? foundProperty.nearestParent : '', [ClassPage.Attributes.OnClick]: 'window.page.onPropertyClick(this);' } }); } } getStaticsRange() { const range = { from: -1, to: -1 }; let staticsParenthesisFlag = -1; let found = false; this.codeMirrorEditor.cmEachLine((lineHandle) => { if(found) return; const text = lineHandle.text; let ignoreFirstParenthesis = false; if(staticsParenthesisFlag === -1 && text.match(/statics:\s*\{/)) { staticsParenthesisFlag = 1; ignoreFirstParenthesis = true; range.from = lineHandle.lineNo(); } if(staticsParenthesisFlag > 0) { for(let i = 0; i < text.length; i++) { if(text.charAt(i) === '{' && ignoreFirstParenthesis) ignoreFirstParenthesis = false; else if (text.charAt(i) === '{') staticsParenthesisFlag++; else if(text.charAt(i) === '}') staticsParenthesisFlag--; } } if(staticsParenthesisFlag === 0) { range.to = lineHandle.lineNo(); found = true; } }); return range; } shortNameExists(shortName) { return Object.keys(ClassList).map((key) => ClassList[key]).filter((item) => item[ClassPage.ClassProperties.ShortName] === shortName).length > 0; } findClassProperty(propertyName) { if(propertyName.startsWith(ClassPage.__static__)) { propertyName = propertyName.slice(10); const statics = Class[ClassPage.ClassProperties.Statics]; const foundStatic = statics.filter((prop) => prop.key === propertyName)[0]; return foundStatic; } const dynamicProperties = Class[ClassPage.ClassProperties.DynamicProperties]; const properties = Class[ClassPage.ClassProperties.Properties]; const foundDynamic = dynamicProperties.filter((prop) => prop.key === propertyName)[0]; const foundProperty = properties.filter((prop) => prop.key === propertyName)[0]; return foundDynamic || foundProperty; } onClassLinkClick(fragment) { const codeMirrorEditorElement = this.codeMirrorEditorElement; if(codeMirrorEditorElement.hasClass(ClassPage.StyleClasses.CtrlPressed)) Url.goTo(`/class/${fragment.getAttribute(ClassPage.Attributes.DataClassName)}`, codeMirrorEditorElement.hasClass(ClassPage.StyleClasses.ShiftPressed)); } onPropertyClick(fragment) { const codeMirrorEditorElement = this.codeMirrorEditorElement; if(codeMirrorEditorElement.hasClass(ClassPage.StyleClasses.CtrlPressed)) { const parentClassName = fragment.getAttribute(ClassPage.Attributes.DataPropertyParent); const propertyType = fragment.getAttribute(ClassPage.Attributes.DataPropertyType); const propertyName = fragment.getAttribute(ClassPage.Attributes.DataPropertyName); if(parentClassName.length > 0) Url.goTo(`/class/${parentClassName}#${propertyType}:${propertyName}`); else Url.setHash(`${propertyType}:${propertyName}`).updateLocation(); } } onTabClick(e) { const element = CDElement.get(e.target); if(element.hasClass(ClassPage.StyleClasses.Selected)) return; this.openTab(element.getAttribute(ClassPage.Attributes.DataTab)); } openTab(tabName) { this.selectTab(tabName); this.activateContent(tabName); Url.setHash(tabName).updateLocation(); } onClassClick(e) { let element = CDElement.get(e.target); while(!element.hasClass(ClassPage.StyleClasses.ClassItem)) element = element.getParent(); Url.goTo(`/class/${element.getAttribute(ClassPage.Attributes.DataClassName)}`); } onModeButtonClick(e) { const button = CDElement.get(e.target); const mode = button.getAttribute(ClassPage.Attributes.DataDisplayMode); this.switchMode(mode); button.addClass(ClassPage.StyleClasses.Selected); (mode === ClassPage.Mode.Tabs ? this.listModeButton : this.tabsModeButton).removeClass(ClassPage.StyleClasses.Selected); } searchInEditor(isStatic, ...queries) { this.switchFullSource(true); const editor = this.codeMirrorEditor; const staticsRange = this.getStaticsRange(); const staticsStart = staticsRange.from; const staticsEnd = staticsRange.to; for(const query of queries) { const cursor = editor.getSearchCursor(query, CodeMirror.Pos(editor.cmFirstLine(), 0), { caseFold: false, multiline: true }); while(cursor.find()) { const from = cursor.from(); const to = cursor.to(); const lineIndex = from.line; if((isStatic && lineIndex >= staticsStart && lineIndex <= staticsEnd) || (!isStatic && (lineIndex < staticsStart || lineIndex > staticsEnd))) { this.openTab('Editor'); editor.setSelection(from, to); editor.scrollIntoView({ from: from, to: to }, 100); return; } } } } searchPropertyInEditor(isMethod, isDynamic, propertyName) { const isStatic = propertyName.startsWith(ClassPage.__static__); propertyName = isStatic ? propertyName.slice(10) : propertyName; if(isMethod) { this.searchInEditor(isStatic, `${propertyName}: function`); } else { if(isDynamic) this.searchInEditor(false, `this.${propertyName} =`, `this.${propertyName}`); else this.searchInEditor(isStatic, `${propertyName}: `); } } scrollToProperty(hashTab, hashProp) { if(!hashProp) return; const item = this.contentElements[hashTab].getFirstChild(`.property-item[data-property-name="${hashProp}"]`); if(!item) return; const categoryList = item.getParent(); const categoryHeader = categoryList.previousSibling(); if(categoryList.hasClass(ClassPage.StyleClasses.Hidden)) categoryList.removeClass(ClassPage.StyleClasses.Hidden); if(categoryHeader.hasClass(ClassPage.StyleClasses.Collapsed)) categoryHeader.removeClass(ClassPage.StyleClasses.Collapsed); item.addClass(ClassPage.StyleClasses.Highlighted); item.addClass(ClassPage.StyleClasses.White); if(this.mode === ClassPage.Mode.Tabs) this.contentElements[hashTab].scrollTo(`.property-item[data-property-name="${hashProp}"]`); else item.scrollIntoView(); setTimeout(() => { item.removeClass(ClassPage.StyleClasses.White); }, 1000); setTimeout(() => { item.removeClass(ClassPage.StyleClasses.Highlighted); }, 2000); } delayedAdjustCommentInputHeight(e) { setTimeout(() => { this.adjustCommentInputHeight(e) }, 0); } adjustCommentInputHeight(e) { const textArea = e.target; if(textArea.scrollHeight < 422 || e.key === DOM.Keys.Backspace) { textArea.style.height = 'auto'; textArea.style.height = `${Math.min(422, textArea.scrollHeight + 2)}px`; } textArea.scrollTop = textArea.scrollHeight; } applyHash() { const hash = (Url.getHash() || '').split(':'); const hashTab = hash[0]; const hashProp = hash[1]; const tabElements = this.tabElements; const contentElements = this.contentElements; const selectedTab = tabElements[hashTab] || tabElements[ClassPage.TabNames.Editor]; const activeContent = contentElements[hashTab] || contentElements[ClassPage.TabNames.Editor]; this.selectTab(selectedTab); this.activateContent(activeContent); this.scrollToProperty(hashTab, hashProp); } openSocket() { this.socket = new Socket('/ws').onMessage(this.onSocketMessage.bind(this)); } onSocketMessage(e) { const changes = JSON.parse(e.data) || []; changes.forEach((changedComment) => { this.processChange(changedComment); }); if(this.statistics) this.statistics.load(); } processChange(changedComment) { if(changedComment.root !== Class[ClassPage.ClassProperties.Root] || changedComment.className !== Class[ClassPage.ClassProperties.Name]) return; const propertyItem = this.propertyItemElements[changedComment.propertyName]; propertyItem.removeClass('saving'); switch(changedComment.action) { case 'create': this.documented++; this.renderDocumentedPercentage(); Comments[changedComment.propertyName] = changedComment; propertyItem.getFirstChild('.property-item-comment').append(this.createCommentDateElement(changedComment.timestamp, changedComment.author)); break; case 'update': if(Comments[changedComment.propertyName].text.length === 0) { this.documented++; this.renderDocumentedPercentage(); } Comments[changedComment.propertyName].text = changedComment.text; const dateElement = propertyItem.getFirstChild('.property-item-comment-date > .property-item-comment-date-date'); if(dateElement) { dateElement.setInnerHTML(CDUtils.dateFormatUTC(changedComment.timestamp, 3, 'D.M.Y, H:I:S')); } else { propertyItem.getFirstChild('.property-item-comment').append(this.createCommentDateElement(changedComment.timestamp, changedComment.author)); } break; case 'remove': this.documented--; if(Comments[changedComment.propertyName]) Comments[changedComment.propertyName].text = ''; this.renderDocumentedPercentage(); propertyItem.getFirstChild('.property-item-comment-date').remove(); break; } const commentContent = changedComment.text; const itemCommentStatic = propertyItem.getFirstChild('.property-item-comment-static'); const itemCommentInput = propertyItem.getFirstChild('.property-item-comment-input'); const itemCommentOkButton = propertyItem.getFirstChild('.property-item-comment-button'); if(itemCommentInput) { itemCommentInput.setValue(commentContent); itemCommentInput.addClass(ClassPage.StyleClasses.Hidden); itemCommentOkButton.addClass(ClassPage.StyleClasses.Hidden); } itemCommentStatic.setInnerHTML(commentContent.length > 0 ? CDUtils.nl2br(commentContent) : 'Not commented yet...'); itemCommentStatic.switchClass(ClassPage.StyleClasses.Empty, commentContent.length === 0); itemCommentStatic.removeClass(ClassPage.StyleClasses.Hidden); } renderContribution() { const contributionElement = this.contentElements[ClassPage.TabNames.Contribution]; this.statistics = Statistics.init(contributionElement, Statistics.Modes.CLASS); } /* >>> Context menu | TODO: move to a completely independent module? */ showContextMenu(contextMenuType, target, pos) { while(!target.hasClass(contextMenuType)) target = target.getParent(); switch(contextMenuType) { case ClassPage.ContextMenuType.PropertyItem: const propertyItemName = target.getAttribute(ClassPage.Attributes.DataPropertyName); const propertyItemType = target.getAttribute(ClassPage.Attributes.DataPropertyType); const propertyItemParent = target.getAttribute(ClassPage.Attributes.DataPropertyParent); const propertyItemDynamic = target.getAttribute(ClassPage.Attributes.DataPropertyDynamic); const propertyItemInhertied = target.getAttribute(ClassPage.Attributes.DataPropertyInherited); if(propertyItemInhertied !== 'true') { this.createContextMenuItem('ShowInEditor', 'Show in Editor', () => { this.searchPropertyInEditor(propertyItemType === 'method', propertyItemDynamic === 'true', propertyItemName); }); } if(propertyItemParent != null) { this.createContextMenuItem('MoveToParent', 'Move to parent', () => { Url.goTo(`/class/${propertyItemParent}#${propertyItemType === 'method' ? 'Methods' : 'Properties'}:${propertyItemName}`); }); } if(isEditor && !target.getFirstChild('.property-item-comment-static').hasClass(ClassPage.StyleClasses.Hidden)) { this.createContextMenuDelimiter(); this.createContextMenuItem('EditComment', 'Edit comment', () => { target.getFirstChild('.property-item-comment-static').click(); target.getFirstChild('.property-item-comment-input').focus(); }); } this.createContextMenuDelimiter(); this.createContextMenuItem('CopyLink', 'Copy link', () => { DOM.copyToClipboard(`${Url.getFullPath()}#${propertyItemType === 'method' ? 'Methods' : 'Properties'}:${propertyItemName}`); }); this.createContextMenuDelimiter(); this.createContextMenuItem('CopyHtmlLink', 'Copy HTML link', () => { let linkText = propertyItemName; if(propertyItemName.startsWith(ClassPage.__static__)) linkText = `${Class[ClassPage.ClassProperties.ShortName] || Class[ClassPage.ClassProperties.Name]}.${propertyItemName.replace(ClassPage.__static__, '')}`; if(propertyItemType === 'method') linkText = `${linkText}()`; DOM.copyToClipboard(`${linkText}`); }); break; } this.contextMenu.style('left', `${pos.x}px`).style('top', `${pos.y}px`); this.contextMenu.removeClass(ClassPage.StyleClasses.Hidden); } createContextMenuItem(name, text, action) { const itemAction = (e) => { action(e); this.hideContextMenu(); }; const item = DOM.create({ tag: DOM.Tags.Div, cls: 'context-menu-item', innerHTML: text, attr: { 'data-context-menu-item-name': name } }, this.contextMenu) .on(DOM.Events.Click, itemAction); this.contextMenuItems[name] = { item: item, action: itemAction }; } createContextMenuDelimiter() { DOM.create({ tag: DOM.Tags.Div, cls: 'context-menu-delimiter' }, this.contextMenu); } clearContextMenu() { for(const item of this.contextMenu.getChildren()) { const name = item.getAttribute('data-context-menu-item-name'); if(name) { const action = this.contextMenuItems[name].action; item.un(DOM.Events.Click, action); this.contextMenuItems[name] = null; delete this.contextMenuItems[name]; } item.remove(); } } hideContextMenu() { this.contextMenu.addClass(ClassPage.StyleClasses.Hidden); this.clearContextMenu(); } /* <<< Context menu */ }; window_.on(DOM.Events.Load, (e) => { window.page = new ClassPage().start(); }); window_.on(DOM.Events.KeyDown, (e) => { if(window.page && e.key === DOM.Keys.Control) window.page.codeMirrorEditorElement.addClass(ClassPage.StyleClasses.CtrlPressed); if(window.page && e.key === DOM.Keys.Shift) window.page.codeMirrorEditorElement.addClass(ClassPage.StyleClasses.ShiftPressed); }); window_.on(DOM.Events.KeyUp, (e) => { if(window.page && e.key === DOM.Keys.Control) window.page.codeMirrorEditorElement.removeClass(ClassPage.StyleClasses.CtrlPressed); if(window.page && e.key === DOM.Keys.Shift) window.page.codeMirrorEditorElement.removeClass(ClassPage.StyleClasses.ShiftPressed); }); window_.on(DOM.Events.HashChange, (e) => { if(window.page) window.page.applyHash(); }); window_.on(DOM.Events.MouseDown, (e) => { if(window.page) { let target = CDElement.get(e.target); while(target != null && !target.hasClass('context-menu')) { target = target.getParent(); } if(target != null && target.hasClass('context-menu')) return; if(!window.page.contextMenu.hasClass(ClassPage.StyleClasses.Hidden)) window.page.hideContextMenu(); } });